merge: sync orchestration before queue 00001KVSMJJNV
This commit is contained in:
commit
ca2ad18ded
|
|
@ -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"}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'Persist local Workspace identity in .yoi/workspace.toml'
|
||||
state: 'queued'
|
||||
state: 'closed'
|
||||
created_at: '2026-06-23T06:43:28Z'
|
||||
updated_at: '2026-06-23T06:47:18Z'
|
||||
updated_at: '2026-06-23T07:39:10Z'
|
||||
assignee: null
|
||||
queued_by: 'workspace-panel'
|
||||
queued_at: '2026-06-23T06:47:18Z'
|
||||
|
|
|
|||
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 で扱う。
|
||||
|
|
@ -30,4 +30,507 @@ Marked ready by `yoi ticket state`.
|
|||
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,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"}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'Dashboard reload と初期表示で row を自動選択しない'
|
||||
state: 'queued'
|
||||
state: 'closed'
|
||||
created_at: '2026-06-23T06:44:20Z'
|
||||
updated_at: '2026-06-23T06:47:17Z'
|
||||
updated_at: '2026-06-23T07:24:20Z'
|
||||
assignee: null
|
||||
readiness: 'implementation_ready'
|
||||
risk_flags: ['tui-ux', 'panel-selection', 'reload-state']
|
||||
|
|
|
|||
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`)
|
||||
|
||||
残作業:
|
||||
- なし。
|
||||
|
|
@ -29,4 +29,285 @@ LocalTicketBackend によって作成されました。
|
|||
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`)
|
||||
|
||||
残作業:
|
||||
- なし。
|
||||
|
||||
---
|
||||
|
|
|
|||
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"
|
||||
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -6050,6 +6050,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"manifest",
|
||||
"pod-store",
|
||||
"project-record",
|
||||
|
|
@ -6061,8 +6062,10 @@ dependencies = [
|
|||
"thiserror 2.0.18",
|
||||
"ticket",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tower",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -1266,8 +1266,7 @@ impl DashboardApp {
|
|||
.list
|
||||
.selected_name
|
||||
.clone()
|
||||
.filter(|name| list.entries.iter().any(|entry| entry.name == *name))
|
||||
.or_else(|| list.entries.first().map(|entry| entry.name.clone()));
|
||||
.filter(|name| list.entries.iter().any(|entry| entry.name == *name));
|
||||
let panel = build_workspace_panel(¤t_workspace_root(), &list);
|
||||
self.apply_reloaded_snapshot(DashboardSnapshot { list, panel });
|
||||
}
|
||||
|
|
@ -1276,25 +1275,17 @@ impl DashboardApp {
|
|||
self.apply_companion_lifecycle_memory(&mut snapshot.panel);
|
||||
self.apply_orchestrator_lifecycle_memory(&mut snapshot.panel);
|
||||
let previous_selected_pod = self.list.selected_name.clone();
|
||||
snapshot.list.selected_name = previous_selected_pod
|
||||
.filter(|name| {
|
||||
snapshot.list.selected_name = previous_selected_pod.filter(|name| {
|
||||
snapshot
|
||||
.list
|
||||
.entries
|
||||
.iter()
|
||||
.any(|entry| entry.name == *name)
|
||||
})
|
||||
.or_else(|| {
|
||||
snapshot
|
||||
.list
|
||||
.entries
|
||||
.first()
|
||||
.map(|entry| entry.name.clone())
|
||||
});
|
||||
let previous_row = self.selected_row.clone();
|
||||
self.list = snapshot.list;
|
||||
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_composer_target_available();
|
||||
self.refresh_orchestrator_work_set();
|
||||
|
|
@ -1444,12 +1435,14 @@ impl DashboardApp {
|
|||
self.list.selected_name = None;
|
||||
return;
|
||||
}
|
||||
let selected_pos = self
|
||||
let next_pos = match self
|
||||
.selected_row
|
||||
.as_ref()
|
||||
.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());
|
||||
}
|
||||
|
||||
|
|
@ -1460,12 +1453,15 @@ impl DashboardApp {
|
|||
self.list.selected_name = None;
|
||||
return;
|
||||
}
|
||||
let selected_pos = self
|
||||
let prev_pos = match self
|
||||
.selected_row
|
||||
.as_ref()
|
||||
.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 {
|
||||
|
|
@ -1623,55 +1619,32 @@ impl DashboardApp {
|
|||
|
||||
fn ensure_selection_visible(&mut self) {
|
||||
let visible = visible_panel_keys(&self.panel, &self.list);
|
||||
if visible.is_empty() {
|
||||
self.selected_row = None;
|
||||
let Some(selected_key) = self.selected_row.as_ref() else {
|
||||
self.list.selected_name = None;
|
||||
return;
|
||||
}
|
||||
let selected_visible = self
|
||||
.selected_row
|
||||
.as_ref()
|
||||
.is_some_and(|key| visible.iter().any(|visible_key| visible_key == key));
|
||||
if !selected_visible {
|
||||
let has_action_rows = self.panel.rows.iter().any(|row| row.is_ticket_action());
|
||||
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,
|
||||
};
|
||||
if visible
|
||||
.iter()
|
||||
.any(|visible_key| visible_key == selected_key)
|
||||
{
|
||||
match selected_key {
|
||||
PanelRowKey::Pod(name) => self.list.selected_name = Some(name.clone()),
|
||||
PanelRowKey::Ticket(_)
|
||||
| PanelRowKey::InvalidTicket(_)
|
||||
| PanelRowKey::TicketIntakePod { .. } => true,
|
||||
}) {
|
||||
self.select_panel_key(key.clone());
|
||||
return;
|
||||
| PanelRowKey::TicketIntakePod { .. } => self.list.selected_name = None,
|
||||
}
|
||||
} else {
|
||||
self.selected_row = 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) {
|
||||
if let PanelRowKey::Pod(name) = &key {
|
||||
self.list.selected_name = Some(name.clone());
|
||||
match &key {
|
||||
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")]
|
||||
let selected_key = key.clone();
|
||||
|
|
|
|||
|
|
@ -481,6 +481,7 @@ fn ready_ticket_intake_enter_prepares_planning_return_not_queue_or_generic_launc
|
|||
"ready",
|
||||
));
|
||||
let mut app = app_with_panel(empty_test_list(), panel);
|
||||
app.select_next();
|
||||
app.cycle_composer_target();
|
||||
app.input.insert_str("clarify expected behavior");
|
||||
|
||||
|
|
@ -938,6 +939,111 @@ fn no_ticket_selection_keeps_enter_pod_centric() {
|
|||
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]
|
||||
fn dashboard_ticket_action_rows_precede_pods_and_pod_actions_still_work() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
|
@ -960,6 +1066,8 @@ fn dashboard_ticket_action_rows_precede_pods_and_pod_actions_still_work() {
|
|||
);
|
||||
let panel = build_workspace_panel(temp.path(), &list);
|
||||
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_open_eligibility(), OpenEligibility::Disabled);
|
||||
|
|
@ -1176,6 +1284,7 @@ fn selected_ticket_row_with_non_empty_composer_hides_redundant_status_hints() {
|
|||
10,
|
||||
);
|
||||
let mut app = app_with_panel(list, panel);
|
||||
app.select_next();
|
||||
app.input.insert_str("draft to companion");
|
||||
|
||||
assert_eq!(
|
||||
|
|
@ -1201,7 +1310,7 @@ fn dashboard_bare_panel_letters_append_to_composer_and_arrows_select_when_blank(
|
|||
live_info("alpha", 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'] {
|
||||
assert!(matches!(
|
||||
|
|
@ -1211,14 +1320,14 @@ fn dashboard_bare_panel_letters_append_to_composer_and_arrows_select_when_blank(
|
|||
}
|
||||
|
||||
assert_eq!(input_text(&app), "jkor");
|
||||
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
||||
assert!(app.selected_row.is_none());
|
||||
|
||||
assert!(matches!(
|
||||
app.handle_key(key(KeyCode::Down)),
|
||||
DashboardAction::None
|
||||
));
|
||||
assert_eq!(input_text(&app), "jkor");
|
||||
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
||||
assert!(app.selected_row.is_none());
|
||||
|
||||
app.input.clear();
|
||||
assert!(matches!(
|
||||
|
|
@ -1226,6 +1335,12 @@ fn dashboard_bare_panel_letters_append_to_composer_and_arrows_select_when_blank(
|
|||
DashboardAction::None
|
||||
));
|
||||
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!(matches!(
|
||||
|
|
@ -1248,7 +1363,7 @@ fn dashboard_selection_changes_preserve_composer_contents() {
|
|||
app.select_next();
|
||||
|
||||
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]
|
||||
|
|
@ -1258,7 +1373,7 @@ fn dashboard_poll_reload_preserves_selection_composer_and_notice() {
|
|||
live_info_with_updated_at("beta", PodStatus::Idle, 20),
|
||||
]);
|
||||
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.notice = Some("keep this notice".to_string());
|
||||
let refreshed = PodList::from_sources(
|
||||
|
|
@ -1275,7 +1390,7 @@ fn dashboard_poll_reload_preserves_selection_composer_and_notice() {
|
|||
|
||||
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!(
|
||||
app.list
|
||||
.selected_entry()
|
||||
|
|
@ -1284,7 +1399,7 @@ fn dashboard_poll_reload_preserves_selection_composer_and_notice() {
|
|||
.as_ref()
|
||||
.unwrap()
|
||||
.status,
|
||||
Some(PodStatus::Running)
|
||||
Some(PodStatus::Idle)
|
||||
);
|
||||
assert_eq!(input_text(&app), "draft survives polling");
|
||||
assert_eq!(app.notice.as_deref(), Some("keep this notice"));
|
||||
|
|
@ -1296,6 +1411,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("beta", PodStatus::Running, 20),
|
||||
]);
|
||||
app.select_next();
|
||||
app.select_next();
|
||||
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
|
||||
let refreshed = PodList::from_sources(
|
||||
PodVisibilitySource::ResumePicker,
|
||||
|
|
@ -1307,7 +1424,8 @@ fn dashboard_poll_reload_falls_back_when_selected_pod_disappears() {
|
|||
|
||||
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]);
|
||||
}
|
||||
|
||||
|
|
@ -1526,7 +1644,8 @@ async fn dashboard_quit_aborts_background_reload_and_notice_without_waiting() {
|
|||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
|
|
@ -1855,6 +1974,9 @@ fn dashboard_running_paused_and_stopped_targets_are_open_eligible() {
|
|||
app.selected_row = None;
|
||||
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);
|
||||
app.select_next();
|
||||
assert_eq!(app.list.selected_entry().unwrap().name, "paused");
|
||||
|
|
@ -1933,6 +2055,8 @@ fn dashboard_selection_follows_visible_section_order_without_hidden_closed_rows(
|
|||
);
|
||||
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");
|
||||
app.select_next();
|
||||
assert_eq!(app.list.selected_entry().unwrap().name, "running");
|
||||
|
|
@ -1969,7 +2093,7 @@ fn dashboard_selection_does_not_default_to_orchestrator_only_row() {
|
|||
}
|
||||
|
||||
#[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(
|
||||
PodVisibilitySource::ResumePicker,
|
||||
vec![],
|
||||
|
|
@ -1988,7 +2112,8 @@ fn dashboard_selection_prefers_non_orchestrator_pod_by_default() {
|
|||
));
|
||||
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]
|
||||
|
|
@ -2139,6 +2264,7 @@ fn dashboard_companion_finish_success_clears_composer() {
|
|||
fn dashboard_open_request_keeps_dashboard_state_for_nested_console() {
|
||||
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
||||
app.input.insert_str("draft survives open");
|
||||
app.select_next();
|
||||
|
||||
let request = app.prepare_open().unwrap();
|
||||
|
||||
|
|
@ -2207,6 +2333,7 @@ fn dashboard_nested_console_success_continues_without_dropping_state() {
|
|||
live_info("beta", PodStatus::Idle),
|
||||
]);
|
||||
app.select_next();
|
||||
app.select_next();
|
||||
app.input.insert_str("keep this draft");
|
||||
app.panel_diagnostic = Some(PanelDiagnostic {
|
||||
title: "diagnostic stays".to_string(),
|
||||
|
|
@ -2245,6 +2372,7 @@ fn dashboard_nested_console_recoverable_failure_continues_without_dropping_state
|
|||
live_info("beta", PodStatus::Idle),
|
||||
]);
|
||||
app.select_next();
|
||||
app.select_next();
|
||||
app.input.insert_str("keep this draft");
|
||||
app.panel_diagnostic = Some(PanelDiagnostic {
|
||||
title: "diagnostic stays".to_string(),
|
||||
|
|
@ -2299,6 +2427,7 @@ fn dashboard_open_disabled_target_stays_in_dashboard() {
|
|||
live.reachable = false;
|
||||
live.status = None;
|
||||
let mut app = test_app(vec![live]);
|
||||
app.select_next();
|
||||
|
||||
assert!(app.prepare_open().is_none());
|
||||
assert!(app.notice.as_deref().unwrap().contains("cannot be opened"));
|
||||
|
|
@ -2307,6 +2436,7 @@ fn dashboard_open_disabled_target_stays_in_dashboard() {
|
|||
#[test]
|
||||
fn dashboard_empty_enter_uses_open_action() {
|
||||
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
||||
app.select_next();
|
||||
|
||||
assert!(matches!(
|
||||
app.handle_key(key(KeyCode::Enter)),
|
||||
|
|
@ -2331,6 +2461,7 @@ fn dashboard_empty_enter_uses_open_action() {
|
|||
#[test]
|
||||
fn dashboard_whitespace_only_enter_uses_open_action() {
|
||||
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
||||
app.select_next();
|
||||
app.input.insert_str(" \n\t");
|
||||
|
||||
assert!(matches!(
|
||||
|
|
@ -2407,6 +2538,7 @@ fn dashboard_alt_enter_on_blank_ticket_action_inserts_newline_without_dispatch()
|
|||
PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10),
|
||||
panel,
|
||||
);
|
||||
app.select_next();
|
||||
let selected_before = app.selected_row.clone();
|
||||
assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Queue));
|
||||
|
||||
|
|
@ -2450,6 +2582,7 @@ fn dashboard_composer_shared_word_motion_and_delete_keys() {
|
|||
#[test]
|
||||
fn dashboard_esc_clears_row_selection_without_quitting_and_preserves_draft() {
|
||||
let mut app = ticket_enabled_app(vec![live_info("alpha", PodStatus::Idle)]);
|
||||
app.select_next();
|
||||
app.input.insert_str("draft message");
|
||||
|
||||
assert!(app.selected_row.is_some());
|
||||
|
|
@ -2793,6 +2926,7 @@ fn intake_registry_update_claim_conflict_is_diagnostic_not_overwrite() {
|
|||
#[test]
|
||||
fn dashboard_empty_enter_on_non_openable_row_reports_open_diagnostic() {
|
||||
let mut app = test_app(vec![unreachable_live_info("unreachable")]);
|
||||
app.select_next();
|
||||
assert!(matches!(
|
||||
app.handle_key(key(KeyCode::Enter)),
|
||||
DashboardAction::Open
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ publish = false
|
|||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
axum.workspace = true
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
manifest = { workspace = true }
|
||||
pod-store = { workspace = true }
|
||||
project-record.workspace = true
|
||||
|
|
@ -18,7 +19,9 @@ serde_yaml.workspace = true
|
|||
thiserror.workspace = true
|
||||
ticket.workspace = true
|
||||
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
|
|
|||
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.
|
||||
|
||||
pub mod hosts;
|
||||
pub mod identity;
|
||||
pub mod records;
|
||||
pub mod repositories;
|
||||
pub mod server;
|
||||
pub mod store;
|
||||
|
||||
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
|
||||
pub use records::{
|
||||
LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary,
|
||||
};
|
||||
|
|
@ -40,6 +42,8 @@ pub enum Error {
|
|||
UnknownHost(String),
|
||||
#[error("unknown local repository `{0}`")]
|
||||
UnknownRepository(String),
|
||||
#[error("workspace identity error: {0}")]
|
||||
WorkspaceIdentity(String),
|
||||
#[error("store error: {0}")]
|
||||
Store(String),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::process::ExitCode;
|
|||
use std::sync::Arc;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use yoi_workspace_server::{ServerConfig, SqliteWorkspaceStore, serve};
|
||||
use yoi_workspace_server::{ServerConfig, SqliteWorkspaceStore, WorkspaceIdentity, serve};
|
||||
|
||||
#[derive(Debug)]
|
||||
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>> {
|
||||
let identity = WorkspaceIdentity::load_or_init(&options.workspace)?;
|
||||
let db = options
|
||||
.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 mut config = ServerConfig::local_dev(&options.workspace);
|
||||
let mut config = ServerConfig::local_dev(&options.workspace, identity);
|
||||
config.static_assets_dir = options.frontend;
|
||||
let listener = TcpListener::bind(options.listen).await?;
|
||||
eprintln!(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
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 DEFAULT_LOG_LIMIT: usize = 10;
|
||||
const MAX_LOG_LIMIT: usize = 50;
|
||||
|
|
@ -14,12 +15,14 @@ const MAX_FIELD_LEN: usize = 240;
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct LocalRepositoryReader {
|
||||
workspace_root: PathBuf,
|
||||
workspace_id: String,
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
let git = inspect_git(&self.workspace_root);
|
||||
RepositorySummary {
|
||||
id: LOCAL_REPOSITORY_ID.to_string(),
|
||||
id: Self::repository_id_for_workspace(&self.workspace_id),
|
||||
display_name: workspace_display_name.to_string(),
|
||||
kind: "local".to_string(),
|
||||
workspace_root: self.workspace_root.clone(),
|
||||
|
|
@ -46,8 +49,42 @@ impl LocalRepositoryReader {
|
|||
git_log(&self.workspace_root, limit)
|
||||
}
|
||||
|
||||
pub fn is_local_repository_id(id: &str) -> bool {
|
||||
id == LOCAL_REPOSITORY_ID
|
||||
pub fn repository_id_for_workspace(workspace_id: &str) -> String {
|
||||
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 crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary};
|
||||
use crate::identity::WorkspaceIdentity;
|
||||
use crate::records::{
|
||||
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
|
||||
};
|
||||
|
|
@ -28,6 +29,8 @@ pub enum AuthConfig {
|
|||
#[derive(Clone)]
|
||||
pub struct ServerConfig {
|
||||
pub workspace_id: String,
|
||||
pub workspace_display_name: String,
|
||||
pub workspace_created_at: String,
|
||||
pub workspace_root: PathBuf,
|
||||
pub static_assets_dir: Option<PathBuf>,
|
||||
pub auth: AuthConfig,
|
||||
|
|
@ -36,11 +39,12 @@ pub struct 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 display = workspace_display_name_from_root(&workspace_root);
|
||||
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,
|
||||
static_assets_dir: None,
|
||||
auth: AuthConfig::LocalDevToken {
|
||||
|
|
@ -61,14 +65,13 @@ pub struct WorkspaceApi {
|
|||
|
||||
impl WorkspaceApi {
|
||||
pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> {
|
||||
let display_name = workspace_display_name_from_root(&config.workspace_root);
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: config.workspace_id.clone(),
|
||||
display_name,
|
||||
display_name: config.workspace_display_name.clone(),
|
||||
state: "active".to_string(),
|
||||
created_at: "1970-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "1970-01-01T00:00:00Z".to_string(),
|
||||
created_at: config.workspace_created_at.clone(),
|
||||
updated_at: config.workspace_created_at.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(Self {
|
||||
|
|
@ -91,20 +94,19 @@ impl WorkspaceApi {
|
|||
}
|
||||
|
||||
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 {
|
||||
workspace_display_name_from_root(&self.config.workspace_root)
|
||||
fn local_repository_id(&self) -> String {
|
||||
LocalRepositoryReader::repository_id_for_workspace(self.workspace_id())
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_display_name_from_root(workspace_root: &std::path::Path) -> String {
|
||||
workspace_root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.expect("workspace root must have a final path component")
|
||||
.to_string()
|
||||
fn workspace_display_name(&self) -> &str {
|
||||
self.config.workspace_display_name.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_router(api: WorkspaceApi) -> Router {
|
||||
|
|
@ -238,7 +240,7 @@ async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<Worksp
|
|||
let display_name = stored
|
||||
.as_ref()
|
||||
.map(|record| record.display_name.clone())
|
||||
.unwrap_or_else(|| workspace_display_name_from_root(&api.config.workspace_root));
|
||||
.unwrap_or_else(|| api.config.workspace_display_name.clone());
|
||||
Ok(Json(WorkspaceResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
display_name,
|
||||
|
|
@ -314,7 +316,7 @@ async fn list_repositories(
|
|||
State(api): State<WorkspaceApi>,
|
||||
) -> ApiResult<Json<RepositoryListResponse>> {
|
||||
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 {
|
||||
workspace_id: api.config.workspace_id,
|
||||
items,
|
||||
|
|
@ -327,11 +329,11 @@ async fn repository_detail(
|
|||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(repository_id): AxumPath<String>,
|
||||
) -> ApiResult<Json<RepositoryDetailResponse>> {
|
||||
ensure_local_repository(&repository_id)?;
|
||||
let _canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
|
||||
let reader = api.local_repository_reader();
|
||||
Ok(Json(RepositoryDetailResponse {
|
||||
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(),
|
||||
}))
|
||||
}
|
||||
|
|
@ -341,7 +343,7 @@ async fn repository_log(
|
|||
AxumPath(repository_id): AxumPath<String>,
|
||||
Query(query): Query<LogQuery>,
|
||||
) -> ApiResult<Json<RepositoryLogResponse>> {
|
||||
ensure_local_repository(&repository_id)?;
|
||||
let canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
|
||||
let RepositoryLogRead {
|
||||
limit,
|
||||
items,
|
||||
|
|
@ -349,7 +351,7 @@ async fn repository_log(
|
|||
} = api.local_repository_reader().recent_log(query.limit);
|
||||
Ok(Json(RepositoryLogResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
repository_id,
|
||||
repository_id: canonical_repository_id,
|
||||
limit,
|
||||
items,
|
||||
diagnostics,
|
||||
|
|
@ -361,7 +363,7 @@ async fn repository_tickets(
|
|||
AxumPath(repository_id): AxumPath<String>,
|
||||
Query(query): Query<TicketKanbanQuery>,
|
||||
) -> 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 ProjectRecordList {
|
||||
items,
|
||||
|
|
@ -370,7 +372,7 @@ async fn repository_tickets(
|
|||
} = api.records.list_tickets(limit)?;
|
||||
Ok(Json(RepositoryTicketsResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
repository_id,
|
||||
repository_id: canonical_repository_id,
|
||||
limit,
|
||||
columns: ticket_kanban_columns(items),
|
||||
invalid_records,
|
||||
|
|
@ -429,9 +431,10 @@ fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSu
|
|||
})
|
||||
}
|
||||
|
||||
fn ensure_local_repository(repository_id: &str) -> Result<()> {
|
||||
if LocalRepositoryReader::is_local_repository_id(repository_id) {
|
||||
Ok(())
|
||||
fn ensure_local_repository(api: &WorkspaceApi, repository_id: &str) -> Result<String> {
|
||||
let canonical_repository_id = api.local_repository_id();
|
||||
if LocalRepositoryReader::is_local_repository_id(repository_id, api.workspace_id()) {
|
||||
Ok(canonical_repository_id)
|
||||
} else {
|
||||
Err(Error::UnknownRepository(repository_id.to_string()))
|
||||
}
|
||||
|
|
@ -612,6 +615,18 @@ mod tests {
|
|||
|
||||
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]
|
||||
async fn serves_bounded_read_apis_and_static_spa_separately() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
@ -623,15 +638,15 @@ mod tests {
|
|||
std::fs::write(static_dir.join("assets/app.js"), "console.log('yoi');").unwrap();
|
||||
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let mut config = ServerConfig::local_dev(dir.path());
|
||||
config.workspace_id = "local:test".to_string();
|
||||
let mut config = ServerConfig::local_dev(dir.path(), test_identity());
|
||||
config.static_assets_dir = Some(static_dir);
|
||||
config.local_runtime_data_dir = Some(dir.path().join("data"));
|
||||
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
|
||||
let app = build_router(api);
|
||||
|
||||
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["extension_points"]["host_worker_bridge"]["status"],
|
||||
|
|
@ -647,18 +662,18 @@ mod tests {
|
|||
assert_eq!(objectives["items"][0]["summary"], "Objective body.");
|
||||
|
||||
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");
|
||||
|
||||
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;
|
||||
assert_eq!(repository_log["repository_id"], "local");
|
||||
assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID);
|
||||
assert_eq!(repository_log["limit"], 3);
|
||||
|
||||
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"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
|
|
@ -684,7 +699,7 @@ mod tests {
|
|||
assert_eq!(unknown_repository_response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
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]["capabilities"]["local_pod_inspection"],
|
||||
|
|
@ -698,7 +713,11 @@ mod tests {
|
|||
"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());
|
||||
|
||||
let runs_response = app
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
|
|||
filter = sourceFilter;
|
||||
};
|
||||
|
||||
cargoHash = "sha256-M8cGY+eskFXSRjq3kBbRusflghvVKrWc1Pj50uKAlg8=";
|
||||
cargoHash = "sha256-XZxqEKKDU42fFjFnCCcRRFTA0jkkiaSn3eQ8QwXRYPk=";
|
||||
|
||||
depsExtraArgs = {
|
||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user