Compare commits
60 Commits
5428837605
...
1ca5663298
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ca5663298 | |||
| b28d64c3c6 | |||
| 76c800542c | |||
| cca073f8aa | |||
| c0f4c184ca | |||
| 8c8fb01426 | |||
| 052408392d | |||
| cfb215ab0c | |||
| 3501e0dffe | |||
| af683af2d2 | |||
| 90b1a1fccb | |||
| 5954021cc5 | |||
| 108088d811 | |||
| ca2ad18ded | |||
| d2e6a2a1a7 | |||
| e275a80f4c | |||
| 2745f3d516 | |||
| 8f7b87a29e | |||
| 2a7b659be4 | |||
| 3dd680683c | |||
| 49c9e19074 | |||
| ddd2f86e26 | |||
| fac79dc962 | |||
| 71a39002fb | |||
| 58904c441a | |||
| 1e812b793b | |||
| ff8aa6bcbf | |||
| 2b213a8add | |||
| 021aca8d5e | |||
| 21b14ba440 | |||
| 5c242d9620 | |||
| 31565c9b9e | |||
| 99f31e0055 | |||
| 4cda83b748 | |||
| 13e76d3544 | |||
| 85b6d9f027 | |||
| 8daf9eacb7 | |||
| 48672e4317 | |||
| eb998f0ad4 | |||
| 9cbb5b9b71 | |||
| 108666664a | |||
| 5abf16f9e6 | |||
| 78c98a34d1 | |||
| 68f1ddbd5a | |||
| 71284cdc92 | |||
| 03ad525fcc | |||
| af9e940873 | |||
| b547203fde | |||
| 9728b533b4 | |||
| 9c4d1559fd | |||
| e0ec8ce78a | |||
| 9de04f7266 | |||
| eea26f9174 | |||
| 0b1e9fdc5b | |||
| 277af3dec3 | |||
| 3bfd1ca07d | |||
| 0e635ba6b4 | |||
| 017ac70876 | |||
| a13fb6933b | |||
| 6f68bb8d95 |
|
|
@ -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'
|
||||||
|
|
|
||||||
29
.yoi/tickets/00001KVSEBF56/resolution.md
Normal file
29
.yoi/tickets/00001KVSEBF56/resolution.md
Normal 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 を使う際は注意。
|
||||||
|
|
@ -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。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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']
|
||||||
|
|
|
||||||
20
.yoi/tickets/00001KVSFXY88/resolution.md
Normal file
20
.yoi/tickets/00001KVSFXY88/resolution.md
Normal 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 とした。
|
||||||
|
|
@ -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。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
22
.yoi/tickets/00001KVSGFM65/resolution.md
Normal file
22
.yoi/tickets/00001KVSGFM65/resolution.md
Normal 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 は追加していない。
|
||||||
|
|
@ -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 ticket’s 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。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
|
||||||
0
.yoi/tickets/00001KVSKGDYS/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KVSKGDYS/artifacts/.gitkeep
Normal 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"}
|
||||||
119
.yoi/tickets/00001KVSKGDYS/item.md
Normal file
119
.yoi/tickets/00001KVSKGDYS/item.md
Normal 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` が通る。
|
||||||
38
.yoi/tickets/00001KVSKGDYS/resolution.md
Normal file
38
.yoi/tickets/00001KVSKGDYS/resolution.md
Normal 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 で扱う。
|
||||||
536
.yoi/tickets/00001KVSKGDYS/thread.md
Normal file
536
.yoi/tickets/00001KVSKGDYS/thread.md
Normal 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 で扱う。
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KVSKJ0EA/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KVSKJ0EA/artifacts/.gitkeep
Normal 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"}
|
||||||
107
.yoi/tickets/00001KVSKJ0EA/item.md
Normal file
107
.yoi/tickets/00001KVSKJ0EA/item.md
Normal 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`
|
||||||
26
.yoi/tickets/00001KVSKJ0EA/resolution.md
Normal file
26
.yoi/tickets/00001KVSKJ0EA/resolution.md
Normal 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`)
|
||||||
|
|
||||||
|
残作業:
|
||||||
|
- なし。
|
||||||
313
.yoi/tickets/00001KVSKJ0EA/thread.md
Normal file
313
.yoi/tickets/00001KVSKJ0EA/thread.md
Normal 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`)
|
||||||
|
|
||||||
|
残作業:
|
||||||
|
- なし。
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KVSMJJNV/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KVSMJJNV/artifacts/.gitkeep
Normal 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"}
|
||||||
105
.yoi/tickets/00001KVSMJJNV/item.md
Normal file
105
.yoi/tickets/00001KVSMJJNV/item.md
Normal 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`
|
||||||
28
.yoi/tickets/00001KVSMJJNV/resolution.md
Normal file
28
.yoi/tickets/00001KVSMJJNV/resolution.md
Normal 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 予定。
|
||||||
318
.yoi/tickets/00001KVSMJJNV/thread.md
Normal file
318
.yoi/tickets/00001KVSMJJNV/thread.md
Normal 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
3
.yoi/workspace.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
workspace_id = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c5b6"
|
||||||
|
created_at = "2026-06-23T00:00:00Z"
|
||||||
|
display_name = "yoi"
|
||||||
|
|
@ -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
26
Cargo.lock
generated
|
|
@ -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]]
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
PodStatus::Paused => match pod.cancel_paused_turn() {
|
||||||
|
Ok(()) => {
|
||||||
|
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 {
|
let _ = event_tx.send(Event::Error {
|
||||||
code: ErrorCode::NotRunning,
|
code: ErrorCode::NotRunning,
|
||||||
message: "Pod is not running".into(),
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 _mutation = self.mutations.lock().await;
|
||||||
|
let snapshot = {
|
||||||
let mut records = self.records.lock().await;
|
let mut records = self.records.lock().await;
|
||||||
records.push(record);
|
records.push(record);
|
||||||
self.persist_records(records.as_slice()).await
|
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>,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"] }
|
||||||
|
|
|
||||||
16
crates/protocol/examples/generate_typescript.rs
Normal file
16
crates/protocol/examples/generate_typescript.rs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
97
crates/protocol/src/typescript.rs
Normal file
97
crates/protocol/src/typescript.rs
Normal 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`"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(&[
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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(¤t_workspace_root(), &list);
|
let panel = build_workspace_panel(¤t_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
|
|
||||||
.panel
|
|
||||||
.header
|
|
||||||
.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::Ticket(_)
|
||||||
| PanelRowKey::InvalidTicket(_)
|
| PanelRowKey::InvalidTicket(_)
|
||||||
| PanelRowKey::TicketIntakePod { .. } => true,
|
| PanelRowKey::TicketIntakePod { .. } => self.list.selected_name = None,
|
||||||
}) {
|
|
||||||
self.select_panel_key(key.clone());
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
self.selected_row = None;
|
self.selected_row = None;
|
||||||
self.list.selected_name = None;
|
self.list.selected_name = None;
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.select_panel_key(visible[0].clone());
|
|
||||||
} else if let Some(PanelRowKey::Pod(name)) = self.selected_row.as_ref() {
|
|
||||||
self.list.selected_name = Some(name.clone());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() => {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
} else if app.panel.header.ticket_configured {
|
|
||||||
" 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",
|
"workspace dashboard",
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
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 {
|
} 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 {
|
|
||||||
"↑/↓ select row Enter selected row Esc clear selection Left/Right cursor Ctrl+C quit"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
323
crates/workspace-server/src/identity.rs
Normal file
323
crates/workspace-server/src/identity.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
123
web/workspace/src/lib/generated/protocol.ts
Normal file
123
web/workspace/src/lib/generated/protocol.ts
Normal 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" };
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,17 @@
|
||||||
<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>
|
<p class="workspace-status">{workspace.workspace_id}</p>
|
||||||
|
<h1>{workspace.display_name}</h1>
|
||||||
|
{:else}
|
||||||
|
<h1>Yoi workspace</h1>
|
||||||
{#if workspaceError}
|
{#if workspaceError}
|
||||||
<p class="workspace-status error">Workspace summary unavailable.</p>
|
<p class="workspace-status error">Workspace summary unavailable.</p>
|
||||||
{:else if workspace}
|
|
||||||
<p class="workspace-status">{workspace.workspace_id}</p>
|
|
||||||
{:else}
|
{:else}
|
||||||
<p class="workspace-status">Loading workspace…</p>
|
<p class="workspace-status">Loading workspace…</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user