Compare commits

...

60 Commits

Author SHA1 Message Date
1ca5663298
fix: harden StopPod registry cleanup 2026-06-24 00:23:10 +09:00
b28d64c3c6
ticket: close 00001KVSMJJNV 2026-06-23 22:56:21 +09:00
76c800542c
merge: 00001KVSMJJNV paused ctrl-x cancel 2026-06-23 22:54:38 +09:00
cca073f8aa
ticket: approve 00001KVSMJJNV implementation 2026-06-23 22:53:20 +09:00
c0f4c184ca
ticket: report 00001KVSMJJNV review fix 2026-06-23 22:49:40 +09:00
8c8fb01426
fix: log paused cancel lifecycle explicitly 2026-06-23 22:48:21 +09:00
052408392d
ticket: update 00001KVSMJJNV review metadata 2026-06-23 22:43:05 +09:00
cfb215ab0c
ticket: review 00001KVSMJJNV changes 2026-06-23 22:42:52 +09:00
3501e0dffe
ticket: update 00001KVSMJJNV metadata 2026-06-23 22:38:06 +09:00
af683af2d2
ticket: report 00001KVSMJJNV implementation 2026-06-23 22:38:01 +09:00
90b1a1fccb
tui: cancel paused turns with ctrl-x 2026-06-23 22:37:40 +09:00
5954021cc5
ticket: accept 00001KVSMJJNV 2026-06-23 22:21:56 +09:00
108088d811
ticket: queue 00001KVSMJJNV 2026-06-23 17:41:00 +09:00
ca2ad18ded
merge: sync orchestration before queue 00001KVSMJJNV 2026-06-23 17:41:00 +09:00
d2e6a2a1a7
ui: simplify workspace sidebar identity 2026-06-23 16:56:00 +09:00
e275a80f4c
ticket: close workspace identity 2026-06-23 16:39:16 +09:00
2745f3d516
merge: workspace identity persistence 2026-06-23 16:35:17 +09:00
8f7b87a29e
ticket: record workspace identity approval 2026-06-23 16:35:13 +09:00
2a7b659be4
ticket: restart workspace identity review 2026-06-23 16:31:38 +09:00
3dd680683c
ticket: record workspace identity fix 2026-06-23 16:30:50 +09:00
49c9e19074
fix: return persisted workspace identity 2026-06-23 16:29:48 +09:00
ddd2f86e26
ticket: reroute workspace identity fixes 2026-06-23 16:25:39 +09:00
fac79dc962
ticket: request workspace identity changes 2026-06-23 16:25:03 +09:00
71a39002fb
ticket: close dashboard selection 2026-06-23 16:24:24 +09:00
58904c441a
merge: dashboard no auto selection 2026-06-23 16:23:16 +09:00
1e812b793b
ticket: record dashboard selection review 2026-06-23 16:23:11 +09:00
ff8aa6bcbf
ticket: start dashboard selection review 2026-06-23 16:16:42 +09:00
2b213a8add
ticket: record dashboard selection implementation 2026-06-23 16:15:59 +09:00
021aca8d5e
ticket: start workspace identity review 2026-06-23 16:14:48 +09:00
21b14ba440
ticket: record workspace identity implementation 2026-06-23 16:13:56 +09:00
5c242d9620
fix: keep dashboard row selection explicit 2026-06-23 16:13:37 +09:00
31565c9b9e
feat: persist workspace identity 2026-06-23 16:12:39 +09:00
99f31e0055
ticket: start workspace identity and selection work 2026-06-23 15:51:53 +09:00
4cda83b748
ticket: accept workspace identity and selection work 2026-06-23 15:50:36 +09:00
13e76d3544
ticket: queue 00001KVSKGDYS 2026-06-23 15:47:18 +09:00
85b6d9f027
ticket: queue 00001KVSKJ0EA 2026-06-23 15:47:17 +09:00
8daf9eacb7
merge: sync orchestration before queue 00001KVSKJ0EA 2026-06-23 15:47:17 +09:00
48672e4317
fix: canonicalize workspace server root 2026-06-23 15:47:11 +09:00
eb998f0ad4
ticket: add workspace identity initialization 2026-06-23 15:44:29 +09:00
9cbb5b9b71
ticket: record dashboard hint cleanup cleanup 2026-06-23 15:33:30 +09:00
108666664a
ticket: complete dashboard hint cleanup 2026-06-23 15:32:57 +09:00
5abf16f9e6
merge: dashboard hint cleanup 2026-06-23 15:31:31 +09:00
78c98a34d1
ticket: approve dashboard hint cleanup 2026-06-23 15:31:31 +09:00
68f1ddbd5a
ticket: start dashboard hint cleanup review 2026-06-23 15:29:26 +09:00
71284cdc92
ticket: record dashboard hint cleanup implementation 2026-06-23 15:28:24 +09:00
03ad525fcc
tui: trim dashboard redundant hints 2026-06-23 15:27:04 +09:00
af9e940873
ticket: record protocol ts cleanup 2026-06-23 15:22:07 +09:00
b547203fde
ticket: complete protocol ts generation 2026-06-23 15:21:30 +09:00
9728b533b4
merge: protocol typescript generation 2026-06-23 15:17:09 +09:00
9c4d1559fd
ticket: approve protocol ts generation 2026-06-23 15:17:09 +09:00
e0ec8ce78a
ticket: record kanban lazy rows cleanup 2026-06-23 15:16:19 +09:00
9de04f7266
ticket: complete kanban lazy rows 2026-06-23 15:15:33 +09:00
eea26f9174
merge: kanban lazy rows 2026-06-23 15:13:43 +09:00
0b1e9fdc5b
ticket: approve kanban lazy rows 2026-06-23 15:13:43 +09:00
277af3dec3
ticket: start dashboard hint cleanup 2026-06-23 15:13:01 +09:00
3bfd1ca07d
ticket: accept dashboard hint cleanup 2026-06-23 15:11:48 +09:00
0e635ba6b4
ticket: start protocol ts review 2026-06-23 15:10:38 +09:00
017ac70876
ticket: record protocol ts generation 2026-06-23 15:09:50 +09:00
a13fb6933b
protocol: generate workspace TypeScript types 2026-06-23 15:08:22 +09:00
6f68bb8d95
web: group repository ticket kanban rows 2026-06-23 15:04:03 +09:00
59 changed files with 4047 additions and 577 deletions

View File

@ -1,8 +1,8 @@
--- ---
title: 'Generate Workspace web TypeScript types from protocol crate' title: 'Generate Workspace web TypeScript types from protocol crate'
state: 'inprogress' state: 'closed'
created_at: '2026-06-23T05:13:22Z' created_at: '2026-06-23T05:13:22Z'
updated_at: '2026-06-23T05:42:14Z' updated_at: '2026-06-23T06:22:01Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-06-23T05:40:01Z' queued_at: '2026-06-23T05:40:01Z'

View File

@ -0,0 +1,29 @@
Protocol crate 由来の Workspace web TypeScript type generation を実装し、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- `crates/protocol``stream` module / tokio dependency を default `stream` feature に分離し、DTO-only build で tokio を不要にした。
- Optional `typescript` feature と `ts-rs` による TypeScript export を追加。
- Protocol DTOs に `cfg_attr(feature = "typescript", derive(ts_rs::TS))` を追加。
- Deterministic generator を追加:
- `crates/protocol/src/typescript.rs`
- `crates/protocol/examples/generate_typescript.rs`
- Drift check を追加:
- `cargo test -p protocol --features typescript generated_protocol_types_are_current`
- Generated artifact を追加:
- `web/workspace/src/lib/generated/protocol.ts`
- Workspace web が generated root protocol types を re-export:
- `PodProtocolMethod`
- `PodProtocolEvent`
- `PodProtocolSegment`
- Workspace backend extension-point notes に、browser が Pod Unix socket に直接接続せず、将来の backend proxy が Worker identity と method allow/block boundary を enforce する方針を記録。
- `Cargo.lock``package.nix` cargo hash を更新。
統合・検証:
- Merge commit: `9728b533 merge: protocol typescript generation`
- Implementation commit: `a13fb693 protocol: generate workspace TypeScript types`
- Reviewer final verdict: approve
- Validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p protocol`, `cargo test -p protocol --features typescript generated_protocol_types_are_current`, `cargo test -p protocol --features typescript`, `cargo check -p protocol --target wasm32-unknown-unknown --no-default-features`, `deno task check`, `deno task build`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
既知の非ブロッキング事項:
- `ts-rs``#[serde(other)]` on `Segment::Unknown` に warning を出すが、generated artifact には `{ "kind": "unknown" }` が含まれ、current validation は pass。
- 一部 `Option<T>` + `skip_serializing_if` fields は TS で optional field ではなく required nullable に出る。将来 UI が該当 field を使う際は注意。

View File

@ -126,3 +126,267 @@ Next action:
- Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff。 - Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff。
--- ---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T06:09:42Z -->
## Implementation report
Coder implementation report received from `yoi-coder-00001KVSEBF56`.
Implementation commit:
- `a13fb693 protocol: generate workspace TypeScript types`
Changed areas:
- `crates/protocol`
- Made `stream` optional behind default feature `stream`
- Added optional `typescript` feature using `ts-rs`
- Added `cfg_attr(feature = "typescript", derive(ts_rs::TS))` to protocol DTOs。
- Added deterministic generator module and example:
- `crates/protocol/src/typescript.rs`
- `crates/protocol/examples/generate_typescript.rs`
- Added drift check:
- `cargo test -p protocol --features typescript generated_protocol_types_are_current`
- `web/workspace`
- Added generated TypeScript artifact:
- `web/workspace/src/lib/generated/protocol.ts`
- Re-exported generated root protocol types from workspace sidebar types:
- `PodProtocolMethod`
- `PodProtocolEvent`
- `PodProtocolSegment`
- `crates/workspace-server`
- Updated extension-point notes to explicitly preserve no direct browser-to-Pod socket exposure and require future Workspace-server proxy enforcement of Worker identity / method allow-block boundaries。
- Packaging/dependencies:
- Updated `Cargo.lock`
- Updated `package.nix` `cargoHash`
Coder validation reported:
- `cargo fmt --check`: passed
- `cargo test -p protocol`: passed
- `cargo test -p protocol --features typescript generated_protocol_types_are_current`: passed
- `cargo test -p protocol --features typescript`: passed
- `cargo check -p protocol --target wasm32-unknown-unknown --no-default-features`: passed
- `cd web/workspace && deno task check && deno task build`: passed
- `cargo run -p yoi -- ticket doctor`: passed
- `git diff --check`: passed
- `nix build .#yoi --no-link`: passed
Generated artifact cleanup:
- Only intended generated TypeScript import artifact is tracked。
- Orchestrator removed ignored `web/workspace/node_modules/` before Reviewer routing; no ignored frontend artifacts remain under `web/workspace`
Orchestrator evidence checked:
- Worktree clean on `impl/00001KVSEBF56-protocol-typescript-types` at `a13fb693`
- Diff `746c51c7..HEAD` touches expected protocol/frontend/workspace-server/package files only。
- `git diff --check 746c51c7..HEAD`: passed。
Next action:
- Route to read-only Reviewer Pod for acceptance review。
---
<!-- event: comment author: yoi-orchestrator at: 2026-06-23T06:10:31Z -->
## Comment
Review routing:
Spawned read-only Reviewer Pod:
- `yoi-reviewer-00001KVSEBF56-r1`
Review target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVSEBF56-protocol-typescript-types`
- Branch: `impl/00001KVSEBF56-protocol-typescript-types`
- Baseline: `746c51c7`
- Implementation commit: `a13fb693 protocol: generate workspace TypeScript types`
- Diff: `746c51c7..a13fb693`
Reviewer scope:
- read-only `/home/hare/Projects/yoi`
Reviewer focus:
- Generated TS shape vs serde wire shape。
- `Method` / `Event` / `Segment` coverage。
- Deterministic drift check。
- `stream` feature gating and wasm/no-default protocol compile。
- No direct browser-to-Pod socket implication。
- Package/Nix updates。
Orchestrator will wait for reviewer verdict before integration。
---
<!-- event: review author: yoi-reviewer-00001KVSEBF56-r1 at: 2026-06-23T06:16:54Z status: approve -->
## Review: approve
Verdict: `approve`
確認範囲:
- Ticket authority:
- `.yoi/tickets/00001KVSEBF56/item.md`
- `.yoi/tickets/00001KVSEBF56/thread.md`
- Diff `746c51c7..a13fb693`:
- `crates/protocol/Cargo.toml`
- `crates/protocol/src/lib.rs`
- `crates/protocol/src/typescript.rs`
- `crates/protocol/examples/generate_typescript.rs`
- `web/workspace/src/lib/generated/protocol.ts`
- `web/workspace/src/lib/workspace-sidebar/types.ts`
- `crates/workspace-server/src/server.rs`
- `Cargo.lock`
- `package.nix`
Blocking issues:
- None。
Non-blocking concerns:
- `cargo test -p protocol --features typescript` emits `ts-rs` warnings for `#[serde(other)]` on `Segment::Unknown`: `ts-rs failed to parse this attribute. It will be ignored.` The generated artifact still includes `{ "kind": "unknown" }`, and this does not break current validation, but TS generator is not fully understanding serde fallback semantics。
- Some `Option<T>` fields with `skip_serializing_if = "Option::is_none"` are generated as required nullable rather than optional nullable, e.g. `RewindTarget.disabled_reason: string | null` and `warning: string | null`. Acceptable for this initial generated-types ticket, but future UI code should be careful because runtime JSON may omit those fields。
Review findings:
- `crates/protocol` remains Rust DTO authority。
- `stream` is now an optional default feature:
- `default = ["stream"]`
- `stream = ["dep:tokio"]`
- `tokio` is optional。
- DTO-only/no-default protocol build compiles for wasm without `tokio`
- `typescript` feature is optional and uses `ts-rs`
- Generated TS root includes `Method`, `Event`, `Segment`, plus related DTOs such as `PodEvent`, `ScopeRule`, `Greeting`, `InFlight*`, rewind DTOs, completion DTOs, status/result/error enums。
- Generated TS file is committed at `web/workspace/src/lib/generated/protocol.ts`
- Workspace web re-exports generated root protocol types as `PodProtocolMethod`, `PodProtocolEvent`, and `PodProtocolSegment`
- Generated TS tagged enums and casing match observed serde wire shape for critical roots:
- `Method`: `{ "method": "...", "params": ... }`
- `Event`: `{ "event": "...", "data": ... }`
- `Segment` / `PodEvent` / `InFlightBlock`: internal `"kind"` tags with snake_case variants。
- Drift check `generated_protocol_types_are_current` compares committed generated artifact to deterministic generator output。
- Backend proxy stance is represented in Workspace API extension-point notes and does not add direct browser-to-Pod socket access or broad Worker operation UI。
Reviewer validation:
- `cargo fmt --check`: passed
- `git diff --check 746c51c7..HEAD`: passed
- `cargo test -p protocol`: passed (`40 passed`)
- `cargo test -p protocol --features typescript generated_protocol_types_are_current`: passed; emitted non-blocking `ts-rs` warning for `serde(other)`
- `cargo test -p protocol --features typescript`: passed (`41 passed`); emitted same non-blocking warning。
- `cargo check -p protocol --target wasm32-unknown-unknown --no-default-features`: passed
- `cd web/workspace && deno task check && deno task build`: passed (`svelte-check found 0 errors and 0 warnings`; build succeeded)
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link`: passed
Generated artifact status:
- `web/workspace/src/lib/generated/protocol.ts` remained unchanged after validation。
- SHA-256 after validation: `3984a043ded1179545656e5402171c49b2d22b5529aa17f016b675e394b7e553`
Worktree status:
- Clean on `impl/00001KVSEBF56-protocol-typescript-types` at `a13fb693`
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T06:20:49Z -->
## Implementation report
Reviewer r1 approvalを受けて、実装ブランチを Orchestrator worktree の `orchestration` branch に統合した。
統合:
- Merge commit: `9728b533 merge: protocol typescript generation`
- Merged branch: `impl/00001KVSEBF56-protocol-typescript-types`
- Implementation commit:
- `a13fb693 protocol: generate workspace TypeScript types`
Orchestrator worktree validation:
- `cargo fmt --check`: passed
- `git diff --check HEAD^1..HEAD`: passed
- `cargo test -p protocol`: passed
- `cargo test -p protocol --features typescript generated_protocol_types_are_current`: passed
- `cargo test -p protocol --features typescript`: passed
- Non-blocking `ts-rs` warning for `#[serde(other)]` on `Segment::Unknown` remains as reviewer noted。
- `cargo check -p protocol --target wasm32-unknown-unknown --no-default-features`: passed
- `cd web/workspace && rm -rf node_modules .svelte-kit build && deno task check`: passed (`svelte-check found 0 errors and 0 warnings`)
- `cd web/workspace && deno task build`: passed
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link`: passed
Validation generated ignored frontend artifacts in Orchestrator worktree:
- `web/workspace/node_modules/`
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
These were removed after validation. Final Orchestrator worktree status after validation cleanup is clean on `orchestration` at `9728b533`
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-23T06:21:01Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval、Orchestrator worktree への統合、protocol tests/drift check/wasm no-default check、Deno check/build、Ticket doctor、Nix build が完了したため `done` に遷移する。
---
<!-- event: state_changed author: hare at: 2026-06-23T06:21:19Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-23T06:21:19Z status: closed -->
## 完了
Protocol crate 由来の Workspace web TypeScript type generation を実装し、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- `crates/protocol``stream` module / tokio dependency を default `stream` feature に分離し、DTO-only build で tokio を不要にした。
- Optional `typescript` feature と `ts-rs` による TypeScript export を追加。
- Protocol DTOs に `cfg_attr(feature = "typescript", derive(ts_rs::TS))` を追加。
- Deterministic generator を追加:
- `crates/protocol/src/typescript.rs`
- `crates/protocol/examples/generate_typescript.rs`
- Drift check を追加:
- `cargo test -p protocol --features typescript generated_protocol_types_are_current`
- Generated artifact を追加:
- `web/workspace/src/lib/generated/protocol.ts`
- Workspace web が generated root protocol types を re-export:
- `PodProtocolMethod`
- `PodProtocolEvent`
- `PodProtocolSegment`
- Workspace backend extension-point notes に、browser が Pod Unix socket に直接接続せず、将来の backend proxy が Worker identity と method allow/block boundary を enforce する方針を記録。
- `Cargo.lock``package.nix` cargo hash を更新。
統合・検証:
- Merge commit: `9728b533 merge: protocol typescript generation`
- Implementation commit: `a13fb693 protocol: generate workspace TypeScript types`
- Reviewer final verdict: approve
- Validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p protocol`, `cargo test -p protocol --features typescript generated_protocol_types_are_current`, `cargo test -p protocol --features typescript`, `cargo check -p protocol --target wasm32-unknown-unknown --no-default-features`, `deno task check`, `deno task build`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
既知の非ブロッキング事項:
- `ts-rs``#[serde(other)]` on `Segment::Unknown` に warning を出すが、generated artifact には `{ "kind": "unknown" }` が含まれ、current validation は pass。
- 一部 `Option<T>` + `skip_serializing_if` fields は TS で optional field ではなく required nullable に出る。将来 UI が該当 field を使う際は注意。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T06:22:01Z -->
## Implementation report
Post-close cleanup completed。
- Stopped child Pods and reclaimed scope:
- `yoi-coder-00001KVSEBF56`
- `yoi-reviewer-00001KVSEBF56-r1`
- Removed ignored frontend validation artifacts from child worktree before worktree removal if present:
- `web/workspace/node_modules/`
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
- Removed implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVSEBF56-protocol-typescript-types`
- Deleted implementation branch:
- `impl/00001KVSEBF56-protocol-typescript-types`
- Orchestrator worktree remains clean on `orchestration` at `b547203f`
Root/original workspace was not used for merge/validation/cleanup operations。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260623-061132-1","ticket_id":"00001KVSFXY88","kind":"accepted_plan","accepted_plan":{"summary":"Remove redundant Workspace Dashboard title/actionbar key hints and selected-row textual status display while preserving row selection markers and Dashboard keyboard/action semantics, updating render tests accordingly.","branch":"impl/00001KVSFXY88-dashboard-hint-cleanup","worktree":"/home/hare/Projects/yoi/.worktree/00001KVSFXY88-dashboard-hint-cleanup","role_plan":"Orchestrator creates a dedicated child worktree and spawns a narrow-scope Coder. Reviewer will be spawned read-only after Coder reports implementation commit(s). After approval, Orchestrator integrates into `orchestration`, validates TUI tests, records closure, and cleans only the child worktree/branch."},"author":"yoi-orchestrator","at":"2026-06-23T06:11:32Z"}

View File

@ -1,8 +1,8 @@
--- ---
title: 'TUI Dashboard の冗長な key hints と selected-row 状態表示を削る' title: 'TUI Dashboard の冗長な key hints と selected-row 状態表示を削る'
state: 'queued' state: 'closed'
created_at: '2026-06-23T05:40:56Z' created_at: '2026-06-23T05:40:56Z'
updated_at: '2026-06-23T06:08:42Z' updated_at: '2026-06-23T06:33:21Z'
assignee: null assignee: null
readiness: 'implementation_ready' readiness: 'implementation_ready'
risk_flags: ['tui-ux', 'terminal-layout'] risk_flags: ['tui-ux', 'terminal-layout']

View File

@ -0,0 +1,20 @@
TUI Dashboard の冗長な key hints と selected-row textual status display を削除し、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- Top title line から `Row selection`, `blank Enter`, `Tab target` などの key hint guidance を削除。
- Selected Ticket / Pod / Intake / no-row selected textual status line を blank にした。
- Composer bottom actionbar を always-on key hints ではなく notices / diagnostics only に最小化。
- Row marker / highlighting rendering は維持。
- Keyboard/action behavior は変更していない。
- Unused selected-row display-status helper を削除。
- Dashboard render/unit tests を新 display contract に更新し、selected row marker の visibility assertion は維持。
統合・検証:
- Merge commit: `5abf16f9 merge: dashboard hint cleanup`
- Implementation commit: `03ad525f tui: trim dashboard redundant hints`
- Reviewer final verdict: approve
- Validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p tui dashboard --lib`, `cargo test -p tui workspace_panel --lib`, and `cargo run -p yoi -- ticket doctor`
範囲外:
- Console / single-Pod TUI hints は変更していない。
- Manual/PTY `yoi panel` visual check は実施していない。Focused render/unit tests を主 validation とした。

View File

