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'
|
title: 'Persist local Workspace identity in .yoi/workspace.toml'
|
||||||
state: 'queued'
|
state: 'closed'
|
||||||
created_at: '2026-06-23T06:43:28Z'
|
created_at: '2026-06-23T06:43:28Z'
|
||||||
updated_at: '2026-06-23T06:47:18Z'
|
updated_at: '2026-06-23T07:39:10Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
queued_by: 'workspace-panel'
|
queued_by: 'workspace-panel'
|
||||||
queued_at: '2026-06-23T06:47:18Z'
|
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 にしました。
|
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 を自動選択しない'
|
title: 'Dashboard reload と初期表示で row を自動選択しない'
|
||||||
state: 'queued'
|
state: 'closed'
|
||||||
created_at: '2026-06-23T06:44:20Z'
|
created_at: '2026-06-23T06:44:20Z'
|
||||||
updated_at: '2026-06-23T06:47:17Z'
|
updated_at: '2026-06-23T07:24:20Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
readiness: 'implementation_ready'
|
readiness: 'implementation_ready'
|
||||||
risk_flags: ['tui-ux', 'panel-selection', 'reload-state']
|
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 にしました。
|
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 = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"chrono",
|
||||||
"manifest",
|
"manifest",
|
||||||
"pod-store",
|
"pod-store",
|
||||||
"project-record",
|
"project-record",
|
||||||
|
|
@ -6061,8 +6062,10 @@ dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"ticket",
|
"ticket",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"toml",
|
||||||
"tower",
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -1266,8 +1266,7 @@ impl DashboardApp {
|
||||||
.list
|
.list
|
||||||
.selected_name
|
.selected_name
|
||||||
.clone()
|
.clone()
|
||||||
.filter(|name| list.entries.iter().any(|entry| entry.name == *name))
|
.filter(|name| list.entries.iter().any(|entry| entry.name == *name));
|
||||||
.or_else(|| list.entries.first().map(|entry| entry.name.clone()));
|
|
||||||
let panel = build_workspace_panel(¤t_workspace_root(), &list);
|
let panel = build_workspace_panel(¤t_workspace_root(), &list);
|
||||||
self.apply_reloaded_snapshot(DashboardSnapshot { list, panel });
|
self.apply_reloaded_snapshot(DashboardSnapshot { list, panel });
|
||||||
}
|
}
|
||||||
|
|
@ -1276,25 +1275,17 @@ impl DashboardApp {
|
||||||
self.apply_companion_lifecycle_memory(&mut snapshot.panel);
|
self.apply_companion_lifecycle_memory(&mut snapshot.panel);
|
||||||
self.apply_orchestrator_lifecycle_memory(&mut snapshot.panel);
|
self.apply_orchestrator_lifecycle_memory(&mut snapshot.panel);
|
||||||
let previous_selected_pod = self.list.selected_name.clone();
|
let previous_selected_pod = self.list.selected_name.clone();
|
||||||
snapshot.list.selected_name = previous_selected_pod
|
snapshot.list.selected_name = previous_selected_pod.filter(|name| {
|
||||||
.filter(|name| {
|
snapshot
|
||||||
snapshot
|
.list
|
||||||
.list
|
.entries
|
||||||
.entries
|
.iter()
|
||||||
.iter()
|
.any(|entry| entry.name == *name)
|
||||||
.any(|entry| entry.name == *name)
|
});
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
snapshot
|
|
||||||
.list
|
|
||||||
.entries
|
|
||||||
.first()
|
|
||||||
.map(|entry| entry.name.clone())
|
|
||||||
});
|
|
||||||
let previous_row = self.selected_row.clone();
|
let previous_row = self.selected_row.clone();
|
||||||
self.list = snapshot.list;
|
self.list = snapshot.list;
|
||||||
self.panel = snapshot.panel;
|
self.panel = snapshot.panel;
|
||||||
self.selected_row = previous_row.filter(|key| self.panel.row(key).is_some());
|
self.selected_row = previous_row;
|
||||||
self.ensure_selection_visible();
|
self.ensure_selection_visible();
|
||||||
self.ensure_composer_target_available();
|
self.ensure_composer_target_available();
|
||||||
self.refresh_orchestrator_work_set();
|
self.refresh_orchestrator_work_set();
|
||||||
|
|
@ -1444,12 +1435,14 @@ impl DashboardApp {
|
||||||
self.list.selected_name = None;
|
self.list.selected_name = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let selected_pos = self
|
let next_pos = match self
|
||||||
.selected_row
|
.selected_row
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|key| visible.iter().position(|visible_key| visible_key == key))
|
.and_then(|key| visible.iter().position(|visible_key| visible_key == key))
|
||||||
.unwrap_or(0);
|
{
|
||||||
let next_pos = (selected_pos + 1).min(visible.len() - 1);
|
Some(selected_pos) => (selected_pos + 1).min(visible.len() - 1),
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
self.select_panel_key(visible[next_pos].clone());
|
self.select_panel_key(visible[next_pos].clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1460,12 +1453,15 @@ impl DashboardApp {
|
||||||
self.list.selected_name = None;
|
self.list.selected_name = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let selected_pos = self
|
let prev_pos = match self
|
||||||
.selected_row
|
.selected_row
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|key| visible.iter().position(|visible_key| visible_key == key))
|
.and_then(|key| visible.iter().position(|visible_key| visible_key == key))
|
||||||
.unwrap_or(0);
|
{
|
||||||
self.select_panel_key(visible[selected_pos.saturating_sub(1)].clone());
|
Some(selected_pos) => selected_pos.saturating_sub(1),
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
self.select_panel_key(visible[prev_pos].clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_mouse_event(&mut self, event: MouseEvent) -> bool {
|
fn handle_mouse_event(&mut self, event: MouseEvent) -> bool {
|
||||||
|
|
@ -1623,55 +1619,32 @@ impl DashboardApp {
|
||||||
|
|
||||||
fn ensure_selection_visible(&mut self) {
|
fn ensure_selection_visible(&mut self) {
|
||||||
let visible = visible_panel_keys(&self.panel, &self.list);
|
let visible = visible_panel_keys(&self.panel, &self.list);
|
||||||
if visible.is_empty() {
|
let Some(selected_key) = self.selected_row.as_ref() else {
|
||||||
self.selected_row = None;
|
|
||||||
self.list.selected_name = None;
|
self.list.selected_name = None;
|
||||||
return;
|
return;
|
||||||
}
|
};
|
||||||
let selected_visible = self
|
if visible
|
||||||
.selected_row
|
.iter()
|
||||||
.as_ref()
|
.any(|visible_key| visible_key == selected_key)
|
||||||
.is_some_and(|key| visible.iter().any(|visible_key| visible_key == key));
|
{
|
||||||
if !selected_visible {
|
match selected_key {
|
||||||
let has_action_rows = self.panel.rows.iter().any(|row| row.is_ticket_action());
|
PanelRowKey::Pod(name) => self.list.selected_name = Some(name.clone()),
|
||||||
let orchestrator_pod_name = self
|
PanelRowKey::Ticket(_)
|
||||||
.panel
|
| PanelRowKey::InvalidTicket(_)
|
||||||
.header
|
| PanelRowKey::TicketIntakePod { .. } => self.list.selected_name = None,
|
||||||
.orchestrator
|
|
||||||
.as_ref()
|
|
||||||
.map(|state| state.pod_name.as_str());
|
|
||||||
if !has_action_rows {
|
|
||||||
if let Some(selected_name) = self.list.selected_name.as_ref() {
|
|
||||||
if Some(selected_name.as_str()) != orchestrator_pod_name {
|
|
||||||
let key = PanelRowKey::Pod(selected_name.clone());
|
|
||||||
if visible.iter().any(|visible_key| visible_key == &key) {
|
|
||||||
self.select_panel_key(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(key) = visible.iter().find(|key| match key {
|
|
||||||
PanelRowKey::Pod(name) => Some(name.as_str()) != orchestrator_pod_name,
|
|
||||||
PanelRowKey::Ticket(_)
|
|
||||||
| PanelRowKey::InvalidTicket(_)
|
|
||||||
| PanelRowKey::TicketIntakePod { .. } => true,
|
|
||||||
}) {
|
|
||||||
self.select_panel_key(key.clone());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.selected_row = None;
|
|
||||||
self.list.selected_name = None;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
self.select_panel_key(visible[0].clone());
|
} else {
|
||||||
} else if let Some(PanelRowKey::Pod(name)) = self.selected_row.as_ref() {
|
self.selected_row = None;
|
||||||
self.list.selected_name = Some(name.clone());
|
self.list.selected_name = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select_panel_key(&mut self, key: PanelRowKey) {
|
fn select_panel_key(&mut self, key: PanelRowKey) {
|
||||||
if let PanelRowKey::Pod(name) = &key {
|
match &key {
|
||||||
self.list.selected_name = Some(name.clone());
|
PanelRowKey::Pod(name) => self.list.selected_name = Some(name.clone()),
|
||||||
|
PanelRowKey::Ticket(_)
|
||||||
|
| PanelRowKey::InvalidTicket(_)
|
||||||
|
| PanelRowKey::TicketIntakePod { .. } => self.list.selected_name = None,
|
||||||
}
|
}
|
||||||
#[cfg(feature = "e2e-test")]
|
#[cfg(feature = "e2e-test")]
|
||||||
let selected_key = key.clone();
|
let selected_key = key.clone();
|
||||||
|
|
|
||||||
|
|
@ -481,6 +481,7 @@ fn ready_ticket_intake_enter_prepares_planning_return_not_queue_or_generic_launc
|
||||||
"ready",
|
"ready",
|
||||||
));
|
));
|
||||||
let mut app = app_with_panel(empty_test_list(), panel);
|
let mut app = app_with_panel(empty_test_list(), panel);
|
||||||
|
app.select_next();
|
||||||
app.cycle_composer_target();
|
app.cycle_composer_target();
|
||||||
app.input.insert_str("clarify expected behavior");
|
app.input.insert_str("clarify expected behavior");
|
||||||
|
|
||||||
|
|
@ -938,6 +939,111 @@ fn no_ticket_selection_keeps_enter_pod_centric() {
|
||||||
assert_eq!(app.notice.as_deref(), Some("No Ticket action is selected."));
|
assert_eq!(app.notice.as_deref(), Some("No Ticket action is selected."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_panel_initial_display_does_not_auto_select_visible_rows() {
|
||||||
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
||||||
|
panel.rows.push(panel_test_ticket_row(
|
||||||
|
"TICKET-1",
|
||||||
|
"Ready",
|
||||||
|
ActionPriority::ReadyForQueue,
|
||||||
|
NextUserAction::Queue,
|
||||||
|
"ready",
|
||||||
|
));
|
||||||
|
let app = app_with_panel(
|
||||||
|
PodList::from_sources(
|
||||||
|
PodVisibilitySource::ResumePicker,
|
||||||
|
vec![],
|
||||||
|
vec![live_info("alpha", PodStatus::Idle)],
|
||||||
|
None,
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
panel,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(visible_panel_keys(&app.panel, &app.list).len() > 1);
|
||||||
|
assert!(app.selected_row.is_none());
|
||||||
|
assert!(app.list.selected_name.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_panel_clear_selection_survives_reload_and_keeps_draft() {
|
||||||
|
let mut app = test_app(vec![live_info_with_updated_at(
|
||||||
|
"alpha",
|
||||||
|
PodStatus::Idle,
|
||||||
|
10,
|
||||||
|
)]);
|
||||||
|
app.select_next();
|
||||||
|
assert_eq!(
|
||||||
|
app.selected_row,
|
||||||
|
Some(PanelRowKey::Pod("alpha".to_string()))
|
||||||
|
);
|
||||||
|
app.input.insert_str("draft survives");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
app.handle_key(key(KeyCode::Esc)),
|
||||||
|
DashboardAction::None
|
||||||
|
));
|
||||||
|
assert!(app.selected_row.is_none());
|
||||||
|
assert!(app.list.selected_name.is_none());
|
||||||
|
|
||||||
|
app.apply_reloaded_list(PodList::from_sources(
|
||||||
|
PodVisibilitySource::ResumePicker,
|
||||||
|
vec![],
|
||||||
|
vec![live_info_with_updated_at("alpha", PodStatus::Running, 20)],
|
||||||
|
None,
|
||||||
|
10,
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(app.selected_row.is_none());
|
||||||
|
assert!(app.list.selected_name.is_none());
|
||||||
|
assert_eq!(input_text(&app), "draft survives");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_panel_no_selection_ticket_intake_submit_uses_global_intake() {
|
||||||
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
||||||
|
panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled();
|
||||||
|
panel.rows.push(panel_test_ticket_row(
|
||||||
|
"TICKET-1",
|
||||||
|
"Ready",
|
||||||
|
ActionPriority::ReadyForQueue,
|
||||||
|
NextUserAction::Queue,
|
||||||
|
"ready",
|
||||||
|
));
|
||||||
|
let mut app = app_with_panel(empty_test_list(), panel);
|
||||||
|
app.cycle_composer_target();
|
||||||
|
app.input.insert_str("new planning request");
|
||||||
|
|
||||||
|
let request = match app.handle_key(key(KeyCode::Enter)) {
|
||||||
|
DashboardAction::LaunchIntake(request) => request,
|
||||||
|
_ => panic!("no selection should launch global Intake"),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(request.context.ticket.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
request.context.user_instruction.as_deref(),
|
||||||
|
Some("new planning request")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_panel_keyboard_navigation_explicitly_creates_selection() {
|
||||||
|
let mut app = test_app(vec![
|
||||||
|
live_info("alpha", PodStatus::Idle),
|
||||||
|
live_info("beta", PodStatus::Idle),
|
||||||
|
]);
|
||||||
|
assert!(app.selected_row.is_none());
|
||||||
|
|
||||||
|
app.select_next();
|
||||||
|
assert_eq!(
|
||||||
|
app.selected_row,
|
||||||
|
Some(PanelRowKey::Pod("alpha".to_string()))
|
||||||
|
);
|
||||||
|
|
||||||
|
app.select_next();
|
||||||
|
assert_eq!(app.selected_row, Some(PanelRowKey::Pod("beta".to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dashboard_ticket_action_rows_precede_pods_and_pod_actions_still_work() {
|
fn dashboard_ticket_action_rows_precede_pods_and_pod_actions_still_work() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
@ -960,6 +1066,8 @@ fn dashboard_ticket_action_rows_precede_pods_and_pod_actions_still_work() {
|
||||||
);
|
);
|
||||||
let panel = build_workspace_panel(temp.path(), &list);
|
let panel = build_workspace_panel(temp.path(), &list);
|
||||||
let mut app = app_with_panel(list, panel);
|
let mut app = app_with_panel(list, panel);
|
||||||
|
assert!(app.selected_row.is_none());
|
||||||
|
app.select_next();
|
||||||
|
|
||||||
assert_eq!(app.selected_panel_row().unwrap().title, "Ready Ticket");
|
assert_eq!(app.selected_panel_row().unwrap().title, "Ready Ticket");
|
||||||
assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled);
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled);
|
||||||
|
|
@ -1176,6 +1284,7 @@ fn selected_ticket_row_with_non_empty_composer_hides_redundant_status_hints() {
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
let mut app = app_with_panel(list, panel);
|
let mut app = app_with_panel(list, panel);
|
||||||
|
app.select_next();
|
||||||
app.input.insert_str("draft to companion");
|
app.input.insert_str("draft to companion");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -1201,7 +1310,7 @@ fn dashboard_bare_panel_letters_append_to_composer_and_arrows_select_when_blank(
|
||||||
live_info("alpha", PodStatus::Idle),
|
live_info("alpha", PodStatus::Idle),
|
||||||
live_info("beta", PodStatus::Idle),
|
live_info("beta", PodStatus::Idle),
|
||||||
]);
|
]);
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
assert!(app.selected_row.is_none());
|
||||||
|
|
||||||
for c in ['j', 'k', 'o', 'r'] {
|
for c in ['j', 'k', 'o', 'r'] {
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|
@ -1211,14 +1320,14 @@ fn dashboard_bare_panel_letters_append_to_composer_and_arrows_select_when_blank(
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_eq!(input_text(&app), "jkor");
|
assert_eq!(input_text(&app), "jkor");
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
assert!(app.selected_row.is_none());
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
app.handle_key(key(KeyCode::Down)),
|
app.handle_key(key(KeyCode::Down)),
|
||||||
DashboardAction::None
|
DashboardAction::None
|
||||||
));
|
));
|
||||||
assert_eq!(input_text(&app), "jkor");
|
assert_eq!(input_text(&app), "jkor");
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
assert!(app.selected_row.is_none());
|
||||||
|
|
||||||
app.input.clear();
|
app.input.clear();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|
@ -1226,6 +1335,12 @@ fn dashboard_bare_panel_letters_append_to_composer_and_arrows_select_when_blank(
|
||||||
DashboardAction::None
|
DashboardAction::None
|
||||||
));
|
));
|
||||||
assert_eq!(input_text(&app), "");
|
assert_eq!(input_text(&app), "");
|
||||||
|
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
app.handle_key(key(KeyCode::Down)),
|
||||||
|
DashboardAction::None
|
||||||
|
));
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
|
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|
@ -1248,7 +1363,7 @@ fn dashboard_selection_changes_preserve_composer_contents() {
|
||||||
app.select_next();
|
app.select_next();
|
||||||
|
|
||||||
assert_eq!(input_text(&app), before);
|
assert_eq!(input_text(&app), before);
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
|
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1258,7 +1373,7 @@ fn dashboard_poll_reload_preserves_selection_composer_and_notice() {
|
||||||
live_info_with_updated_at("beta", PodStatus::Idle, 20),
|
live_info_with_updated_at("beta", PodStatus::Idle, 20),
|
||||||
]);
|
]);
|
||||||
app.select_next();
|
app.select_next();
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
|
||||||
app.input.insert_str("draft survives polling");
|
app.input.insert_str("draft survives polling");
|
||||||
app.notice = Some("keep this notice".to_string());
|
app.notice = Some("keep this notice".to_string());
|
||||||
let refreshed = PodList::from_sources(
|
let refreshed = PodList::from_sources(
|
||||||
|
|
@ -1275,7 +1390,7 @@ fn dashboard_poll_reload_preserves_selection_composer_and_notice() {
|
||||||
|
|
||||||
app.apply_reloaded_list(refreshed);
|
app.apply_reloaded_list(refreshed);
|
||||||
|
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.list
|
app.list
|
||||||
.selected_entry()
|
.selected_entry()
|
||||||
|
|
@ -1284,7 +1399,7 @@ fn dashboard_poll_reload_preserves_selection_composer_and_notice() {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.status,
|
.status,
|
||||||
Some(PodStatus::Running)
|
Some(PodStatus::Idle)
|
||||||
);
|
);
|
||||||
assert_eq!(input_text(&app), "draft survives polling");
|
assert_eq!(input_text(&app), "draft survives polling");
|
||||||
assert_eq!(app.notice.as_deref(), Some("keep this notice"));
|
assert_eq!(app.notice.as_deref(), Some("keep this notice"));
|
||||||
|
|
@ -1296,6 +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("alpha", PodStatus::Idle, 10),
|
||||||
live_info_with_updated_at("beta", PodStatus::Running, 20),
|
live_info_with_updated_at("beta", PodStatus::Running, 20),
|
||||||
]);
|
]);
|
||||||
|
app.select_next();
|
||||||
|
app.select_next();
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
|
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
|
||||||
let refreshed = PodList::from_sources(
|
let refreshed = PodList::from_sources(
|
||||||
PodVisibilitySource::ResumePicker,
|
PodVisibilitySource::ResumePicker,
|
||||||
|
|
@ -1307,7 +1424,8 @@ fn dashboard_poll_reload_falls_back_when_selected_pod_disappears() {
|
||||||
|
|
||||||
app.apply_reloaded_list(refreshed);
|
app.apply_reloaded_list(refreshed);
|
||||||
|
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
assert!(app.selected_row.is_none());
|
||||||
|
assert!(app.list.selected_name.is_none());
|
||||||
assert_eq!(visible_entry_indices(&app.list), vec![0, 1]);
|
assert_eq!(visible_entry_indices(&app.list), vec![0, 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1526,7 +1644,8 @@ async fn dashboard_quit_aborts_background_reload_and_notice_without_waiting() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dashboard_idle_live_selected_target_is_open_eligible() {
|
fn dashboard_idle_live_selected_target_is_open_eligible() {
|
||||||
let app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||||
|
app.select_next();
|
||||||
|
|
||||||
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
|
||||||
}
|
}
|
||||||
|
|
@ -1855,6 +1974,9 @@ fn dashboard_running_paused_and_stopped_targets_are_open_eligible() {
|
||||||
app.selected_row = None;
|
app.selected_row = None;
|
||||||
app.ensure_selection_visible();
|
app.ensure_selection_visible();
|
||||||
|
|
||||||
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled);
|
||||||
|
app.select_next();
|
||||||
|
assert_eq!(app.list.selected_entry().unwrap().name, "running");
|
||||||
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
|
||||||
app.select_next();
|
app.select_next();
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "paused");
|
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);
|
let mut app = app_with_list(list);
|
||||||
|
|
||||||
|
assert!(app.selected_row.is_none());
|
||||||
|
app.select_next();
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "idle");
|
assert_eq!(app.list.selected_entry().unwrap().name, "idle");
|
||||||
app.select_next();
|
app.select_next();
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "running");
|
assert_eq!(app.list.selected_entry().unwrap().name, "running");
|
||||||
|
|
@ -1969,7 +2093,7 @@ fn dashboard_selection_does_not_default_to_orchestrator_only_row() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dashboard_selection_prefers_non_orchestrator_pod_by_default() {
|
fn dashboard_selection_has_no_default_when_orchestrator_pod_exists() {
|
||||||
let list = PodList::from_sources(
|
let list = PodList::from_sources(
|
||||||
PodVisibilitySource::ResumePicker,
|
PodVisibilitySource::ResumePicker,
|
||||||
vec![],
|
vec![],
|
||||||
|
|
@ -1988,7 +2112,8 @@ fn dashboard_selection_prefers_non_orchestrator_pod_by_default() {
|
||||||
));
|
));
|
||||||
let app = app_with_panel(list, panel);
|
let app = app_with_panel(list, panel);
|
||||||
|
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "worker");
|
assert!(app.selected_row.is_none());
|
||||||
|
assert!(app.list.selected_name.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -2139,6 +2264,7 @@ fn dashboard_companion_finish_success_clears_composer() {
|
||||||
fn dashboard_open_request_keeps_dashboard_state_for_nested_console() {
|
fn dashboard_open_request_keeps_dashboard_state_for_nested_console() {
|
||||||
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
||||||
app.input.insert_str("draft survives open");
|
app.input.insert_str("draft survives open");
|
||||||
|
app.select_next();
|
||||||
|
|
||||||
let request = app.prepare_open().unwrap();
|
let request = app.prepare_open().unwrap();
|
||||||
|
|
||||||
|
|
@ -2207,6 +2333,7 @@ fn dashboard_nested_console_success_continues_without_dropping_state() {
|
||||||
live_info("beta", PodStatus::Idle),
|
live_info("beta", PodStatus::Idle),
|
||||||
]);
|
]);
|
||||||
app.select_next();
|
app.select_next();
|
||||||
|
app.select_next();
|
||||||
app.input.insert_str("keep this draft");
|
app.input.insert_str("keep this draft");
|
||||||
app.panel_diagnostic = Some(PanelDiagnostic {
|
app.panel_diagnostic = Some(PanelDiagnostic {
|
||||||
title: "diagnostic stays".to_string(),
|
title: "diagnostic stays".to_string(),
|
||||||
|
|
@ -2245,6 +2372,7 @@ fn dashboard_nested_console_recoverable_failure_continues_without_dropping_state
|
||||||
live_info("beta", PodStatus::Idle),
|
live_info("beta", PodStatus::Idle),
|
||||||
]);
|
]);
|
||||||
app.select_next();
|
app.select_next();
|
||||||
|
app.select_next();
|
||||||
app.input.insert_str("keep this draft");
|
app.input.insert_str("keep this draft");
|
||||||
app.panel_diagnostic = Some(PanelDiagnostic {
|
app.panel_diagnostic = Some(PanelDiagnostic {
|
||||||
title: "diagnostic stays".to_string(),
|
title: "diagnostic stays".to_string(),
|
||||||
|
|
@ -2299,6 +2427,7 @@ fn dashboard_open_disabled_target_stays_in_dashboard() {
|
||||||
live.reachable = false;
|
live.reachable = false;
|
||||||
live.status = None;
|
live.status = None;
|
||||||
let mut app = test_app(vec![live]);
|
let mut app = test_app(vec![live]);
|
||||||
|
app.select_next();
|
||||||
|
|
||||||
assert!(app.prepare_open().is_none());
|
assert!(app.prepare_open().is_none());
|
||||||
assert!(app.notice.as_deref().unwrap().contains("cannot be opened"));
|
assert!(app.notice.as_deref().unwrap().contains("cannot be opened"));
|
||||||
|
|
@ -2307,6 +2436,7 @@ fn dashboard_open_disabled_target_stays_in_dashboard() {
|
||||||
#[test]
|
#[test]
|
||||||
fn dashboard_empty_enter_uses_open_action() {
|
fn dashboard_empty_enter_uses_open_action() {
|
||||||
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
||||||
|
app.select_next();
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
app.handle_key(key(KeyCode::Enter)),
|
app.handle_key(key(KeyCode::Enter)),
|
||||||
|
|
@ -2331,6 +2461,7 @@ fn dashboard_empty_enter_uses_open_action() {
|
||||||
#[test]
|
#[test]
|
||||||
fn dashboard_whitespace_only_enter_uses_open_action() {
|
fn dashboard_whitespace_only_enter_uses_open_action() {
|
||||||
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
||||||
|
app.select_next();
|
||||||
app.input.insert_str(" \n\t");
|
app.input.insert_str(" \n\t");
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|
@ -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),
|
PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10),
|
||||||
panel,
|
panel,
|
||||||
);
|
);
|
||||||
|
app.select_next();
|
||||||
let selected_before = app.selected_row.clone();
|
let selected_before = app.selected_row.clone();
|
||||||
assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Queue));
|
assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Queue));
|
||||||
|
|
||||||
|
|
@ -2450,6 +2582,7 @@ fn dashboard_composer_shared_word_motion_and_delete_keys() {
|
||||||
#[test]
|
#[test]
|
||||||
fn dashboard_esc_clears_row_selection_without_quitting_and_preserves_draft() {
|
fn dashboard_esc_clears_row_selection_without_quitting_and_preserves_draft() {
|
||||||
let mut app = ticket_enabled_app(vec![live_info("alpha", PodStatus::Idle)]);
|
let mut app = ticket_enabled_app(vec![live_info("alpha", PodStatus::Idle)]);
|
||||||
|
app.select_next();
|
||||||
app.input.insert_str("draft message");
|
app.input.insert_str("draft message");
|
||||||
|
|
||||||
assert!(app.selected_row.is_some());
|
assert!(app.selected_row.is_some());
|
||||||
|
|
@ -2793,6 +2926,7 @@ fn intake_registry_update_claim_conflict_is_diagnostic_not_overwrite() {
|
||||||
#[test]
|
#[test]
|
||||||
fn dashboard_empty_enter_on_non_openable_row_reports_open_diagnostic() {
|
fn dashboard_empty_enter_on_non_openable_row_reports_open_diagnostic() {
|
||||||
let mut app = test_app(vec![unreachable_live_info("unreachable")]);
|
let mut app = test_app(vec![unreachable_live_info("unreachable")]);
|
||||||
|
app.select_next();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
app.handle_key(key(KeyCode::Enter)),
|
app.handle_key(key(KeyCode::Enter)),
|
||||||
DashboardAction::Open
|
DashboardAction::Open
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ publish = false
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
manifest = { workspace = true }
|
manifest = { workspace = true }
|
||||||
pod-store = { workspace = true }
|
pod-store = { workspace = true }
|
||||||
project-record.workspace = true
|
project-record.workspace = true
|
||||||
|
|
@ -18,7 +19,9 @@ serde_yaml.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
ticket.workspace = true
|
ticket.workspace = true
|
||||||
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
|
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
|
||||||
|
toml.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
uuid = { workspace = true, features = ["v7"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|
|
||||||
323
crates/workspace-server/src/identity.rs
Normal file
323
crates/workspace-server/src/identity.rs
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::io::{ErrorKind, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use chrono::{SecondsFormat, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{Error, Result};
|
||||||
|
|
||||||
|
pub const WORKSPACE_IDENTITY_RELATIVE_PATH: &str = ".yoi/workspace.toml";
|
||||||
|
|
||||||
|
/// Stable local Workspace identity persisted as a tracked, safe project record.
|
||||||
|
///
|
||||||
|
/// The v0 TOML schema intentionally contains identity metadata only:
|
||||||
|
/// `workspace_id`, `created_at`, and `display_name`. Unknown fields are rejected
|
||||||
|
/// instead of preserved because this loader cannot safely round-trip future local
|
||||||
|
/// runtime settings without risking accidental path or secret persistence.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WorkspaceIdentity {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct WorkspaceIdentityFile {
|
||||||
|
workspace_id: String,
|
||||||
|
created_at: String,
|
||||||
|
display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceIdentity {
|
||||||
|
pub fn load_or_init(workspace_root: impl AsRef<Path>) -> Result<Self> {
|
||||||
|
Self::load_or_init_with_clock(workspace_root.as_ref(), || {
|
||||||
|
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(workspace_root: impl AsRef<Path>) -> PathBuf {
|
||||||
|
workspace_root
|
||||||
|
.as_ref()
|
||||||
|
.join(WORKSPACE_IDENTITY_RELATIVE_PATH)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
let parsed: WorkspaceIdentityFile = toml::from_str(raw).map_err(|error| {
|
||||||
|
workspace_identity_error(path, format!("failed to parse TOML: {error}"))
|
||||||
|
})?;
|
||||||
|
Self::from_file(parsed, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_or_init_with_clock(
|
||||||
|
workspace_root: &Path,
|
||||||
|
now_utc_rfc3339: impl FnOnce() -> String,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let path = Self::path(workspace_root);
|
||||||
|
match fs::read_to_string(&path) {
|
||||||
|
Ok(raw) => Self::parse_str(&raw, &path),
|
||||||
|
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||||
|
Self::init(workspace_root, &path, now_utc_rfc3339())
|
||||||
|
}
|
||||||
|
Err(error) => Err(Error::Io(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(workspace_root: &Path, path: &Path, created_at: String) -> Result<Self> {
|
||||||
|
validate_created_at(&created_at, path)?;
|
||||||
|
let display_name = workspace_display_name_from_root(workspace_root, path)?;
|
||||||
|
let workspace_id = Uuid::now_v7().to_string();
|
||||||
|
let identity = Self {
|
||||||
|
workspace_id,
|
||||||
|
created_at,
|
||||||
|
display_name,
|
||||||
|
};
|
||||||
|
identity.write_new_or_read_existing(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_file(parsed: WorkspaceIdentityFile, path: &Path) -> Result<Self> {
|
||||||
|
let workspace_id = validate_workspace_id(&parsed.workspace_id, path)?;
|
||||||
|
validate_created_at(&parsed.created_at, path)?;
|
||||||
|
validate_display_name(&parsed.display_name, path)?;
|
||||||
|
Ok(Self {
|
||||||
|
workspace_id,
|
||||||
|
created_at: parsed.created_at,
|
||||||
|
display_name: parsed.display_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_new_or_read_existing(&self, path: &Path) -> Result<Self> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let raw = toml::to_string_pretty(&WorkspaceIdentityFile {
|
||||||
|
workspace_id: self.workspace_id.clone(),
|
||||||
|
created_at: self.created_at.clone(),
|
||||||
|
display_name: self.display_name.clone(),
|
||||||
|
})
|
||||||
|
.map_err(|error| {
|
||||||
|
workspace_identity_error(path, format!("failed to encode TOML: {error}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match OpenOptions::new().write(true).create_new(true).open(path) {
|
||||||
|
Ok(mut file) => {
|
||||||
|
file.write_all(raw.as_bytes())?;
|
||||||
|
file.sync_all()?;
|
||||||
|
Ok(self.clone())
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == ErrorKind::AlreadyExists => {
|
||||||
|
let raw = fs::read_to_string(path)?;
|
||||||
|
Self::parse_str(&raw, path)
|
||||||
|
}
|
||||||
|
Err(error) => Err(Error::Io(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_workspace_id(value: &str, path: &Path) -> Result<String> {
|
||||||
|
let uuid = Uuid::parse_str(value).map_err(|error| {
|
||||||
|
workspace_identity_error(path, format!("workspace_id is not a UUID: {error}"))
|
||||||
|
})?;
|
||||||
|
if uuid.get_version_num() != 7 {
|
||||||
|
return Err(workspace_identity_error(
|
||||||
|
path,
|
||||||
|
"workspace_id must be a UUIDv7 canonical string".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let canonical = uuid.to_string();
|
||||||
|
if value != canonical {
|
||||||
|
return Err(workspace_identity_error(
|
||||||
|
path,
|
||||||
|
"workspace_id must use lowercase hyphenated UUID canonical form".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(canonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_created_at(value: &str, path: &Path) -> Result<()> {
|
||||||
|
let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|error| {
|
||||||
|
workspace_identity_error(path, format!("created_at is not RFC3339: {error}"))
|
||||||
|
})?;
|
||||||
|
if parsed.offset().local_minus_utc() != 0 || !value.ends_with('Z') {
|
||||||
|
return Err(workspace_identity_error(
|
||||||
|
path,
|
||||||
|
"created_at must be a UTC RFC3339 timestamp ending in Z".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_display_name(value: &str, path: &Path) -> Result<()> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return Err(workspace_identity_error(
|
||||||
|
path,
|
||||||
|
"display_name must not be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if value.contains('\0') || value.chars().any(|ch| ch.is_control()) {
|
||||||
|
return Err(workspace_identity_error(
|
||||||
|
path,
|
||||||
|
"display_name must not contain control characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_display_name_from_root(workspace_root: &Path, path: &Path) -> Result<String> {
|
||||||
|
let display_name = workspace_root
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
workspace_identity_error(
|
||||||
|
path,
|
||||||
|
"workspace root must have a UTF-8 final path component".to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
validate_display_name(&display_name, path)?;
|
||||||
|
Ok(display_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_identity_error(path: &Path, message: String) -> Error {
|
||||||
|
Error::WorkspaceIdentity(format!("{}: {message}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const FIXED_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001";
|
||||||
|
const FIXED_CREATED_AT: &str = "2026-06-23T06:43:28Z";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_identity_file_is_created_with_safe_fields() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let workspace_root = temp.path().join("example-workspace");
|
||||||
|
fs::create_dir_all(&workspace_root).unwrap();
|
||||||
|
|
||||||
|
let identity = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
|
||||||
|
FIXED_CREATED_AT.to_string()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(identity.display_name, "example-workspace");
|
||||||
|
assert_eq!(identity.created_at, FIXED_CREATED_AT);
|
||||||
|
validate_workspace_id(
|
||||||
|
&identity.workspace_id,
|
||||||
|
&WorkspaceIdentity::path(&workspace_root),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let raw = fs::read_to_string(WorkspaceIdentity::path(&workspace_root)).unwrap();
|
||||||
|
assert!(raw.contains("workspace_id"));
|
||||||
|
assert!(raw.contains("display_name"));
|
||||||
|
assert!(raw.contains("created_at"));
|
||||||
|
assert!(!raw.contains(&workspace_root.to_string_lossy().to_string()));
|
||||||
|
|
||||||
|
let reloaded = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
|
||||||
|
"2026-06-24T00:00:00Z".to_string()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(reloaded, identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn existing_identity_file_is_stable() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let workspace_root = temp.path().join("moved-workspace");
|
||||||
|
let yoi_dir = workspace_root.join(".yoi");
|
||||||
|
fs::create_dir_all(&yoi_dir).unwrap();
|
||||||
|
let path = yoi_dir.join("workspace.toml");
|
||||||
|
let raw = format!(
|
||||||
|
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Stable Project\"\n"
|
||||||
|
);
|
||||||
|
fs::write(&path, &raw).unwrap();
|
||||||
|
|
||||||
|
let identity = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
|
||||||
|
"2026-06-24T00:00:00Z".to_string()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(identity.workspace_id, FIXED_WORKSPACE_ID);
|
||||||
|
assert_eq!(identity.created_at, FIXED_CREATED_AT);
|
||||||
|
assert_eq!(identity.display_name, "Stable Project");
|
||||||
|
assert_eq!(fs::read_to_string(path).unwrap(), raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_new_race_returns_existing_persisted_identity() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let path = temp.path().join(".yoi/workspace.toml");
|
||||||
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
let persisted_raw = format!(
|
||||||
|
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Persisted Project\"\n"
|
||||||
|
);
|
||||||
|
fs::write(&path, &persisted_raw).unwrap();
|
||||||
|
let generated = WorkspaceIdentity {
|
||||||
|
workspace_id: "0192f0e8-4d84-7d6e-b000-000000000002".to_string(),
|
||||||
|
created_at: "2026-06-24T00:00:00Z".to_string(),
|
||||||
|
display_name: "Generated Project".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let returned = generated.write_new_or_read_existing(&path).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(returned.workspace_id, FIXED_WORKSPACE_ID);
|
||||||
|
assert_eq!(returned.created_at, FIXED_CREATED_AT);
|
||||||
|
assert_eq!(returned.display_name, "Persisted Project");
|
||||||
|
assert_eq!(fs::read_to_string(path).unwrap(), persisted_raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_identity_file_fails_closed_without_rewriting() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let workspace_root = temp.path().join("invalid-workspace");
|
||||||
|
let yoi_dir = workspace_root.join(".yoi");
|
||||||
|
fs::create_dir_all(&yoi_dir).unwrap();
|
||||||
|
let path = yoi_dir.join("workspace.toml");
|
||||||
|
let raw = "workspace_id = \"not-a-uuid\"\ncreated_at = \"2026-06-23T06:43:28Z\"\ndisplay_name = \"Invalid\"\n";
|
||||||
|
fs::write(&path, raw).unwrap();
|
||||||
|
|
||||||
|
let error = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
|
||||||
|
FIXED_CREATED_AT.to_string()
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("workspace_id is not a UUID"));
|
||||||
|
assert_eq!(fs::read_to_string(path).unwrap(), raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_identity_does_not_leak_parent_paths() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let secret_parent = temp.path().join("user-secret-parent");
|
||||||
|
let workspace_root = secret_parent.join("public-project-name");
|
||||||
|
fs::create_dir_all(&workspace_root).unwrap();
|
||||||
|
|
||||||
|
WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
|
||||||
|
FIXED_CREATED_AT.to_string()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let raw = fs::read_to_string(WorkspaceIdentity::path(&workspace_root)).unwrap();
|
||||||
|
|
||||||
|
assert!(raw.contains("public-project-name"));
|
||||||
|
assert!(!raw.contains(&secret_parent.to_string_lossy().to_string()));
|
||||||
|
assert!(!raw.contains("user-secret-parent"));
|
||||||
|
assert!(!raw.contains("/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_fields_are_rejected() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let path = temp.path().join("workspace.toml");
|
||||||
|
let raw = format!(
|
||||||
|
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Stable Project\"\nlocal_root = \"/tmp/secret\"\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = WorkspaceIdentity::parse_str(&raw, &path).unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("unknown field"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,11 +5,13 @@
|
||||||
//! remain the canonical project records and are read through bounded bridge APIs.
|
//! remain the canonical project records and are read through bounded bridge APIs.
|
||||||
|
|
||||||
pub mod hosts;
|
pub mod hosts;
|
||||||
|
pub mod identity;
|
||||||
pub mod records;
|
pub mod records;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
|
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
|
||||||
pub use records::{
|
pub use records::{
|
||||||
LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary,
|
LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary,
|
||||||
};
|
};
|
||||||
|
|
@ -40,6 +42,8 @@ pub enum Error {
|
||||||
UnknownHost(String),
|
UnknownHost(String),
|
||||||
#[error("unknown local repository `{0}`")]
|
#[error("unknown local repository `{0}`")]
|
||||||
UnknownRepository(String),
|
UnknownRepository(String),
|
||||||
|
#[error("workspace identity error: {0}")]
|
||||||
|
WorkspaceIdentity(String),
|
||||||
#[error("store error: {0}")]
|
#[error("store error: {0}")]
|
||||||
Store(String),
|
Store(String),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use std::process::ExitCode;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use yoi_workspace_server::{ServerConfig, SqliteWorkspaceStore, serve};
|
use yoi_workspace_server::{ServerConfig, SqliteWorkspaceStore, WorkspaceIdentity, serve};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ServeOptions {
|
struct ServeOptions {
|
||||||
|
|
@ -64,6 +64,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
|
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let identity = WorkspaceIdentity::load_or_init(&options.workspace)?;
|
||||||
let db = options
|
let db = options
|
||||||
.db
|
.db
|
||||||
.unwrap_or_else(|| options.workspace.join(".yoi/workspace.db"));
|
.unwrap_or_else(|| options.workspace.join(".yoi/workspace.db"));
|
||||||
|
|
@ -72,7 +73,7 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
||||||
}
|
}
|
||||||
|
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&db)?);
|
let store = Arc::new(SqliteWorkspaceStore::open(&db)?);
|
||||||
let mut config = ServerConfig::local_dev(&options.workspace);
|
let mut config = ServerConfig::local_dev(&options.workspace, identity);
|
||||||
config.static_assets_dir = options.frontend;
|
config.static_assets_dir = options.frontend;
|
||||||
let listener = TcpListener::bind(options.listen).await?;
|
let listener = TcpListener::bind(options.listen).await?;
|
||||||
eprintln!(
|
eprintln!(
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::hosts::RuntimeDiagnostic;
|
use crate::hosts::RuntimeDiagnostic;
|
||||||
|
|
||||||
const LOCAL_REPOSITORY_ID: &str = "local";
|
const LEGACY_LOCAL_REPOSITORY_ID: &str = "local";
|
||||||
|
const LOCAL_REPOSITORY_PREFIX: &str = "local-";
|
||||||
const MAX_COMMAND_OUTPUT: usize = 4096;
|
const MAX_COMMAND_OUTPUT: usize = 4096;
|
||||||
const DEFAULT_LOG_LIMIT: usize = 10;
|
const DEFAULT_LOG_LIMIT: usize = 10;
|
||||||
const MAX_LOG_LIMIT: usize = 50;
|
const MAX_LOG_LIMIT: usize = 50;
|
||||||
|
|
@ -14,12 +15,14 @@ const MAX_FIELD_LEN: usize = 240;
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct LocalRepositoryReader {
|
pub struct LocalRepositoryReader {
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
|
workspace_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LocalRepositoryReader {
|
impl LocalRepositoryReader {
|
||||||
pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
|
pub fn new(workspace_root: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
workspace_root: workspace_root.into(),
|
workspace_root: workspace_root.into(),
|
||||||
|
workspace_id: workspace_id.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,7 +33,7 @@ impl LocalRepositoryReader {
|
||||||
pub fn summary(&self, workspace_display_name: &str) -> RepositorySummary {
|
pub fn summary(&self, workspace_display_name: &str) -> RepositorySummary {
|
||||||
let git = inspect_git(&self.workspace_root);
|
let git = inspect_git(&self.workspace_root);
|
||||||
RepositorySummary {
|
RepositorySummary {
|
||||||
id: LOCAL_REPOSITORY_ID.to_string(),
|
id: Self::repository_id_for_workspace(&self.workspace_id),
|
||||||
display_name: workspace_display_name.to_string(),
|
display_name: workspace_display_name.to_string(),
|
||||||
kind: "local".to_string(),
|
kind: "local".to_string(),
|
||||||
workspace_root: self.workspace_root.clone(),
|
workspace_root: self.workspace_root.clone(),
|
||||||
|
|
@ -46,8 +49,42 @@ impl LocalRepositoryReader {
|
||||||
git_log(&self.workspace_root, limit)
|
git_log(&self.workspace_root, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_local_repository_id(id: &str) -> bool {
|
pub fn repository_id_for_workspace(workspace_id: &str) -> String {
|
||||||
id == LOCAL_REPOSITORY_ID
|
format!(
|
||||||
|
"{LOCAL_REPOSITORY_PREFIX}{}",
|
||||||
|
sanitize_identifier_fragment(workspace_id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_local_repository_id(id: &str, workspace_id: &str) -> bool {
|
||||||
|
id == LEGACY_LOCAL_REPOSITORY_ID || id == Self::repository_id_for_workspace(workspace_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_identifier_fragment(value: &str) -> String {
|
||||||
|
let mut output = String::with_capacity(value.len());
|
||||||
|
let mut previous_dash = false;
|
||||||
|
for ch in value.chars() {
|
||||||
|
let mapped = if ch.is_ascii_alphanumeric() {
|
||||||
|
ch.to_ascii_lowercase()
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
};
|
||||||
|
if mapped == '-' {
|
||||||
|
if !previous_dash {
|
||||||
|
output.push(mapped);
|
||||||
|
}
|
||||||
|
previous_dash = true;
|
||||||
|
} else {
|
||||||
|
output.push(mapped);
|
||||||
|
previous_dash = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let output = output.trim_matches('-').to_string();
|
||||||
|
if output.is_empty() {
|
||||||
|
"workspace".to_string()
|
||||||
|
} else {
|
||||||
|
output
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
use crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary};
|
use crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary};
|
||||||
|
use crate::identity::WorkspaceIdentity;
|
||||||
use crate::records::{
|
use crate::records::{
|
||||||
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
|
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
|
||||||
};
|
};
|
||||||
|
|
@ -28,6 +29,8 @@ pub enum AuthConfig {
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ServerConfig {
|
pub struct ServerConfig {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
|
pub workspace_display_name: String,
|
||||||
|
pub workspace_created_at: String,
|
||||||
pub workspace_root: PathBuf,
|
pub workspace_root: PathBuf,
|
||||||
pub static_assets_dir: Option<PathBuf>,
|
pub static_assets_dir: Option<PathBuf>,
|
||||||
pub auth: AuthConfig,
|
pub auth: AuthConfig,
|
||||||
|
|
@ -36,11 +39,12 @@ pub struct ServerConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerConfig {
|
impl ServerConfig {
|
||||||
pub fn local_dev(workspace_root: impl Into<PathBuf>) -> Self {
|
pub fn local_dev(workspace_root: impl Into<PathBuf>, identity: WorkspaceIdentity) -> Self {
|
||||||
let workspace_root = workspace_root.into();
|
let workspace_root = workspace_root.into();
|
||||||
let display = workspace_display_name_from_root(&workspace_root);
|
|
||||||
Self {
|
Self {
|
||||||
workspace_id: format!("local:{display}"),
|
workspace_id: identity.workspace_id,
|
||||||
|
workspace_display_name: identity.display_name,
|
||||||
|
workspace_created_at: identity.created_at,
|
||||||
workspace_root,
|
workspace_root,
|
||||||
static_assets_dir: None,
|
static_assets_dir: None,
|
||||||
auth: AuthConfig::LocalDevToken {
|
auth: AuthConfig::LocalDevToken {
|
||||||
|
|
@ -61,14 +65,13 @@ pub struct WorkspaceApi {
|
||||||
|
|
||||||
impl WorkspaceApi {
|
impl WorkspaceApi {
|
||||||
pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> {
|
pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> {
|
||||||
let display_name = workspace_display_name_from_root(&config.workspace_root);
|
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
workspace_id: config.workspace_id.clone(),
|
workspace_id: config.workspace_id.clone(),
|
||||||
display_name,
|
display_name: config.workspace_display_name.clone(),
|
||||||
state: "active".to_string(),
|
state: "active".to_string(),
|
||||||
created_at: "1970-01-01T00:00:00Z".to_string(),
|
created_at: config.workspace_created_at.clone(),
|
||||||
updated_at: "1970-01-01T00:00:00Z".to_string(),
|
updated_at: config.workspace_created_at.clone(),
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
|
@ -91,20 +94,19 @@ impl WorkspaceApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn local_repository_reader(&self) -> LocalRepositoryReader {
|
fn local_repository_reader(&self) -> LocalRepositoryReader {
|
||||||
LocalRepositoryReader::new(self.config.workspace_root.clone())
|
LocalRepositoryReader::new(
|
||||||
|
self.config.workspace_root.clone(),
|
||||||
|
self.config.workspace_id.clone(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_display_name(&self) -> String {
|
fn local_repository_id(&self) -> String {
|
||||||
workspace_display_name_from_root(&self.config.workspace_root)
|
LocalRepositoryReader::repository_id_for_workspace(self.workspace_id())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn workspace_display_name_from_root(workspace_root: &std::path::Path) -> String {
|
fn workspace_display_name(&self) -> &str {
|
||||||
workspace_root
|
self.config.workspace_display_name.as_str()
|
||||||
.file_name()
|
}
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.expect("workspace root must have a final path component")
|
|
||||||
.to_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_router(api: WorkspaceApi) -> Router {
|
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
|
let display_name = stored
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|record| record.display_name.clone())
|
.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 {
|
Ok(Json(WorkspaceResponse {
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
display_name,
|
display_name,
|
||||||
|
|
@ -314,7 +316,7 @@ async fn list_repositories(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
) -> ApiResult<Json<RepositoryListResponse>> {
|
) -> ApiResult<Json<RepositoryListResponse>> {
|
||||||
let reader = api.local_repository_reader();
|
let reader = api.local_repository_reader();
|
||||||
let items = reader.list(&api.workspace_display_name());
|
let items = reader.list(api.workspace_display_name());
|
||||||
Ok(Json(RepositoryListResponse {
|
Ok(Json(RepositoryListResponse {
|
||||||
workspace_id: api.config.workspace_id,
|
workspace_id: api.config.workspace_id,
|
||||||
items,
|
items,
|
||||||
|
|
@ -327,11 +329,11 @@ async fn repository_detail(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(repository_id): AxumPath<String>,
|
AxumPath(repository_id): AxumPath<String>,
|
||||||
) -> ApiResult<Json<RepositoryDetailResponse>> {
|
) -> ApiResult<Json<RepositoryDetailResponse>> {
|
||||||
ensure_local_repository(&repository_id)?;
|
let _canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
|
||||||
let reader = api.local_repository_reader();
|
let reader = api.local_repository_reader();
|
||||||
Ok(Json(RepositoryDetailResponse {
|
Ok(Json(RepositoryDetailResponse {
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
item: reader.summary(&api.workspace_display_name()),
|
item: reader.summary(api.workspace_display_name()),
|
||||||
source: "local_workspace_root".to_string(),
|
source: "local_workspace_root".to_string(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -341,7 +343,7 @@ async fn repository_log(
|
||||||
AxumPath(repository_id): AxumPath<String>,
|
AxumPath(repository_id): AxumPath<String>,
|
||||||
Query(query): Query<LogQuery>,
|
Query(query): Query<LogQuery>,
|
||||||
) -> ApiResult<Json<RepositoryLogResponse>> {
|
) -> ApiResult<Json<RepositoryLogResponse>> {
|
||||||
ensure_local_repository(&repository_id)?;
|
let canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
|
||||||
let RepositoryLogRead {
|
let RepositoryLogRead {
|
||||||
limit,
|
limit,
|
||||||
items,
|
items,
|
||||||
|
|
@ -349,7 +351,7 @@ async fn repository_log(
|
||||||
} = api.local_repository_reader().recent_log(query.limit);
|
} = api.local_repository_reader().recent_log(query.limit);
|
||||||
Ok(Json(RepositoryLogResponse {
|
Ok(Json(RepositoryLogResponse {
|
||||||
workspace_id: api.config.workspace_id,
|
workspace_id: api.config.workspace_id,
|
||||||
repository_id,
|
repository_id: canonical_repository_id,
|
||||||
limit,
|
limit,
|
||||||
items,
|
items,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
|
|
@ -361,7 +363,7 @@ async fn repository_tickets(
|
||||||
AxumPath(repository_id): AxumPath<String>,
|
AxumPath(repository_id): AxumPath<String>,
|
||||||
Query(query): Query<TicketKanbanQuery>,
|
Query(query): Query<TicketKanbanQuery>,
|
||||||
) -> ApiResult<Json<RepositoryTicketsResponse>> {
|
) -> ApiResult<Json<RepositoryTicketsResponse>> {
|
||||||
ensure_local_repository(&repository_id)?;
|
let canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
|
||||||
let limit = query.limit.unwrap_or(api.config.max_records).min(200);
|
let limit = query.limit.unwrap_or(api.config.max_records).min(200);
|
||||||
let ProjectRecordList {
|
let ProjectRecordList {
|
||||||
items,
|
items,
|
||||||
|
|
@ -370,7 +372,7 @@ async fn repository_tickets(
|
||||||
} = api.records.list_tickets(limit)?;
|
} = api.records.list_tickets(limit)?;
|
||||||
Ok(Json(RepositoryTicketsResponse {
|
Ok(Json(RepositoryTicketsResponse {
|
||||||
workspace_id: api.config.workspace_id,
|
workspace_id: api.config.workspace_id,
|
||||||
repository_id,
|
repository_id: canonical_repository_id,
|
||||||
limit,
|
limit,
|
||||||
columns: ticket_kanban_columns(items),
|
columns: ticket_kanban_columns(items),
|
||||||
invalid_records,
|
invalid_records,
|
||||||
|
|
@ -429,9 +431,10 @@ fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSu
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_local_repository(repository_id: &str) -> Result<()> {
|
fn ensure_local_repository(api: &WorkspaceApi, repository_id: &str) -> Result<String> {
|
||||||
if LocalRepositoryReader::is_local_repository_id(repository_id) {
|
let canonical_repository_id = api.local_repository_id();
|
||||||
Ok(())
|
if LocalRepositoryReader::is_local_repository_id(repository_id, api.workspace_id()) {
|
||||||
|
Ok(canonical_repository_id)
|
||||||
} else {
|
} else {
|
||||||
Err(Error::UnknownRepository(repository_id.to_string()))
|
Err(Error::UnknownRepository(repository_id.to_string()))
|
||||||
}
|
}
|
||||||
|
|
@ -612,6 +615,18 @@ mod tests {
|
||||||
|
|
||||||
use crate::store::SqliteWorkspaceStore;
|
use crate::store::SqliteWorkspaceStore;
|
||||||
|
|
||||||
|
const TEST_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001";
|
||||||
|
const TEST_REPOSITORY_ID: &str = "local-0192f0e8-4d84-7d6e-a000-000000000001";
|
||||||
|
const TEST_CREATED_AT: &str = "2026-06-23T06:43:28Z";
|
||||||
|
|
||||||
|
fn test_identity() -> WorkspaceIdentity {
|
||||||
|
WorkspaceIdentity {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
display_name: "Test Workspace".to_string(),
|
||||||
|
created_at: TEST_CREATED_AT.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn serves_bounded_read_apis_and_static_spa_separately() {
|
async fn serves_bounded_read_apis_and_static_spa_separately() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
@ -623,15 +638,15 @@ mod tests {
|
||||||
std::fs::write(static_dir.join("assets/app.js"), "console.log('yoi');").unwrap();
|
std::fs::write(static_dir.join("assets/app.js"), "console.log('yoi');").unwrap();
|
||||||
|
|
||||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||||
let mut config = ServerConfig::local_dev(dir.path());
|
let mut config = ServerConfig::local_dev(dir.path(), test_identity());
|
||||||
config.workspace_id = "local:test".to_string();
|
|
||||||
config.static_assets_dir = Some(static_dir);
|
config.static_assets_dir = Some(static_dir);
|
||||||
config.local_runtime_data_dir = Some(dir.path().join("data"));
|
config.local_runtime_data_dir = Some(dir.path().join("data"));
|
||||||
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
|
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
|
||||||
let app = build_router(api);
|
let app = build_router(api);
|
||||||
|
|
||||||
let workspace = get_json(app.clone(), "/api/workspace").await;
|
let workspace = get_json(app.clone(), "/api/workspace").await;
|
||||||
assert_eq!(workspace["workspace_id"], "local:test");
|
assert_eq!(workspace["workspace_id"], TEST_WORKSPACE_ID);
|
||||||
|
assert_eq!(workspace["display_name"], "Test Workspace");
|
||||||
assert_eq!(workspace["record_authority"], "local_yoi_project_records");
|
assert_eq!(workspace["record_authority"], "local_yoi_project_records");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
workspace["extension_points"]["host_worker_bridge"]["status"],
|
workspace["extension_points"]["host_worker_bridge"]["status"],
|
||||||
|
|
@ -647,18 +662,18 @@ mod tests {
|
||||||
assert_eq!(objectives["items"][0]["summary"], "Objective body.");
|
assert_eq!(objectives["items"][0]["summary"], "Objective body.");
|
||||||
|
|
||||||
let repositories = get_json(app.clone(), "/api/repositories").await;
|
let repositories = get_json(app.clone(), "/api/repositories").await;
|
||||||
assert_eq!(repositories["items"][0]["id"], "local");
|
assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID);
|
||||||
assert_eq!(repositories["items"][0]["kind"], "local");
|
assert_eq!(repositories["items"][0]["kind"], "local");
|
||||||
|
|
||||||
let repository_detail = get_json(app.clone(), "/api/repositories/local").await;
|
let repository_detail = get_json(app.clone(), "/api/repositories/local").await;
|
||||||
assert_eq!(repository_detail["item"]["id"], "local");
|
assert_eq!(repository_detail["item"]["id"], TEST_REPOSITORY_ID);
|
||||||
|
|
||||||
let repository_log = get_json(app.clone(), "/api/repositories/local/log?limit=3").await;
|
let repository_log = get_json(app.clone(), "/api/repositories/local/log?limit=3").await;
|
||||||
assert_eq!(repository_log["repository_id"], "local");
|
assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID);
|
||||||
assert_eq!(repository_log["limit"], 3);
|
assert_eq!(repository_log["limit"], 3);
|
||||||
|
|
||||||
let repository_tickets = get_json(app.clone(), "/api/repositories/local/tickets").await;
|
let repository_tickets = get_json(app.clone(), "/api/repositories/local/tickets").await;
|
||||||
assert_eq!(repository_tickets["repository_id"], "local");
|
assert_eq!(repository_tickets["repository_id"], TEST_REPOSITORY_ID);
|
||||||
let ready_column = repository_tickets["columns"]
|
let ready_column = repository_tickets["columns"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
@ -684,7 +699,7 @@ mod tests {
|
||||||
assert_eq!(unknown_repository_response.status(), StatusCode::NOT_FOUND);
|
assert_eq!(unknown_repository_response.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
let hosts = get_json(app.clone(), "/api/hosts").await;
|
let hosts = get_json(app.clone(), "/api/hosts").await;
|
||||||
assert_eq!(hosts["items"][0]["host_id"], "local-local-test");
|
assert_eq!(hosts["items"][0]["host_id"], TEST_REPOSITORY_ID);
|
||||||
assert_eq!(hosts["items"][0]["kind"], "local_host");
|
assert_eq!(hosts["items"][0]["kind"], "local_host");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hosts["items"][0]["capabilities"]["local_pod_inspection"],
|
hosts["items"][0]["capabilities"]["local_pod_inspection"],
|
||||||
|
|
@ -698,7 +713,11 @@ mod tests {
|
||||||
"local_pod_metadata_root_missing"
|
"local_pod_metadata_root_missing"
|
||||||
);
|
);
|
||||||
|
|
||||||
let host_workers = get_json(app.clone(), "/api/hosts/local-local-test/workers").await;
|
let host_workers = get_json(
|
||||||
|
app.clone(),
|
||||||
|
&format!("/api/hosts/{TEST_REPOSITORY_ID}/workers"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert!(host_workers["items"].as_array().unwrap().is_empty());
|
assert!(host_workers["items"].as_array().unwrap().is_empty());
|
||||||
|
|
||||||
let runs_response = app
|
let runs_response = app
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
|
||||||
filter = sourceFilter;
|
filter = sourceFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoHash = "sha256-M8cGY+eskFXSRjq3kBbRusflghvVKrWc1Pj50uKAlg8=";
|
cargoHash = "sha256-XZxqEKKDU42fFjFnCCcRRFTA0jkkiaSn3eQ8QwXRYPk=";
|
||||||
|
|
||||||
depsExtraArgs = {
|
depsExtraArgs = {
|
||||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user