diff --git a/.yoi/tickets/00001KVSKGDYS/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVSKGDYS/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..bff3e9bb --- /dev/null +++ b/.yoi/tickets/00001KVSKGDYS/artifacts/orchestration-plan.jsonl @@ -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"} diff --git a/.yoi/tickets/00001KVSKGDYS/item.md b/.yoi/tickets/00001KVSKGDYS/item.md index af9d6d78..19eccb87 100644 --- a/.yoi/tickets/00001KVSKGDYS/item.md +++ b/.yoi/tickets/00001KVSKGDYS/item.md @@ -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' diff --git a/.yoi/tickets/00001KVSKGDYS/resolution.md b/.yoi/tickets/00001KVSKGDYS/resolution.md new file mode 100644 index 00000000..85c4d80c --- /dev/null +++ b/.yoi/tickets/00001KVSKGDYS/resolution.md @@ -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 で扱う。 \ No newline at end of file diff --git a/.yoi/tickets/00001KVSKGDYS/thread.md b/.yoi/tickets/00001KVSKGDYS/thread.md index 58d35841..d83f8c81 100644 --- a/.yoi/tickets/00001KVSKGDYS/thread.md +++ b/.yoi/tickets/00001KVSKGDYS/thread.md @@ -30,4 +30,507 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## 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。 + +--- + + + +## State changed + +Human authorized queue routing from Workspace Dashboard. Ticket has concrete local workspace identity requirements and no recorded blockers, so Orchestrator accepts implementation. + +--- + + + +## 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。 + +--- + + + +## 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。 + +--- + + + +## 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。 + +--- + + + +## 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/` + +--- + + + +## 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。 + +--- + + + +## 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。 + +--- + + + +## 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。 + +--- + + + +## 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。 + +--- + + + +## 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。 + +--- + + + +## State changed + +Reviewer approval 後に implementation を orchestration worktree へ merge し、post-merge validation と生成物 cleanup が完了したため done とする。 + +--- + + + +## State changed + +Ticket を 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 で扱う。 + --- diff --git a/.yoi/tickets/00001KVSKJ0EA/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVSKJ0EA/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..da83405e --- /dev/null +++ b/.yoi/tickets/00001KVSKJ0EA/artifacts/orchestration-plan.jsonl @@ -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"} diff --git a/.yoi/tickets/00001KVSKJ0EA/item.md b/.yoi/tickets/00001KVSKJ0EA/item.md index 37bf1857..1ba50f39 100644 --- a/.yoi/tickets/00001KVSKJ0EA/item.md +++ b/.yoi/tickets/00001KVSKJ0EA/item.md @@ -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'] diff --git a/.yoi/tickets/00001KVSKJ0EA/resolution.md b/.yoi/tickets/00001KVSKJ0EA/resolution.md new file mode 100644 index 00000000..b7a3b8b7 --- /dev/null +++ b/.yoi/tickets/00001KVSKJ0EA/resolution.md @@ -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`) + +残作業: +- なし。 \ No newline at end of file diff --git a/.yoi/tickets/00001KVSKJ0EA/thread.md b/.yoi/tickets/00001KVSKJ0EA/thread.md index bd6b86ed..71c44bc6 100644 --- a/.yoi/tickets/00001KVSKJ0EA/thread.md +++ b/.yoi/tickets/00001KVSKJ0EA/thread.md @@ -29,4 +29,285 @@ LocalTicketBackend によって作成されました。 Ticket を `workspace-panel` が queued にしました。 +--- + + + +## 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`。 + +--- + + + +## State changed + +Human authorized queue routing from Workspace Dashboard. Ticket has concrete Dashboard selection semantics and no recorded blockers, so Orchestrator accepts implementation. + +--- + + + +## 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。 + +--- + + + +## 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。 + +--- + + + +## 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。 + +--- + + + +## 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`。 + +--- + + + +## 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 は変更していない。 + +--- + + + +## State changed + +実装は reviewer approval 後に orchestration worktree へ merge され、post-merge validation も通過したため done とする。 + +--- + + + +## State changed + +Ticket を 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`) + +残作業: +- なし。 + --- diff --git a/.yoi/workspace.toml b/.yoi/workspace.toml new file mode 100644 index 00000000..6cfcc713 --- /dev/null +++ b/.yoi/workspace.toml @@ -0,0 +1,3 @@ +workspace_id = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c5b6" +created_at = "2026-06-23T00:00:00Z" +display_name = "yoi" diff --git a/Cargo.lock b/Cargo.lock index b9102512..1909b286 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/crates/tui/src/dashboard/mod.rs b/crates/tui/src/dashboard/mod.rs index 75db186d..c8e60ef0 100644 --- a/crates/tui/src/dashboard/mod.rs +++ b/crates/tui/src/dashboard/mod.rs @@ -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 - .entries - .iter() - .any(|entry| entry.name == *name) - }) - .or_else(|| { - snapshot - .list - .entries - .first() - .map(|entry| entry.name.clone()) - }); + snapshot.list.selected_name = previous_selected_pod.filter(|name| { + snapshot + .list + .entries + .iter() + .any(|entry| entry.name == *name) + }); 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, - PanelRowKey::Ticket(_) - | PanelRowKey::InvalidTicket(_) - | PanelRowKey::TicketIntakePod { .. } => true, - }) { - self.select_panel_key(key.clone()); - return; - } - self.selected_row = None; - self.list.selected_name = None; - return; + }; + 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 { .. } => self.list.selected_name = None, } - 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()); + } else { + self.selected_row = None; + self.list.selected_name = None; } } 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(); diff --git a/crates/tui/src/dashboard/tests.rs b/crates/tui/src/dashboard/tests.rs index af3f3846..35065225 100644 --- a/crates/tui/src/dashboard/tests.rs +++ b/crates/tui/src/dashboard/tests.rs @@ -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 diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 515bd2a4..cd147fe7 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -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 diff --git a/crates/workspace-server/src/identity.rs b/crates/workspace-server/src/identity.rs new file mode 100644 index 00000000..52acf4bd --- /dev/null +++ b/crates/workspace-server/src/identity.rs @@ -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) -> Result { + 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) -> PathBuf { + workspace_root + .as_ref() + .join(WORKSPACE_IDENTITY_RELATIVE_PATH) + } + + pub fn parse_str(raw: &str, path: impl AsRef) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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")); + } +} diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 1e538396..8d3b9f69 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -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), } diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index f1591014..4f9fc546 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -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> { } async fn run_serve(options: ServeOptions) -> Result<(), Box> { + 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) -> Self { + pub fn new(workspace_root: impl Into, workspace_id: impl Into) -> 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 } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 6aada3ab..7c9e786d 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -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, pub auth: AuthConfig, @@ -36,11 +39,12 @@ pub struct ServerConfig { } impl ServerConfig { - pub fn local_dev(workspace_root: impl Into) -> Self { + pub fn local_dev(workspace_root: impl Into, 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) -> Result { - 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) -> ApiResult, ) -> ApiResult> { 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, AxumPath(repository_id): AxumPath, ) -> ApiResult> { - 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, Query(query): Query, ) -> ApiResult> { - 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, Query(query): Query, ) -> ApiResult> { - 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 Result<()> { - if LocalRepositoryReader::is_local_repository_id(repository_id) { - Ok(()) +fn ensure_local_repository(api: &WorkspaceApi, repository_id: &str) -> Result { + 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 diff --git a/package.nix b/package.nix index f7f10655..59e02b00 100644 --- a/package.nix +++ b/package.nix @@ -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,