merge: sync orchestration before queue 00001KVSMJJNV

This commit is contained in:
Keisuke Hirata 2026-06-23 17:41:00 +09:00
commit ca2ad18ded
No known key found for this signature in database
19 changed files with 1476 additions and 126 deletions

View File

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

View File

@ -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'

View File

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

View File

@ -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 で扱う。
--- ---

View File

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

View File

@ -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']

View File

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

View File

@ -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
View 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
View File

@ -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]]

View File

@ -1266,8 +1266,7 @@ impl DashboardApp {
.list .list
.selected_name .selected_name
.clone() .clone()
.filter(|name| list.entries.iter().any(|entry| entry.name == *name)) .filter(|name| list.entries.iter().any(|entry| entry.name == *name));
.or_else(|| list.entries.first().map(|entry| entry.name.clone()));
let panel = build_workspace_panel(&current_workspace_root(), &list); let panel = build_workspace_panel(&current_workspace_root(), &list);
self.apply_reloaded_snapshot(DashboardSnapshot { list, panel }); self.apply_reloaded_snapshot(DashboardSnapshot { list, panel });
} }
@ -1276,25 +1275,17 @@ impl DashboardApp {
self.apply_companion_lifecycle_memory(&mut snapshot.panel); self.apply_companion_lifecycle_memory(&mut snapshot.panel);
self.apply_orchestrator_lifecycle_memory(&mut snapshot.panel); self.apply_orchestrator_lifecycle_memory(&mut snapshot.panel);
let previous_selected_pod = self.list.selected_name.clone(); let previous_selected_pod = self.list.selected_name.clone();
snapshot.list.selected_name = previous_selected_pod snapshot.list.selected_name = previous_selected_pod.filter(|name| {
.filter(|name| {
snapshot snapshot
.list .list
.entries .entries
.iter() .iter()
.any(|entry| entry.name == *name) .any(|entry| entry.name == *name)
})
.or_else(|| {
snapshot
.list
.entries
.first()
.map(|entry| entry.name.clone())
}); });
let previous_row = self.selected_row.clone(); let previous_row = self.selected_row.clone();
self.list = snapshot.list; self.list = snapshot.list;
self.panel = snapshot.panel; self.panel = snapshot.panel;
self.selected_row = previous_row.filter(|key| self.panel.row(key).is_some()); self.selected_row = previous_row;
self.ensure_selection_visible(); self.ensure_selection_visible();
self.ensure_composer_target_available(); self.ensure_composer_target_available();
self.refresh_orchestrator_work_set(); self.refresh_orchestrator_work_set();
@ -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
.panel
.header
.orchestrator
.as_ref()
.map(|state| state.pod_name.as_str());
if !has_action_rows {
if let Some(selected_name) = self.list.selected_name.as_ref() {
if Some(selected_name.as_str()) != orchestrator_pod_name {
let key = PanelRowKey::Pod(selected_name.clone());
if visible.iter().any(|visible_key| visible_key == &key) {
self.select_panel_key(key);
return;
}
}
}
if let Some(key) = visible.iter().find(|key| match key {
PanelRowKey::Pod(name) => Some(name.as_str()) != orchestrator_pod_name,
PanelRowKey::Ticket(_) PanelRowKey::Ticket(_)
| PanelRowKey::InvalidTicket(_) | PanelRowKey::InvalidTicket(_)
| PanelRowKey::TicketIntakePod { .. } => true, | PanelRowKey::TicketIntakePod { .. } => self.list.selected_name = None,
}) {
self.select_panel_key(key.clone());
return;
} }
} else {
self.selected_row = None; self.selected_row = None;
self.list.selected_name = None; self.list.selected_name = None;
return;
}
self.select_panel_key(visible[0].clone());
} else if let Some(PanelRowKey::Pod(name)) = self.selected_row.as_ref() {
self.list.selected_name = Some(name.clone());
} }
} }
fn select_panel_key(&mut self, key: PanelRowKey) { fn select_panel_key(&mut self, key: PanelRowKey) {
if let PanelRowKey::Pod(name) = &key { match &key {
self.list.selected_name = Some(name.clone()); PanelRowKey::Pod(name) => self.list.selected_name = Some(name.clone()),
PanelRowKey::Ticket(_)
| PanelRowKey::InvalidTicket(_)
| PanelRowKey::TicketIntakePod { .. } => self.list.selected_name = None,
} }
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
let selected_key = key.clone(); let selected_key = key.clone();

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary}; use crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary};
use crate::identity::WorkspaceIdentity;
use crate::records::{ use crate::records::{
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary, LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
}; };
@ -28,6 +29,8 @@ pub enum AuthConfig {
#[derive(Clone)] #[derive(Clone)]
pub struct ServerConfig { pub struct ServerConfig {
pub workspace_id: String, pub workspace_id: String,
pub workspace_display_name: String,
pub workspace_created_at: String,
pub workspace_root: PathBuf, pub workspace_root: PathBuf,
pub static_assets_dir: Option<PathBuf>, pub static_assets_dir: Option<PathBuf>,
pub auth: AuthConfig, pub auth: AuthConfig,
@ -36,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

View File

@ -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,