@ -13,4 +13,294 @@ LocalTicketBackend によって作成されました。
Ticket を `workspace-panel` が queued にしました。 Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-23T06:11:11Z -->
## Decision
Routing decision: `implementation_ready_parallel`
Reason:
- Ticket body has concrete Dashboard render targets and acceptance criteria。
- No relations / blockers / orchestration plan records exist。
- Active work `00001KVSEBF56` (protocol TS generation) and `00001KVSGFM65` (Workspace web Kanban review) are separate from TUI Dashboard render code。
- Orchestrator worktree is clean on `orchestration` at `0e635ba6`; target worktree / branch is not present。
- Bounded code map confirms relevant functions in `crates/tui/src/dashboard/render.rs`: `draw_title`, `target_status_line`, `actionbar_left_text`, `actionbar_right_text`, `draw_actionbar`, and tests in `crates/tui/src/dashboard/tests.rs`
IntentPacket:
Intent:
- Remove redundant Dashboard key hints and selected-row textual status while preserving actual row selection visibility and keyboard behavior。
Binding decisions / invariants:
- This Ticket only changes Dashboard display cleanup。
- Console / single-Pod TUI key hints are out of scope。
- Do not remove list row selection markers / highlighting / keyboard navigation visibility。
- Do not reintroduce direct selected-Pod send。
- Companion lifecycle / Orchestrator lifecycle / Ticket workflow semantics must not change。
- Existing Dashboard actions must remain: blank Enter row action, text Enter composer-target action, Tab target switching, Esc clear selection, Pod open/attach, Ticket Intake/Queue flows。
Requirements / acceptance criteria:
- Top title line no longer shows key hint guidance such as `Row selection`, `blank Enter`, `Tab target`
- Composer bottom actionbar no longer shows always-on key hint groups, or is minimized to non-redundant notices only。
- Selected Ticket / selected Pod / selected Intake Pod / no row selected textual status line is removed or no longer recognized as redundant selected-row status。
- List still visibly indicates current selected row。
- Related render/unit tests are updated to the new display specification。
Implementation latitude:
- `target_status_line` layout row may be removed, made empty/minimal, or repurposed only if it does not show redundant selected-row status。
- Actionbar may be fully removed, notice-only, or otherwise minimized depending on layout/tests。
- Keep row markers like `▶` and selected styling unless explicitly escalated。
- If layout row removal creates resize/hitbox complications, choose minimal empty/notice behavior and document the tradeoff。
Escalate if:
- Removing status/actionbar rows makes current composer target/action ambiguous enough to require alternative UI。
- Row selection marker/visibility also needs removal。
- Layout changes cause terminal resize / hitbox / mouse selection side effects that require broader Dashboard layout refactor。
Validation plan:
- `cargo fmt --check`
- `cargo test -p tui dashboard --lib`
- `cargo test -p tui workspace_panel --lib` if affected by shared tests/helpers。
- `git diff --check`
- `cargo run -p yoi -- ticket doctor`
- Manual/PTY `yoi panel` check if practical, but focused render tests are primary。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-23T06:11:22Z from: queued to: inprogress reason: human_authorized_unblocked_dashboard_hint_cleanup field: state -->
## State changed
Human authorized queue routing from Workspace Dashboard. Ticket has concrete Dashboard render acceptance criteria and no recorded blockers, so Orchestrator accepts implementation.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T06:12:36Z -->
## Implementation report
Implementation start report:
- Created child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVSFXY88-dashboard-hint-cleanup`
- Created branch:
- `impl/00001KVSFXY88-dashboard-hint-cleanup`
- Base commit:
- `3bfd1ca0 ticket: accept dashboard hint cleanup`
- Confirmed tracked Ticket project records are visible in the child worktree。
- Confirmed `.yoi/memory` has no tracked/untracked entries in the child worktree check。
- Spawned sibling Coder Pod:
- `yoi-coder-00001KVSFXY88`
- Coder scope:
- read: `/home/hare/Projects/yoi`
- write: `/home/hare/Projects/yoi/.worktree/00001KVSFXY88-dashboard-hint-cleanup`
Parallelization note:
- Active Workspace web / protocol work is separate from TUI Dashboard rendering. This Ticket should stay limited to `crates/tui/src/dashboard/*` unless tests reveal a narrow shared helper impact。
Next action:
- Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T06:28:12Z -->
## Implementation report
Coder implementation report received from `yoi-coder-00001KVSFXY88`.
Implementation commit:
- `03ad525f tui: trim dashboard redundant hints`
Changed areas:
- `crates/tui/src/dashboard/render.rs`
- Removed top title key-hint guidance。
- Made selected-row/composer target status line blank。
- Minimized actionbar to notices/diagnostic-only text。
- Preserved row marker/highlighting rendering。
- `crates/tui/src/dashboard/mod.rs`
- Removed now-unused selected-row display-status helper。
- `crates/tui/src/dashboard/tests.rs`
- Updated render/unit expectations for no redundant hints/status。
- Added title guidance cleanup assertion。
- Kept assertions that selected rows remain visibly marked。
Coder validation reported:
- `cargo fmt --check`: passed
- `cargo test -p tui dashboard --lib`: passed
- `cargo test -p tui workspace_panel --lib`: passed
- `git diff --check`: passed
- `cargo run -p yoi -- ticket doctor`: passed
Manual/PTY `yoi panel` check:
- Not run; focused render/unit tests were used as primary validation。
Orchestrator evidence checked:
- Worktree clean on `impl/00001KVSFXY88-dashboard-hint-cleanup`
- HEAD is `03ad525f`
- Diff `3bfd1ca0..HEAD` touches expected dashboard render/module/tests files only。
- `git diff --check 3bfd1ca0..HEAD`: passed。
Next action:
- Route to read-only Reviewer Pod for acceptance review。
---
<!-- event: comment author: yoi-orchestrator at: 2026-06-23T06:28:54Z -->
## Comment
Review routing:
Spawned read-only Reviewer Pod:
- `yoi-reviewer-00001KVSFXY88-r1`
Review target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVSFXY88-dashboard-hint-cleanup`
- Branch: `impl/00001KVSFXY88-dashboard-hint-cleanup`
- Baseline: `3bfd1ca0`
- Implementation commit: `03ad525f tui: trim dashboard redundant hints`
- Diff: `3bfd1ca0..03ad525f`
Reviewer scope:
- read-only `/home/hare/Projects/yoi`
Reviewer focus:
- Removal of redundant title/actionbar key hints and selected-row textual status。
- Preservation of visible row selection marker/highlighting。
- No keyboard behavior/action semantics changes。
- Dashboard render tests updated to new display contract。
Orchestrator will wait for reviewer verdict before integration。
---
<!-- event: review author: yoi-reviewer-00001KVSFXY88-r1 at: 2026-06-23T06:31:21Z status: approve -->
## Review: approve
Verdict: `approve`
確認範囲:
- Ticket authority:
- `.yoi/tickets/00001KVSFXY88/item.md`
- `.yoi/tickets/00001KVSFXY88/thread.md`
- Diff `3bfd1ca0..03ad525f`:
- `crates/tui/src/dashboard/render.rs`
- `crates/tui/src/dashboard/mod.rs`
- `crates/tui/src/dashboard/tests.rs`
Evidence:
- Top title guidance was removed; `title_line` now keeps only `workspace dashboard` plus companion/orchestrator status。
- `target_status_line` now renders blank, removing selected Ticket / Pod / Intake / no-row textual status。
- Actionbar is reduced to sending/refreshing/notices/diagnostic text; always-on key hint groups are gone。
- Row marker/highlighting paths remain; tests assert selected row marker strings such as `▶ ready`, `▶ queued`, and ` ▶ live`
- Keyboard/action behavior was not altered in the diff; removed helpers were display-only and no remaining references exist。
- No Console/single-Pod TUI files changed。
- No direct selected-Pod send was reintroduced; existing dashboard behavior tests still pass。
Blocking issues:
- None。
Non-blocking concerns:
- Manual `yoi panel` PTY visual check was not run; focused render/unit coverage was used as primary validation, as suggested。
Reviewer validation:
- `cargo fmt --check`: passed
- `git diff --check 3bfd1ca0..HEAD`: passed
- `cargo test -p tui dashboard --lib`: passed (`107 passed`)
- `cargo test -p tui workspace_panel --lib`: passed (`23 passed`)
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
Worktree status:
- Clean on `impl/00001KVSFXY88-dashboard-hint-cleanup` at `03ad525f`
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T06:32:25Z -->
## Implementation report
Reviewer r1 approvalを受けて、実装ブランチを Orchestrator worktree の `orchestration` branch に統合した。
統合:
- Merge commit: `5abf16f9 merge: dashboard hint cleanup`
- Merged branch: `impl/00001KVSFXY88-dashboard-hint-cleanup`
- Implementation commit:
- `03ad525f tui: trim dashboard redundant hints`
Orchestrator worktree validation:
- `cargo fmt --check`: passed
- `git diff --check HEAD^1..HEAD`: passed
- `cargo test -p tui dashboard --lib`: passed (`107 passed`)
- `cargo test -p tui workspace_panel --lib`: passed (`23 passed`)
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
Final Orchestrator worktree status after validation is clean on `orchestration` at `5abf16f9`
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-23T06:32:37Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval、Orchestrator worktree への統合、focused Dashboard / workspace_panel tests、Ticket doctor が完了したため `done` に遷移する。
---
<!-- event: state_changed author: hare at: 2026-06-23T06:32:49Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-23T06:32:49Z status: closed -->
## 完了
TUI Dashboard の冗長な key hints と selected-row textual status display を削除し、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- Top title line から `Row selection`, `blank Enter`, `Tab target` などの key hint guidance を削除。
- Selected Ticket / Pod / Intake / no-row selected textual status line を blank にした。
- Composer bottom actionbar を always-on key hints ではなく notices / diagnostics only に最小化。
- Row marker / highlighting rendering は維持。
- Keyboard/action behavior は変更していない。
- Unused selected-row display-status helper を削除。
- Dashboard render/unit tests を新 display contract に更新し、selected row marker の visibility assertion は維持。
統合・検証:
- Merge commit: `5abf16f9 merge: dashboard hint cleanup`
- Implementation commit: `03ad525f tui: trim dashboard redundant hints`
- Reviewer final verdict: approve
- Validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p tui dashboard --lib`, `cargo test -p tui workspace_panel --lib`, and `cargo run -p yoi -- ticket doctor`
範囲外:
- Console / single-Pod TUI hints は変更していない。
- Manual/PTY `yoi panel` visual check は実施していない。Focused render/unit tests を主 validation とした。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T06:33:21Z -->
## Implementation report
Post-close cleanup completed。
- Stopped child Pods and reclaimed scope:
- `yoi-coder-00001KVSFXY88`
- `yoi-reviewer-00001KVSFXY88-r1`
- Removed implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVSFXY88-dashboard-hint-cleanup`
- Deleted implementation branch:
- `impl/00001KVSFXY88-dashboard-hint-cleanup`
- Orchestrator worktree remains clean on `orchestration` at `10866666`
Root/original workspace was not used for merge/validation/cleanup operations。
--- ---

View File

@ -1,8 +1,8 @@
--- ---
title: 'Improve Workspace web ticket Kanban grouping and lazy rows' title: 'Improve Workspace web ticket Kanban grouping and lazy rows'
state: 'inprogress' state: 'closed'
created_at: '2026-06-23T05:50:36Z' created_at: '2026-06-23T05:50:36Z'
updated_at: '2026-06-23T06:06:45Z' updated_at: '2026-06-23T06:16:10Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-06-23T05:53:22Z' queued_at: '2026-06-23T05:53:22Z'

View File

@ -0,0 +1,22 @@
Workspace web Repository Ticket Kanban の grouping / lazy rows 改善を統合した。
主な成果:
- Repository Ticket Kanban を `RepositoryTicketKanban.svelte` component に分離。
- `planning` + `ready` を display-only group とし、`ready` を `planning` より上に表示。
- `queued` + `inprogress` を display-only group とし、`inprogress` を `queued` より上に表示。
- `done`, `closed`, `other` は独立 group として維持。
- 各 row に original Ticket state を表示。
- 各 group の初期表示行数を 30 に cap。
- 各 group に独立 scroll area と independent lazy visible count を実装。
- High-volume `closed` group が page height を無制限に伸ばさないようにした。
- `WorkspacePage.svelte` から inline Kanban logic/markup を削減。
- Styling は existing design tokens を使い、backend/API/Ticket lifecycle semantics は変更していない。
統合・検証:
- Merge commit: `eea26f91 merge: kanban lazy rows`
- Implementation commit: `6f68bb8d web: group repository ticket kanban rows`
- Reviewer final verdict: approve
- Validation passed: `git diff --check HEAD^1..HEAD`, `deno task check`, `deno task build`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
範囲外:
- Backend pagination、Ticket state mutation UI、drag/drop Kanban、browser/manual scroll E2E tests は追加していない。

View File

@ -206,3 +206,167 @@ Reviewer focus:
Orchestrator will wait for reviewer verdict before integration。 Orchestrator will wait for reviewer verdict before integration。
--- ---
<!-- event: review author: yoi-reviewer-00001KVSGFM65-r1 at: 2026-06-23T06:13:34Z status: approve -->
## Review: approve
Verdict: `approve`
確認範囲:
- Ticket authority:
- `.yoi/tickets/00001KVSGFM65/item.md`
- `.yoi/tickets/00001KVSGFM65/thread.md`
- Diff `a6f9019e..6f68bb8d`:
- `web/workspace/src/lib/workspace-pages/RepositoryTicketKanban.svelte`
- `web/workspace/src/lib/workspace-pages/WorkspacePage.svelte`
- `web/workspace/src/app.css`
Review evidence:
- Grouping/sort:
- `planning` + `ready` are grouped as `ready-planning`, labelled `Ready / Planning`
- `queued` + `inprogress` are grouped as `inprogress-queued`, labelled `In progress / Queued`
- `statePriority()` places `ready` before `planning`, and `inprogress` before `queued`
- Other states:
- `done`, `closed`, and `other` remain separate group keys via `state:${state}` / `state:other`
- Per-group lazy state:
- `visibleRowsByGroup` is keyed by group key。
- Initial visible rows are `INITIAL_VISIBLE_ROWS = 30`
- Scroll handling updates only `[group.key]`
- Near-bottom threshold is `NEAR_BOTTOM_PX = 96`
- High-volume containment:
- Each non-empty group row list has its own `.ticket-list-scroll`
- `.ticket-list-scroll` uses `max-height: 34rem; overflow-y: auto;`, so `closed` cannot expand page vertically without bound。
- Original row state:
- Each ticket row renders `<span class="ticket-state">{ticket.state}</span>`
- Component boundary:
- Kanban logic and row rendering moved into `RepositoryTicketKanban.svelte`
- `WorkspacePage.svelte` now delegates with `<RepositoryTicketKanban tickets={repositoryTickets} />`
- Design / boundary:
- Styling uses existing CSS tokens such as `var(--space-*)`, `var(--line)`, `var(--text-muted)`, `var(--text-faint)`, `var(--text-strong)`
- No backend/API/protocol TypeScript generation scope touched。
- No mutation UI added。
Blocking issues:
- None found。
Non-blocking concerns:
- No browser/manual scroll interaction test was run; validation is by code inspection plus Svelte check/build. Given the tickets frontend-only scope and current test tooling, this is acceptable。
Reviewer validation:
- `git diff --check a6f9019e..6f68bb8d`: passed
- `git diff --check a6f9019e..HEAD`: passed
- `cd web/workspace && rm -rf node_modules .svelte-kit build && deno task check && deno task build`: passed (`svelte-check found 0 errors and 0 warnings`; static build completed)
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link`: passed
- `cargo test -p yoi-workspace-server`: not run; no backend/API changes were made。
Generated artifacts:
- Ignored validation artifacts present after review:
- `target/`
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
- `web/workspace/node_modules/`
- No generated artifacts are tracked。
Worktree status:
- Clean tracked state on `impl/00001KVSGFM65-kanban-lazy-rows` at `6f68bb8d`
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T06:15:01Z -->
## Implementation report
Reviewer r1 approvalを受けて、実装ブランチを Orchestrator worktree の `orchestration` branch に統合した。
統合:
- Merge commit: `eea26f91 merge: kanban lazy rows`
- Merged branch: `impl/00001KVSGFM65-kanban-lazy-rows`
- Implementation commit:
- `6f68bb8d web: group repository ticket kanban rows`
Orchestrator worktree validation:
- `git diff --check HEAD^1..HEAD`: passed
- `cd web/workspace && rm -rf node_modules .svelte-kit build && deno task check`: passed (`svelte-check found 0 errors and 0 warnings`)
- `cd web/workspace && deno task build`: passed
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link`: passed
Validation generated ignored frontend artifacts in Orchestrator worktree:
- `web/workspace/node_modules/`
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
These were removed after validation. Final Orchestrator worktree status after validation cleanup is clean on `orchestration` at `eea26f91`
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-23T06:15:09Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval、Orchestrator worktree への統合、Deno check/build、Ticket doctor、Nix build が完了したため `done` に遷移する。
---
<!-- event: state_changed author: hare at: 2026-06-23T06:15:22Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-23T06:15:22Z status: closed -->
## 完了
Workspace web Repository Ticket Kanban の grouping / lazy rows 改善を統合した。
主な成果:
- Repository Ticket Kanban を `RepositoryTicketKanban.svelte` component に分離。
- `planning` + `ready` を display-only group とし、`ready` を `planning` より上に表示。
- `queued` + `inprogress` を display-only group とし、`inprogress` を `queued` より上に表示。
- `done`, `closed`, `other` は独立 group として維持。
- 各 row に original Ticket state を表示。
- 各 group の初期表示行数を 30 に cap。
- 各 group に独立 scroll area と independent lazy visible count を実装。
- High-volume `closed` group が page height を無制限に伸ばさないようにした。
- `WorkspacePage.svelte` から inline Kanban logic/markup を削減。
- Styling は existing design tokens を使い、backend/API/Ticket lifecycle semantics は変更していない。
統合・検証:
- Merge commit: `eea26f91 merge: kanban lazy rows`
- Implementation commit: `6f68bb8d web: group repository ticket kanban rows`
- Reviewer final verdict: approve
- Validation passed: `git diff --check HEAD^1..HEAD`, `deno task check`, `deno task build`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
範囲外:
- Backend pagination、Ticket state mutation UI、drag/drop Kanban、browser/manual scroll E2E tests は追加していない。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T06:16:10Z -->
## Implementation report
Post-close cleanup completed。
- Stopped child Pods and reclaimed scope:
- `yoi-coder-00001KVSGFM65`
- `yoi-reviewer-00001KVSGFM65-r1`
- Removed ignored frontend validation artifacts from child worktree before worktree removal if present:
- `web/workspace/node_modules/`
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
- Removed implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVSGFM65-kanban-lazy-rows`
- Deleted implementation branch:
- `impl/00001KVSGFM65-kanban-lazy-rows`
- Orchestrator worktree remains clean on `orchestration` at `9de04f72`
Root/original workspace was not used for merge/validation/cleanup operations。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260623-065020-1","ticket_id":"00001KVSKGDYS","kind":"accepted_plan","accepted_plan":{"summary":"Add tracked `.yoi/workspace.toml` schema/loader/create-if-missing path for safe local Workspace identity and use persisted workspace id in Workspace APIs/repository/host id derivation, with tests for missing/existing/invalid/no-path-leak behavior.","branch":"impl/00001KVSKGDYS-workspace-identity","worktree":"/home/hare/Projects/yoi/.worktree/00001KVSKGDYS-workspace-identity","role_plan":"Orchestrator creates a dedicated child worktree and spawns a narrow-scope Workspace backend Coder. Reviewer will be spawned read-only after Coder reports implementation commit(s). After approval, Orchestrator integrates into `orchestration`, validates workspace-server/frontend/Nix as needed, records closure, and cleans only the child worktree/branch."},"author":"yoi-orchestrator","at":"2026-06-23T06:50:20Z"}

View File

@ -0,0 +1,119 @@
---
title: 'Persist local Workspace identity in .yoi/workspace.toml'
state: 'closed'
created_at: '2026-06-23T06:43:28Z'
updated_at: '2026-06-23T07:39:10Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-06-23T06:47:18Z'
---
## 背景
Workspace server の local dev bootstrap は現在、`workspace_id` を `local:{display}` のように workspace root の basename から導出している。これは暫定 namespace としては分かりやすいが、DB の primary key / Workspace identity としては不適切である。
問題:
- `workspace_id` が表示名 / path basename から予測可能に作られている。
- 同じ basename の workspace が複数あると衝突しうる。
- workspace root の rename / move で identity が変わる。
- `local:` namespace と display label が DB PK に混ざっている。
- `--workspace .` のような相対 path では display fallback に落ちるなど、初期化責務が `ServerConfig::local_dev` に寄りすぎている。
Workspace identity は `.yoi/workspace.toml` を local Workspace record として永続化し、未作成なら Workspace backend の初期化処理で払い出す。これにより、DB を再作成しても Workspace identity は維持され、将来の DB authority / hosted Workspace / migration でも扱いやすくなる。
## 方針
- `.yoi/workspace.toml` を local Workspace identity の authority とする。
- `workspace_id` は path / display name から導出しない。
- 新規 local Workspace 初期化時に UUIDv7 を発行して `workspace_id` として保存する。
- `display_name` は identity ではなく UI label として保存する。
- 初期値は canonicalized workspace root の basename でよい。
- 将来的に rename 可能な field として扱う。
- `ServerConfig::local_dev` は Workspace ID を生成しない。
- CLI/bootstrap 層で workspace root canonicalization と `.yoi/workspace.toml` load-or-init を行い、結果を `ServerConfig` に渡す。
- `.yoi/workspace.toml` が壊れている場合は勝手に上書きせず、明示的な error で止める。
- 既存 `.yoi/workspace.db` 内の `local:*` workspace row については、初期実装では destructive migration を必須にしない。
- 新しい UUIDv7 workspace row を upsert し、古い row は残してよい。
- 必要なら後続で cleanup/migration policy を設計する。
## `.yoi/workspace.toml` v0
最小 schema:
```toml
workspace_id = "0197..." # UUIDv7 canonical string
created_at = "2026-06-23T...Z"
display_name = "yoi"
```
Validation:
- `workspace_id` は UUID として parse できること。
- 可能なら UUID version 7 であることを検証する。
- `created_at` は RFC3339 UTC timestamp として parse できること。
- `display_name` は空文字列でないこと。
- unknown fields の扱いは将来拡張を考え、明示的に決める。
- v0 では deny でも preserve でもよいが、選択理由を実装に残す。
## 初期化フロー
`yoi-workspace-server serve --workspace <path>` の local bootstrap は将来的に以下の責務分離にする。
1. `--workspace` を canonicalize する。
2. workspace root 配下の `.yoi/` を確認し、なければ作成する。
3. `.yoi/workspace.toml` を読む。
4. 存在しない場合:
- UUIDv7 `workspace_id` を発行する。
- `created_at` を現在 UTC にする。
- `display_name` を canonicalized workspace root basename から作る。
- `.yoi/workspace.toml` を atomic-ish に書き込む。
5. 存在する場合:
- parse / validate する。
- 壊れていたら停止し、上書きしない。
6. `ServerConfig` には canonicalized workspace root と Workspace identity を渡す。
7. SQLite `workspaces` row は `.yoi/workspace.toml``workspace_id` / `display_name` で upsert する。
8. API `/api/workspace` は DB row / identity record を元に stable id と display name を返す。
## 実装候補
- `crates/workspace-server` に Workspace identity loader を追加する。
- 例: `workspace_identity.rs` または `identity.rs`
- 型例:
```rust
struct WorkspaceIdentity {
workspace_id: Uuid,
created_at: DateTime<Utc>,
display_name: String,
}
impl WorkspaceIdentity {
fn load_or_init(root: &Path) -> Result<Self, WorkspaceIdentityError>;
}
```
- `uuid` crate の UUIDv7 support を確認し、必要なら依存 feature を追加する。
- TOML parse/write には既存 dependency を優先し、なければ最小 dependency を検討する。
- `ServerConfig::local_dev``workspace_id` / `display_name` を引数または identity object で受け取る形に寄せる。
## Non-goals
- Hosted / multi-user Workspace identity の完全設計。
- Workspace rename UI。
- 既存 `.yoi/workspace.db` の destructive migration。
- Ticket / Objective storage の DB authority migration。
- `.yoi/workspace.toml` を全 Workspace settings store に拡張すること。
## 受け入れ条件
- local Workspace identity が `.yoi/workspace.toml` に永続化される。
- `.yoi/workspace.toml` 未作成時、Workspace backend 初期化時に UUIDv7 `workspace_id` が払い出される。
- `workspace_id` が path basename / display name から導出されなくなる。
- `display_name``.yoi/workspace.toml` の field として扱われ、identity と分離されている。
- `--workspace .` でも canonicalized workspace root をもとに初期化される。
- `.yoi/workspace.toml` が壊れている場合、勝手に再生成/上書きせず error で停止する。
- `ServerConfig::local_dev` または同等の bootstrap helper が workspace id を自前生成しない。
- SQLite `workspaces` row は `.yoi/workspace.toml` の identity を使って upsert される。
- 既存 `local:{display}` 形式は新規 workspace id として生成されない。
- `cargo test -p yoi-workspace-server`、`git diff --check`、`nix build .#yoi --no-link` が通る。

View File

@ -0,0 +1,38 @@
Local Workspace identity を tracked `.yoi/workspace.toml` に永続化する実装を完了した。
完了内容:
- `.yoi/workspace.toml` を local Workspace identity の authority として追加。
- v0 schema は `workspace_id`, `created_at`, `display_name` のみ。
- `workspace_id` は UUIDv7 canonical string として検証し、path / display basename から導出しない。
- `created_at` は UTC RFC3339 timestamp として検証。
- `display_name` は空文字列を拒否。
- unknown fields は v0 では明示的に deny。
- invalid existing file は fail closed し、自動上書きしない。
- missing file 初期化では `OpenOptions::create_new(true)` を使い、既存 file が race で作られた場合は persisted identity を read/parse して返す。
- fixed temp file / `path.exists()` + `rename` finalization は使わない。
- `ServerConfig::local_dev``local:{display}` を生成せず、persisted `WorkspaceIdentity` を受け取る。
- SQLite `workspaces` upsert、Workspace API、repository ids、host ids は persisted Workspace id を使う。
- legacy `/api/repositories/local` alias は維持。
- 既存 `local:*` DB rows の destructive migration はしない。
- `.yoi/workspace.toml` は絶対 path / secret / socket / runtime data を含まない安全な project identity record として維持。
統合:
- Implementation: `31565c9b feat: persist workspace identity`
- Fix: `49c9e190 fix: return persisted workspace identity`
- Merge: `2745f3d5 merge: workspace identity persistence`
検証:
- Reviewer approval: `yoi-reviewer-00001KVSKGDYS-r1`
- `cargo fmt --check`: passed
- `git diff --check HEAD^1..HEAD`: passed
- `cargo test -p yoi-workspace-server`: passed
- `cargo check -p yoi-workspace-server`: passed
- `cd web/workspace && deno task check && deno task build`: passed
- `cargo run -q -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link`: passed
生成物 cleanup:
- orchestration worktree の `target/`, `web/workspace/node_modules/`, `web/workspace/.svelte-kit/`, `web/workspace/build/` を削除済み。
残作業:
- なし。将来必要なら legacy `local:*` row cleanup / migration policy は別 Ticket で扱う。

View File

@ -0,0 +1,536 @@
<!-- event: create author: "yoi ticket" at: 2026-06-23T06:43:28Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-06-23T06:44:19Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-06-23T06:44:19Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: workspace-panel at: 2026-06-23T06:47:18Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-23T06:49:54Z -->
## Decision
Routing decision: `implementation_ready_parallel`
Reason:
- Ticket body has concrete `workspace.toml` semantics, backend/API consistency requirements, migration-safe behavior, tests, and validation criteria。
- No relations / blockers / orchestration plan records exist。
- Active Dashboard no-auto-selection work is TUI-only and separate from Workspace backend identity persistence, so parallel implementation is acceptable。
- Orchestrator worktree is clean on `orchestration` at `13e76d35`; target worktree / branch is not present。
- Current Workspace backend has ad-hoc/stable-ish IDs; Ticket asks for tracked local identity at `.yoi/workspace.toml`
IntentPacket:
Intent:
- Persist local Workspace identity in tracked `.yoi/workspace.toml` and use it as the stable local Workspace id across backend APIs, repository IDs, host ID derivation, and frontend display where applicable。
Binding decisions / invariants:
- `.yoi/workspace.toml` is tracked project record, not local runtime/secret file。
- It should contain only safe project identity fields, e.g. `workspace_id`, `display_name`, `created_at` or equivalent; no absolute paths, user names, socket paths, data-dir paths, tokens, or runtime secrets。
- Existing checkouts without the file must remain usable with safe auto-create or fallback behavior。
- Workspace id should not change on process restart, repo path move, or sibling worktree checkout when the file is present。
- Avoid changing Ticket/Objectives canonical authority。
- Do not confuse this with Profile/manifest/override runtime config。
- Handle invalid/corrupt `workspace.toml` fail-closed with clear diagnostic; do not silently generate a different id over a bad tracked file unless explicitly safe。
Requirements / acceptance criteria:
- Define `.yoi/workspace.toml` schema and parser/loader.
- Add create-if-missing behavior for local workspace server/bootstrap path, or a documented CLI/tool path if auto-create is unsuitable.
- Use persisted workspace id in Workspace API responses and any Repository/Host ids derived from workspace id.
- Ensure generated file is safe to commit and has no local absolute paths/secrets.
- Existing tests updated to deterministic temp workspace identity behavior。
- Add tests for missing file creation/fallback, existing stable id, invalid file error, and no local path leakage。
- Validation includes workspace-server tests, Deno check/build if frontend output changes, git diff check, Ticket doctor, and Nix build if package/source behavior changes。
Implementation latitude:
- Put parser in `workspace-server` crate if currently only the web backend needs it, or a small shared crate if needed; avoid broad architecture churn。
- Workspace id can reuse project-record id allocator if suitable, or a stable slug/uuid/base32 type if already used。
- If auto-writing tracked file during server startup is risky, implement explicit ensure function used by tests/bootstrap and document behavior; but Ticket prefers tracked persistence。
- Frontend can just display the id returned by existing `/api/workspace` if backend response changes。
Escalate if:
- Creating tracked `.yoi/workspace.toml` from a server process violates current project-record write boundaries。
- Workspace id generation requires global registry/coordination beyond local checkout。
- Existing code strongly assumes workspace id is derived from path and changing it would break multiple APIs unexpectedly。
- Nix/source filtering excludes `.yoi/workspace.toml` unexpectedly and package behavior needs product decision。
Validation plan:
- `cargo fmt --check`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi-workspace-server`
- `cd web/workspace && deno task check && deno task build` if frontend-visible changes occur。
- `git diff --check`
- `cargo run -p yoi -- ticket doctor`
- `nix build .#yoi --no-link` if package/source filtering or tracked file behavior changed。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-23T06:50:08Z from: queued to: inprogress reason: human_authorized_unblocked_workspace_identity_persistence field: state -->
## State changed
Human authorized queue routing from Workspace Dashboard. Ticket has concrete local workspace identity requirements and no recorded blockers, so Orchestrator accepts implementation.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T06:51:44Z -->
## Implementation report
Implementation start report:
- Created child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVSKGDYS-workspace-identity`
- Created branch:
- `impl/00001KVSKGDYS-workspace-identity`
- Base commit:
- `4cda83b7 ticket: accept workspace identity and selection work`
- Confirmed tracked Ticket project records are visible in the child worktree。
- Confirmed `.yoi/memory` has no tracked/untracked entries in the child worktree check。
- Spawned sibling Coder Pod:
- `yoi-coder-00001KVSKGDYS`
- Coder scope:
- read: `/home/hare/Projects/yoi`
- write: `/home/hare/Projects/yoi/.worktree/00001KVSKGDYS-workspace-identity`
Parallelization note:
- `00001KVSKJ0EA` is active separately and targets TUI Dashboard selection semantics. This Ticket should stay limited to Workspace backend identity persistence and safe project record behavior。
Next action:
- Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T07:13:47Z -->
## Implementation report
Coder implementation report received from `yoi-coder-00001KVSKGDYS`.
Implementation commit:
- `31565c9b feat: persist workspace identity`
Changed areas:
- Added tracked project identity record:
- `.yoi/workspace.toml`
- current contents are safe project identity fields only: `workspace_id`, `created_at`, `display_name`
- Added Workspace identity schema/loader:
- `crates/workspace-server/src/identity.rs`
- strict TOML parser with `workspace_id`, `created_at`, `display_name`
- UUIDv7 validation。
- UTC RFC3339 `created_at` validation。
- unknown fields rejected。
- create-if-missing behavior。
- invalid existing file fails closed without rewrite。
- tests for missing creation, stable existing identity, invalid file, no path leakage。
- Integrated persisted identity into workspace server/bootstrap:
- `crates/workspace-server/src/main.rs`
- `crates/workspace-server/src/server.rs`
- workspace API uses persisted `workspace_id` / display metadata。
- Updated repository ID behavior:
- `crates/workspace-server/src/repositories.rs`
- local repository ids derive from persisted Workspace id。
- legacy `/api/repositories/local` remains accepted as an alias。
- Host ID derivation now receives persisted Workspace id through existing API flow。
- Added dependencies / packaging update:
- `crates/workspace-server/Cargo.toml`
- `Cargo.lock`
- `package.nix` cargo hash。
Coder validation reported:
- `cargo fmt --check`: passed
- `cargo test -p yoi-workspace-server`: passed
- `cargo check -p yoi-workspace-server`: passed
- `cd web/workspace && deno task check && deno task build`: passed
- `git diff --check`: passed
- `git diff --cached --check`: passed
- `cargo run -p yoi -- ticket doctor`: passed
- `nix build .#yoi --no-link`: passed
Generated artifact cleanup:
- Coder removed generated frontend artifacts:
- `web/workspace/.svelte-kit`
- `web/workspace/build`
- `web/workspace/node_modules`
- Coder removed generated Rust build cache:
- `target`
Orchestrator evidence checked:
- Worktree clean on `impl/00001KVSKGDYS-workspace-identity` at `31565c9b`
- Diff `4cda83b7..HEAD` touches expected Workspace identity/backend/package files and adds `.yoi/workspace.toml`
- `git diff --check 4cda83b7..HEAD`: passed。
Next action:
- Route to read-only Reviewer Pod for acceptance review。
---
<!-- event: comment author: yoi-orchestrator at: 2026-06-23T07:14:37Z -->
## Comment
Review routing:
Spawned read-only Reviewer Pod:
- `yoi-reviewer-00001KVSKGDYS-r1`
Review target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVSKGDYS-workspace-identity`
- Branch: `impl/00001KVSKGDYS-workspace-identity`
- Baseline: `4cda83b7`
- Implementation commit: `31565c9b feat: persist workspace identity`
- Diff: `4cda83b7..31565c9b`
Reviewer scope:
- read-only `/home/hare/Projects/yoi`
Reviewer focus:
- Safety and tracked-project-record suitability of `.yoi/workspace.toml`
- Stable persisted id usage in Workspace API / repository / host ids。
- Missing/existing/invalid file behavior。
- No local path/secret leakage。
- Package/Nix updates。
Orchestrator will wait for reviewer verdict before integration。
---
<!-- event: review author: yoi-orchestrator at: 2026-06-23T07:24:58Z status: request_changes -->
## Review: request changes
Reviewer Pod `yoi-reviewer-00001KVSKGDYS-r1` requested changes.
Reviewed target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVSKGDYS-workspace-identity`
- Branch: `impl/00001KVSKGDYS-workspace-identity`
- Implementation: `31565c9b feat: persist workspace identity`
- Diff: `4cda83b7..31565c9b`
Blocking issue:
1. Missing-file create path can return/use an identity that is not the persisted `.yoi/workspace.toml` identity under a local bootstrap race。
Evidence:
- `crates/workspace-server/src/identity.rs` generates a new `WorkspaceIdentity`, calls `identity.write_new(path)?`, and then returns the generated identity。
- In `write_new`, if another process creates `workspace.toml` after the initial missing-file path, the code validates the existing file but discards the parsed identity:
```rust
if path.exists() {
let _ = fs::remove_file(&tmp);
let raw = fs::read_to_string(path)?;
return Self::parse_str(&raw, path).map(|_| ());
}
```
- The caller still returns the newly generated identity, so a server instance can upsert SQLite / serve Workspace API / repository / host ids using an id that is not the tracked file and will not survive restart。
- Related TOCTOU risk: `path.exists()` followed by `fs::rename(tmp, path)` can overwrite a concurrently created file on Unix, and fixed temp path `workspace.toml.tmp` can be clobbered by concurrent creators。
Required fix direction:
- Ensure the returned `WorkspaceIdentity` is always the identity actually persisted or read from `.yoi/workspace.toml`
- Use a no-overwrite / create-new style finalization or equivalent safe retry/read behavior for local bootstrap races。
- Add focused test coverage for the race/return-authority behavior if feasible。
Non-blocking reviewer findings:
- `.yoi/workspace.toml` contents look safe to track。
- Unknown fields are intentionally denied and documented/tested。
- `ServerConfig::local_dev` no longer derives `local:{display}` internally。
- Workspace API/store/repository/host plumbing appears correctly based on persisted identity once loaded。
- Existing `local:*` rows are not destructively migrated。
Reviewer validation all passed before request_changes:
- `cargo fmt --check`
- `git diff --check 4cda83b7..HEAD`
- `cargo test -p yoi-workspace-server` (`15 passed`)
- `cargo check -p yoi-workspace-server`
- `cd web/workspace && deno task check && deno task build`
- `cargo run -p yoi -- ticket doctor`
- `nix build .#yoi --no-link`
Reviewer notes generated ignored artifacts from validation remained in worktree:
- `target/`
- `web/workspace/node_modules/`
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
---
<!-- event: comment author: yoi-orchestrator at: 2026-06-23T07:25:27Z -->
## Comment
Rerouted request-changes feedback to Coder Pod `yoi-coder-00001KVSKGDYS`.
Requested fix:
- `WorkspaceIdentity::load_or_init` must return the identity actually persisted/read from `.yoi/workspace.toml`
- Missing-file creation must handle local bootstrap races without returning an unpersisted generated identity。
- Avoid or safely handle TOCTOU / fixed temp-file clobbering around `workspace.toml.tmp`
- Add focused deterministic test coverage if feasible。
Validation requested after fix:
- `cargo fmt --check`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi-workspace-server`
- `cd web/workspace && deno task check && deno task build`
- `git diff --check`
- `git diff --cached --check`
- `cargo run -p yoi -- ticket doctor`
- `nix build .#yoi --no-link`
Coder was also asked to remove ignored validation artifacts before reporting。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T07:30:46Z -->
## Implementation report
Coder fix report received from `yoi-coder-00001KVSKGDYS` after request_changes。
New commit:
- `49c9e190 fix: return persisted workspace identity`
Fix summary:
- Replaced temp-file + `rename` finalization in `crates/workspace-server/src/identity.rs`
- New creation path uses `OpenOptions::create_new(true)` directly on `.yoi/workspace.toml`
- If creation succeeds, generated identity is written and returned。
- If creation fails because the file already exists from a concurrent bootstrap race, the existing file is read/parsed and that persisted identity is returned。
- Removed fixed `workspace.toml.tmp` path and the `path.exists()` + `rename` TOCTOU overwrite risk。
Test coverage:
- Added deterministic unit test `create_new_race_returns_existing_persisted_identity`
- Test verifies an already-persisted identity wins over a generated candidate, returned identity matches the file, and the file is not rewritten。
- Existing tests for missing creation, stable existing identity, invalid fail-closed behavior, no path leakage, and unknown field rejection remain。
Coder validation reported:
- `cargo fmt --check`: passed
- `cargo test -p yoi-workspace-server`: passed
- `cargo check -p yoi-workspace-server`: passed
- `cd web/workspace && deno task check && deno task build`: passed
- `git diff --check`: passed
- `git diff --cached --check`: passed
- `cargo run -p yoi -- ticket doctor`: passed
- `nix build .#yoi --no-link`: passed
Cleanup reported:
- Removed ignored validation artifacts:
- `target/`
- `web/workspace/node_modules/`
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
Orchestrator evidence checked:
- Worktree clean on `impl/00001KVSKGDYS-workspace-identity` at `49c9e190`
- Diff `4cda83b7..HEAD` still touches expected Workspace identity/backend/package files。
- Latest fix commit changes only `crates/workspace-server/src/identity.rs`
- `git diff --check 4cda83b7..HEAD`: passed。
- Spot-check of `identity.rs` confirmed `write_new_or_read_existing` returns parsed persisted identity on `AlreadyExists` and no longer uses fixed temp file / rename finalization。
Next action:
- Route back to Reviewer Pod for re-review of request_changes fix。
---
<!-- event: comment author: yoi-orchestrator at: 2026-06-23T07:31:34Z -->
## Comment
Re-review routing:
Sent the request_changes fix back to Reviewer Pod:
- `yoi-reviewer-00001KVSKGDYS-r1`
Review target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVSKGDYS-workspace-identity`
- Branch: `impl/00001KVSKGDYS-workspace-identity`
- Baseline: `4cda83b7`
- Previous implementation: `31565c9b feat: persist workspace identity`
- Fix commit: `49c9e190 fix: return persisted workspace identity`
- Main review diff: `4cda83b7..49c9e190`
- Focus diff: `31565c9b..49c9e190`
Reviewer was asked to verify the prior blocking issue is fixed and re-run validation including workspace-server tests/checks, web check/build, TicketDoctor, and Nix build。
---
<!-- event: review author: yoi-orchestrator at: 2026-06-23T07:35:05Z status: approve -->
## Review: approve
Reviewer Pod `yoi-reviewer-00001KVSKGDYS-r1` approved the request_changes fix and the full implementation.
Reviewed target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVSKGDYS-workspace-identity`
- Branch: `impl/00001KVSKGDYS-workspace-identity`
- Final HEAD: `49c9e190 fix: return persisted workspace identity`
- Full diff: `4cda83b7..49c9e190`
- Fix diff: `31565c9b..49c9e190`
Reviewer result:
- Prior blocker is fixed。
- `write_new_or_read_existing` now uses `OpenOptions::new().write(true).create_new(true).open(path)` for no-overwrite creation。
- On successful create, generated identity is written/synced and returned。
- On `AlreadyExists`, existing `.yoi/workspace.toml` is read/parsed and the persisted identity is returned。
- Fixed temp path and `path.exists() + rename` finalization are gone。
- This satisfies the invariant that the returned identity is the persisted/read authority under a local bootstrap race。
Blocking issues:
- なし。
Non-blocking concern:
- Direct final-file `create_new` write can expose an empty/partial file to a concurrent reader during the short write window or after crash/write failure, but this fails closed rather than returning the wrong identity and was not considered blocking for local bootstrap scope。
Requirement checks:
- Missing-file creation writes minimal v0 TOML schema。
- Invalid existing file fails closed without overwrite。
- Unknown fields remain denied and tested。
- UUIDv7 validation is real: UUID parse + version check + canonical lowercase hyphenated form check。
- `ServerConfig::local_dev` no longer constructs `local:{display}`
- SQLite `workspaces` upsert uses `.yoi/workspace.toml` identity values。
- Repository ids and host ids derive from persisted workspace id。
- Legacy `/api/repositories/local` alias remains accepted。
- Existing `local:*` DB rows are not destructively migrated。
- `.yoi/workspace.toml` remains safe to track and contains only `workspace_id`, `created_at`, `display_name`
Reviewer validation:
- `cargo fmt --check`: passed
- `git diff --check 4cda83b7..HEAD`: passed
- `cargo test -p yoi-workspace-server`: passed (`16 passed`)
- `cargo check -p yoi-workspace-server`: passed
- `cd web/workspace && deno task check && deno task build`: passed
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link`: passed
Worktree status:
- clean for tracked/untracked files at `49c9e190`
- Reviewer validation left only ignored generated artifacts in the child worktree。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T07:38:38Z -->
## Implementation report
Merge and validation completed.
Merged implementation:
- Merge commit: `2745f3d5 merge: workspace identity persistence`
- Implementation commits:
- `31565c9b feat: persist workspace identity`
- `49c9e190 fix: return persisted workspace identity`
Post-merge validation in orchestration worktree:
- `cargo fmt --check`: passed
- `git diff --check HEAD^1..HEAD`: passed
- `cargo test -p yoi-workspace-server`: passed
- `cargo check -p yoi-workspace-server`: passed
- `cd web/workspace && deno task check && deno task build`: passed (`svelte-check` 0 errors / 0 warnings; build completed)
- `cargo run -q -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link`: passed
Generated artifact cleanup:
- Removed validation artifacts from orchestration worktree:
- `target/`
- `web/workspace/node_modules/`
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
- `git status --short --branch --ignored` shows only ignored `.yoi/tickets/.ticket-backend.lock`
Outcome:
- Local Workspace identity now persists in tracked `.yoi/workspace.toml`
- New ids are UUIDv7 and no longer derived from path/display basename。
- `.yoi/workspace.toml` parsing validates UUIDv7, UTC RFC3339 timestamp, non-empty display name, canonical id form, and denies unknown fields。
- Invalid existing identity files fail closed and are not overwritten。
- Missing-file creation returns the identity actually persisted/read, including `AlreadyExists` race handling。
- Workspace API / SQLite upsert / repository ids / host ids use the persisted Workspace identity。
- Existing legacy `local:*` DB rows are not destructively migrated。
- `.yoi/workspace.toml` remains a minimal safe project identity record, not a runtime/settings/secret store。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-23T07:38:42Z from: inprogress to: done reason: merged_and_validated field: state -->
## State changed
Reviewer approval 後に implementation を orchestration worktree へ merge し、post-merge validation と生成物 cleanup が完了したため done とする。
---
<!-- event: state_changed author: hare at: 2026-06-23T07:39:10Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-23T07:39:10Z status: closed -->
## 完了
Local Workspace identity を tracked `.yoi/workspace.toml` に永続化する実装を完了した。
完了内容:
- `.yoi/workspace.toml` を local Workspace identity の authority として追加。
- v0 schema は `workspace_id`, `created_at`, `display_name` のみ。
- `workspace_id` は UUIDv7 canonical string として検証し、path / display basename から導出しない。
- `created_at` は UTC RFC3339 timestamp として検証。
- `display_name` は空文字列を拒否。
- unknown fields は v0 では明示的に deny。
- invalid existing file は fail closed し、自動上書きしない。
- missing file 初期化では `OpenOptions::create_new(true)` を使い、既存 file が race で作られた場合は persisted identity を read/parse して返す。
- fixed temp file / `path.exists()` + `rename` finalization は使わない。
- `ServerConfig::local_dev``local:{display}` を生成せず、persisted `WorkspaceIdentity` を受け取る。
- SQLite `workspaces` upsert、Workspace API、repository ids、host ids は persisted Workspace id を使う。
- legacy `/api/repositories/local` alias は維持。
- 既存 `local:*` DB rows の destructive migration はしない。
- `.yoi/workspace.toml` は絶対 path / secret / socket / runtime data を含まない安全な project identity record として維持。
統合:
- Implementation: `31565c9b feat: persist workspace identity`
- Fix: `49c9e190 fix: return persisted workspace identity`
- Merge: `2745f3d5 merge: workspace identity persistence`
検証:
- Reviewer approval: `yoi-reviewer-00001KVSKGDYS-r1`
- `cargo fmt --check`: passed
- `git diff --check HEAD^1..HEAD`: passed
- `cargo test -p yoi-workspace-server`: passed
- `cargo check -p yoi-workspace-server`: passed
- `cd web/workspace && deno task check && deno task build`: passed
- `cargo run -q -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link`: passed
生成物 cleanup:
- orchestration worktree の `target/`, `web/workspace/node_modules/`, `web/workspace/.svelte-kit/`, `web/workspace/build/` を削除済み。
残作業:
- なし。将来必要なら legacy `local:*` row cleanup / migration policy は別 Ticket で扱う。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260623-064931-1","ticket_id":"00001KVSKJ0EA","kind":"accepted_plan","accepted_plan":{"summary":"Change Workspace Dashboard selection semantics so initial display and reload/background refresh do not auto-select rows; explicit keyboard/mouse selection still works; no-selection + TicketIntake composer submits global/new Intake; add focused tests.","branch":"impl/00001KVSKJ0EA-dashboard-no-auto-selection","worktree":"/home/hare/Projects/yoi/.worktree/00001KVSKJ0EA-dashboard-no-auto-selection","role_plan":"Orchestrator creates a dedicated child worktree and spawns a narrow-scope TUI Coder. Reviewer will be spawned read-only after Coder reports implementation commit(s). After approval, Orchestrator integrates into `orchestration`, validates TUI focused tests, records closure, and cleans only the child worktree/branch."},"author":"yoi-orchestrator","at":"2026-06-23T06:49:31Z"}

View File

@ -0,0 +1,107 @@
---
title: 'Dashboard reload と初期表示で row を自動選択しない'
state: 'closed'
created_at: '2026-06-23T06:44:20Z'
updated_at: '2026-06-23T07:24:20Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['tui-ux', 'panel-selection', 'reload-state']
queued_by: 'workspace-panel'
queued_at: '2026-06-23T06:47:17Z'
---
## User claims / request snapshot
- Dashboard で `Esc` を押して row selection を消しても、reload によって頻繁に選択が戻る。
- そのため、実使用上は「何も選択していない状態」で composer を global / 新規 Intake として使うのが難しい。
- reload で勝手に row selection しないようにしたい。
- 追加合意: 初回 `yoi panel` 起動時から未選択にする。
## Confirmed facts / sources
- `crates/tui/src/dashboard/mod.rs`
- Dashboard は `selected_row: Option<PanelRowKey>` を持ち、`None` の未選択状態が実装上存在する。
- `Esc` 経路で `clear_panel_selection()` が呼ばれ、selection を消せる。
- reload / snapshot 更新後の selection visibility 補正により、visible row があると selection が戻る可能性がある。
- `crates/tui/src/dashboard/tests.rs`
- `Esc` が row selection を clear する既存テストがある。
- 関連 Ticket:
- `00001KTNS1AA8` closed: Panel composer / no-selection / `Esc` semantics の過去文脈。
- `00001KTFMMZP0` closed: background reload と selection/composer preservation の過去文脈。
## Unverified hypotheses
- 現行の reload 完了時の `ensure_selection_visible()` 系処理が、明示的な no-selection と「まだ selection がない初期/補正状態」を区別していない可能性が高い。
- 初期表示時の自動選択も同じ selection visibility 補正または初期化経路に由来している可能性がある。
## Undecided points / open questions
- なし。初回表示から未選択にする方針で合意済み。
## Background
Dashboard composer は row selection と composer target の組み合わせで挙動が変わる。`TicketIntake` target で Ticket row が選択されていると、入力が既存 Ticket の詳細化 / planning return として扱われる経路がある。一方、ユーザーが global / 新規 Intake として入力したい場合は row selection がない状態を維持できる必要がある。
現状は `Esc` で selection を消せても、background reload 等で選択が戻るため、ユーザーが意図した no-selection 状態を維持しづらい。
## Requirements
- 初回 `yoi panel` 表示時は、Ticket row / Pod row を自動選択しない。
- ユーザーが `Esc` で row selection を clear した後、reload / background reload / snapshot 更新が完了しても `selected_row = None` を維持する。
- reload によって既存の明示選択がまだ visible な場合は、その selection を維持してよい。
- reload によって選択中 row が消えた場合は安全に未選択へ落とす。
- Up/Down、mouse click、その他既存の明示的な row selection 操作では従来どおり selection を作れる。
- 未選択状態で `TicketIntake` target に非空 composer 入力して Enter した場合、既存 Ticket refinement ではなく global / 新規 Intake launch として扱われる。
## Acceptance criteria
- 初回 `yoi panel` 起動後、visible row があっても row selection はない。
- `Esc` で selection を clear した後、panel reload completion を挟んでも selection は復活しない。
- 未選択状態で `TicketIntake` target に composer 入力して Enter すると、新規/global Intake handoff になり、既存 Ticket の詳細化 handoff にならない。
- keyboard navigation または mouse click により、ユーザーは明示的に row を再選択できる。
- 既存の selected-row action、queue/close/open 操作、Ticket workflow state semantics は変えない。
- focused tests で少なくとも以下を確認する:
- initial Dashboard state has no selected row when rows exist;
- `Esc` clear -> reload completion -> still no selected row;
- no-selection + `TicketIntake` composer submit routes to global/new Intake rather than selected Ticket refinement.
## Binding decisions / invariants
- no-selection は first-class UX state として扱う。
- reload は、ユーザーが明示的に作った no-selection state を勝手に解除しない。
- 初回表示でも自動選択しない。
- composer draft は reload / selection clear によって失わない。
- Ticket state transition / lifecycle authority は変更しない。
- row click は selection のみを変更し、click だけで action dispatch しない既存方針を維持する。
## Implementation latitude
- 実装は `selected_row: Option<PanelRowKey>` に加えて「明示 no-selection」フラグを持つ、または selection visibility 補正の呼び出し条件を整理するなど、局所的に選んでよい。
- 初期表示・reload 完了・row 消失・navigation 開始の各経路で、selection を作る責務を明示的 user action に寄せられればよい。
- UI 表示文言の小変更は許容するが、この Ticket の主目的は selection semantics の修正であり、広い Dashboard layout redesign は含めない。
## Readiness
- readiness: implementation_ready
- risk_flags: [tui-ux, panel-selection, reload-state]
## Escalation conditions
- 初回未選択にすると keyboard-only navigation や blank Enter action の既存 contract が大きく変わることが判明した場合。
- selection visibility 補正が reload 以外の重要な safety/correctness を担っており、単純に止めると stale selection や action 誤爆が起きる場合。
- focused tests では確認できず、実端末 `yoi panel` / PTY 経路での挙動確認が必要になった場合。
## Validation
- `cargo test -q -p tui workspace_panel`
- 必要に応じて Dashboard 関連の focused test 名指定。
- 変更範囲に応じて `cargo fmt --check`、`git diff --check`、`cargo check -q`。
- terminal delivery / timing に依存する挙動が疑わしい場合は、既存の Panel PTY/E2E 系テストまたは実 `yoi panel` で確認する。
## Related work
- `00001KTNS1AA8` — Panel composer / no-selection / `Esc` semantics の関連 closed Ticket。
- `00001KTFMMZP0` — Dashboard background reload / selection preservation の関連 closed Ticket。
- `crates/tui/src/dashboard/mod.rs`
- `crates/tui/src/dashboard/tests.rs`
- `crates/tui/src/dashboard/render.rs`

View File

@ -0,0 +1,26 @@
Dashboard の row selection を明示的 user action の結果として扱うように修正した。
完了内容:
- 初期表示で visible row があっても自動選択しない。
- reload / snapshot reconciliation で `selected_row = None` を勝手に selection へ戻さない。
- `Esc` による no-selection が reload を跨いで維持される。
- 選択中 row が消えた場合は `None` へ安全に落とす。
- keyboard navigation では no-selection から明示的に selection を作成できる。
- `list.selected_name` は実際の Pod row selection と同期し、no-selection / non-Pod selection では clear する。
- no-selection + `TicketIntake` composer submit が既存 Ticket refinement ではなく global/new Intake route へ進むことを focused test で確認した。
統合:
- Implementation: `5c242d96 fix: keep dashboard row selection explicit`
- Merge: `58904c44 merge: dashboard no auto selection`
検証:
- Reviewer approval: `yoi-reviewer-00001KVSKJ0EA-r1`
- `cargo fmt --check`: passed
- `git diff --check HEAD^1..HEAD`: passed
- `cargo test -q -p tui workspace_panel`: passed (`27 passed`)
- `cargo test -q -p tui dashboard`: passed (`111 passed`)
- reviewer 側追加確認 `cargo test -q -p tui`: passed (`372 passed`)
- `cargo run -q -p yoi -- ticket doctor`: passed (`doctor: ok`)
残作業:
- なし。

View File

@ -0,0 +1,313 @@
<!-- event: create author: LocalTicketBackend at: 2026-06-23T06:44:20Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: ticket-intake at: 2026-06-23T06:46:36Z -->
## Intake summary
ユーザー合意により ready 化。Ticket は `implementation_ready` で、初回 `yoi panel` 表示時から未選択、`Esc` 後の no-selection を reload が復活させない、未選択 + `TicketIntake` composer submit を global/new Intake に流す、という要件・受け入れ条件・validation が明記されている。未決定点なし。
---
<!-- event: state_changed author: ticket-intake at: 2026-06-23T06:46:36Z from: planning to: ready reason: user_requested_ready field: state -->
## State changed
ユーザーから `readyにして` の明示指示があり、対象 Ticket の item/thread を確認した。要件・受け入れ条件・risk flags・validation が揃っており、Orchestrator が routing 可能なため `ready` に遷移する。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-23T06:47:17Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-23T06:49:08Z -->
## Decision
Routing decision: `implementation_ready_parallel`
Reason:
- Ticket body has concrete Dashboard selection semantics and focused test requirements。
- No relations / blockers / orchestration plan records exist。
- Active queued Workspace identity Ticket is backend/workspace-server focused and separate from TUI Dashboard row-selection code, so parallel implementation is acceptable。
- Orchestrator worktree is clean on `orchestration` at `13e76d35`; target worktree / branch is not present。
- Code map from Ticket body points to `crates/tui/src/dashboard/mod.rs`, `tests.rs`, and `render.rs`
IntentPacket:
Intent:
- Treat no-selection as a first-class Dashboard UX state: initial panel display and reload/background refresh must not auto-select rows, while explicit keyboard/mouse selection still works。
Binding decisions / invariants:
- Initial `yoi panel` display should have `selected_row = None` even when rows are visible。
- `Esc` clear must persist across reload/snapshot refresh; reload must not recreate selection after explicit no-selection。
- If a user explicitly selected a row and it remains visible after reload, that selection may be preserved。
- If selected row disappears after reload, fall back safely to no-selection。
- Composer draft must survive selection clear/reload。
- Existing row actions, queue/close/open operations, Ticket workflow state semantics, and row-click-is-selection-only policy must remain unchanged。
- No broad Dashboard layout redesign in this Ticket。
Requirements / acceptance criteria:
- Initial Dashboard state has no selected row when rows exist。
- Esc clear -> reload completion -> still no selected row。
- No-selection + `TicketIntake` composer submit routes to global/new Intake, not selected Ticket refinement。
- Keyboard navigation or mouse click can explicitly create selection again。
- Focused tests cover the above semantics。
Implementation latitude:
- Use an explicit no-selection flag or refine selection visibility correction conditions。
- The key is to make selection creation happen only from explicit user navigation/click/action paths, not from reload/init visibility correction。
Escalate if:
- Initial no-selection substantially breaks keyboard-only navigation or blank Enter action contract。
- Existing selection visibility correction has safety roles that cannot be preserved with a local change。
- Real terminal / PTY behavior is required beyond focused tests。
Validation plan:
- `cargo fmt --check`
- `cargo test -q -p tui workspace_panel`
- Dashboard focused tests as needed。
- `git diff --check`
- `cargo run -p yoi -- ticket doctor`
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-23T06:49:18Z from: queued to: inprogress reason: human_authorized_unblocked_dashboard_no_auto_selection field: state -->
## State changed
Human authorized queue routing from Workspace Dashboard. Ticket has concrete Dashboard selection semantics and no recorded blockers, so Orchestrator accepts implementation.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T06:51:32Z -->
## Implementation report
Implementation start report:
- Created child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVSKJ0EA-dashboard-no-auto-selection`
- Created branch:
- `impl/00001KVSKJ0EA-dashboard-no-auto-selection`
- Base commit:
- `4cda83b7 ticket: accept workspace identity and selection work`
- Confirmed tracked Ticket project records are visible in the child worktree。
- Confirmed `.yoi/memory` has no tracked/untracked entries in the child worktree check。
- Spawned sibling Coder Pod:
- `yoi-coder-00001KVSKJ0EA`
- Coder scope:
- read: `/home/hare/Projects/yoi`
- write: `/home/hare/Projects/yoi/.worktree/00001KVSKJ0EA-dashboard-no-auto-selection`
Parallelization note:
- `00001KVSKGDYS` is active separately and targets Workspace backend identity persistence. This Ticket should stay limited to TUI Dashboard selection semantics。
Next action:
- Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T07:15:44Z -->
## Implementation report
Coder implementation report received from `yoi-coder-00001KVSKJ0EA`.
Implementation commit:
- `5c242d96 fix: keep dashboard row selection explicit`
Changed areas:
- `crates/tui/src/dashboard/mod.rs`
- Reload/snapshot reconciliation preserves only an explicitly selected visible row。
- Initial/reload visibility correction no longer auto-selects a row。
- Stale/disappeared selections fall back to `None`
- `Esc` clear state remains no-selection across reload。
- Keyboard navigation from no-selection explicitly creates selection on first visible row。
- `list.selected_name` stays aligned with actual Pod row selection and clears for non-Pod/no-selection states。
- `crates/tui/src/dashboard/tests.rs`
- Added focused coverage for initial visible rows with no selected row。
- Added `Esc` clear surviving reload while preserving composer draft。
- Added no-selection + `TicketIntake` submit routing to global/new Intake。
- Added keyboard navigation creating selection explicitly。
- Added reload fallback to no-selection when selected row disappears。
- Updated existing Dashboard tests to explicitly select rows where previous behavior depended on auto-selection。
Coder validation reported:
- `cargo fmt --check`: passed
- `cargo test -q -p tui workspace_panel`: passed
- `cargo test -q -p tui dashboard`: passed
- `git diff --check`: passed
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
Orchestrator evidence checked:
- Worktree clean on `impl/00001KVSKJ0EA-dashboard-no-auto-selection` at `5c242d96`
- Diff `4cda83b7..HEAD` touches expected Dashboard model/tests only。
- `git diff --check 4cda83b7..HEAD`: passed。
Next action:
- Route to read-only Reviewer Pod for acceptance review。
---
<!-- event: comment author: yoi-orchestrator at: 2026-06-23T07:16:33Z -->
## Comment
Review routing:
Spawned read-only Reviewer Pod:
- `yoi-reviewer-00001KVSKJ0EA-r1`
Review target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVSKJ0EA-dashboard-no-auto-selection`
- Branch: `impl/00001KVSKJ0EA-dashboard-no-auto-selection`
- Baseline: `4cda83b7`
- Implementation commit: `5c242d96 fix: keep dashboard row selection explicit`
- Diff: `4cda83b7..5c242d96`
Reviewer scope:
- read-only `/home/hare/Projects/yoi`
Reviewer focus:
- Initial/reload no-auto-selection semantics。
- Esc clear persistence and composer draft preservation。
- Explicit keyboard/mouse selection still works。
- Stale selection falls back to none。
- No-selection TicketIntake composer routing。
- `list.selected_name` sync and no stale Pod selection。
- No row-action / workflow semantic regressions。
Orchestrator will wait for reviewer verdict before integration。
---
<!-- event: review author: yoi-orchestrator at: 2026-06-23T07:23:02Z status: approve -->
## Review: approve
Reviewer Pod `yoi-reviewer-00001KVSKJ0EA-r1` approved the implementation.
Reviewed target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVSKJ0EA-dashboard-no-auto-selection`
- Branch: `impl/00001KVSKJ0EA-dashboard-no-auto-selection`
- Implementation: `5c242d96 fix: keep dashboard row selection explicit`
- Diff: `4cda83b7..5c242d96`
Reviewer evidence:
- 初期表示 / reload path は row selection を自動生成しない。
- `selected_row = None``Esc` 後も reload/snapshot reconciliation を跨いで維持される。
- 明示的な keyboard navigation は no-selection から selection を作成できる。
- 明示的 selection は row が visible な間は維持される。
- 消えた / stale row selection は `None` へ落ちる。
- `list.selected_name` は no-selection / non-Pod selection で clear され、実際の Pod row selection のみで設定される。
- no-selection + `TicketIntake` submit は global/new Intake launch へ進む。
- mouse click は selection-only のまま。
- 差分は Dashboard selection logic と focused tests に限定され、layout / render / workflow semantic の不要変更はない。
Blocking issues:
- なし。
Non-blocking concerns:
- なし。
Reviewer validation:
- `cargo fmt --check`: passed
- `git diff --check 4cda83b7..HEAD`: passed
- `cargo test -q -p tui workspace_panel`: passed (`27 passed`)
- `cargo test -q -p tui dashboard`: passed (`111 passed`)
- `cargo run -q -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `cargo test -q -p tui`: passed (`372 passed`)
Worktree status:
- clean at `5c242d96`
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T07:24:05Z -->
## Implementation report
Merge and validation completed.
Merged implementation:
- Merge commit: `58904c44 merge: dashboard no auto selection`
- Implementation commit: `5c242d96 fix: keep dashboard row selection explicit`
Post-merge validation in orchestration worktree:
- `cargo fmt --check`: passed
- `git diff --check HEAD^1..HEAD`: passed
- `cargo test -q -p tui workspace_panel`: passed (`27 passed`)
- `cargo test -q -p tui dashboard`: passed (`111 passed`)
- `cargo run -q -p yoi -- ticket doctor`: passed (`doctor: ok`)
Outcome:
- 初期表示と reload/snapshot reconciliation が row selection を自動生成しなくなった。
- `Esc` 後の no-selection は reload を跨いで維持される。
- 明示的 keyboard navigation による selection 作成は維持された。
- `TicketIntake` composer の no-selection submit は global/new Intake route になる。
- stale selection は安全に `None` へ落ちる。
- Ticket lifecycle / row action semantics は変更していない。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-23T07:24:10Z from: inprogress to: done reason: merged_and_validated field: state -->
## State changed
実装は reviewer approval 後に orchestration worktree へ merge され、post-merge validation も通過したため done とする。
---
<!-- event: state_changed author: hare at: 2026-06-23T07:24:20Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-23T07:24:20Z status: closed -->
## 完了
Dashboard の row selection を明示的 user action の結果として扱うように修正した。
完了内容:
- 初期表示で visible row があっても自動選択しない。
- reload / snapshot reconciliation で `selected_row = None` を勝手に selection へ戻さない。
- `Esc` による no-selection が reload を跨いで維持される。
- 選択中 row が消えた場合は `None` へ安全に落とす。
- keyboard navigation では no-selection から明示的に selection を作成できる。
- `list.selected_name` は実際の Pod row selection と同期し、no-selection / non-Pod selection では clear する。
- no-selection + `TicketIntake` composer submit が既存 Ticket refinement ではなく global/new Intake route へ進むことを focused test で確認した。
統合:
- Implementation: `5c242d96 fix: keep dashboard row selection explicit`
- Merge: `58904c44 merge: dashboard no auto selection`
検証:
- Reviewer approval: `yoi-reviewer-00001KVSKJ0EA-r1`
- `cargo fmt --check`: passed
- `git diff --check HEAD^1..HEAD`: passed
- `cargo test -q -p tui workspace_panel`: passed (`27 passed`)
- `cargo test -q -p tui dashboard`: passed (`111 passed`)
- reviewer 側追加確認 `cargo test -q -p tui`: passed (`372 passed`)
- `cargo run -q -p yoi -- ticket doctor`: passed (`doctor: ok`)
残作業:
- なし。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260623-132129-1","ticket_id":"00001KVSMJJNV","kind":"accepted_plan","accepted_plan":{"summary":"Ticket 00001KVSMJJNV を implementation worktree `.worktree/00001KVSMJJNV-paused-ctrlx-cancel` / branch `work/00001KVSMJJNV-paused-ctrlx-cancel` で開始する。Paused 中の `Ctrl+X` が shutdown ではなく cancel になり、paused turn を破棄して次入力可能にすることが主目的。","branch":"work/00001KVSMJJNV-paused-ctrlx-cancel","worktree":"/home/hare/Projects/yoi/.worktree/00001KVSMJJNV-paused-ctrlx-cancel","role_plan":"Coder Pod: implementation worktree で TUI key handling / controller paused cancel semantics / focused tests を実装し、ローカル commit まで行う。Orchestrator: output/diff/tests を確認後、Reviewer Pod に recorded IntentPacket に基づく review を依頼する。"},"author":"yoi-orchestrator","at":"2026-06-23T13:21:29Z"}

View File

@ -0,0 +1,105 @@
---
title: 'TUI Console: Paused 中の Ctrl+X で中断ターンをキャンセルできるようにする'
state: 'closed'
created_at: '2026-06-23T07:02:07Z'
updated_at: '2026-06-23T13:56:16Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['tui-keybinding', 'pod-lifecycle']
queued_by: 'workspace-panel'
queued_at: '2026-06-23T08:41:00Z'
---
## User claims / request snapshot
- TUI Console で、`Ctrl+C` により Pause している状態のとき、`Ctrl+X` でキャンセルできるようにしたい。
## Confirmed facts / sources
- 既存 Ticket:
- 未完了 Ticket に直接 duplicate は確認できなかった。
- `00001KSVP63K8 Support immediate in-flight TUI composer injection` は TUI 実行中入力に関連するが、「Do not interrupt/cancel the current run」が non-goal なので今回の要求とは別件。
- `crates/tui/src/console/mod.rs`
- 現在の `Ctrl+X``PodStatus::Running` のときだけ `Method::Cancel` を返す。
- `PodStatus::Paused | PodStatus::Idle` のときは `Method::Shutdown` を返している。
- `Ctrl+C` は Running では `Method::Pause`、Idle/Paused では TUI 終了の 2-tap guard として扱われている。
- 既存 test `pause_and_cancel_clear_queued_input` は名前上 pause/cancel を扱うが、`Ctrl+C` 後に app status を Paused に更新していないため、Paused 状態での `Ctrl+X` cancel は実質カバーしていない。
- `crates/protocol/src/lib.rs`
- `Method::Pause` は in-flight turn を止めて Paused に遷移するもの。
- コメント上、`Cancel` は破棄して Idle に戻る意味を持ち、Paused は `Resume` または新規 `Run` が可能。
- `crates/pod/src/controller.rs`
- 非 running 側の `Method::Cancel` は現状 `NotRunning` error を返す経路があるため、TUI keymap 変更だけでなく Paused 状態の cancel semantics を確認・必要なら実装する必要がある。
## Unverified hypotheses
- 実装は比較的小さい可能性が高いが、`Paused` 状態で `Method::Cancel` を受け取った時に、Worker/Pod の interrupted state や orphan tool-use cleanup をどう clear するべきかは実装時に確認が必要。
- UI 表示は paused hint に `Ctrl+X to cancel` 相当を追加するだけで足りる可能性がある。
## Undecided points / open questions
- blocking な未決定点はなし。
- Orchestrator/実装者判断で、`Ctrl+X` の Idle 挙動を現状通り `Shutdown` に残すか、Paused のみ `Cancel` に分岐するのがよさそう。
## Background
`Ctrl+C` で Pause した turn は resume 可能だが、ユーザーが「やっぱりこの中断 turn は破棄したい」と判断した時に、Console 上の自然な操作として `Ctrl+X` cancel が効くべき。現在は Paused/Idle の `Ctrl+X` が shutdown に割り当てられており、Paused turn のキャンセル導線として機能していない。
## Requirements
- TUI Console で Pod が `Paused` のとき、`Ctrl+X` は shutdown ではなく paused/interrupted turn の cancel を要求する。
- `Running` 中の `Ctrl+X -> Method::Cancel` は維持する。
- `Idle` 中の `Ctrl+X` の挙動は、意図的に変更する理由がなければ現状の shutdown を維持する。
- Paused 状態で cancel した後、Pod は resume 可能な paused turn を残さず、Idle 相当として次の入力を受けられる。
- queued input がある場合は、既存 Running cancel と同様に誤送信されないよう clear される。
- UI の paused hint / actionbar / tests など、ユーザーが操作を理解できる範囲を必要に応じて更新する。
## Acceptance criteria
- `Ctrl+C` で Running turn を Pause した後、Console 上で `Ctrl+X` を押すと、TUI 終了や Pod shutdown ではなく turn cancel が実行される。
- Paused cancel 後、`Enter` で前の turn を resume しようとしても resume されない。
- Paused cancel 後、新規入力は通常の新規 turn として扱われる。
- Running 中の `Ctrl+X` cancel と Idle 中の `Ctrl+X` 挙動に意図しない regressions がない。
- Focused tests が Paused 状態の `Ctrl+X` key handling と Pod/controller 側の cancel semantics をカバーする。
## Binding decisions / invariants
- `Ctrl+C` の Pause semantics は変更しない。
- `Ctrl+C` の Idle/Paused 2-tap TUI quit guard は、この Ticket では置き換えない。
- この Ticket は in-flight input injection / history append policy を扱わない。
- provider stream を直接 mutate しない。
- cancel は hidden context/history mutation ではなく、既存の typed `Method::Cancel` semantics またはそれに準じる明示的 lifecycle 操作で表現する。
## Implementation latitude
- `Paused` 中の `Ctrl+X``Method::Cancel` に変えるだけで足りるか、controller/worker 側に Paused cancel handling を追加するかは実装調査で判断してよい。
- UI hint の文言や配置は実装者に任せてよい。
- 必要なら既存 test を修正・分割して、「Running cancel」と「Paused cancel」を明確に分ける。
## Readiness
- readiness: implementation_ready
- risk_flags: [tui-keybinding, pod-lifecycle]
## Escalation conditions
- `Method::Cancel` を Paused 状態に拡張すると session/history cleanup や orphan tool-result closure の設計判断が必要になる場合は、実装者は Orchestrator/maintainer に戻す。
- `Ctrl+X` の Idle 挙動変更が必要になる場合は、別途合意を取る。
## Validation
- Focused tests:
- `cargo test -p tui pause` または該当 TUI key handling tests
- `cargo test -p pod cancel` または controller paused cancel の focused tests
- 通常確認:
- `cargo fmt --check`
- 必要に応じて `cargo check -q`
- terminal UX 変更なので、可能なら実機 TUI/manual check も推奨。
## Related work
- Related, not duplicate: `.yoi/tickets/00001KSVP63K8`
- Files:
- `crates/tui/src/console/mod.rs`
- `crates/tui/src/ui.rs`
- `crates/protocol/src/lib.rs`
- `crates/pod/src/controller.rs`

View File

@ -0,0 +1,28 @@
Ticket 00001KVSMJJNV は完了。
実装内容:
- TUI Console の `Paused` 状態で `Ctrl+X` を TUI/Pod shutdown ではなく `Method::Cancel` として扱うようにした。
- `Idle``Ctrl+X` shutdown behavior と、`Running` の cancel behavior は維持した。
- paused cancel 後に resume 可能な interrupted state が残らず、次の入力が fresh run になるよう controller / Pod lifecycle を更新した。
- paused cancel を normal `RunCompleted { Finished }` として永続化せず、typed session log entry `LogEntry::PausedTurnAbandoned { ts }` として記録するようにした。
- replay / restore で `PausedTurnAbandoned``last_run_interrupted` を clear するようにした。
- focused tests を追加・更新し、TUI key handling、controller behavior、session log JSON/replay semantics、fake normal-finished run が残らないことを確認した。
主要 commits:
- `90b1a1fc tui: cancel paused turns with ctrl-x`
- `8c8fb014 fix: log paused cancel lifecycle explicitly`
- `76c80054 merge: 00001KVSMJJNV paused ctrl-x cancel`
Review:
- 初回 review は lifecycle log semantics の不一致で request_changes。
- `PausedTurnAbandoned` による typed lifecycle representation を追加後、Reviewer が approve。
Validation:
- `cargo test -p session-store paused_turn_abandoned -- --nocapture`
- `cargo test -p pod paused_cancel_abandons_resume_and_next_input_is_fresh_run -- --nocapture`
- `cargo test -p tui ctrl_x -- --nocapture`
- `cargo fmt --check`
- `git diff --check HEAD~1..HEAD`
Merge note:
- orchestration branch で実装・Ticket 記録・close resolution をまとめ、merge target へ fast-forward 予定。

View File

@ -0,0 +1,318 @@
<!-- event: create author: ticket-intake at: 2026-06-23T07:02:07Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-23T08:41:00Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-23T12:42:28Z -->
## Decision
Routing decision: blocked_by_dependency_or_missing_authority
Reason:
- Ticket 自体は `implementation_ready` と判断できる。目的、binding decisions / invariants、acceptance criteria、validation、escalation conditions は揃っており、`tui-keybinding` / `pod-lifecycle` risk は reviewer focus として扱える。
- ただし routing 時点の merge target `/home/hare/Projects/yoi` に、この Ticket の対象ではない `crates/tui/src/dashboard/mod.rs``crates/tui/src/dashboard/tests.rs` の未コミット変更がある。workflow の dirty-state gate に従い、この状態では implementation worktree 作成 / Coder Pod 起動 / merge へ進めない。
- 既存 worktree entry `.worktree/00001KVSMJJNV-paused-ctrlx-cancel``git worktree list` 上で prunable と表示され、gitdir が存在しないため、再利用には cleanup/recreate 判断が必要。
Evidence checked:
- Ticket body / thread / artifacts: TicketShow で body、queued event、artifacts なしを確認。
- Ticket relations: TicketRelationQuery で blocker relation なし。
- Orchestration plan: TicketOrchestrationPlanQuery で accepted-plan / conflict / waiting record なし。
- Queue state: queued Ticket はこの 1 件のみ。ready / inprogress は 0 件。
- Workspace/worktree/Pod state: `/home/hare/Projects/yoi``develop...origin/develop [ahead 48]` かつ dashboard files に dirty change あり。`.worktree/orchestration` は clean。visible Pods は peer `yoi` idle と self `yoi-orchestrator` running、spawned child なし。
Next action:
- merge target の未コミット dashboard 変更を所有者が commit / revert / 明示的に退避許可した後、この Ticket を再 routing する。
- その時点で blocker がなければ `queued -> inprogress` を記録してから、worktree を clean に recreate し、Coder/Reviewer loop に渡す。
Escalate if:
- dashboard 変更がこの Ticket と同時に保持すべき作業なら、別 Ticket/branch として扱うか、今回の implementation worktree/merge 計画に含めるかを人間が決める。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-23T13:21:02Z -->
## Decision
Routing correction:
直前の `blocked_by_dependency_or_missing_authority` 判断は取り下げる。merge target `/home/hare/Projects/yoi` の dashboard files dirty は最終 merge 時の確認事項だが、この Ticket の implementation acceptance / implementation worktree 作成を止める gate として扱うのは過剰だった。
Corrected routing decision: implementation_ready
Evidence checked:
- Ticket body / thread: 目的、requirements、acceptance criteria、binding decisions / invariants、implementation latitude、escalation conditions、validation が揃っている。
- TicketRelationQuery: blocker relation なし。
- TicketOrchestrationPlanQuery: accepted-plan / conflict / blocker / waiting-capacity record なし。
- Queue/capacity: queued Ticket はこの 1 件。visible spawned child はなし。
- Worktree state: orchestration worktree は Ticket 記録変更のみ。既存 `.worktree/00001KVSMJJNV-paused-ctrlx-cancel` は prunable なので cleanup/recreate 対象。
Next action:
- `queued -> inprogress` を記録する。
- stale/prunable implementation worktree entry を掃除して、`.worktree/00001KVSMJJNV-paused-ctrlx-cancel` を作り直す。
- Coder Pod に IntentPacket を渡して実装を開始する。
Merge note:
- merge target 側の unrelated dashboard dirty は final merge 前に再確認し、必要なら人間へ確認する。implementation worktree 内の作業開始 blocker にはしない。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-23T13:21:07Z from: queued to: inprogress reason: orchestrator_acceptance field: state -->
## State changed
Orchestrator acceptance: Ticket evidence、relation blockers、orchestration plan、worktree/capacity 状態を再確認し、implementation_ready と判断した。implementation side effect 前に queued -> inprogress を記録する。
Acceptance basis:
- blocker relation なし。
- orchestration plan blocker/conflict なし。
- queued Ticket はこの 1 件で child capacity あり。
- risk flags は `tui-keybinding` / `pod-lifecycle` として IntentPacket の reviewer focus に反映する。
Next:
- implementation worktree を clean に作成し、Coder Pod に routing する。
---
<!-- event: plan author: yoi-orchestrator at: 2026-06-23T13:21:47Z -->
## Plan
IntentPacket for implementation
Intent:
- TUI Console で Pod が `Paused` のとき、`Ctrl+X` を TUI shutdown ではなく paused/interrupted turn の cancel として扱えるようにする。
- Paused cancel 後は resume 可能な paused turn を残さず、次の新規入力を通常の新規 turn として受けられる状態にする。
Binding decisions / invariants:
- `Ctrl+C` の Running -> Pause semantics は変更しない。
- `Ctrl+C` の Idle/Paused 2-tap TUI quit guard はこの Ticket では置き換えない。
- `Running` 中の `Ctrl+X -> Method::Cancel` は維持する。
- `Idle` 中の `Ctrl+X` は、必要な明示理由がなければ現状の shutdown を維持する。
- in-flight input injection / history append policy は扱わない。
- provider stream を直接 mutate しない。
- cancel は hidden context/history mutation ではなく、既存の typed `Method::Cancel` semantics またはそれに準じる明示的 lifecycle 操作で表現する。
Requirements / acceptance criteria:
- `Ctrl+C` で Running turn を Pause した後、Console 上で `Ctrl+X` を押すと TUI exit / Pod shutdown ではなく turn cancel が実行される。
- Paused cancel 後、`Enter` で前の turn が resume されない。
- Paused cancel 後、新規入力は通常の新規 turn として扱われる。
- Running cancel と Idle `Ctrl+X` に意図しない regression がない。
- focused tests が Paused 状態の `Ctrl+X` key handling と Pod/controller 側の cancel semantics をカバーする。
Implementation latitude:
- `Paused` 中の `Ctrl+X``Method::Cancel` に変えるだけで足りるか、controller/worker 側に Paused cancel handling を追加するかは実装調査で判断してよい。
- UI hint の文言や配置は実装者に任せてよい。
- 既存 test の修正・分割は許容する。
Escalate if:
- `Method::Cancel` を Paused 状態に拡張すると session/history cleanup や orphan tool-result closure の新しい設計判断が必要になる。
- `Ctrl+X` の Idle 挙動変更が必要になる。
- provider stream / history mutation / lifecycle authority の境界を変える必要が出る。
Validation:
- focused: `cargo test -p tui pause` または該当 TUI key handling tests。
- focused: `cargo test -p pod cancel` または controller paused cancel tests。
- `cargo fmt --check`
- 必要に応じて `cargo check -q`
Current code map:
- 主対象: `crates/tui/src/console/mod.rs`
- 関連: `crates/tui/src/ui.rs`, `crates/protocol/src/lib.rs`, `crates/pod/src/controller.rs`
- 既存事実: `Ctrl+X` は Running のみ `Method::Cancel`、Paused/Idle は shutdown 側。controller の非-running Cancel は `NotRunning` 経路がある可能性。
Critical risks / reviewer focus:
- Paused と Idle の key handling を混同して shutdown behavior を壊していないか。
- Running cancel の既存 behavior / queued input clear に regression がないか。
- Paused cancel 後に resume 可能な interrupted state が残らないか。
- Cancel semantics が hidden history/context mutation になっていないか。
- tests が実際に app status を Paused に更新した状態をカバーしているか。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T13:37:51Z -->
## Implementation report
Implementation report
Worktree / branch:
- `/home/hare/Projects/yoi/.worktree/00001KVSMJJNV-paused-ctrlx-cancel`
- `work/00001KVSMJJNV-paused-ctrlx-cancel`
Commit:
- `90b1a1fc tui: cancel paused turns with ctrl-x`
Implemented changes:
- `crates/tui/src/console/mod.rs`: `Ctrl+X` key handling を Paused 状態でも cancel path に乗せ、Idle の shutdown behavior と分離。
- `crates/tui/src/ui.rs`: Paused UI hint を cancel 操作に合わせて更新。
- `crates/pod/src/controller.rs` / `crates/pod/src/pod.rs`: Paused 状態の `Method::Cancel``NotRunning` ではなく paused turn cancel として処理し、resume 可能な interrupted state を破棄して次入力を fresh run として扱う経路を追加。
- `crates/pod/tests/controller_test.rs`: Paused cancel 後に resume されず、次入力が fresh run になることを確認する focused test を追加。
Validation run by Orchestrator:
- `cargo test -p tui ctrl_x -- --nocapture`
- `cargo test -p pod paused_cancel_abandons_resume_and_next_input_is_fresh_run -- --nocapture`
- `git diff --check`
- `cargo fmt --check`
Next:
- Reviewer Pod に recorded IntentPacket と commit diff を基準に review を依頼する。
---
<!-- event: review author: reviewer-00001KVSMJJNV-ctrlx-cancel at: 2026-06-23T13:42:48Z status: request_changes -->
## Review: request changes
Review result: request_changes
Findings:
- `crates/pod/src/pod.rs:1871-1884` は paused cancel cleanup を `LogEntry::RunCompleted { result: WorkerResult::Finished, interrupted: false }` として記録している。しかしここでは `run()` / `resume()` が完了したわけではなく、paused turn を明示的に abandon している。`crates/session-store/src/segment_log.rs:31` および `:100-106` の session log semantics では、`RunCompleted` は `run()` / `resume()` call の終了を表し、`Finished` は通常の Worker completion を意味する。現在の immediate replay は `interrupted` を使うため focused behavior は通るが、durable session/audit stream が cancel 後に normal `Finished` run を記録してしまう。
- Ticket の invariant は cancel を explicit typed lifecycle semantics で表現することなので、typed paused-cancel / abandoned-turn lifecycle record を追加するか、log schema / consumers / tests を更新して durable history が normal `Finished` run を主張しないようにする必要がある。
Validation performed:
- `cargo test -p tui ctrl_x -- --nocapture` passed
- `cargo test -p pod paused_cancel_abandons_resume_and_next_input_is_fresh_run -- --nocapture` passed
- `git diff --check 5954021c..90b1a1fc` passed
- `cargo fmt --check` passed
- `git status --short` clean
Rationale:
- TUI key handling separation は正しい。`Paused` の `Ctrl+X``Method::Cancel`、`Idle` は `Shutdown` のまま、`Ctrl+C` quit/pause behavior も変更されていない。
- controller test は Pod を実際に `Paused` にし、cancel 後の `Resume` rejection と次入力の fresh `Run` を確認している。
- blocker は persisted lifecycle representation。functional state reset は実装されているが、session log が abandoned paused turn を normal finished run として記録している点が durable semantics と不一致。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T13:49:36Z -->
## Implementation report
Implementation report update after review request
Reviewer-requested change addressed in implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVSMJJNV-paused-ctrlx-cancel`
- branch `work/00001KVSMJJNV-paused-ctrlx-cancel`
New commit:
- `8c8fb014 fix: log paused cancel lifecycle explicitly`
Change summary:
- `crates/session-store/src/segment_log.rs`: typed `LogEntry::PausedTurnAbandoned { ts }` を追加し、replay / `collect_state``last_run_interrupted` を clear するようにした。JSON round-trip と replay semantics の tests を追加。
- `crates/pod/src/pod.rs`: paused cancel cleanup で fake `RunCompleted { result: Finished }` を記録せず、`PausedTurnAbandoned` を記録するように変更。
- `crates/pod/src/segment_log_sink.rs`: lifecycle コメントを更新。
- `crates/pod/tests/controller_test.rs`: paused cancel が `PausedTurnAbandoned` を記録し、normal finished run としては記録されないことを確認する assertion を追加。
Validation run by Orchestrator:
- `cargo test -p session-store paused_turn_abandoned -- --nocapture`
- `cargo test -p pod paused_cancel_abandons_resume_and_next_input_is_fresh_run -- --nocapture`
- `cargo test -p tui ctrl_x -- --nocapture`
- `git diff --check 5954021c..HEAD`
Next:
- Reviewer に再 review を依頼する。
---
<!-- event: review author: reviewer-00001KVSMJJNV-ctrlx-cancel at: 2026-06-23T13:53:16Z status: approve -->
## Review: approve
Review result: approve
Findings:
- なし。
Validation performed by Reviewer:
- `cargo test -p session-store paused_turn_abandoned -- --nocapture` — passed
- `cargo test -p pod paused_cancel_abandons_resume_and_next_input_is_fresh_run -- --nocapture` — passed
- `cargo test -p tui ctrl_x -- --nocapture` — passed
- `git diff --check 5954021c..HEAD` — passed
- `cargo fmt --check` — passed
- `git status --short` — clean
Rationale:
- prior blocker は解消済み。`Pod::cancel_paused_turn()` は fake `RunCompleted { Finished, interrupted: false }` ではなく `LogEntry::PausedTurnAbandoned` を記録する。
- typed lifecycle entry は session log schema / replay に含まれ、`last_run_interrupted` を clear するため restore 後も resume 不可になる。
- tests は JSON kind `paused_turn_abandoned`、replay semantics、explicit lifecycle log、fake normal-finished run が残らないことを確認している。
- TUI acceptance criteria も満たしている。Paused `Ctrl+X``Method::Cancel`、Idle `Ctrl+X` は shutdown のまま、Running cancel は queued input clear を含む既存 behavior を維持している。
- controller test は Paused 到達、cancel 後 Idle、resume rejection、cancel/resume で追加 LLM request が発生しないこと、次入力が fresh run になることを確認している。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-23T13:56:05Z from: inprogress to: done reason: implementation_review_validation_passed field: state -->
## State changed
Implementation, review, and post-merge validation completed in orchestration worktree.
Evidence:
- Implementation commits merged into orchestration: `90b1a1fc`, `8c8fb014`, merge commit `76c80054`.
- Reviewer approved after requested lifecycle-log fix.
- Validation on merged orchestration branch passed:
- `cargo test -p session-store paused_turn_abandoned -- --nocapture`
- `cargo test -p pod paused_cancel_abandons_resume_and_next_input_is_fresh_run -- --nocapture`
- `cargo test -p tui ctrl_x -- --nocapture`
- `cargo fmt --check`
- `git diff --check HEAD~1..HEAD`
Next:
- Close Ticket with resolution, then fast-forward merge target if safe.
---
<!-- event: state_changed author: hare at: 2026-06-23T13:56:16Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-23T13:56:16Z status: closed -->
## 完了
Ticket 00001KVSMJJNV は完了。
実装内容:
- TUI Console の `Paused` 状態で `Ctrl+X` を TUI/Pod shutdown ではなく `Method::Cancel` として扱うようにした。
- `Idle``Ctrl+X` shutdown behavior と、`Running` の cancel behavior は維持した。
- paused cancel 後に resume 可能な interrupted state が残らず、次の入力が fresh run になるよう controller / Pod lifecycle を更新した。
- paused cancel を normal `RunCompleted { Finished }` として永続化せず、typed session log entry `LogEntry::PausedTurnAbandoned { ts }` として記録するようにした。
- replay / restore で `PausedTurnAbandoned``last_run_interrupted` を clear するようにした。
- focused tests を追加・更新し、TUI key handling、controller behavior、session log JSON/replay semantics、fake normal-finished run が残らないことを確認した。
主要 commits:
- `90b1a1fc tui: cancel paused turns with ctrl-x`
- `8c8fb014 fix: log paused cancel lifecycle explicitly`
- `76c80054 merge: 00001KVSMJJNV paused ctrl-x cancel`
Review:
- 初回 review は lifecycle log semantics の不一致で request_changes。
- `PausedTurnAbandoned` による typed lifecycle representation を追加後、Reviewer が approve。
Validation:
- `cargo test -p session-store paused_turn_abandoned -- --nocapture`
- `cargo test -p pod paused_cancel_abandons_resume_and_next_input_is_fresh_run -- --nocapture`
- `cargo test -p tui ctrl_x -- --nocapture`
- `cargo fmt --check`
- `git diff --check HEAD~1..HEAD`
Merge note:
- orchestration branch で実装・Ticket 記録・close resolution をまとめ、merge target へ fast-forward 予定。
---

3
.yoi/workspace.toml Normal file
View File

@ -0,0 +1,3 @@
workspace_id = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c5b6"
created_at = "2026-06-23T00:00:00Z"
display_name = "yoi"

View File

@ -31,6 +31,7 @@ Podの状態から純粋に再現可能で、且つ揮発性の無い操作で
明示的に指示されない限り、読み取り以外の操作は控えること。 明示的に指示されない限り、読み取り以外の操作は控えること。
基本はworktree上の一時的なブランチでコミットを重ね、メインブランチに取り込む運用をしている。 基本はworktree上の一時的なブランチでコミットを重ね、メインブランチに取り込む運用をしている。
Orchestrator の cwd が orchestration 用ブランチ/worktree の場合、通常作業では親ブランチの dirty state を気にしない。
コミットメッセージは適当に`<prefix>: *簡潔な1行*`で書いている。 コミットメッセージは適当に`<prefix>: *簡潔な1行*`で書いている。
外部の参考プロジェクトは必要に応じてローカルの外部 checkout からReadすること。 外部の参考プロジェクトは必要に応じてローカルの外部 checkout からReadすること。

26
Cargo.lock generated
View File

@ -3035,6 +3035,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"ts-rs",
"uuid", "uuid",
] ]
@ -4696,6 +4697,28 @@ dependencies = [
"toml", "toml",
] ]
[[package]]
name = "ts-rs"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
dependencies = [
"thiserror 2.0.18",
"ts-rs-macros",
]
[[package]]
name = "ts-rs-macros"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"termcolor",
]
[[package]] [[package]]
name = "ttf-parser" name = "ttf-parser"
version = "0.25.1" version = "0.25.1"
@ -6027,6 +6050,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"chrono",
"manifest", "manifest",
"pod-store", "pod-store",
"project-record", "project-record",
@ -6038,8 +6062,10 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"ticket", "ticket",
"tokio", "tokio",
"toml",
"tower", "tower",
"tracing", "tracing",
"uuid",
] ]
[[package]] [[package]]

View File

@ -547,9 +547,6 @@ fn append_operation_targets(out: &mut String, context: &TicketRoleLaunchContext)
if context.role != TicketRole::Orchestrator { if context.role != TicketRole::Orchestrator {
return; return;
} }
if context.original_workspace_root.is_none() && context.target_workspace_root.is_none() {
return;
}
out.push_str("\nOrchestrator operation targets:\n"); out.push_str("\nOrchestrator operation targets:\n");
push_bounded_bullet( push_bounded_bullet(
@ -557,13 +554,6 @@ fn append_operation_targets(out: &mut String, context: &TicketRoleLaunchContext)
"implementation_worktree_root", "implementation_worktree_root",
&context.implementation_worktree_root().display().to_string(), &context.implementation_worktree_root().display().to_string(),
); );
if context.target_workspace_root.is_some() {
push_bounded_bullet(
out,
"merge_target_workspace_root",
&context.target_workspace_root().display().to_string(),
);
}
} }
fn default_pod_name(role: TicketRole, ticket: Option<&TicketRef>) -> String { fn default_pod_name(role: TicketRole, ticket: Option<&TicketRef>) -> String {
@ -706,6 +696,7 @@ mod tests {
context_tokens: 0, context_tokens: 0,
}, },
status: PodStatus::Idle, status: PodStatus::Idle,
in_flight: protocol::InFlightSnapshot::default(),
} }
} }
@ -1152,7 +1143,7 @@ workflow = "ticket-review-workflow"
assert!(text.contains("Orchestrator operation targets:")); assert!(text.contains("Orchestrator operation targets:"));
assert!(text.contains("implementation_worktree_root")); assert!(text.contains("implementation_worktree_root"));
assert!(text.contains("merge_target_workspace_root")); assert!(!text.contains("merge_target_workspace_root"));
assert!(!text.contains("Workspace routing context:")); assert!(!text.contains("Workspace routing context:"));
assert!(!text.contains("role_workspace_root")); assert!(!text.contains("role_workspace_root"));
assert!(!text.contains("role_cwd")); assert!(!text.contains("role_cwd"));

View File

@ -4,12 +4,17 @@ use std::fs::{DirBuilder, File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write}; use std::io::{self, Read, Seek, SeekFrom, Write};
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, Instant};
use fs4::fs_std::FileExt; use fs4::fs_std::FileExt;
use manifest::{ScopeRule, paths}; use manifest::{ScopeRule, paths};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use session_store::SegmentId; use session_store::SegmentId;
const LOCK_WAIT_TIMEOUT: Duration = Duration::from_secs(10);
const LOCK_WAIT_POLL_INTERVAL: Duration = Duration::from_millis(25);
/// On-disk representation of the allocation table. /// On-disk representation of the allocation table.
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LockFile { pub struct LockFile {
@ -119,7 +124,37 @@ impl LockFileGuard {
.truncate(false) .truncate(false)
.mode(0o600) .mode(0o600)
.open(path)?; .open(path)?;
FileExt::lock_exclusive(&file)?; let started = Instant::now();
loop {
match FileExt::try_lock_exclusive(&file) {
Ok(true) => break,
Ok(false) => {
if started.elapsed() >= LOCK_WAIT_TIMEOUT {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!(
"timed out waiting for pod registry lock `{}`",
path.display()
),
));
}
thread::sleep(LOCK_WAIT_POLL_INTERVAL);
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
if started.elapsed() >= LOCK_WAIT_TIMEOUT {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!(
"timed out waiting for pod registry lock `{}`",
path.display()
),
));
}
thread::sleep(LOCK_WAIT_POLL_INTERVAL);
}
Err(error) => return Err(error),
}
}
let mut this = Self { let mut this = Self {
file, file,
data: LockFile::default(), data: LockFile::default(),

View File

@ -900,12 +900,36 @@ async fn controller_loop<C, St>(
pending = Some(PendingRun::Resume); pending = Some(PendingRun::Resume);
} }
Method::Cancel => { Method::Cancel => match shared_state.get_status() {
let _ = event_tx.send(Event::Error { PodStatus::Paused => match pod.cancel_paused_turn() {
code: ErrorCode::NotRunning, Ok(()) => {
message: "Pod is not running".into(), set_controller_status(
}); &shared_state,
} &runtime_dir,
&event_tx,
PodStatus::Idle,
)
.await;
}
Err(error) => {
let _ = event_tx.send(Event::Error {
code: worker_error_code(&error),
message: error.to_string(),
});
}
},
PodStatus::Idle => {
let _ = event_tx.send(Event::Error {
code: ErrorCode::NotRunning,
message: "Pod is not running".into(),
});
}
PodStatus::Running => {
// Running turns receive Cancel through drive_turn; this is
// only reachable across a defensive race window.
let _ = cancel_tx.try_send(());
}
},
Method::Pause => { Method::Pause => {
// Already paused → idempotent no-op. Otherwise the // Already paused → idempotent no-op. Otherwise the

View File

@ -1863,6 +1863,26 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
Ok(()) Ok(())
} }
/// Abandon a paused/interrupted turn without resuming it.
///
/// This uses the same explicit interrupt preparation as the next fresh
/// `run` would have used, then clears the worker's interrupted marker so
/// future input is treated as a normal new turn instead of a resume.
/// The explicit `PausedTurnAbandoned` marker preserves durable lifecycle
/// semantics without claiming another `run` / `resume` completed.
pub fn cancel_paused_turn(&mut self) -> Result<(), PodError> {
if !self.worker().last_run_interrupted() {
return Ok(());
}
self.apply_interrupt_prep()?;
self.worker_mut().set_last_run_interrupted(false);
self.commit_entry(LogEntry::PausedTurnAbandoned {
ts: segment_log::now_millis(),
})?;
Ok(())
}
/// Validate explicit workflow invocations without reading dependency /// Validate explicit workflow invocations without reading dependency
/// bodies. Called from `Pod::run` entry so an invalid slug aborts /// bodies. Called from `Pod::run` entry so an invalid slug aborts
/// the turn before any session-log commit or interrupt-prep side /// the turn before any session-log commit or interrupt-prep side

View File

@ -94,8 +94,8 @@ impl SegmentLogSink {
/// - `LogEntry::SystemItem` → `Event::SystemItem`. /// - `LogEntry::SystemItem` → `Event::SystemItem`.
/// - `LogEntry::Invoke` → `Event::InvokeStart`. /// - `LogEntry::Invoke` → `Event::InvokeStart`.
/// Everything else (AssistantItem, ToolResult, TurnEnd, /// Everything else (AssistantItem, ToolResult, TurnEnd,
/// RunCompleted, RunErrored, LlmUsage, Extension, ConfigChanged) is /// RunCompleted, RunErrored, PausedTurnAbandoned, LlmUsage, Extension,
/// reflected in the mirror so reconnect snapshots stay accurate, /// ConfigChanged) is reflected in the mirror so reconnect snapshots stay accurate,
/// but is not sent live — the streaming events (TextDelta / /// but is not sent live — the streaming events (TextDelta /
/// ToolCallStart / ToolResult / TurnEnd / etc.) already provide /// ToolCallStart / ToolResult / TurnEnd / etc.) already provide
/// that data, and re-broadcasting it as a typed entry would just /// that data, and re-broadcasting it as a typed entry would just

View File

@ -35,10 +35,12 @@ type RegistryStateWriter = Arc<dyn Fn(&[SpawnedPodRecord]) -> io::Result<()> + S
type RegistryReclaimWriter = Arc<dyn Fn(&SpawnedPodRecord) -> io::Result<()> + Send + Sync>; type RegistryReclaimWriter = Arc<dyn Fn(&SpawnedPodRecord) -> io::Result<()> + Send + Sync>;
const RESTORE_REACHABILITY_TIMEOUT: Duration = Duration::from_millis(500); const RESTORE_REACHABILITY_TIMEOUT: Duration = Duration::from_millis(500);
const REGISTRY_CLEANUP_TIMEOUT: Duration = Duration::from_secs(15);
pub struct SpawnedPodRegistry { pub struct SpawnedPodRegistry {
records: Mutex<Vec<SpawnedPodRecord>>, records: Mutex<Vec<SpawnedPodRecord>>,
cursors: Mutex<HashMap<String, usize>>, cursors: Mutex<HashMap<String, usize>>,
mutations: Mutex<()>,
runtime_dir: Arc<RuntimeDir>, runtime_dir: Arc<RuntimeDir>,
state_writer: Option<RegistryStateWriter>, state_writer: Option<RegistryStateWriter>,
reclaim_writer: Option<RegistryReclaimWriter>, reclaim_writer: Option<RegistryReclaimWriter>,
@ -56,6 +58,7 @@ impl SpawnedPodRegistry {
Arc::new(Self { Arc::new(Self {
records: Mutex::new(Vec::new()), records: Mutex::new(Vec::new()),
cursors: Mutex::new(HashMap::new()), cursors: Mutex::new(HashMap::new()),
mutations: Mutex::new(()),
runtime_dir, runtime_dir,
state_writer: None, state_writer: None,
reclaim_writer: None, reclaim_writer: None,
@ -164,6 +167,7 @@ impl SpawnedPodRegistry {
registry: Arc::new(Self { registry: Arc::new(Self {
records: Mutex::new(records), records: Mutex::new(records),
cursors: Mutex::new(HashMap::new()), cursors: Mutex::new(HashMap::new()),
mutations: Mutex::new(()),
runtime_dir, runtime_dir,
state_writer: Some(state_writer), state_writer: Some(state_writer),
reclaim_writer: Some(reclaim_writer), reclaim_writer: Some(reclaim_writer),
@ -178,9 +182,13 @@ impl SpawnedPodRegistry {
/// error if either persisted write fails; the in-memory state is still /// error if either persisted write fails; the in-memory state is still
/// updated in that case — the next successful write will reconcile. /// updated in that case — the next successful write will reconcile.
pub async fn add(&self, record: SpawnedPodRecord) -> io::Result<()> { pub async fn add(&self, record: SpawnedPodRecord) -> io::Result<()> {
let mut records = self.records.lock().await; let _mutation = self.mutations.lock().await;
records.push(record); let snapshot = {
self.persist_records(records.as_slice()).await let mut records = self.records.lock().await;
records.push(record);
records.clone()
};
self.persist_records(&snapshot).await
} }
/// Look up a record by pod name. Cloned so callers can drop the lock. /// Look up a record by pod name. Cloned so callers can drop the lock.
@ -201,29 +209,39 @@ impl SpawnedPodRegistry {
/// reclaim any delegated Write scope owned by that child. Returns the /// reclaim any delegated Write scope owned by that child. Returns the
/// removed record (if any). /// removed record (if any).
pub async fn remove(&self, pod_name: &str) -> io::Result<Option<SpawnedPodRecord>> { pub async fn remove(&self, pod_name: &str) -> io::Result<Option<SpawnedPodRecord>> {
let removed = { let _mutation = self.mutations.lock().await;
let (removed, snapshot) = {
let mut records = self.records.lock().await; let mut records = self.records.lock().await;
let idx = records.iter().position(|r| r.pod_name == pod_name); let idx = records.iter().position(|r| r.pod_name == pod_name);
let removed = idx.map(|i| records.remove(i)); let removed = idx.map(|i| records.remove(i));
self.persist_records(records.as_slice()).await?; let snapshot = records.clone();
removed (removed, snapshot)
}; };
self.persist_records(&snapshot).await?;
self.cursors.lock().await.remove(pod_name); self.cursors.lock().await.remove(pod_name);
if let Some(record) = &removed { if let Some(record) = &removed {
self.reclaim_record(record)?; self.reclaim_removed_record(record.clone()).await?;
if let Some(write_reclaim) = &self.reclaim_writer {
write_reclaim(record)?;
}
} }
Ok(removed) Ok(removed)
} }
fn reclaim_record(&self, record: &SpawnedPodRecord) -> io::Result<()> { async fn reclaim_removed_record(&self, record: SpawnedPodRecord) -> io::Result<()> {
let Some(parent_name) = &self.parent_name else { let parent_name = self.parent_name.clone();
release_child_allocation(&record.pod_name)?; let parent_scope = self.parent_scope.clone();
return Ok(()); let reclaim_writer = self.reclaim_writer.clone();
}; let pod_name = record.pod_name.clone();
reclaim_record(parent_name, self.parent_scope.as_ref(), record) let reclaim = tokio::task::spawn_blocking(move || {
reclaim_removed_record_blocking(parent_name, parent_scope, reclaim_writer, record)
});
tokio::time::timeout(REGISTRY_CLEANUP_TIMEOUT, reclaim)
.await
.map_err(|_| {
io::Error::new(
io::ErrorKind::TimedOut,
format!("timed out reclaiming spawned pod `{pod_name}`"),
)
})?
.map_err(|err| io::Error::other(format!("spawned-pod reclaim task failed: {err}")))?
} }
/// Read-only cursor lookup. Returns 0 when no cursor has been set. /// Read-only cursor lookup. Returns 0 when no cursor has been set.
@ -288,6 +306,23 @@ where
}) })
} }
fn reclaim_removed_record_blocking(
parent_name: Option<String>,
parent_scope: Option<SharedScope>,
reclaim_writer: Option<RegistryReclaimWriter>,
record: SpawnedPodRecord,
) -> io::Result<()> {
if let Some(parent_name) = parent_name {
reclaim_record(&parent_name, parent_scope.as_ref(), &record)?;
} else {
release_child_allocation(&record.pod_name)?;
}
if let Some(write_reclaim) = reclaim_writer {
write_reclaim(&record)?;
}
Ok(())
}
fn reclaim_record( fn reclaim_record(
parent_name: &str, parent_name: &str,
parent_scope: Option<&SharedScope>, parent_scope: Option<&SharedScope>,

View File

@ -1946,6 +1946,152 @@ async fn paused_then_run_closes_orphan_tool_use_for_next_request() {
); );
} }
#[tokio::test]
async fn paused_cancel_abandons_resume_and_next_input_is_fresh_run() {
let tool_name = "HangyTool";
let first = MockResponse::Complete(vec![
LlmEvent::tool_use_start(0, "call_cancelled", tool_name),
LlmEvent::tool_input_delta(0, "{}"),
LlmEvent::tool_use_stop(0),
LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]);
let second = MockResponse::Complete(vec![
LlmEvent::text_block_start(0),
LlmEvent::text_delta(0, "fresh output"),
LlmEvent::text_block_stop(0, None),
LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]);
let client = MockClient::sequential(vec![first, second]);
let client_for_assert = client.clone();
let mut pod = make_pod(client).await;
pod.worker_mut()
.register_tool(hanging_tool_definition(tool_name));
let handle = spawn_controller(pod).await;
let mut rx = handle.subscribe();
handle.send(Method::run_text("first")).await.unwrap();
assert!(
drain_until(&mut rx, std::time::Duration::from_secs(2), |e| matches!(
e,
Event::ToolCallDone { .. }
))
.await,
"tool_call_done should arrive before pause"
);
handle.send(Method::Pause).await.unwrap();
assert!(
drain_until(&mut rx, std::time::Duration::from_secs(2), |e| matches!(
e,
Event::RunEnd {
result: protocol::RunResult::Paused
}
))
.await,
"expected RunEnd::Paused"
);
wait_for_status(&handle, PodStatus::Paused).await;
handle.send(Method::Cancel).await.unwrap();
wait_for_status(&handle, PodStatus::Idle).await;
let (entries_after_cancel, _rx_after_cancel) = handle.sink.subscribe_with_snapshot();
assert!(
entries_after_cancel
.iter()
.any(|entry| matches!(entry, LogEntry::PausedTurnAbandoned { .. })),
"paused cancel should have an explicit lifecycle log entry: {entries_after_cancel:?}"
);
assert!(
!entries_after_cancel.iter().any(|entry| matches!(
entry,
LogEntry::RunCompleted {
result: llm_worker::WorkerResult::Finished,
interrupted: false,
..
}
)),
"paused cancel must not be logged as a normal finished run: {entries_after_cancel:?}"
);
assert_eq!(
client_for_assert.captured_requests().len(),
1,
"paused cancel must not resume or start another LLM request"
);
handle.send(Method::Resume).await.unwrap();
assert!(
drain_until(&mut rx, std::time::Duration::from_secs(2), |e| matches!(
e,
Event::Error {
code: pod::ErrorCode::NotPaused,
..
}
))
.await,
"resume after paused cancel should be rejected as not paused"
);
assert_eq!(
client_for_assert.captured_requests().len(),
1,
"rejected resume must not call the LLM"
);
handle
.send(Method::run_text("fresh request"))
.await
.unwrap();
assert!(
drain_until(&mut rx, std::time::Duration::from_secs(2), |e| matches!(
e,
Event::RunEnd {
result: protocol::RunResult::Finished
}
))
.await,
"expected RunEnd::Finished for fresh run"
);
let requests = client_for_assert.captured_requests();
assert_eq!(
requests.len(),
2,
"fresh input should start exactly one new LLM request"
);
let items = &requests[1].items;
assert!(
items.iter().any(|item| matches!(
item,
llm_worker::Item::ToolResult { call_id, summary, .. }
if call_id == "call_cancelled" && summary == "[Interrupted by user]"
)),
"paused cancel should close orphan tool_use before future requests: {items:?}"
);
assert!(
items.iter().any(|item| matches!(
item,
llm_worker::Item::Message {
role: llm_worker::Role::System,
..
} if item_text_contains(item, "interrupted by the user")
)),
"paused cancel should record an explicit interruption note: {items:?}"
);
assert!(
items.iter().any(|item| matches!(
item,
llm_worker::Item::Message {
role: llm_worker::Role::User,
..
} if item_text_contains(item, "fresh request")
)),
"fresh user input should be part of the next normal run: {items:?}"
);
}
fn item_text_contains(item: &Item, needle: &str) -> bool { fn item_text_contains(item: &Item, needle: &str) -> bool {
item.as_text().unwrap_or_default().contains(needle) item.as_text().unwrap_or_default().contains(needle)
} }

View File

@ -4,8 +4,14 @@ version = "0.1.0"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
[features]
default = ["stream"]
stream = ["dep:tokio"]
typescript = ["dep:ts-rs"]
[dependencies] [dependencies]
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
tokio = { workspace = true, features = ["io-util"] } tokio = { workspace = true, features = ["io-util"], optional = true }
ts-rs = { version = "12.0.1", optional = true }
uuid = { workspace = true, features = ["serde"] } uuid = { workspace = true, features = ["serde"] }

View File

@ -0,0 +1,16 @@
#[cfg(feature = "typescript")]
fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = protocol::typescript::generated_typescript_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, protocol::typescript::generated_protocol_types())?;
println!("wrote {}", path.display());
Ok(())
}
#[cfg(not(feature = "typescript"))]
fn main() {
eprintln!("enable the `typescript` feature to generate protocol TypeScript bindings");
std::process::exit(2);
}

View File

@ -1,4 +1,7 @@
#[cfg(feature = "stream")]
pub mod stream; pub mod stream;
#[cfg(feature = "typescript")]
pub mod typescript;
use std::path::PathBuf; use std::path::PathBuf;
@ -21,6 +24,7 @@ fn is_false(value: &bool) -> bool {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "method", content = "params", rename_all = "snake_case")] #[serde(tag = "method", content = "params", rename_all = "snake_case")]
pub enum Method { pub enum Method {
Run { Run {
@ -103,6 +107,7 @@ pub enum Method {
/// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same /// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same
/// child Pod). /// child Pod).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum PodEvent { pub enum PodEvent {
/// Child finished one turn and is back to IDLE. /// Child finished one turn and is back to IDLE.
@ -175,6 +180,7 @@ impl PodEvent {
/// placeholder into the LLM context so neither user nor LLM is blind to /// placeholder into the LLM context so neither user nor LLM is blind to
/// the dropped intent. /// the dropped intent.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum Segment { pub enum Segment {
/// Free-form text. The fallback every client can produce. /// Free-form text. The fallback every client can produce.
@ -266,6 +272,7 @@ impl Method {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "event", content = "data", rename_all = "snake_case")] #[serde(tag = "event", content = "data", rename_all = "snake_case")]
pub enum Event { pub enum Event {
/// A user input message was accepted, persisted as /// A user input message was accepted, persisted as
@ -294,6 +301,7 @@ pub enum Event {
/// One event per `LogEntry::SystemItem` commit. Disk-side and /// One event per `LogEntry::SystemItem` commit. Disk-side and
/// wire-side are 1:1. /// wire-side are 1:1.
SystemItem { SystemItem {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
item: serde_json::Value, item: serde_json::Value,
}, },
/// A new self-driving cycle has begun (IDLE → active transition). /// A new self-driving cycle has begun (IDLE → active transition).
@ -453,6 +461,7 @@ pub enum Event {
/// role-specific entry events (`SegmentRotated` / `SystemItem`) — /// role-specific entry events (`SegmentRotated` / `SystemItem`) —
/// there is no generic "every committed entry" broadcast. /// there is no generic "every committed entry" broadcast.
Snapshot { Snapshot {
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))]
entries: Vec<serde_json::Value>, entries: Vec<serde_json::Value>,
greeting: Greeting, greeting: Greeting,
#[serde(default)] #[serde(default)]
@ -471,6 +480,7 @@ pub enum Event {
/// ///
/// Payload is the JSON form of `session_store::LogEntry::SegmentStart`. /// Payload is the JSON form of `session_store::LogEntry::SegmentStart`.
SegmentRotated { SegmentRotated {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
entry: serde_json::Value, entry: serde_json::Value,
}, },
/// Current Pod controller status. Broadcast on every controller-level /// Current Pod controller status. Broadcast on every controller-level
@ -495,6 +505,7 @@ pub enum Event {
/// A rewind has truncated the authoritative session. `entries` is the /// A rewind has truncated the authoritative session. `entries` is the
/// retained session-log prefix clients should use to reseed display state. /// retained session-log prefix clients should use to reseed display state.
RewindApplied { RewindApplied {
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))]
entries: Vec<serde_json::Value>, entries: Vec<serde_json::Value>,
input: Vec<Segment>, input: Vec<Segment>,
summary: RewindSummary, summary: RewindSummary,
@ -503,14 +514,17 @@ pub enum Event {
/// crate can evolve discovery fields without introducing a protocol /// crate can evolve discovery fields without introducing a protocol
/// dependency on session-store. /// dependency on session-store.
PodsListed { PodsListed {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
pods: serde_json::Value, pods: serde_json::Value,
}, },
/// Reply to `Method::RestorePod`. /// Reply to `Method::RestorePod`.
PodRestored { PodRestored {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
result: serde_json::Value, result: serde_json::Value,
}, },
/// Reply to `Method::RegisterPeer`. /// Reply to `Method::RegisterPeer`.
PeerRegistered { PeerRegistered {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
result: serde_json::Value, result: serde_json::Value,
}, },
Alert(Alert), Alert(Alert),
@ -530,6 +544,7 @@ pub enum Event {
/// `new_segment_id` is the UUID of the freshly created session that /// `new_segment_id` is the UUID of the freshly created session that
/// replaced the old history. /// replaced the old history.
CompactDone { CompactDone {
#[cfg_attr(feature = "typescript", ts(type = "string"))]
new_segment_id: uuid::Uuid, new_segment_id: uuid::Uuid,
}, },
/// Compaction failed. The session is unchanged. /// Compaction failed. The session is unchanged.
@ -546,6 +561,7 @@ pub enum Event {
/// surfaced to the person driving the client. Keep messages short and /// surfaced to the person driving the client. Keep messages short and
/// human-readable. /// human-readable.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct Alert { pub struct Alert {
pub level: AlertLevel, pub level: AlertLevel,
pub source: AlertSource, pub source: AlertSource,
@ -555,6 +571,7 @@ pub struct Alert {
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct MemoryWorkerEvent { pub struct MemoryWorkerEvent {
pub worker: String, pub worker: String,
pub status: String, pub status: String,
@ -568,6 +585,7 @@ pub struct MemoryWorkerEvent {
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum AlertLevel { pub enum AlertLevel {
Warn, Warn,
@ -575,6 +593,7 @@ pub enum AlertLevel {
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum AlertSource { pub enum AlertSource {
Pod, Pod,
@ -591,6 +610,7 @@ pub enum AlertSource {
/// nailed down here so the TUI side can ship without waiting for /// nailed down here so the TUI side can ship without waiting for
/// the memory / workflow tickets. /// the memory / workflow tickets.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum CompletionKind { pub enum CompletionKind {
File, File,
@ -605,6 +625,7 @@ pub enum CompletionKind {
/// keep a trailing `/` after a directory selection so the user can /// keep a trailing `/` after a directory selection so the user can
/// drill in without re-typing the prefix. /// drill in without re-typing the prefix.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct CompletionEntry { pub struct CompletionEntry {
pub value: String, pub value: String,
#[serde(default)] #[serde(default)]
@ -612,12 +633,15 @@ pub struct CompletionEntry {
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct RewindTargetId { pub struct RewindTargetId {
#[cfg_attr(feature = "typescript", ts(type = "string"))]
pub segment_id: uuid::Uuid, pub segment_id: uuid::Uuid,
pub user_input_entry_index: usize, pub user_input_entry_index: usize,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct RewindTarget { pub struct RewindTarget {
pub id: RewindTargetId, pub id: RewindTargetId,
pub expected_head_entries: usize, pub expected_head_entries: usize,
@ -633,6 +657,7 @@ pub struct RewindTarget {
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct RewindSummary { pub struct RewindSummary {
pub truncated_to_entries: usize, pub truncated_to_entries: usize,
pub discarded_entries: usize, pub discarded_entries: usize,
@ -647,6 +672,7 @@ pub struct RewindSummary {
/// history. Finalized assistant items continue to come from ordinary snapshot /// history. Finalized assistant items continue to come from ordinary snapshot
/// entries. /// entries.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct InFlightSnapshot { pub struct InFlightSnapshot {
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocks: Vec<InFlightBlock>, pub blocks: Vec<InFlightBlock>,
@ -659,6 +685,7 @@ impl InFlightSnapshot {
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum InFlightBlock { pub enum InFlightBlock {
Text { Text {
@ -681,6 +708,7 @@ pub enum InFlightBlock {
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum InFlightToolCallState { pub enum InFlightToolCallState {
#[default] #[default]
@ -701,6 +729,7 @@ impl InFlightToolCallState {
/// transmitted alongside `Event::Snapshot` so clients don't need /// transmitted alongside `Event::Snapshot` so clients don't need
/// their own view of the manifest. /// their own view of the manifest.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct Greeting { pub struct Greeting {
pub pod_name: String, pub pod_name: String,
pub cwd: String, pub cwd: String,
@ -721,6 +750,7 @@ pub struct Greeting {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum PodStatus { pub enum PodStatus {
#[default] #[default]
@ -730,6 +760,7 @@ pub enum PodStatus {
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum TurnResult { pub enum TurnResult {
Finished, Finished,
@ -743,6 +774,7 @@ pub enum TurnResult {
/// notify message, pod event body) is delivered by the immediately /// notify message, pod event body) is delivered by the immediately
/// following Turn entry, not by the marker itself. /// following Turn entry, not by the marker itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum InvokeKind { pub enum InvokeKind {
/// `Method::Run` — a user submission. /// `Method::Run` — a user submission.
@ -762,6 +794,7 @@ pub enum InvokeKind {
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum RunResult { pub enum RunResult {
Finished, Finished,
@ -775,6 +808,7 @@ pub enum RunResult {
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ErrorCode { pub enum ErrorCode {
AlreadyRunning, AlreadyRunning,
@ -796,6 +830,7 @@ pub enum ErrorCode {
/// A single allow or deny rule inside a scope configuration. /// A single allow or deny rule inside a scope configuration.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct ScopeRule { pub struct ScopeRule {
/// Target path. Must be absolute by the time a `Scope` is built from /// Target path. Must be absolute by the time a `Scope` is built from
/// this rule — relative paths are resolved per-layer against the /// this rule — relative paths are resolved per-layer against the
@ -822,6 +857,7 @@ fn default_recursive() -> bool {
/// everything below); deny rules cap the effective level **strictly /// everything below); deny rules cap the effective level **strictly
/// below** the stated level. /// below** the stated level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum Permission { pub enum Permission {
Read, Read,

View File

@ -0,0 +1,97 @@
use std::path::PathBuf;
use ts_rs::{Config, TS};
use crate::{
Alert, AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting,
InFlightBlock, InFlightSnapshot, InFlightToolCallState, InvokeKind, MemoryWorkerEvent, Method,
Permission, PodEvent, PodStatus, RewindSummary, RewindTarget, RewindTargetId, RunResult,
ScopeRule, Segment, TurnResult,
};
const GENERATED_RELATIVE_PATH: &str = "../../web/workspace/src/lib/generated/protocol.ts";
/// Repository-relative destination for the Workspace web protocol bindings.
pub fn generated_typescript_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GENERATED_RELATIVE_PATH)
}
/// Render Workspace web TypeScript bindings for the Pod wire protocol DTOs.
///
/// Rust DTOs in this crate remain the source of truth; this function is used by
/// both the checked-in artifact generator and the stale-output drift test.
pub fn generated_protocol_types() -> String {
let cfg = Config::new().with_large_int("number");
let mut output = String::from(
"// @generated by `cargo run -p protocol --example generate_typescript --features typescript`\n\
// Do not edit by hand. Rust DTO authority lives in `crates/protocol`.\n\
// Large integer fields are JSON numbers on the wire and are emitted as TypeScript `number`.\n\n",
);
push_decl::<AlertLevel>(&cfg, &mut output);
push_decl::<AlertSource>(&cfg, &mut output);
push_decl::<CompletionKind>(&cfg, &mut output);
push_decl::<PodStatus>(&cfg, &mut output);
push_decl::<TurnResult>(&cfg, &mut output);
push_decl::<InvokeKind>(&cfg, &mut output);
push_decl::<RunResult>(&cfg, &mut output);
push_decl::<ErrorCode>(&cfg, &mut output);
push_decl::<Permission>(&cfg, &mut output);
push_decl::<InFlightToolCallState>(&cfg, &mut output);
push_decl::<ScopeRule>(&cfg, &mut output);
push_decl::<CompletionEntry>(&cfg, &mut output);
push_decl::<RewindTargetId>(&cfg, &mut output);
push_decl::<RewindTarget>(&cfg, &mut output);
push_decl::<RewindSummary>(&cfg, &mut output);
push_decl::<InFlightBlock>(&cfg, &mut output);
push_decl::<InFlightSnapshot>(&cfg, &mut output);
push_decl::<Greeting>(&cfg, &mut output);
push_decl::<Alert>(&cfg, &mut output);
push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
push_decl::<Segment>(&cfg, &mut output);
push_decl::<PodEvent>(&cfg, &mut output);
push_decl::<Method>(&cfg, &mut output);
push_decl::<Event>(&cfg, &mut output);
normalize_typescript(output)
}
fn normalize_typescript(output: String) -> String {
let mut lines = output.lines().map(str::trim_end).collect::<Vec<_>>();
while lines.last() == Some(&"") {
lines.pop();
}
lines.join("\n") + "\n"
}
fn push_decl<T: TS>(cfg: &Config, output: &mut String) {
let decl = T::decl(cfg);
output.push_str(&export_decl(&decl));
output.push_str("\n\n");
}
fn export_decl(decl: &str) -> String {
for prefix in ["type ", "interface ", "enum "] {
if decl.starts_with(prefix) {
return format!("export {decl}");
}
}
decl.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_protocol_types_are_current() {
let expected = generated_protocol_types();
let path = generated_typescript_path();
let actual = std::fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
assert_eq!(
actual, expected,
"generated TypeScript protocol bindings are stale; run `cargo run -p protocol --example generate_typescript --features typescript`"
);
}
}

View File

@ -29,6 +29,7 @@ use crate::system_item::SystemItem;
/// run completion); the fork-point seq for `at_turn_index` is the /// run completion); the fork-point seq for `at_turn_index` is the
/// preceding `Invoke` entry, not the TurnEnd. /// preceding `Invoke` entry, not the TurnEnd.
/// - `RunCompleted` / `RunErrored` — marks end of a `run()` or `resume()` call /// - `RunCompleted` / `RunErrored` — marks end of a `run()` or `resume()` call
/// - `PausedTurnAbandoned` — explicit abandon/cancel of a paused interrupted turn
/// - `ConfigChanged` — `RequestConfig` mutation /// - `ConfigChanged` — `RequestConfig` mutation
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
@ -114,6 +115,11 @@ pub enum LogEntry {
message: String, message: String,
}, },
/// A paused interrupted turn was explicitly abandoned without calling
/// `run()` or `resume()` again. Replay clears the interrupted marker so
/// the restored Pod is idle and future user input starts a normal new turn.
PausedTurnAbandoned { ts: u64 },
/// `RequestConfig` changed. /// `RequestConfig` changed.
ConfigChanged { ts: u64, config: RequestConfig }, ConfigChanged { ts: u64, config: RequestConfig },
@ -257,6 +263,9 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
LogEntry::RunErrored { interrupted, .. } => { LogEntry::RunErrored { interrupted, .. } => {
state.last_run_interrupted = *interrupted; state.last_run_interrupted = *interrupted;
} }
LogEntry::PausedTurnAbandoned { .. } => {
state.last_run_interrupted = false;
}
LogEntry::ConfigChanged { config, .. } => { LogEntry::ConfigChanged { config, .. } => {
state.config = config.clone(); state.config = config.clone();
} }
@ -569,6 +578,41 @@ mod tests {
assert_eq!(state.turn_count, 1); assert_eq!(state.turn_count, 1);
} }
#[test]
fn replay_paused_turn_abandoned_clears_interrupted_marker() {
let state = collect_state(&[
LogEntry::SegmentStart {
ts: 0,
session_id: uuid::Uuid::nil(),
system_prompt: None,
config: RequestConfig::default(),
history: vec![],
forked_from: None,
compacted_from: None,
},
LogEntry::RunCompleted {
ts: 100,
interrupted: true,
result: WorkerResult::Paused,
},
LogEntry::PausedTurnAbandoned { ts: 200 },
]);
assert!(!state.last_run_interrupted);
}
#[test]
fn paused_turn_abandoned_entry_round_trip_via_json() {
let entry = LogEntry::PausedTurnAbandoned { ts: 12345 };
let json = serde_json::to_string(&entry).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["kind"], "paused_turn_abandoned");
let decoded: LogEntry = serde_json::from_str(&json).unwrap();
match decoded {
LogEntry::PausedTurnAbandoned { ts } => assert_eq!(ts, 12345),
other => panic!("expected PausedTurnAbandoned, got {other:?}"),
}
}
#[test] #[test]
fn replay_extension_collects_domain_payload_pairs() { fn replay_extension_collects_domain_payload_pairs() {
let state = collect_state(&[ let state = collect_state(&[

View File

@ -938,11 +938,11 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
} }
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)), KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
KeyCode::Char('x') if ctrl => Some(match app.pod_status { KeyCode::Char('x') if ctrl => Some(match app.pod_status {
PodStatus::Running => { PodStatus::Running | PodStatus::Paused => {
app.clear_queued_inputs(); app.clear_queued_inputs();
Some(Method::Cancel) Some(Method::Cancel)
} }
PodStatus::Paused | PodStatus::Idle => Some(Method::Shutdown), PodStatus::Idle => Some(Method::Shutdown),
}), }),
KeyCode::Char('d') if ctrl => { KeyCode::Char('d') if ctrl => {
app.quit = true; app.quit = true;
@ -1518,6 +1518,30 @@ mod tests {
assert_eq!(app.queued_input_count(), 0); assert_eq!(app.queued_input_count(), 0);
} }
#[test]
fn ctrl_x_cancels_paused_turn_without_shutdown() {
let mut app = App::new("agent".to_string());
app.set_pod_status(PodStatus::Paused);
let cancel = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
);
assert!(matches!(cancel, Some(Method::Cancel)));
}
#[test]
fn ctrl_x_shutdown_while_idle_is_unchanged() {
let mut app = App::new("agent".to_string());
app.set_pod_status(PodStatus::Idle);
let shutdown = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
);
assert!(matches!(shutdown, Some(Method::Shutdown)));
}
#[test] #[test]
fn word_navigation_keys_edit_composer() { fn word_navigation_keys_edit_composer() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());

View File

@ -1266,8 +1266,7 @@ impl DashboardApp {
.list .list
.selected_name .selected_name
.clone() .clone()
.filter(|name| list.entries.iter().any(|entry| entry.name == *name)) .filter(|name| list.entries.iter().any(|entry| entry.name == *name));
.or_else(|| list.entries.first().map(|entry| entry.name.clone()));
let panel = build_workspace_panel(&current_workspace_root(), &list); let panel = build_workspace_panel(&current_workspace_root(), &list);
self.apply_reloaded_snapshot(DashboardSnapshot { list, panel }); self.apply_reloaded_snapshot(DashboardSnapshot { list, panel });
} }
@ -1276,25 +1275,17 @@ impl DashboardApp {
self.apply_companion_lifecycle_memory(&mut snapshot.panel); self.apply_companion_lifecycle_memory(&mut snapshot.panel);
self.apply_orchestrator_lifecycle_memory(&mut snapshot.panel); self.apply_orchestrator_lifecycle_memory(&mut snapshot.panel);
let previous_selected_pod = self.list.selected_name.clone(); let previous_selected_pod = self.list.selected_name.clone();
snapshot.list.selected_name = previous_selected_pod snapshot.list.selected_name = previous_selected_pod.filter(|name| {
.filter(|name| { snapshot
snapshot .list
.list .entries
.entries .iter()
.iter() .any(|entry| entry.name == *name)
.any(|entry| entry.name == *name) });
})
.or_else(|| {
snapshot
.list
.entries
.first()
.map(|entry| entry.name.clone())
});
let previous_row = self.selected_row.clone(); let previous_row = self.selected_row.clone();
self.list = snapshot.list; self.list = snapshot.list;
self.panel = snapshot.panel; self.panel = snapshot.panel;
self.selected_row = previous_row.filter(|key| self.panel.row(key).is_some()); self.selected_row = previous_row;
self.ensure_selection_visible(); self.ensure_selection_visible();
self.ensure_composer_target_available(); self.ensure_composer_target_available();
self.refresh_orchestrator_work_set(); self.refresh_orchestrator_work_set();
@ -1437,31 +1428,6 @@ impl DashboardApp {
} }
} }
pub(crate) fn selected_open_disabled_reason(&self) -> Option<String> {
if let Some(row) = self
.selected_panel_row()
.filter(|row| row.is_ticket_action())
{
return Some(
row.disabled_reason
.clone()
.or_else(|| row.key_hint.clone())
.unwrap_or_else(|| {
"Enter dispatches this Ticket action after re-checking current Ticket authority."
.to_string()
}),
);
}
if let Some(entry) = self.selected_pod_entry() {
if entry.actions.can_open {
return None;
}
return Some(open_disabled_reason(entry));
}
self.selected_panel_row()
.and_then(|row| row.disabled_reason.clone().or_else(|| row.key_hint.clone()))
}
pub(crate) fn select_next(&mut self) { pub(crate) fn select_next(&mut self) {
let visible = visible_panel_keys(&self.panel, &self.list); let visible = visible_panel_keys(&self.panel, &self.list);
if visible.is_empty() { if visible.is_empty() {
@ -1469,12 +1435,14 @@ impl DashboardApp {
self.list.selected_name = None; self.list.selected_name = None;
return; return;
} }
let selected_pos = self let next_pos = match self
.selected_row .selected_row
.as_ref() .as_ref()
.and_then(|key| visible.iter().position(|visible_key| visible_key == key)) .and_then(|key| visible.iter().position(|visible_key| visible_key == key))
.unwrap_or(0); {
let next_pos = (selected_pos + 1).min(visible.len() - 1); Some(selected_pos) => (selected_pos + 1).min(visible.len() - 1),
None => 0,
};
self.select_panel_key(visible[next_pos].clone()); self.select_panel_key(visible[next_pos].clone());
} }
@ -1485,12 +1453,15 @@ impl DashboardApp {
self.list.selected_name = None; self.list.selected_name = None;
return; return;
} }
let selected_pos = self let prev_pos = match self
.selected_row .selected_row
.as_ref() .as_ref()
.and_then(|key| visible.iter().position(|visible_key| visible_key == key)) .and_then(|key| visible.iter().position(|visible_key| visible_key == key))
.unwrap_or(0); {
self.select_panel_key(visible[selected_pos.saturating_sub(1)].clone()); Some(selected_pos) => selected_pos.saturating_sub(1),
None => 0,
};
self.select_panel_key(visible[prev_pos].clone());
} }
fn handle_mouse_event(&mut self, event: MouseEvent) -> bool { fn handle_mouse_event(&mut self, event: MouseEvent) -> bool {
@ -1648,55 +1619,32 @@ impl DashboardApp {
fn ensure_selection_visible(&mut self) { fn ensure_selection_visible(&mut self) {
let visible = visible_panel_keys(&self.panel, &self.list); let visible = visible_panel_keys(&self.panel, &self.list);
if visible.is_empty() { let Some(selected_key) = self.selected_row.as_ref() else {
self.selected_row = None;
self.list.selected_name = None; self.list.selected_name = None;
return; return;
} };
let selected_visible = self if visible
.selected_row .iter()
.as_ref() .any(|visible_key| visible_key == selected_key)
.is_some_and(|key| visible.iter().any(|visible_key| visible_key == key)); {
if !selected_visible { match selected_key {
let has_action_rows = self.panel.rows.iter().any(|row| row.is_ticket_action()); PanelRowKey::Pod(name) => self.list.selected_name = Some(name.clone()),
let orchestrator_pod_name = self PanelRowKey::Ticket(_)
.panel | PanelRowKey::InvalidTicket(_)
.header | PanelRowKey::TicketIntakePod { .. } => self.list.selected_name = None,
.orchestrator
.as_ref()
.map(|state| state.pod_name.as_str());
if !has_action_rows {
if let Some(selected_name) = self.list.selected_name.as_ref() {
if Some(selected_name.as_str()) != orchestrator_pod_name {
let key = PanelRowKey::Pod(selected_name.clone());
if visible.iter().any(|visible_key| visible_key == &key) {
self.select_panel_key(key);
return;
}
}
}
if let Some(key) = visible.iter().find(|key| match key {
PanelRowKey::Pod(name) => Some(name.as_str()) != orchestrator_pod_name,
PanelRowKey::Ticket(_)
| PanelRowKey::InvalidTicket(_)
| PanelRowKey::TicketIntakePod { .. } => true,
}) {
self.select_panel_key(key.clone());
return;
}
self.selected_row = None;
self.list.selected_name = None;
return;
} }
self.select_panel_key(visible[0].clone()); } else {
} else if let Some(PanelRowKey::Pod(name)) = self.selected_row.as_ref() { self.selected_row = None;
self.list.selected_name = Some(name.clone()); self.list.selected_name = None;
} }
} }
fn select_panel_key(&mut self, key: PanelRowKey) { fn select_panel_key(&mut self, key: PanelRowKey) {
if let PanelRowKey::Pod(name) = &key { match &key {
self.list.selected_name = Some(name.clone()); PanelRowKey::Pod(name) => self.list.selected_name = Some(name.clone()),
PanelRowKey::Ticket(_)
| PanelRowKey::InvalidTicket(_)
| PanelRowKey::TicketIntakePod { .. } => self.list.selected_name = None,
} }
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
let selected_key = key.clone(); let selected_key = key.clone();
@ -2942,7 +2890,7 @@ fn build_orchestrator_launch_context(
pod_name: &str, pod_name: &str,
) -> TicketRoleLaunchContext { ) -> TicketRoleLaunchContext {
let mut context = TicketRoleLaunchContext::new( let mut context = TicketRoleLaunchContext::new(
orchestration_workspace_root.to_path_buf(), original_workspace_root.to_path_buf(),
TicketRole::Orchestrator, TicketRole::Orchestrator,
) )
.with_cwd(orchestration_workspace_root.to_path_buf()) .with_cwd(orchestration_workspace_root.to_path_buf())
@ -5037,33 +4985,6 @@ fn segments_are_blank(segments: &[Segment]) -> bool {
}) })
} }
fn open_disabled_reason(entry: &PodListEntry) -> String {
if let Some(live) = entry.live.as_ref() {
if !live.reachable {
return "Selected live Pod is unreachable.".to_string();
}
return match live.status {
Some(PodStatus::Running) => {
"Selected Pod is running; Enter opens/attaches for inspection.".to_string()
}
Some(PodStatus::Paused) => {
"Selected Pod is paused; open it explicitly to resume or start a new turn."
.to_string()
}
Some(PodStatus::Idle) => "Selected Pod can be opened/attached.".to_string(),
None => "Selected Pod did not report a live status.".to_string(),
};
}
if entry.stored.is_some() {
return "Selected Pod is stopped; Enter restores/opens for inspection.".to_string();
}
entry
.actions
.disabled_reason
.clone()
.unwrap_or_else(|| "Selected Pod cannot be opened from this row.".to_string())
}
fn selected_ticket_notice(row: Option<&PanelRow>) -> String { fn selected_ticket_notice(row: Option<&PanelRow>) -> String {
match row { match row {
Some(row) if row.is_ticket_action() => { Some(row) if row.is_ticket_action() => {

View File

@ -57,24 +57,14 @@ pub(super) fn input_area_height(render: &crate::input::InputRender, terminal_hei
} }
pub(super) fn draw_title(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) { pub(super) fn draw_title(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) {
let guidance = if app frame.render_widget(Paragraph::new(title_line(app)), area);
.panel }
.composer
.is_available(ComposerTarget::TicketIntake) pub(super) fn title_line(app: &DashboardApp) -> Line<'static> {
{ let mut spans = vec![Span::styled(
" Row selection: blank Enter opens/dispatches · text Enter uses target · Tab target" "workspace dashboard",
} else if app.panel.header.ticket_configured { Style::default().add_modifier(Modifier::BOLD),
" Row selection: blank Enter opens/dispatches · text Enter sends to Companion" )];
} else {
" Pod-centric view · Row selection: blank Enter opens · text Enter sends to Companion"
};
let mut spans = vec![
Span::styled(
"workspace dashboard",
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(guidance, Style::default().fg(Color::DarkGray)),
];
if let Some(companion) = &app.panel.header.companion { if let Some(companion) = &app.panel.header.companion {
spans.push(Span::styled( spans.push(Span::styled(
" · companion ", " · companion ",
@ -101,7 +91,7 @@ pub(super) fn draw_title(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect)
orchestrator_status_style(orchestrator.status), orchestrator_status_style(orchestrator.status),
)); ));
} }
frame.render_widget(Paragraph::new(Line::from(spans)), area); Line::from(spans)
} }
pub(super) fn companion_status_style(status: CompanionPanelStatus) -> Style { pub(super) fn companion_status_style(status: CompanionPanelStatus) -> Style {
@ -688,115 +678,8 @@ pub(super) fn draw_target_status(frame: &mut Frame<'_>, app: &DashboardApp, area
frame.render_widget(Paragraph::new(target_status_line(app)), area); frame.render_widget(Paragraph::new(target_status_line(app)), area);
} }
pub(super) fn target_status_line(app: &DashboardApp) -> Line<'static> { pub(super) fn target_status_line(_app: &DashboardApp) -> Line<'static> {
if !app.composer_is_blank() { Line::from(Span::raw(""))
return Line::from(vec![
Span::styled("composer target ", Style::default().fg(Color::DarkGray)),
Span::styled(
app.composer_target().label(),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(" · draft Enter ", Style::default().fg(Color::DarkGray)),
Span::styled(
composer_enter_status_text(app),
Style::default().fg(Color::Green),
),
Span::styled(
" · row selection waits until composer is blank",
Style::default().fg(Color::DarkGray),
),
]);
}
if let Some(row) = app
.selected_panel_row()
.filter(|row| row.is_ticket_action())
{
let action = row
.next_action
.map(|action| panel_ticket_action_label(row, action))
.unwrap_or("View");
let mut spans = vec![
Span::styled("composer target ", Style::default().fg(Color::DarkGray)),
Span::styled(
app.composer_target().label(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::styled(" · selected Ticket ", Style::default().fg(Color::DarkGray)),
Span::styled(row.status.clone(), panel_priority_style(row.priority)),
Span::styled(" · blank Enter ", Style::default().fg(Color::DarkGray)),
Span::styled(action, Style::default().fg(Color::Magenta)),
];
if let Some(reason) = panel_ticket_reason(row) {
spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
truncate_with_ellipsis(reason, 100),
ticket_detail_style(row),
));
}
Line::from(spans)
} else if let Some(row) = app
.selected_panel_row()
.filter(|row| row.kind == PanelRowKind::TicketIntakePod)
{
let ticket_id = panel_ticket_reference(row);
let action = if row.next_action == Some(NextUserAction::OpenPod) {
"open/attach"
} else {
"unavailable"
};
Line::from(vec![
Span::styled("composer target ", Style::default().fg(Color::DarkGray)),
Span::styled(
app.composer_target().label(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" · selected Intake Pod ",
Style::default().fg(Color::DarkGray),
),
Span::styled(row.status.clone(), intake_status_style(&row.status)),
Span::styled(
format!(" · Ticket {ticket_id} · blank Enter {action}"),
Style::default().fg(Color::DarkGray),
),
])
} else if let Some(entry) = app.selected_pod_entry() {
let (status, status_style) = row_status_label(entry);
Line::from(vec![
Span::styled("composer target ", Style::default().fg(Color::DarkGray)),
Span::styled(
app.composer_target().label(),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(" · selected Pod ", Style::default().fg(Color::DarkGray)),
Span::styled(status.to_string(), status_style),
Span::styled(
" · blank Enter open/attach",
Style::default().fg(Color::DarkGray),
),
])
} else {
Line::from(vec![
Span::styled("composer target ", Style::default().fg(Color::DarkGray)),
Span::styled(
app.composer_target().label(),
Style::default().fg(Color::DarkGray),
),
Span::styled(
" · no row selected · ↑/↓ selects a row",
Style::default().fg(Color::DarkGray),
),
])
}
} }
pub(super) fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: Rect) { pub(super) fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: Rect) {
@ -817,103 +700,6 @@ pub(super) fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRend
} }
} }
pub(super) fn composer_enter_status_text(app: &DashboardApp) -> String {
match app.composer_target() {
ComposerTarget::Companion => companion_enter_status_text(app),
ComposerTarget::TicketIntake
if app.selected_ticket_action() == Some(NextUserAction::Queue) =>
{
"return selected ready Ticket to planning".to_string()
}
ComposerTarget::TicketIntake => "launch Intake with composer text".to_string(),
}
}
pub(super) fn composer_enter_actionbar_text(app: &DashboardApp) -> String {
match app.composer_target() {
ComposerTarget::Companion => companion_enter_actionbar_text(app),
ComposerTarget::TicketIntake if app.selected_ticket_action() == Some(NextUserAction::Queue) => {
"Ticket Intake target: Enter records instructions and returns selected ready Ticket to planning".to_string()
}
ComposerTarget::TicketIntake => {
"Ticket Intake target: Enter launches Intake with composer text".to_string()
}
}
}
pub(super) fn companion_enter_status_text(app: &DashboardApp) -> String {
match companion_send_availability(app) {
CompanionSendAvailability::Ready => "send composer text to workspace Companion".to_string(),
CompanionSendAvailability::Unavailable(reason) => format!("keep draft; {reason}"),
}
}
pub(super) fn companion_enter_actionbar_text(app: &DashboardApp) -> String {
match companion_send_availability(app) {
CompanionSendAvailability::Ready => {
"Companion target: Enter sends composer text to workspace Companion".to_string()
}
CompanionSendAvailability::Unavailable(reason) => {
format!("Companion target: Enter keeps draft; {reason}")
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum CompanionSendAvailability {
Ready,
Unavailable(String),
}
pub(super) fn companion_send_availability(app: &DashboardApp) -> CompanionSendAvailability {
let Some(companion) = app.panel.header.companion.as_ref() else {
return CompanionSendAvailability::Unavailable(
"workspace Companion is unavailable".to_string(),
);
};
if matches!(
companion.status,
CompanionPanelStatus::Unavailable
| CompanionPanelStatus::Missing
| CompanionPanelStatus::Stopped
) {
return CompanionSendAvailability::Unavailable(format!(
"workspace Companion is {}",
companion.status.label()
));
}
let Some(entry) = app
.list
.entries
.iter()
.find(|entry| entry.name == companion.pod_name)
else {
return CompanionSendAvailability::Unavailable(format!(
"workspace Companion `{}` is not in the Pod list",
companion.pod_name
));
};
let Some(live) = entry.live.as_ref() else {
return CompanionSendAvailability::Unavailable(format!(
"workspace Companion `{}` is stopped",
companion.pod_name
));
};
if !live.reachable {
return CompanionSendAvailability::Unavailable(format!(
"workspace Companion `{}` is unreachable",
companion.pod_name
));
}
if live.status == Some(PodStatus::Running) {
return CompanionSendAvailability::Unavailable(format!(
"workspace Companion `{}` is running",
companion.pod_name
));
}
CompanionSendAvailability::Ready
}
pub(super) fn actionbar_left_text(app: &DashboardApp) -> String { pub(super) fn actionbar_left_text(app: &DashboardApp) -> String {
if app.sending && app.composer_target() == ComposerTarget::TicketIntake { if app.sending && app.composer_target() == ComposerTarget::TicketIntake {
"launching Ticket Intake…".to_string() "launching Ticket Intake…".to_string()
@ -927,52 +713,20 @@ pub(super) fn actionbar_left_text(app: &DashboardApp) -> String {
Some(notice) => format!("{notice} Refreshing workspace…"), Some(notice) => format!("{notice} Refreshing workspace…"),
None => "Refreshing workspace…".to_string(), None => "Refreshing workspace…".to_string(),
} }
} else if !app.composer_is_blank() {
composer_enter_actionbar_text(app)
} else if let Some(notice) = app.notice.as_deref() { } else if let Some(notice) = app.notice.as_deref() {
notice.to_string() notice.to_string()
} else if let Some(reason) = app.selected_open_disabled_reason() {
reason
} else { } else {
match app.composer_target() { String::new()
ComposerTarget::Companion => {
"Composer target: Companion; type text to send, or use ↑/↓ then blank Enter for rows"
.to_string()
}
ComposerTarget::TicketIntake => {
if app.selected_ticket_action() == Some(NextUserAction::Queue) {
"Composer target: Ticket Intake; text + Enter returns selected ready Ticket to planning".to_string()
} else {
"Composer target: Ticket Intake; type a request, then Enter launches Intake".to_string()
}
}
}
} }
} }
pub(super) fn actionbar_right_text(app: &DashboardApp) -> &'static str { pub(super) fn actionbar_right_text(app: &DashboardApp) -> &'static str {
if app.panel_diagnostic_open { if app.panel_diagnostic_open {
"F2/Esc close details Ctrl+C quit" "F2/Esc close details"
} else if app.panel_diagnostic.is_some() { } else if app.panel_diagnostic.is_some() {
"F2 details ↑/↓ select row Enter selected row Tab target Esc clear selection Left/Right cursor Ctrl+C quit" "F2 details"
} else if !app.composer_is_blank() {
if app
.panel
.composer
.is_available(ComposerTarget::TicketIntake)
{
"↑/↓ draft lines Left/Right cursor Enter composer target Tab target Esc clear selection Ctrl+C quit"
} else {
"↑/↓ draft lines Left/Right cursor Enter composer target Esc clear selection Ctrl+C quit"
}
} else if app
.panel
.composer
.is_available(ComposerTarget::TicketIntake)
{
"↑/↓ select row Enter selected row Tab target Esc clear selection Left/Right cursor Ctrl+C quit"
} else { } else {
"↑/↓ select row Enter selected row Esc clear selection Left/Right cursor Ctrl+C quit" ""
} }
} }

View File

@ -16,14 +16,15 @@ fn orchestration_worktree_layout_is_stable_under_original_workspace_root() {
} }
#[test] #[test]
fn orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace() { fn orchestrator_launch_context_uses_original_root_for_runtime_workspace_and_worktree_cwd() {
let original = PathBuf::from("/repo/yoi"); let original = PathBuf::from("/repo/yoi");
let orchestration = original let orchestration = original
.join(".worktree") .join(".worktree")
.join("orchestration") .join("orchestration")
.join("yoi-orchestrator"); .join("yoi-orchestrator");
let context = build_orchestrator_launch_context(&original, &orchestration, "yoi-orchestrator"); let context = build_orchestrator_launch_context(&original, &orchestration, "yoi-orchestrator");
assert_eq!(context.workspace_root, orchestration); assert_eq!(context.workspace_root, original);
assert_eq!(context.cwd.as_deref(), Some(orchestration.as_path()));
assert_eq!( assert_eq!(
context.original_workspace_root.as_deref(), context.original_workspace_root.as_deref(),
Some(original.as_path()) Some(original.as_path())
@ -481,6 +482,7 @@ fn ready_ticket_intake_enter_prepares_planning_return_not_queue_or_generic_launc
"ready", "ready",
)); ));
let mut app = app_with_panel(empty_test_list(), panel); let mut app = app_with_panel(empty_test_list(), panel);
app.select_next();
app.cycle_composer_target(); app.cycle_composer_target();
app.input.insert_str("clarify expected behavior"); app.input.insert_str("clarify expected behavior");
@ -938,6 +940,111 @@ fn no_ticket_selection_keeps_enter_pod_centric() {
assert_eq!(app.notice.as_deref(), Some("No Ticket action is selected.")); assert_eq!(app.notice.as_deref(), Some("No Ticket action is selected."));
} }
#[test]
fn workspace_panel_initial_display_does_not_auto_select_visible_rows() {
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
panel.rows.push(panel_test_ticket_row(
"TICKET-1",
"Ready",
ActionPriority::ReadyForQueue,
NextUserAction::Queue,
"ready",
));
let app = app_with_panel(
PodList::from_sources(
PodVisibilitySource::ResumePicker,
vec![],
vec![live_info("alpha", PodStatus::Idle)],
None,
10,
),
panel,
);
assert!(visible_panel_keys(&app.panel, &app.list).len() > 1);
assert!(app.selected_row.is_none());
assert!(app.list.selected_name.is_none());
}
#[test]
fn workspace_panel_clear_selection_survives_reload_and_keeps_draft() {
let mut app = test_app(vec![live_info_with_updated_at(
"alpha",
PodStatus::Idle,
10,
)]);
app.select_next();
assert_eq!(
app.selected_row,
Some(PanelRowKey::Pod("alpha".to_string()))
);
app.input.insert_str("draft survives");
assert!(matches!(
app.handle_key(key(KeyCode::Esc)),
DashboardAction::None
));
assert!(app.selected_row.is_none());
assert!(app.list.selected_name.is_none());
app.apply_reloaded_list(PodList::from_sources(
PodVisibilitySource::ResumePicker,
vec![],
vec![live_info_with_updated_at("alpha", PodStatus::Running, 20)],
None,
10,
));
assert!(app.selected_row.is_none());
assert!(app.list.selected_name.is_none());
assert_eq!(input_text(&app), "draft survives");
}
#[test]
fn workspace_panel_no_selection_ticket_intake_submit_uses_global_intake() {
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled();
panel.rows.push(panel_test_ticket_row(
"TICKET-1",
"Ready",
ActionPriority::ReadyForQueue,
NextUserAction::Queue,
"ready",
));
let mut app = app_with_panel(empty_test_list(), panel);
app.cycle_composer_target();
app.input.insert_str("new planning request");
let request = match app.handle_key(key(KeyCode::Enter)) {
DashboardAction::LaunchIntake(request) => request,
_ => panic!("no selection should launch global Intake"),
};
assert!(request.context.ticket.is_none());
assert_eq!(
request.context.user_instruction.as_deref(),
Some("new planning request")
);
}
#[test]
fn workspace_panel_keyboard_navigation_explicitly_creates_selection() {
let mut app = test_app(vec![
live_info("alpha", PodStatus::Idle),
live_info("beta", PodStatus::Idle),
]);
assert!(app.selected_row.is_none());
app.select_next();
assert_eq!(
app.selected_row,
Some(PanelRowKey::Pod("alpha".to_string()))
);
app.select_next();
assert_eq!(app.selected_row, Some(PanelRowKey::Pod("beta".to_string())));
}
#[test] #[test]
fn dashboard_ticket_action_rows_precede_pods_and_pod_actions_still_work() { fn dashboard_ticket_action_rows_precede_pods_and_pod_actions_still_work() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
@ -960,6 +1067,8 @@ fn dashboard_ticket_action_rows_precede_pods_and_pod_actions_still_work() {
); );
let panel = build_workspace_panel(temp.path(), &list); let panel = build_workspace_panel(temp.path(), &list);
let mut app = app_with_panel(list, panel); let mut app = app_with_panel(list, panel);
assert!(app.selected_row.is_none());
app.select_next();
assert_eq!(app.selected_panel_row().unwrap().title, "Ready Ticket"); assert_eq!(app.selected_panel_row().unwrap().title, "Ready Ticket");
assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled); assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled);
@ -1072,7 +1181,9 @@ fn mouse_click_selects_panel_row_for_blank_enter_action() {
); );
assert_eq!(app.selected_panel_row().unwrap().title, "Queued"); assert_eq!(app.selected_panel_row().unwrap().title, "Queued");
assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Wait)); assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Wait));
assert!(plain_line(&target_status_line(&app)).contains("blank Enter Wait")); let selected_title =
plain_line(&panel_row_lines(app.selected_panel_row().unwrap(), true, 80)[0]);
assert!(selected_title.starts_with("▶ queued"));
assert!(matches!( assert!(matches!(
app.handle_key(key(KeyCode::Enter)), app.handle_key(key(KeyCode::Enter)),
DashboardAction::DispatchTicketAction(request) if request.ticket_id == "TICKET-2" DashboardAction::DispatchTicketAction(request) if request.ticket_id == "TICKET-2"
@ -1152,7 +1263,7 @@ fn mouse_click_does_not_override_existing_composer_keyboard_behavior() {
} }
#[test] #[test]
fn selected_ticket_row_with_non_empty_composer_shows_composer_enter_behavior() { fn selected_ticket_row_with_non_empty_composer_hides_redundant_status_hints() {
let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
panel.header.companion = Some(CompanionPanelState::new( panel.header.companion = Some(CompanionPanelState::new(
"yoi", "yoi",
@ -1174,6 +1285,7 @@ fn selected_ticket_row_with_non_empty_composer_shows_composer_enter_behavior() {
10, 10,
); );
let mut app = app_with_panel(list, panel); let mut app = app_with_panel(list, panel);
app.select_next();
app.input.insert_str("draft to companion"); app.input.insert_str("draft to companion");
assert_eq!( assert_eq!(
@ -1185,14 +1297,12 @@ fn selected_ticket_row_with_non_empty_composer_shows_composer_enter_behavior() {
let actionbar_right = actionbar_right_text(&app); let actionbar_right = actionbar_right_text(&app);
let target_status = plain_line(&target_status_line(&app)); let target_status = plain_line(&target_status_line(&app));
assert!(actionbar_left.contains("Companion target: Enter sends composer text")); assert_eq!(actionbar_left, "");
assert!(actionbar_right.contains("Enter composer target")); assert_eq!(actionbar_right, "");
assert!(!actionbar_left.contains("Queue")); assert_eq!(target_status, "");
assert!(!actionbar_right.contains("selected row")); let selected_title =
assert!(target_status.contains("composer target Companion")); plain_line(&panel_row_lines(app.selected_panel_row().unwrap(), true, 80)[0]);
assert!(target_status.contains("draft Enter send composer text to workspace Companion")); assert!(selected_title.starts_with("▶ ready"));
assert!(target_status.contains("row selection waits until composer is blank"));
assert!(!target_status.contains("blank Enter Queue"));
} }
#[test] #[test]
@ -1201,7 +1311,7 @@ fn dashboard_bare_panel_letters_append_to_composer_and_arrows_select_when_blank(
live_info("alpha", PodStatus::Idle), live_info("alpha", PodStatus::Idle),
live_info("beta", PodStatus::Idle), live_info("beta", PodStatus::Idle),
]); ]);
assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); assert!(app.selected_row.is_none());
for c in ['j', 'k', 'o', 'r'] { for c in ['j', 'k', 'o', 'r'] {
assert!(matches!( assert!(matches!(
@ -1211,14 +1321,14 @@ fn dashboard_bare_panel_letters_append_to_composer_and_arrows_select_when_blank(
} }
assert_eq!(input_text(&app), "jkor"); assert_eq!(input_text(&app), "jkor");
assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); assert!(app.selected_row.is_none());
assert!(matches!( assert!(matches!(
app.handle_key(key(KeyCode::Down)), app.handle_key(key(KeyCode::Down)),
DashboardAction::None DashboardAction::None
)); ));
assert_eq!(input_text(&app), "jkor"); assert_eq!(input_text(&app), "jkor");
assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); assert!(app.selected_row.is_none());
app.input.clear(); app.input.clear();
assert!(matches!( assert!(matches!(
@ -1226,6 +1336,12 @@ fn dashboard_bare_panel_letters_append_to_composer_and_arrows_select_when_blank(
DashboardAction::None DashboardAction::None
)); ));
assert_eq!(input_text(&app), ""); assert_eq!(input_text(&app), "");
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
assert!(matches!(
app.handle_key(key(KeyCode::Down)),
DashboardAction::None
));
assert_eq!(app.list.selected_entry().unwrap().name, "beta"); assert_eq!(app.list.selected_entry().unwrap().name, "beta");
assert!(matches!( assert!(matches!(
@ -1248,7 +1364,7 @@ fn dashboard_selection_changes_preserve_composer_contents() {
app.select_next(); app.select_next();
assert_eq!(input_text(&app), before); assert_eq!(input_text(&app), before);
assert_eq!(app.list.selected_entry().unwrap().name, "beta"); assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
} }
#[test] #[test]
@ -1258,7 +1374,7 @@ fn dashboard_poll_reload_preserves_selection_composer_and_notice() {
live_info_with_updated_at("beta", PodStatus::Idle, 20), live_info_with_updated_at("beta", PodStatus::Idle, 20),
]); ]);
app.select_next(); app.select_next();
assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); assert_eq!(app.list.selected_entry().unwrap().name, "beta");
app.input.insert_str("draft survives polling"); app.input.insert_str("draft survives polling");
app.notice = Some("keep this notice".to_string()); app.notice = Some("keep this notice".to_string());
let refreshed = PodList::from_sources( let refreshed = PodList::from_sources(
@ -1275,7 +1391,7 @@ fn dashboard_poll_reload_preserves_selection_composer_and_notice() {
app.apply_reloaded_list(refreshed); app.apply_reloaded_list(refreshed);
assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); assert_eq!(app.list.selected_entry().unwrap().name, "beta");
assert_eq!( assert_eq!(
app.list app.list
.selected_entry() .selected_entry()
@ -1284,7 +1400,7 @@ fn dashboard_poll_reload_preserves_selection_composer_and_notice() {
.as_ref() .as_ref()
.unwrap() .unwrap()
.status, .status,
Some(PodStatus::Running) Some(PodStatus::Idle)
); );
assert_eq!(input_text(&app), "draft survives polling"); assert_eq!(input_text(&app), "draft survives polling");
assert_eq!(app.notice.as_deref(), Some("keep this notice")); assert_eq!(app.notice.as_deref(), Some("keep this notice"));
@ -1296,6 +1412,8 @@ fn dashboard_poll_reload_falls_back_when_selected_pod_disappears() {
live_info_with_updated_at("alpha", PodStatus::Idle, 10), live_info_with_updated_at("alpha", PodStatus::Idle, 10),
live_info_with_updated_at("beta", PodStatus::Running, 20), live_info_with_updated_at("beta", PodStatus::Running, 20),
]); ]);
app.select_next();
app.select_next();
assert_eq!(app.list.selected_entry().unwrap().name, "beta"); assert_eq!(app.list.selected_entry().unwrap().name, "beta");
let refreshed = PodList::from_sources( let refreshed = PodList::from_sources(
PodVisibilitySource::ResumePicker, PodVisibilitySource::ResumePicker,
@ -1307,7 +1425,8 @@ fn dashboard_poll_reload_falls_back_when_selected_pod_disappears() {
app.apply_reloaded_list(refreshed); app.apply_reloaded_list(refreshed);
assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); assert!(app.selected_row.is_none());
assert!(app.list.selected_name.is_none());
assert_eq!(visible_entry_indices(&app.list), vec![0, 1]); assert_eq!(visible_entry_indices(&app.list), vec![0, 1]);
} }
@ -1526,10 +1645,10 @@ async fn dashboard_quit_aborts_background_reload_and_notice_without_waiting() {
#[test] #[test]
fn dashboard_idle_live_selected_target_is_open_eligible() { fn dashboard_idle_live_selected_target_is_open_eligible() {
let app = test_app(vec![live_info("idle", PodStatus::Idle)]); let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]);
app.select_next();
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
assert!(app.selected_open_disabled_reason().is_none());
} }
#[test] #[test]
@ -1557,6 +1676,35 @@ fn dashboard_status_labels_preserve_explicit_live_statuses() {
} }
} }
#[test]
fn dashboard_title_omits_redundant_key_hint_guidance() {
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
panel.header.ticket_configured = true;
panel.header.companion = Some(CompanionPanelState::new(
"yoi",
CompanionPanelStatus::Live,
Some("idle".to_string()),
));
let app = app_with_panel(
PodList::from_sources(
PodVisibilitySource::ResumePicker,
vec![],
vec![live_info("yoi", PodStatus::Idle)],
None,
10,
),
panel,
);
let title = plain_line(&title_line(&app));
assert!(title.contains("workspace dashboard"));
assert!(title.contains("companion live"));
assert!(!title.contains("Row selection"));
assert!(!title.contains("blank Enter"));
assert!(!title.contains("Tab target"));
}
#[test] #[test]
fn panel_ticket_rows_render_state_title_then_detail_line() { fn panel_ticket_rows_render_state_title_then_detail_line() {
let row = panel_test_ticket_row( let row = panel_test_ticket_row(
@ -1725,7 +1873,7 @@ fn panel_ticket_intake_child_rows_render_as_indented_single_line() {
} }
#[test] #[test]
fn selected_ticket_intake_child_status_is_not_rendered_as_generic_ticket_or_pod() { fn selected_ticket_intake_child_keeps_row_marker_without_status_line() {
let ticket_id = "00001TICKET"; let ticket_id = "00001TICKET";
let pod_name = "intake-live"; let pod_name = "intake-live";
let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
@ -1750,11 +1898,11 @@ fn selected_ticket_intake_child_status_is_not_rendered_as_generic_ticket_or_pod(
let status = plain_line(&target_status_line(&app)); let status = plain_line(&target_status_line(&app));
assert!(status.contains("selected Intake Pod live")); assert_eq!(status, "");
assert!(status.contains("Ticket 00001TICKET"));
assert!(status.contains("blank Enter open/attach")); let title_line = plain_line(&panel_row_lines(app.selected_panel_row().unwrap(), true, 80)[0]);
assert!(!status.contains("selected Ticket")); assert!(title_line.starts_with(" ▶ live"));
assert!(!status.contains("selected Pod live")); assert!(title_line.contains("Intake"));
} }
#[test] #[test]
@ -1827,16 +1975,16 @@ fn dashboard_running_paused_and_stopped_targets_are_open_eligible() {
app.selected_row = None; app.selected_row = None;
app.ensure_selection_visible(); app.ensure_selection_visible();
assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled);
app.select_next();
assert_eq!(app.list.selected_entry().unwrap().name, "running");
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
assert!(app.selected_open_disabled_reason().is_none());
app.select_next(); app.select_next();
assert_eq!(app.list.selected_entry().unwrap().name, "paused"); assert_eq!(app.list.selected_entry().unwrap().name, "paused");
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
assert!(app.selected_open_disabled_reason().is_none());
app.select_next(); app.select_next();
assert_eq!(app.list.selected_entry().unwrap().name, "stopped"); assert_eq!(app.list.selected_entry().unwrap().name, "stopped");
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
assert!(app.selected_open_disabled_reason().is_none());
} }
#[test] #[test]
@ -1908,6 +2056,8 @@ fn dashboard_selection_follows_visible_section_order_without_hidden_closed_rows(
); );
let mut app = app_with_list(list); let mut app = app_with_list(list);
assert!(app.selected_row.is_none());
app.select_next();
assert_eq!(app.list.selected_entry().unwrap().name, "idle"); assert_eq!(app.list.selected_entry().unwrap().name, "idle");
app.select_next(); app.select_next();
assert_eq!(app.list.selected_entry().unwrap().name, "running"); assert_eq!(app.list.selected_entry().unwrap().name, "running");
@ -1944,7 +2094,7 @@ fn dashboard_selection_does_not_default_to_orchestrator_only_row() {
} }
#[test] #[test]
fn dashboard_selection_prefers_non_orchestrator_pod_by_default() { fn dashboard_selection_has_no_default_when_orchestrator_pod_exists() {
let list = PodList::from_sources( let list = PodList::from_sources(
PodVisibilitySource::ResumePicker, PodVisibilitySource::ResumePicker,
vec![], vec![],
@ -1963,7 +2113,8 @@ fn dashboard_selection_prefers_non_orchestrator_pod_by_default() {
)); ));
let app = app_with_panel(list, panel); let app = app_with_panel(list, panel);
assert_eq!(app.list.selected_entry().unwrap().name, "worker"); assert!(app.selected_row.is_none());
assert!(app.list.selected_name.is_none());
} }
#[test] #[test]
@ -2114,6 +2265,7 @@ fn dashboard_companion_finish_success_clears_composer() {
fn dashboard_open_request_keeps_dashboard_state_for_nested_console() { fn dashboard_open_request_keeps_dashboard_state_for_nested_console() {
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
app.input.insert_str("draft survives open"); app.input.insert_str("draft survives open");
app.select_next();
let request = app.prepare_open().unwrap(); let request = app.prepare_open().unwrap();
@ -2182,6 +2334,7 @@ fn dashboard_nested_console_success_continues_without_dropping_state() {
live_info("beta", PodStatus::Idle), live_info("beta", PodStatus::Idle),
]); ]);
app.select_next(); app.select_next();
app.select_next();
app.input.insert_str("keep this draft"); app.input.insert_str("keep this draft");
app.panel_diagnostic = Some(PanelDiagnostic { app.panel_diagnostic = Some(PanelDiagnostic {
title: "diagnostic stays".to_string(), title: "diagnostic stays".to_string(),
@ -2220,6 +2373,7 @@ fn dashboard_nested_console_recoverable_failure_continues_without_dropping_state
live_info("beta", PodStatus::Idle), live_info("beta", PodStatus::Idle),
]); ]);
app.select_next(); app.select_next();
app.select_next();
app.input.insert_str("keep this draft"); app.input.insert_str("keep this draft");
app.panel_diagnostic = Some(PanelDiagnostic { app.panel_diagnostic = Some(PanelDiagnostic {
title: "diagnostic stays".to_string(), title: "diagnostic stays".to_string(),
@ -2274,6 +2428,7 @@ fn dashboard_open_disabled_target_stays_in_dashboard() {
live.reachable = false; live.reachable = false;
live.status = None; live.status = None;
let mut app = test_app(vec![live]); let mut app = test_app(vec![live]);
app.select_next();
assert!(app.prepare_open().is_none()); assert!(app.prepare_open().is_none());
assert!(app.notice.as_deref().unwrap().contains("cannot be opened")); assert!(app.notice.as_deref().unwrap().contains("cannot be opened"));
@ -2282,6 +2437,7 @@ fn dashboard_open_disabled_target_stays_in_dashboard() {
#[test] #[test]
fn dashboard_empty_enter_uses_open_action() { fn dashboard_empty_enter_uses_open_action() {
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
app.select_next();
assert!(matches!( assert!(matches!(
app.handle_key(key(KeyCode::Enter)), app.handle_key(key(KeyCode::Enter)),
@ -2306,6 +2462,7 @@ fn dashboard_empty_enter_uses_open_action() {
#[test] #[test]
fn dashboard_whitespace_only_enter_uses_open_action() { fn dashboard_whitespace_only_enter_uses_open_action() {
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
app.select_next();
app.input.insert_str(" \n\t"); app.input.insert_str(" \n\t");
assert!(matches!( assert!(matches!(
@ -2382,6 +2539,7 @@ fn dashboard_alt_enter_on_blank_ticket_action_inserts_newline_without_dispatch()
PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10), PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10),
panel, panel,
); );
app.select_next();
let selected_before = app.selected_row.clone(); let selected_before = app.selected_row.clone();
assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Queue)); assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Queue));
@ -2425,6 +2583,7 @@ fn dashboard_composer_shared_word_motion_and_delete_keys() {
#[test] #[test]
fn dashboard_esc_clears_row_selection_without_quitting_and_preserves_draft() { fn dashboard_esc_clears_row_selection_without_quitting_and_preserves_draft() {
let mut app = ticket_enabled_app(vec![live_info("alpha", PodStatus::Idle)]); let mut app = ticket_enabled_app(vec![live_info("alpha", PodStatus::Idle)]);
app.select_next();
app.input.insert_str("draft message"); app.input.insert_str("draft message");
assert!(app.selected_row.is_some()); assert!(app.selected_row.is_some());
@ -2768,6 +2927,7 @@ fn intake_registry_update_claim_conflict_is_diagnostic_not_overwrite() {
#[test] #[test]
fn dashboard_empty_enter_on_non_openable_row_reports_open_diagnostic() { fn dashboard_empty_enter_on_non_openable_row_reports_open_diagnostic() {
let mut app = test_app(vec![unreachable_live_info("unreachable")]); let mut app = test_app(vec![unreachable_live_info("unreachable")]);
app.select_next();
assert!(matches!( assert!(matches!(
app.handle_key(key(KeyCode::Enter)), app.handle_key(key(KeyCode::Enter)),
DashboardAction::Open DashboardAction::Open

View File

@ -1446,7 +1446,7 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)); ));
spans.push(Span::styled( spans.push(Span::styled(
" — Enter to resume, type to start new turn", " — Enter to resume, Ctrl-X to cancel, type to start new turn",
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)); ));
} else { } else {

View File

@ -8,6 +8,7 @@ publish = false
[dependencies] [dependencies]
async-trait.workspace = true async-trait.workspace = true
axum.workspace = true axum.workspace = true
chrono = { version = "0.4", default-features = false, features = ["clock"] }
manifest = { workspace = true } manifest = { workspace = true }
pod-store = { workspace = true } pod-store = { workspace = true }
project-record.workspace = true project-record.workspace = true
@ -18,7 +19,9 @@ serde_yaml.workspace = true
thiserror.workspace = true thiserror.workspace = true
ticket.workspace = true ticket.workspace = true
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] } tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
toml.workspace = true
tracing.workspace = true tracing.workspace = true
uuid = { workspace = true, features = ["v7"] }
[dev-dependencies] [dev-dependencies]
tempfile.workspace = true tempfile.workspace = true

View File

@ -115,7 +115,7 @@ impl LocalRuntimeBridge {
self.workspace_root self.workspace_root
.file_name() .file_name()
.and_then(|name| name.to_str()) .and_then(|name| name.to_str())
.unwrap_or("workspace") .expect("workspace root must have a final path component")
), ),
kind: "local_host".to_string(), kind: "local_host".to_string(),
status, status,

View File

@ -0,0 +1,323 @@
use std::fs::{self, OpenOptions};
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use chrono::{SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{Error, Result};
pub const WORKSPACE_IDENTITY_RELATIVE_PATH: &str = ".yoi/workspace.toml";
/// Stable local Workspace identity persisted as a tracked, safe project record.
///
/// The v0 TOML schema intentionally contains identity metadata only:
/// `workspace_id`, `created_at`, and `display_name`. Unknown fields are rejected
/// instead of preserved because this loader cannot safely round-trip future local
/// runtime settings without risking accidental path or secret persistence.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceIdentity {
pub workspace_id: String,
pub created_at: String,
pub display_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkspaceIdentityFile {
workspace_id: String,
created_at: String,
display_name: String,
}
impl WorkspaceIdentity {
pub fn load_or_init(workspace_root: impl AsRef<Path>) -> Result<Self> {
Self::load_or_init_with_clock(workspace_root.as_ref(), || {
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
})
}
pub fn path(workspace_root: impl AsRef<Path>) -> PathBuf {
workspace_root
.as_ref()
.join(WORKSPACE_IDENTITY_RELATIVE_PATH)
}
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let parsed: WorkspaceIdentityFile = toml::from_str(raw).map_err(|error| {
workspace_identity_error(path, format!("failed to parse TOML: {error}"))
})?;
Self::from_file(parsed, path)
}
fn load_or_init_with_clock(
workspace_root: &Path,
now_utc_rfc3339: impl FnOnce() -> String,
) -> Result<Self> {
let path = Self::path(workspace_root);
match fs::read_to_string(&path) {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == ErrorKind::NotFound => {
Self::init(workspace_root, &path, now_utc_rfc3339())
}
Err(error) => Err(Error::Io(error)),
}
}
fn init(workspace_root: &Path, path: &Path, created_at: String) -> Result<Self> {
validate_created_at(&created_at, path)?;
let display_name = workspace_display_name_from_root(workspace_root, path)?;
let workspace_id = Uuid::now_v7().to_string();
let identity = Self {
workspace_id,
created_at,
display_name,
};
identity.write_new_or_read_existing(path)
}
fn from_file(parsed: WorkspaceIdentityFile, path: &Path) -> Result<Self> {
let workspace_id = validate_workspace_id(&parsed.workspace_id, path)?;
validate_created_at(&parsed.created_at, path)?;
validate_display_name(&parsed.display_name, path)?;
Ok(Self {
workspace_id,
created_at: parsed.created_at,
display_name: parsed.display_name,
})
}
fn write_new_or_read_existing(&self, path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let raw = toml::to_string_pretty(&WorkspaceIdentityFile {
workspace_id: self.workspace_id.clone(),
created_at: self.created_at.clone(),
display_name: self.display_name.clone(),
})
.map_err(|error| {
workspace_identity_error(path, format!("failed to encode TOML: {error}"))
})?;
match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(mut file) => {
file.write_all(raw.as_bytes())?;
file.sync_all()?;
Ok(self.clone())
}
Err(error) if error.kind() == ErrorKind::AlreadyExists => {
let raw = fs::read_to_string(path)?;
Self::parse_str(&raw, path)
}
Err(error) => Err(Error::Io(error)),
}
}
}
fn validate_workspace_id(value: &str, path: &Path) -> Result<String> {
let uuid = Uuid::parse_str(value).map_err(|error| {
workspace_identity_error(path, format!("workspace_id is not a UUID: {error}"))
})?;
if uuid.get_version_num() != 7 {
return Err(workspace_identity_error(
path,
"workspace_id must be a UUIDv7 canonical string".to_string(),
));
}
let canonical = uuid.to_string();
if value != canonical {
return Err(workspace_identity_error(
path,
"workspace_id must use lowercase hyphenated UUID canonical form".to_string(),
));
}
Ok(canonical)
}
fn validate_created_at(value: &str, path: &Path) -> Result<()> {
let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|error| {
workspace_identity_error(path, format!("created_at is not RFC3339: {error}"))
})?;
if parsed.offset().local_minus_utc() != 0 || !value.ends_with('Z') {
return Err(workspace_identity_error(
path,
"created_at must be a UTC RFC3339 timestamp ending in Z".to_string(),
));
}
Ok(())
}
fn validate_display_name(value: &str, path: &Path) -> Result<()> {
if value.trim().is_empty() {
return Err(workspace_identity_error(
path,
"display_name must not be empty".to_string(),
));
}
if value.contains('\0') || value.chars().any(|ch| ch.is_control()) {
return Err(workspace_identity_error(
path,
"display_name must not contain control characters".to_string(),
));
}
Ok(())
}
fn workspace_display_name_from_root(workspace_root: &Path, path: &Path) -> Result<String> {
let display_name = workspace_root
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
workspace_identity_error(
path,
"workspace root must have a UTF-8 final path component".to_string(),
)
})?
.to_string();
validate_display_name(&display_name, path)?;
Ok(display_name)
}
fn workspace_identity_error(path: &Path, message: String) -> Error {
Error::WorkspaceIdentity(format!("{}: {message}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
const FIXED_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001";
const FIXED_CREATED_AT: &str = "2026-06-23T06:43:28Z";
#[test]
fn missing_identity_file_is_created_with_safe_fields() {
let temp = tempfile::tempdir().unwrap();
let workspace_root = temp.path().join("example-workspace");
fs::create_dir_all(&workspace_root).unwrap();
let identity = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
FIXED_CREATED_AT.to_string()
})
.unwrap();
assert_eq!(identity.display_name, "example-workspace");
assert_eq!(identity.created_at, FIXED_CREATED_AT);
validate_workspace_id(
&identity.workspace_id,
&WorkspaceIdentity::path(&workspace_root),
)
.unwrap();
let raw = fs::read_to_string(WorkspaceIdentity::path(&workspace_root)).unwrap();
assert!(raw.contains("workspace_id"));
assert!(raw.contains("display_name"));
assert!(raw.contains("created_at"));
assert!(!raw.contains(&workspace_root.to_string_lossy().to_string()));
let reloaded = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
"2026-06-24T00:00:00Z".to_string()
})
.unwrap();
assert_eq!(reloaded, identity);
}
#[test]
fn existing_identity_file_is_stable() {
let temp = tempfile::tempdir().unwrap();
let workspace_root = temp.path().join("moved-workspace");
let yoi_dir = workspace_root.join(".yoi");
fs::create_dir_all(&yoi_dir).unwrap();
let path = yoi_dir.join("workspace.toml");
let raw = format!(
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Stable Project\"\n"
);
fs::write(&path, &raw).unwrap();
let identity = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
"2026-06-24T00:00:00Z".to_string()
})
.unwrap();
assert_eq!(identity.workspace_id, FIXED_WORKSPACE_ID);
assert_eq!(identity.created_at, FIXED_CREATED_AT);
assert_eq!(identity.display_name, "Stable Project");
assert_eq!(fs::read_to_string(path).unwrap(), raw);
}
#[test]
fn create_new_race_returns_existing_persisted_identity() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(".yoi/workspace.toml");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let persisted_raw = format!(
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Persisted Project\"\n"
);
fs::write(&path, &persisted_raw).unwrap();
let generated = WorkspaceIdentity {
workspace_id: "0192f0e8-4d84-7d6e-b000-000000000002".to_string(),
created_at: "2026-06-24T00:00:00Z".to_string(),
display_name: "Generated Project".to_string(),
};
let returned = generated.write_new_or_read_existing(&path).unwrap();
assert_eq!(returned.workspace_id, FIXED_WORKSPACE_ID);
assert_eq!(returned.created_at, FIXED_CREATED_AT);
assert_eq!(returned.display_name, "Persisted Project");
assert_eq!(fs::read_to_string(path).unwrap(), persisted_raw);
}
#[test]
fn invalid_identity_file_fails_closed_without_rewriting() {
let temp = tempfile::tempdir().unwrap();
let workspace_root = temp.path().join("invalid-workspace");
let yoi_dir = workspace_root.join(".yoi");
fs::create_dir_all(&yoi_dir).unwrap();
let path = yoi_dir.join("workspace.toml");
let raw = "workspace_id = \"not-a-uuid\"\ncreated_at = \"2026-06-23T06:43:28Z\"\ndisplay_name = \"Invalid\"\n";
fs::write(&path, raw).unwrap();
let error = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
FIXED_CREATED_AT.to_string()
})
.unwrap_err();
assert!(error.to_string().contains("workspace_id is not a UUID"));
assert_eq!(fs::read_to_string(path).unwrap(), raw);
}
#[test]
fn generated_identity_does_not_leak_parent_paths() {
let temp = tempfile::tempdir().unwrap();
let secret_parent = temp.path().join("user-secret-parent");
let workspace_root = secret_parent.join("public-project-name");
fs::create_dir_all(&workspace_root).unwrap();
WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
FIXED_CREATED_AT.to_string()
})
.unwrap();
let raw = fs::read_to_string(WorkspaceIdentity::path(&workspace_root)).unwrap();
assert!(raw.contains("public-project-name"));
assert!(!raw.contains(&secret_parent.to_string_lossy().to_string()));
assert!(!raw.contains("user-secret-parent"));
assert!(!raw.contains("/"));
}
#[test]
fn unknown_fields_are_rejected() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("workspace.toml");
let raw = format!(
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Stable Project\"\nlocal_root = \"/tmp/secret\"\n"
);
let error = WorkspaceIdentity::parse_str(&raw, &path).unwrap_err();
assert!(error.to_string().contains("unknown field"));
}
}

View File

@ -5,11 +5,13 @@
//! remain the canonical project records and are read through bounded bridge APIs. //! remain the canonical project records and are read through bounded bridge APIs.
pub mod hosts; pub mod hosts;
pub mod identity;
pub mod records; pub mod records;
pub mod repositories; pub mod repositories;
pub mod server; pub mod server;
pub mod store; pub mod store;
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
pub use records::{ pub use records::{
LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary, LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary,
}; };
@ -40,6 +42,8 @@ pub enum Error {
UnknownHost(String), UnknownHost(String),
#[error("unknown local repository `{0}`")] #[error("unknown local repository `{0}`")]
UnknownRepository(String), UnknownRepository(String),
#[error("workspace identity error: {0}")]
WorkspaceIdentity(String),
#[error("store error: {0}")] #[error("store error: {0}")]
Store(String), Store(String),
} }

View File

@ -4,7 +4,7 @@ use std::process::ExitCode;
use std::sync::Arc; use std::sync::Arc;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use yoi_workspace_server::{ServerConfig, SqliteWorkspaceStore, serve}; use yoi_workspace_server::{ServerConfig, SqliteWorkspaceStore, WorkspaceIdentity, serve};
#[derive(Debug)] #[derive(Debug)]
struct ServeOptions { struct ServeOptions {
@ -64,6 +64,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
} }
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> { async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
let identity = WorkspaceIdentity::load_or_init(&options.workspace)?;
let db = options let db = options
.db .db
.unwrap_or_else(|| options.workspace.join(".yoi/workspace.db")); .unwrap_or_else(|| options.workspace.join(".yoi/workspace.db"));
@ -72,7 +73,7 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
} }
let store = Arc::new(SqliteWorkspaceStore::open(&db)?); let store = Arc::new(SqliteWorkspaceStore::open(&db)?);
let mut config = ServerConfig::local_dev(&options.workspace); let mut config = ServerConfig::local_dev(&options.workspace, identity);
config.static_assets_dir = options.frontend; config.static_assets_dir = options.frontend;
let listener = TcpListener::bind(options.listen).await?; let listener = TcpListener::bind(options.listen).await?;
eprintln!( eprintln!(
@ -147,6 +148,13 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
index += 1; index += 1;
} }
let workspace = workspace.canonicalize().map_err(|error| {
CliError(format!(
"failed to canonicalize workspace `{}`: {error}",
workspace.display()
))
})?;
Ok(ServeOptions { Ok(ServeOptions {
workspace, workspace,
db, db,

View File

@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize};
use crate::hosts::RuntimeDiagnostic; use crate::hosts::RuntimeDiagnostic;
const LOCAL_REPOSITORY_ID: &str = "local"; const LEGACY_LOCAL_REPOSITORY_ID: &str = "local";
const LOCAL_REPOSITORY_PREFIX: &str = "local-";
const MAX_COMMAND_OUTPUT: usize = 4096; const MAX_COMMAND_OUTPUT: usize = 4096;
const DEFAULT_LOG_LIMIT: usize = 10; const DEFAULT_LOG_LIMIT: usize = 10;
const MAX_LOG_LIMIT: usize = 50; const MAX_LOG_LIMIT: usize = 50;
@ -14,12 +15,14 @@ const MAX_FIELD_LEN: usize = 240;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct LocalRepositoryReader { pub struct LocalRepositoryReader {
workspace_root: PathBuf, workspace_root: PathBuf,
workspace_id: String,
} }
impl LocalRepositoryReader { impl LocalRepositoryReader {
pub fn new(workspace_root: impl Into<PathBuf>) -> Self { pub fn new(workspace_root: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Self {
Self { Self {
workspace_root: workspace_root.into(), workspace_root: workspace_root.into(),
workspace_id: workspace_id.into(),
} }
} }
@ -30,7 +33,7 @@ impl LocalRepositoryReader {
pub fn summary(&self, workspace_display_name: &str) -> RepositorySummary { pub fn summary(&self, workspace_display_name: &str) -> RepositorySummary {
let git = inspect_git(&self.workspace_root); let git = inspect_git(&self.workspace_root);
RepositorySummary { RepositorySummary {
id: LOCAL_REPOSITORY_ID.to_string(), id: Self::repository_id_for_workspace(&self.workspace_id),
display_name: workspace_display_name.to_string(), display_name: workspace_display_name.to_string(),
kind: "local".to_string(), kind: "local".to_string(),
workspace_root: self.workspace_root.clone(), workspace_root: self.workspace_root.clone(),
@ -46,8 +49,42 @@ impl LocalRepositoryReader {
git_log(&self.workspace_root, limit) git_log(&self.workspace_root, limit)
} }
pub fn is_local_repository_id(id: &str) -> bool { pub fn repository_id_for_workspace(workspace_id: &str) -> String {
id == LOCAL_REPOSITORY_ID format!(
"{LOCAL_REPOSITORY_PREFIX}{}",
sanitize_identifier_fragment(workspace_id)
)
}
pub fn is_local_repository_id(id: &str, workspace_id: &str) -> bool {
id == LEGACY_LOCAL_REPOSITORY_ID || id == Self::repository_id_for_workspace(workspace_id)
}
}
fn sanitize_identifier_fragment(value: &str) -> String {
let mut output = String::with_capacity(value.len());
let mut previous_dash = false;
for ch in value.chars() {
let mapped = if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
};
if mapped == '-' {
if !previous_dash {
output.push(mapped);
}
previous_dash = true;
} else {
output.push(mapped);
previous_dash = false;
}
}
let output = output.trim_matches('-').to_string();
if output.is_empty() {
"workspace".to_string()
} else {
output
} }
} }

View File

@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary}; use crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary};
use crate::identity::WorkspaceIdentity;
use crate::records::{ use crate::records::{
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary, LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
}; };
@ -28,6 +29,8 @@ pub enum AuthConfig {
#[derive(Clone)] #[derive(Clone)]
pub struct ServerConfig { pub struct ServerConfig {
pub workspace_id: String, pub workspace_id: String,
pub workspace_display_name: String,
pub workspace_created_at: String,
pub workspace_root: PathBuf, pub workspace_root: PathBuf,
pub static_assets_dir: Option<PathBuf>, pub static_assets_dir: Option<PathBuf>,
pub auth: AuthConfig, pub auth: AuthConfig,
@ -36,14 +39,12 @@ pub struct ServerConfig {
} }
impl ServerConfig { impl ServerConfig {
pub fn local_dev(workspace_root: impl Into<PathBuf>) -> Self { pub fn local_dev(workspace_root: impl Into<PathBuf>, identity: WorkspaceIdentity) -> Self {
let workspace_root = workspace_root.into(); let workspace_root = workspace_root.into();
let display = workspace_root
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("workspace");
Self { Self {
workspace_id: format!("local:{display}"), workspace_id: identity.workspace_id,
workspace_display_name: identity.display_name,
workspace_created_at: identity.created_at,
workspace_root, workspace_root,
static_assets_dir: None, static_assets_dir: None,
auth: AuthConfig::LocalDevToken { auth: AuthConfig::LocalDevToken {
@ -64,19 +65,13 @@ pub struct WorkspaceApi {
impl WorkspaceApi { impl WorkspaceApi {
pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> { pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> {
let display_name = config
.workspace_root
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("workspace")
.to_string();
store store
.upsert_workspace(&WorkspaceRecord { .upsert_workspace(&WorkspaceRecord {
workspace_id: config.workspace_id.clone(), workspace_id: config.workspace_id.clone(),
display_name, display_name: config.workspace_display_name.clone(),
state: "active".to_string(), state: "active".to_string(),
created_at: "1970-01-01T00:00:00Z".to_string(), created_at: config.workspace_created_at.clone(),
updated_at: "1970-01-01T00:00:00Z".to_string(), updated_at: config.workspace_created_at.clone(),
}) })
.await?; .await?;
Ok(Self { Ok(Self {
@ -99,16 +94,18 @@ impl WorkspaceApi {
} }
fn local_repository_reader(&self) -> LocalRepositoryReader { fn local_repository_reader(&self) -> LocalRepositoryReader {
LocalRepositoryReader::new(self.config.workspace_root.clone()) LocalRepositoryReader::new(
self.config.workspace_root.clone(),
self.config.workspace_id.clone(),
)
} }
fn workspace_display_name(&self) -> String { fn local_repository_id(&self) -> String {
self.config LocalRepositoryReader::repository_id_for_workspace(self.workspace_id())
.workspace_root }
.file_name()
.and_then(|name| name.to_str()) fn workspace_display_name(&self) -> &str {
.unwrap_or("workspace") self.config.workspace_display_name.as_str()
.to_string()
} }
} }
@ -243,14 +240,7 @@ async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<Worksp
let display_name = stored let display_name = stored
.as_ref() .as_ref()
.map(|record| record.display_name.clone()) .map(|record| record.display_name.clone())
.or_else(|| { .unwrap_or_else(|| api.config.workspace_display_name.clone());
api.config
.workspace_root
.file_name()
.and_then(|name| name.to_str())
.map(str::to_string)
})
.unwrap_or_else(|| "workspace".to_string());
Ok(Json(WorkspaceResponse { Ok(Json(WorkspaceResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
display_name, display_name,
@ -262,11 +252,11 @@ async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<Worksp
store: "sqlite".to_string(), store: "sqlite".to_string(),
event_stream: ExtensionPointState { event_stream: ExtensionPointState {
status: "reserved".to_string(), status: "reserved".to_string(),
note: "No event stream is exposed in this bootstrap; route/state seams are reserved.".to_string(), note: "No browser-to-Pod socket path is exposed in this bootstrap; any future stream must be a Workspace server proxy that resolves Worker identity and enforces method allow/block boundaries.".to_string(),
}, },
host_worker_bridge: ExtensionPointState { host_worker_bridge: ExtensionPointState {
status: "read_only_local".to_string(), status: "read_only_local".to_string(),
note: "Local Hosts and Workers are exposed as a read-only bridge over existing Pod metadata; no scheduling or lifecycle control is implemented.".to_string(), note: "Local Hosts and Workers are exposed as a read-only bridge over existing Pod metadata; no direct Pod socket, scheduling, or lifecycle control is implemented.".to_string(),
}, },
}, },
})) }))
@ -326,7 +316,7 @@ async fn list_repositories(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RepositoryListResponse>> { ) -> ApiResult<Json<RepositoryListResponse>> {
let reader = api.local_repository_reader(); let reader = api.local_repository_reader();
let items = reader.list(&api.workspace_display_name()); let items = reader.list(api.workspace_display_name());
Ok(Json(RepositoryListResponse { Ok(Json(RepositoryListResponse {
workspace_id: api.config.workspace_id, workspace_id: api.config.workspace_id,
items, items,
@ -339,11 +329,11 @@ async fn repository_detail(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(repository_id): AxumPath<String>, AxumPath(repository_id): AxumPath<String>,
) -> ApiResult<Json<RepositoryDetailResponse>> { ) -> ApiResult<Json<RepositoryDetailResponse>> {
ensure_local_repository(&repository_id)?; let _canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
let reader = api.local_repository_reader(); let reader = api.local_repository_reader();
Ok(Json(RepositoryDetailResponse { Ok(Json(RepositoryDetailResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
item: reader.summary(&api.workspace_display_name()), item: reader.summary(api.workspace_display_name()),
source: "local_workspace_root".to_string(), source: "local_workspace_root".to_string(),
})) }))
} }
@ -353,7 +343,7 @@ async fn repository_log(
AxumPath(repository_id): AxumPath<String>, AxumPath(repository_id): AxumPath<String>,
Query(query): Query<LogQuery>, Query(query): Query<LogQuery>,
) -> ApiResult<Json<RepositoryLogResponse>> { ) -> ApiResult<Json<RepositoryLogResponse>> {
ensure_local_repository(&repository_id)?; let canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
let RepositoryLogRead { let RepositoryLogRead {
limit, limit,
items, items,
@ -361,7 +351,7 @@ async fn repository_log(
} = api.local_repository_reader().recent_log(query.limit); } = api.local_repository_reader().recent_log(query.limit);
Ok(Json(RepositoryLogResponse { Ok(Json(RepositoryLogResponse {
workspace_id: api.config.workspace_id, workspace_id: api.config.workspace_id,
repository_id, repository_id: canonical_repository_id,
limit, limit,
items, items,
diagnostics, diagnostics,
@ -373,7 +363,7 @@ async fn repository_tickets(
AxumPath(repository_id): AxumPath<String>, AxumPath(repository_id): AxumPath<String>,
Query(query): Query<TicketKanbanQuery>, Query(query): Query<TicketKanbanQuery>,
) -> ApiResult<Json<RepositoryTicketsResponse>> { ) -> ApiResult<Json<RepositoryTicketsResponse>> {
ensure_local_repository(&repository_id)?; let canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
let limit = query.limit.unwrap_or(api.config.max_records).min(200); let limit = query.limit.unwrap_or(api.config.max_records).min(200);
let ProjectRecordList { let ProjectRecordList {
items, items,
@ -382,7 +372,7 @@ async fn repository_tickets(
} = api.records.list_tickets(limit)?; } = api.records.list_tickets(limit)?;
Ok(Json(RepositoryTicketsResponse { Ok(Json(RepositoryTicketsResponse {
workspace_id: api.config.workspace_id, workspace_id: api.config.workspace_id,
repository_id, repository_id: canonical_repository_id,
limit, limit,
columns: ticket_kanban_columns(items), columns: ticket_kanban_columns(items),
invalid_records, invalid_records,
@ -441,9 +431,10 @@ fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSu
}) })
} }
fn ensure_local_repository(repository_id: &str) -> Result<()> { fn ensure_local_repository(api: &WorkspaceApi, repository_id: &str) -> Result<String> {
if LocalRepositoryReader::is_local_repository_id(repository_id) { let canonical_repository_id = api.local_repository_id();
Ok(()) if LocalRepositoryReader::is_local_repository_id(repository_id, api.workspace_id()) {
Ok(canonical_repository_id)
} else { } else {
Err(Error::UnknownRepository(repository_id.to_string())) Err(Error::UnknownRepository(repository_id.to_string()))
} }
@ -624,6 +615,18 @@ mod tests {
use crate::store::SqliteWorkspaceStore; use crate::store::SqliteWorkspaceStore;
const TEST_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001";
const TEST_REPOSITORY_ID: &str = "local-0192f0e8-4d84-7d6e-a000-000000000001";
const TEST_CREATED_AT: &str = "2026-06-23T06:43:28Z";
fn test_identity() -> WorkspaceIdentity {
WorkspaceIdentity {
workspace_id: TEST_WORKSPACE_ID.to_string(),
display_name: "Test Workspace".to_string(),
created_at: TEST_CREATED_AT.to_string(),
}
}
#[tokio::test] #[tokio::test]
async fn serves_bounded_read_apis_and_static_spa_separately() { async fn serves_bounded_read_apis_and_static_spa_separately() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@ -635,15 +638,15 @@ mod tests {
std::fs::write(static_dir.join("assets/app.js"), "console.log('yoi');").unwrap(); std::fs::write(static_dir.join("assets/app.js"), "console.log('yoi');").unwrap();
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
let mut config = ServerConfig::local_dev(dir.path()); let mut config = ServerConfig::local_dev(dir.path(), test_identity());
config.workspace_id = "local:test".to_string();
config.static_assets_dir = Some(static_dir); config.static_assets_dir = Some(static_dir);
config.local_runtime_data_dir = Some(dir.path().join("data")); config.local_runtime_data_dir = Some(dir.path().join("data"));
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap(); let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
let app = build_router(api); let app = build_router(api);
let workspace = get_json(app.clone(), "/api/workspace").await; let workspace = get_json(app.clone(), "/api/workspace").await;
assert_eq!(workspace["workspace_id"], "local:test"); assert_eq!(workspace["workspace_id"], TEST_WORKSPACE_ID);
assert_eq!(workspace["display_name"], "Test Workspace");
assert_eq!(workspace["record_authority"], "local_yoi_project_records"); assert_eq!(workspace["record_authority"], "local_yoi_project_records");
assert_eq!( assert_eq!(
workspace["extension_points"]["host_worker_bridge"]["status"], workspace["extension_points"]["host_worker_bridge"]["status"],
@ -659,18 +662,18 @@ mod tests {
assert_eq!(objectives["items"][0]["summary"], "Objective body."); assert_eq!(objectives["items"][0]["summary"], "Objective body.");
let repositories = get_json(app.clone(), "/api/repositories").await; let repositories = get_json(app.clone(), "/api/repositories").await;
assert_eq!(repositories["items"][0]["id"], "local"); assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID);
assert_eq!(repositories["items"][0]["kind"], "local"); assert_eq!(repositories["items"][0]["kind"], "local");
let repository_detail = get_json(app.clone(), "/api/repositories/local").await; let repository_detail = get_json(app.clone(), "/api/repositories/local").await;
assert_eq!(repository_detail["item"]["id"], "local"); assert_eq!(repository_detail["item"]["id"], TEST_REPOSITORY_ID);
let repository_log = get_json(app.clone(), "/api/repositories/local/log?limit=3").await; let repository_log = get_json(app.clone(), "/api/repositories/local/log?limit=3").await;
assert_eq!(repository_log["repository_id"], "local"); assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID);
assert_eq!(repository_log["limit"], 3); assert_eq!(repository_log["limit"], 3);
let repository_tickets = get_json(app.clone(), "/api/repositories/local/tickets").await; let repository_tickets = get_json(app.clone(), "/api/repositories/local/tickets").await;
assert_eq!(repository_tickets["repository_id"], "local"); assert_eq!(repository_tickets["repository_id"], TEST_REPOSITORY_ID);
let ready_column = repository_tickets["columns"] let ready_column = repository_tickets["columns"]
.as_array() .as_array()
.unwrap() .unwrap()
@ -696,7 +699,7 @@ mod tests {
assert_eq!(unknown_repository_response.status(), StatusCode::NOT_FOUND); assert_eq!(unknown_repository_response.status(), StatusCode::NOT_FOUND);
let hosts = get_json(app.clone(), "/api/hosts").await; let hosts = get_json(app.clone(), "/api/hosts").await;
assert_eq!(hosts["items"][0]["host_id"], "local-local-test"); assert_eq!(hosts["items"][0]["host_id"], TEST_REPOSITORY_ID);
assert_eq!(hosts["items"][0]["kind"], "local_host"); assert_eq!(hosts["items"][0]["kind"], "local_host");
assert_eq!( assert_eq!(
hosts["items"][0]["capabilities"]["local_pod_inspection"], hosts["items"][0]["capabilities"]["local_pod_inspection"],
@ -710,7 +713,11 @@ mod tests {
"local_pod_metadata_root_missing" "local_pod_metadata_root_missing"
); );
let host_workers = get_json(app.clone(), "/api/hosts/local-local-test/workers").await; let host_workers = get_json(
app.clone(),
&format!("/api/hosts/{TEST_REPOSITORY_ID}/workers"),
)
.await;
assert!(host_workers["items"].as_array().unwrap().is_empty()); assert!(host_workers["items"].as_array().unwrap().is_empty());
let runs_response = app let runs_response = app

View File

@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter; filter = sourceFilter;
}; };
cargoHash = "sha256-dKkAFUfTAMxSRHq9iNmwRXjQVSBHQBtb0+v8VHkgAGM="; cargoHash = "sha256-XZxqEKKDU42fFjFnCCcRRFTA0jkkiaSn3eQ8QwXRYPk=";
depsExtraArgs = { depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint, # Older fetchCargoVendor utilities used crates.io's API download endpoint,

View File

@ -347,8 +347,7 @@
border-top: 0; border-top: 0;
} }
.runtime-card, .runtime-card {
.kanban-column {
padding: var(--space-4) 0 0; padding: var(--space-4) 0 0;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
@ -423,36 +422,6 @@
text-transform: uppercase; text-transform: uppercase;
} }
.kanban {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(auto-fit, minmax(min(190px, 100%), 1fr));
margin-top: var(--space-4);
}
.kanban-column h3 {
display: flex;
justify-content: space-between;
gap: var(--space-3);
color: var(--text-muted);
font-size: 0.9rem;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.kanban-column ul {
display: grid;
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.kanban-column li {
padding-left: var(--space-3);
border-left: 2px solid var(--line);
}
.diagnostics { .diagnostics {
margin-top: var(--space-4); margin-top: var(--space-4);
} }

View File

@ -0,0 +1,123 @@
// @generated by `cargo run -p protocol --example generate_typescript --features typescript`
// Do not edit by hand. Rust DTO authority lives in `crates/protocol`.
// Large integer fields are JSON numbers on the wire and are emitted as TypeScript `number`.
export type AlertLevel = "warn" | "error";
export type AlertSource = "pod" | "worker" | "compactor" | "agents_md";
export type CompletionKind = "file" | "knowledge" | "workflow";
export type PodStatus = "idle" | "running" | "paused";
export type TurnResult = "finished" | "paused";
export type InvokeKind = "user_send" | "notify" | "pod_event" | "system_reminder" | "wakeup";
export type RunResult = "finished" | "paused" | "limit_reached" | "rolled_back";
export type ErrorCode = "already_running" | "not_running" | "not_paused" | "provider_error" | "tool_error" | "invalid_request" | "internal";
export type Permission = "read" | "write";
export type InFlightToolCallState = "pending" | "streaming_args" | "done";
export type ScopeRule = {
/**
* Target path. Must be absolute by the time a `Scope` is built from
* this rule relative paths are resolved per-layer against the
* manifest file's directory (cwd for overlay layers) before cascade
* merge.
*/
target: string,
/**
* Permission level this rule grants (allow) or caps strictly below
* (deny).
*/
permission: Permission,
/**
* When `false`, the rule only matches the target itself and its
* direct children. Defaults to `true`.
*/
recursive: boolean, };
export type CompletionEntry = { value: string, is_dir: boolean, };
export type RewindTargetId = { segment_id: string, user_input_entry_index: number, };
export type RewindTarget = { id: RewindTargetId, expected_head_entries: number, truncate_entries: number, turn_index: number, timestamp_ms: number | null, preview: string, eligible: boolean, disabled_reason: string | null, warning: string | null, };
export type RewindSummary = { truncated_to_entries: number, discarded_entries: number, tool_side_effect_warning: boolean, };
export type InFlightBlock = { "kind": "text", text: string, finished?: boolean, } | { "kind": "thinking", text: string, finished?: boolean, } | { "kind": "tool_call", id: string, name: string, args: string, state?: InFlightToolCallState, };
export type InFlightSnapshot = { blocks?: Array<InFlightBlock>, };
export type Greeting = { pod_name: string, cwd: string, provider: string, model: string, scope_summary: string, tools: Array<string>,
/**
* Model context window in tokens. Always filled by the Pod greeting.
*/
context_window: number,
/**
* Estimated current session context tokens at connect time.
*/
context_tokens: number, };
export type Alert = { level: AlertLevel, source: AlertSource, message: string,
/**
* Milliseconds since the Unix epoch.
*/
timestamp_ms: number, };
export type MemoryWorkerEvent = { worker: string, status: string, run_id: string, trigger: string, reason: string,
/**
* Human-readable compact form for actionbar rendering.
*/
message: string,
/**
* Milliseconds since the Unix epoch.
*/
timestamp_ms: number, };
export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "file_ref", path: string, } | { "kind": "knowledge_ref", slug: string, } | { "kind": "workflow_invoke", slug: string, } | { "kind": "unknown" };
export type PodEvent = { "kind": "turn_ended", pod_name: string, } | { "kind": "errored", pod_name: string, message: string, } | { "kind": "shut_down", pod_name: string, } | { "kind": "scope_sub_delegated",
/**
* Sub-delegating Pod (= the sender itself).
*/
parent_pod: string,
/**
* Name of the grandchild Pod.
*/
sub_pod: string,
/**
* Unix-socket path where the grandchild is reachable.
*/
sub_socket: string,
/**
* Scope delegated to the grandchild.
*/
scope: Array<ScopeRule>, };
export type Method = { "method": "run", "params": { input: Array<Segment>, } } | { "method": "notify", "params": { message: string, auto_run?: boolean, } } | { "method": "pod_event", "params": PodEvent } | { "method": "resume" } | { "method": "cancel" } | { "method": "pause" } | { "method": "compact" } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown" } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_pods" } | { "method": "restore_pod", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
export type Event = { "event": "user_message", "data": { segments: Array<Segment>, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number,
/**
* The attempt that just failed. 1 origin.
*/
failed_attempt: number, max_attempts: number, wait_ms: number, elapsed_ms: number, status?: number | null, error: string, } } | { "event": "llm_continuation", "data": { llm_call: number, attempt: number, max_attempts: number, reason: string, } } | { "event": "text_delta", "data": { text: string, } } | { "event": "text_done", "data": { text: string, } } | { "event": "thinking_start" } | { "event": "thinking_delta", "data": { text: string, } } | { "event": "thinking_done", "data": { text: string, } } | { "event": "tool_call_start", "data": { id: string, name: string, } } | { "event": "tool_call_args_delta", "data": { id: string, json: string, } } | { "event": "tool_call_done", "data": { id: string, name: string, arguments: string, } } | { "event": "tool_result", "data": { id: string,
/**
* Short human-readable summary. Always present; used by clients
* that only want a 1-line rendering (e.g. collapsed views).
*/
summary: string,
/**
* Full tool output. Absent when the tool chose to return
* summary-only, or when the result was pruned.
*/
output?: string | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { entries: Array<unknown>, greeting: Greeting, status: PodStatus,
/**
* Unfinished model output that has already streamed in the current
* run but is not yet represented by committed snapshot entries.
*/
in_flight?: InFlightSnapshot, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: PodStatus, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { entries: Array<unknown>, input: Array<Segment>, summary: RewindSummary, } } | { "event": "pods_listed", "data": { pods: unknown, } } | { "event": "pod_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" };

View File

@ -0,0 +1,283 @@
<script lang="ts">
import type { RepositoryTicketsResponse, TicketSummary } from '$lib/workspace-sidebar/types';
const INITIAL_VISIBLE_ROWS = 30;
const VISIBLE_ROW_INCREMENT = 30;
const NEAR_BOTTOM_PX = 96;
type KanbanGroup = {
key: string;
label: string;
states: string[];
items: TicketSummary[];
};
type GroupMetadata = {
key: string;
label: string;
states: string[];
};
let { tickets }: { tickets: RepositoryTicketsResponse } = $props();
let visibleRowsByGroup = $state<Record<string, number>>({});
let groups = $derived(buildGroups(tickets.columns));
$effect(() => {
const groupKeys = new Set(groups.map((group) => group.key));
const nextVisibleRows = { ...visibleRowsByGroup };
let changed = false;
for (const group of groups) {
if (nextVisibleRows[group.key] === undefined) {
nextVisibleRows[group.key] = INITIAL_VISIBLE_ROWS;
changed = true;
}
}
for (const key of Object.keys(nextVisibleRows)) {
if (!groupKeys.has(key)) {
delete nextVisibleRows[key];
changed = true;
}
}
if (changed) {
visibleRowsByGroup = nextVisibleRows;
}
});
function buildGroups(columns: RepositoryTicketsResponse['columns']): KanbanGroup[] {
const groupsByKey = new Map<string, KanbanGroup>();
for (const column of columns) {
const metadata = groupMetadataForState(column.state);
let group = groupsByKey.get(metadata.key);
if (!group) {
group = {
key: metadata.key,
label: metadata.label,
states: metadata.states,
items: []
};
groupsByKey.set(metadata.key, group);
}
group.items.push(...column.items);
}
return Array.from(groupsByKey.values()).map((group) => ({
...group,
items: sortGroupItems(group.items)
}));
}
function groupMetadataForState(state: string): GroupMetadata {
if (state === 'planning' || state === 'ready') {
return {
key: 'ready-planning',
label: 'Ready / Planning',
states: ['ready', 'planning']
};
}
if (state === 'queued' || state === 'inprogress') {
return {
key: 'inprogress-queued',
label: 'In progress / Queued',
states: ['inprogress', 'queued']
};
}
if (state === 'other') {
return {
key: 'state:other',
label: 'Other states',
states: ['other']
};
}
return {
key: `state:${state}`,
label: state,
states: [state]
};
}
function sortGroupItems(items: TicketSummary[]): TicketSummary[] {
return items
.map((ticket, index) => ({ ticket, index }))
.sort((left, right) => {
const stateOrder = statePriority(left.ticket.state) - statePriority(right.ticket.state);
if (stateOrder !== 0) {
return stateOrder;
}
return left.index - right.index;
})
.map(({ ticket }) => ticket);
}
function statePriority(state: string): number {
if (state === 'ready' || state === 'inprogress') {
return 0;
}
if (state === 'planning' || state === 'queued') {
return 1;
}
return 2;
}
function visibleCount(groupKey: string): number {
return visibleRowsByGroup[groupKey] ?? INITIAL_VISIBLE_ROWS;
}
function visibleTickets(group: KanbanGroup): TicketSummary[] {
return group.items.slice(0, visibleCount(group.key));
}
function hasMore(group: KanbanGroup): boolean {
return visibleCount(group.key) < group.items.length;
}
function onGroupScroll(group: KanbanGroup, event: Event) {
if (!hasMore(group)) {
return;
}
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) {
return;
}
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
if (distanceFromBottom > NEAR_BOTTOM_PX) {
return;
}
visibleRowsByGroup = {
...visibleRowsByGroup,
[group.key]: Math.min(group.items.length, visibleCount(group.key) + VISIBLE_ROW_INCREMENT)
};
}
function formatDate(value: string | null | undefined): string {
return value ?? 'not recorded';
}
</script>
<div class="repository-ticket-kanban">
{#each groups as group (group.key)}
<article class="ticket-group" aria-labelledby={`${group.key}-heading`}>
<header class="ticket-group-heading">
<div>
<h3 id={`${group.key}-heading`}>{group.label}</h3>
<p>{group.states.join(' + ')}</p>
</div>
<span>{group.items.length}</span>
</header>
{#if group.items.length === 0}
<p class="muted">No tickets.</p>
{:else}
<div
class="ticket-list-scroll"
aria-label={`${group.label} tickets`}
onscroll={(event) => onGroupScroll(group, event)}
>
<ul class="ticket-list">
{#each visibleTickets(group) as ticket (ticket.id)}
<li class="ticket-row">
<div class="ticket-row-heading">
<strong>{ticket.title}</strong>
<span class="ticket-state">{ticket.state}</span>
</div>
<small><code>{ticket.id}</code> · updated {formatDate(ticket.updated_at)}</small>
</li>
{/each}
</ul>
{#if hasMore(group)}
<p class="lazy-note">Showing {visibleCount(group.key)} of {group.items.length}; scroll for more.</p>
{/if}
</div>
{/if}
</article>
{/each}
</div>
<style>
.repository-ticket-kanban {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
margin-top: var(--space-4);
}
.ticket-group {
min-width: 0;
padding: var(--space-4) 0 0;
border-top: 1px solid var(--line);
}
.ticket-group-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.ticket-group-heading h3 {
margin: 0;
color: var(--text-muted);
font-size: 0.9rem;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.ticket-group-heading p,
.ticket-group-heading span,
.lazy-note {
color: var(--text-faint);
font-size: 0.78rem;
}
.ticket-group-heading p,
.lazy-note {
margin: var(--space-1) 0 0;
}
.ticket-list-scroll {
max-height: 34rem;
overflow-y: auto;
padding-right: var(--space-2);
scrollbar-gutter: stable;
}
.ticket-list {
display: grid;
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.ticket-row {
padding-left: var(--space-3);
border-left: 2px solid var(--line);
}
.ticket-row-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.ticket-row-heading strong {
color: var(--text-strong);
}
.ticket-state {
flex: 0 0 auto;
color: var(--text-muted);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
</style>

View File

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import RepositoryTicketKanban from '$lib/workspace-pages/RepositoryTicketKanban.svelte';
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte'; import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
import type { import type {
Diagnostic, Diagnostic,
@ -355,25 +356,7 @@
Read-only grouping of canonical Ticket records. No drag/drop or lifecycle mutation is exposed. Read-only grouping of canonical Ticket records. No drag/drop or lifecycle mutation is exposed.
</p> </p>
{#if repositoryTickets} {#if repositoryTickets}
<div class="kanban"> <RepositoryTicketKanban tickets={repositoryTickets} />
{#each repositoryTickets.columns as column (column.state)}
<article class="kanban-column">
<h3>{column.state} <span>{column.items.length}</span></h3>
{#if column.items.length === 0}
<p class="muted">No tickets.</p>
{:else}
<ul>
{#each column.items as ticket (ticket.id)}
<li>
<strong>{ticket.title}</strong>
<small><code>{ticket.id}</code> · updated {formatDate(ticket.updated_at)}</small>
</li>
{/each}
</ul>
{/if}
</article>
{/each}
</div>
{:else if repositoryTicketsError} {:else if repositoryTicketsError}
<p class="error">{repositoryTicketsError}</p> <p class="error">{repositoryTicketsError}</p>
{:else} {:else}

View File

@ -16,14 +16,16 @@
<aside class="workspace-sidebar" aria-label="Workspace navigation"> <aside class="workspace-sidebar" aria-label="Workspace navigation">
<header class="sidebar-header"> <header class="sidebar-header">
<div class="workspace-label"> <div class="workspace-label">
<span class="eyebrow">workspace</span> {#if workspace}
<h1>{workspace?.display_name ?? 'Yoi workspace'}</h1>
{#if workspaceError}
<p class="workspace-status error">Workspace summary unavailable.</p>
{:else if workspace}
<p class="workspace-status">{workspace.workspace_id}</p> <p class="workspace-status">{workspace.workspace_id}</p>
<h1>{workspace.display_name}</h1>
{:else} {:else}
<p class="workspace-status">Loading workspace…</p> <h1>Yoi workspace</h1>
{#if workspaceError}
<p class="workspace-status error">Workspace summary unavailable.</p>
{:else}
<p class="workspace-status">Loading workspace…</p>
{/if}
{/if} {/if}
</div> </div>

View File

@ -1,3 +1,9 @@
export type {
Event as PodProtocolEvent,
Method as PodProtocolMethod,
Segment as PodProtocolSegment,
} from '$lib/generated/protocol';
export type ExtensionPoint = { export type ExtensionPoint = {
status: string; status: string;
note: string; note: string;