diff --git a/.yoi/tickets/00001KVNG9B9Z/item.md b/.yoi/tickets/00001KVNG9B9Z/item.md index 5b0cc560..ab2e8207 100644 --- a/.yoi/tickets/00001KVNG9B9Z/item.md +++ b/.yoi/tickets/00001KVNG9B9Z/item.md @@ -1,8 +1,8 @@ --- title: 'Workspace web UI: add sidebar navigation panel' -state: 'inprogress' +state: 'closed' created_at: '2026-06-21T16:30:12Z' -updated_at: '2026-06-21T16:37:34Z' +updated_at: '2026-06-21T17:01:46Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-21T16:35:06Z' diff --git a/.yoi/tickets/00001KVNG9B9Z/resolution.md b/.yoi/tickets/00001KVNG9B9Z/resolution.md new file mode 100644 index 00000000..02ac0937 --- /dev/null +++ b/.yoi/tickets/00001KVNG9B9Z/resolution.md @@ -0,0 +1,26 @@ +Workspace web UI に sidebar navigation panel を追加し、Orchestrator worktree の `orchestration` branch に統合した。 + +主な成果: +- `web/workspace/src/lib/workspace-sidebar/` に sidebar components を追加: + - `WorkspaceSidebar.svelte` + - `RepositoriesNavSection.svelte` + - `ObjectivesNavSection.svelte` + - `WorkersNavSection.svelte` + - `types.ts` +- `web/workspace/src/routes/+page.svelte` を sidebar + main content の responsive two-column layout に更新。 +- Sidebar header に workspace label/name と disabled settings placeholder を表示。 +- `repositories`, `objectives`, `workers` sections を追加。 +- Objectives section は `/api/objectives` を読み、title/state と empty/error states を section-local に表示。 +- Workers section は `/api/workers` を読み、Worker label/state/status/role を表示し、Pod を primary UI naming として露出しない。 +- Repository section は future Repository API に繋げやすい placeholder seam として実装。 +- Host/Worker API/UI merge 後の `+page.svelte` conflict を解消し、main Host/Worker content と sidebar skeleton を両方維持。 +- Backend/API authority, SSR, mutation/business logic は追加していない。 + +統合・検証: +- Merge commit: `613f4126 merge: workspace sidebar navigation` +- Implementation commits: `d3b8bdfd`, `4ab696b4` +- Reviewer final verdict: approve +- Validation passed: `git diff --check HEAD^1..HEAD`, `deno task check`, `deno task build`, `cargo test -p yoi-workspace-server`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`。 + +範囲外: +- Settings page, Repository CRUD/API, Objective edit/detail UI, Worker start/stop/attach controls, drag/drop/collapsible tree, auth, multi-workspace switcher は実装していない。 \ No newline at end of file diff --git a/.yoi/tickets/00001KVNG9B9Z/thread.md b/.yoi/tickets/00001KVNG9B9Z/thread.md index 838523fd..a94d662c 100644 --- a/.yoi/tickets/00001KVNG9B9Z/thread.md +++ b/.yoi/tickets/00001KVNG9B9Z/thread.md @@ -135,3 +135,254 @@ Next action: - Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff。 --- + + + +## Implementation report + +Coder conflict-resolution report received from `yoi-coder-00001KVNG9B9Z`. + +Final implementation HEAD: +- `4ab696b4` merge commit from current `orchestration` into `impl/00001KVNG9B9Z-workspace-sidebar` + +Relevant commits: +- `d3b8bdfd feat: add workspace sidebar skeleton` +- `4ab696b4` merge/conflict-resolution commit + +Conflict resolution summary: +- Resolved `web/workspace/src/routes/+page.svelte` conflict after Host/Worker API/UI from `00001KVNEKH9Q` landed。 +- Preserved merged Host/Worker main content/cards/table。 +- Preserved sidebar navigation skeleton。 +- Updated sidebar Workers section to consume available `/api/workers` response through typed `ListResponse`。 +- Kept Pod naming out of sidebar, presenting workers by label/state/status/role。 +- Changes remain scoped to Workspace frontend。 + +Coder validation reported: +- `cd web/workspace && deno task check`: passed +- `cd web/workspace && deno task build`: passed +- `git diff --check`: passed +- `git diff --check orchestration..HEAD`: passed +- `cargo run -p yoi -- ticket doctor`: passed +- `nix build .#yoi --no-link`: passed +- `cargo test -p yoi-workspace-server`: not run because backend code was not changed during conflict resolution + +Generated artifact cleanup: +- Coder removed generated/ignored frontend artifacts after validation: + - `web/workspace/.svelte-kit` + - `web/workspace/build` + - `web/workspace/node_modules` + +Orchestrator evidence checked: +- Worktree clean on `impl/00001KVNG9B9Z-workspace-sidebar` at `4ab696b4`。 +- Diff `orchestration..HEAD` touches expected frontend files only: new `web/workspace/src/lib/workspace-sidebar/*` components and `web/workspace/src/routes/+page.svelte`。 +- `git diff --check orchestration..HEAD`: passed。 +- Merge-tree conflict scan vs current `orchestration`: no conflict markers / no changed-in-both diagnostics。 + +Next action: +- Route to read-only Reviewer Pod for acceptance review。 + +--- + + + +## Comment + +Review routing: + +Spawned read-only Reviewer Pod: +- `yoi-reviewer-00001KVNG9B9Z-r1` + +Review target: +- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVNG9B9Z-workspace-sidebar` +- Branch: `impl/00001KVNG9B9Z-workspace-sidebar` +- Current implementation HEAD: `4ab696b4` +- Implementation commits: + - `d3b8bdfd feat: add workspace sidebar skeleton` + - `4ab696b4` merge/conflict-resolution commit from current `orchestration` +- Primary review diff: `orchestration..HEAD` inside implementation worktree, so review sees final sidebar changes on top of current Host/Worker API/UI state。 + +Reviewer scope: +- read-only `/home/hare/Projects/yoi` + +Reviewer focus: +- Sidebar visibility/layout/responsive behavior。 +- Workspace header/settings placeholder。 +- Repositories/objectives/workers sections。 +- Objective and worker section bounded loading/empty/error handling。 +- Worker naming avoids Pod-primary UI。 +- Main Host/Worker content from `00001KVNEKH9Q` remains preserved after conflict resolution。 +- No backend authority/SSR/business logic changes。 + +Orchestrator will wait for reviewer verdict before integration。 + +--- + + + +## Review: approve + +Verdict: `approve` + +確認範囲: +- Ticket authority: + - `.yoi/tickets/00001KVNG9B9Z/item.md` + - `.yoi/tickets/00001KVNG9B9Z/thread.md` +- Implementation diff: + - `git diff --name-status orchestration..HEAD` + - implementation-only diff against merge-base `a607a1f20d0622b1f556c36cca155da68109fe25..HEAD` +- Frontend files: + - `web/workspace/src/routes/+page.svelte` + - `web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte` + - `web/workspace/src/lib/workspace-sidebar/RepositoriesNavSection.svelte` + - `web/workspace/src/lib/workspace-sidebar/ObjectivesNavSection.svelte` + - `web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte` + - `web/workspace/src/lib/workspace-sidebar/types.ts` +- API shape spot-check: + - `crates/workspace-server/src/server.rs` + - `crates/workspace-server/src/hosts.rs` + - `crates/workspace-server/src/records.rs` + +Blocking issues: +- None。 + +Acceptance verification: +- Left sidebar is wired into `+page.svelte` and layout is separated from main content。 +- Sidebar header shows workspace label/name and disabled settings placeholder。 +- `repositories`, `objectives`, and `workers` sections are present。 +- Objectives section fetches `/api/objectives` and handles loading/error/empty locally。 +- Workers section fetches `/api/workers`, uses Worker naming in visible UI, and handles 404/empty/error locally。 +- Main Host/Worker content from current orchestration branch is preserved。 +- No backend/API authority changes were introduced by the implementation-only diff。 +- Responsive CSS uses a two-column layout on wide viewports and single-column layout below 760px, with `min-width: 0`, wrapping, and table overflow containment。 + +Non-blocking concerns: +- `orchestration..HEAD` currently shows apparent `.yoi/tickets/00001KVNG9B9Z/item.md` / `thread.md` reversions because `orchestration` has newer review-routing/report commits after the implementation branch’s merge-base. The implementation-only diff against the merge-base is frontend-only, and `git merge-tree --write-tree orchestration HEAD` reports a clean merge. This should not block a normal merge, but avoid applying the literal `orchestration..HEAD` patch/squash in a way that drops newer ticket thread entries。 + +Reviewer validation: +- `git diff --check`: passed +- `git diff --check orchestration..HEAD`: passed +- `git diff --check $(git merge-base orchestration HEAD)..HEAD`: passed +- `cd web/workspace && deno task check`: passed (`svelte-check found 0 errors and 0 warnings`) +- `cd web/workspace && deno task build`: passed; static adapter wrote site to `build` +- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`) +- `nix build .#yoi --no-link`: passed +- `cargo test -p yoi-workspace-server`: passed (`6 passed; 0 failed`) +- `git merge-tree --write-tree orchestration HEAD`: passed with status 0; produced merged tree `24e0e026264333fc63d60c0e2d649839445acb64` + +Generated artifacts: +- Reviewer did not clean generated ignored artifacts after validation: + - `web/workspace/.svelte-kit/` + - `web/workspace/build/` + - `web/workspace/node_modules/` +- They are ignored and not tracked; `git ls-files --others --exclude-standard` reports `0`。 + +Worktree status: +- Clean tracked state on `impl/00001KVNG9B9Z-workspace-sidebar` at `4ab696b4`。 + +--- + + + +## Implementation report + +Reviewer r1 approvalを受けて、実装ブランチを Orchestrator worktree の `orchestration` branch に統合した。 + +統合: +- Merge commit: `613f4126 merge: workspace sidebar navigation` +- Merged branch: `impl/00001KVNG9B9Z-workspace-sidebar` +- Implementation commits: + - `d3b8bdfd feat: add workspace sidebar skeleton` + - `4ab696b4` merge/conflict-resolution commit from current `orchestration` + +Orchestrator worktree validation: +- `git diff --check HEAD^1..HEAD`: passed +- `cd web/workspace && rm -rf node_modules .svelte-kit build && deno task check`: passed (`svelte-check found 0 errors and 0 warnings`) +- `cd web/workspace && deno task build`: passed +- `cargo test -p yoi-workspace-server`: passed (`6 passed`) +- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`) +- `nix build .#yoi --no-link`: passed + +Validation generated ignored frontend artifacts in Orchestrator worktree: +- `web/workspace/node_modules/` +- `web/workspace/.svelte-kit/` +- `web/workspace/build/` + +These were removed after validation. Final Orchestrator worktree status after validation cleanup is clean on `orchestration` at `613f4126`。 + +--- + + + +## State changed + +Reviewer approval、Orchestrator worktree への統合、Deno check/build、workspace-server tests、Ticket doctor、Nix build が完了したため `done` に遷移する。 + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Workspace web UI に sidebar navigation panel を追加し、Orchestrator worktree の `orchestration` branch に統合した。 + +主な成果: +- `web/workspace/src/lib/workspace-sidebar/` に sidebar components を追加: + - `WorkspaceSidebar.svelte` + - `RepositoriesNavSection.svelte` + - `ObjectivesNavSection.svelte` + - `WorkersNavSection.svelte` + - `types.ts` +- `web/workspace/src/routes/+page.svelte` を sidebar + main content の responsive two-column layout に更新。 +- Sidebar header に workspace label/name と disabled settings placeholder を表示。 +- `repositories`, `objectives`, `workers` sections を追加。 +- Objectives section は `/api/objectives` を読み、title/state と empty/error states を section-local に表示。 +- Workers section は `/api/workers` を読み、Worker label/state/status/role を表示し、Pod を primary UI naming として露出しない。 +- Repository section は future Repository API に繋げやすい placeholder seam として実装。 +- Host/Worker API/UI merge 後の `+page.svelte` conflict を解消し、main Host/Worker content と sidebar skeleton を両方維持。 +- Backend/API authority, SSR, mutation/business logic は追加していない。 + +統合・検証: +- Merge commit: `613f4126 merge: workspace sidebar navigation` +- Implementation commits: `d3b8bdfd`, `4ab696b4` +- Reviewer final verdict: approve +- Validation passed: `git diff --check HEAD^1..HEAD`, `deno task check`, `deno task build`, `cargo test -p yoi-workspace-server`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`。 + +範囲外: +- Settings page, Repository CRUD/API, Objective edit/detail UI, Worker start/stop/attach controls, drag/drop/collapsible tree, auth, multi-workspace switcher は実装していない。 + +--- + + + +## Implementation report + +Post-close cleanup completed。 + +- Stopped child Pods and reclaimed scope: + - `yoi-coder-00001KVNG9B9Z` + - `yoi-reviewer-00001KVNG9B9Z-r1` +- Removed ignored frontend validation artifacts from child worktree before worktree removal: + - `web/workspace/node_modules/` + - `web/workspace/.svelte-kit/` + - `web/workspace/build/` +- Removed implementation worktree: + - `/home/hare/Projects/yoi/.worktree/00001KVNG9B9Z-workspace-sidebar` +- Deleted implementation branch: + - `impl/00001KVNG9B9Z-workspace-sidebar` +- Orchestrator worktree remains clean on `orchestration` at `c29eba0c`。 + +Root/original workspace was not used for merge/validation/cleanup operations。 + +Follow-up note: +- `00001KVNGJPRG` had been left queued behind this sidebar work due `do_not_parallelize`; this blocker is now cleared for re-routing on top of the settled sidebar/navigation structure。 + +--- diff --git a/.yoi/tickets/00001KVNGJPRG/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVNGJPRG/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..8d7a5141 --- /dev/null +++ b/.yoi/tickets/00001KVNGJPRG/artifacts/orchestration-plan.jsonl @@ -0,0 +1,2 @@ +{"id":"orch-plan-20260621-164056-1","ticket_id":"00001KVNGJPRG","kind":"do_not_parallelize","related_ticket":"00001KVNG9B9Z","note":"Repository/objective pages and sidebar navigation both modify the Workspace SPA navigation/layout surface, especially `web/workspace/src/routes/+page.svelte` and route/component structure. Start this Ticket after `00001KVNG9B9Z` lands or after its implementation diff is known enough to route a non-conflicting continuation.","author":"yoi-orchestrator","at":"2026-06-21T16:40:56Z"} +{"id":"orch-plan-20260621-170253-2","ticket_id":"00001KVNGJPRG","kind":"accepted_plan","accepted_plan":{"summary":"Add read-only current-workspace Repository APIs/pages with bounded Git summary/log and Ticket Kanban, plus Objective list page and sidebar links, preserving filesystem Ticket/Objective authority and static SPA boundaries.","branch":"impl/00001KVNGJPRG-repository-objective-pages","worktree":"/home/hare/Projects/yoi/.worktree/00001KVNGJPRG-repository-objective-pages","role_plan":"Orchestrator creates a dedicated child worktree from current sidebar-integrated `orchestration` and spawns a narrow-scope Coder. Reviewer will be spawned read-only after Coder reports implementation commit(s). After approval, Orchestrator integrates into `orchestration`, validates workspace-server/frontend/Nix, records closure, and cleans only the child worktree/branch."},"author":"yoi-orchestrator","at":"2026-06-21T17:02:53Z"} diff --git a/.yoi/tickets/00001KVNGJPRG/item.md b/.yoi/tickets/00001KVNGJPRG/item.md index 5f95d343..1cd7f9ec 100644 --- a/.yoi/tickets/00001KVNGJPRG/item.md +++ b/.yoi/tickets/00001KVNGJPRG/item.md @@ -1,8 +1,8 @@ --- title: 'Workspace web: repository and objective pages' -state: 'queued' +state: 'closed' created_at: '2026-06-21T16:35:19Z' -updated_at: '2026-06-21T16:40:35Z' +updated_at: '2026-06-21T17:31:43Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-21T16:40:35Z' diff --git a/.yoi/tickets/00001KVNGJPRG/resolution.md b/.yoi/tickets/00001KVNGJPRG/resolution.md new file mode 100644 index 00000000..fa57085a --- /dev/null +++ b/.yoi/tickets/00001KVNGJPRG/resolution.md @@ -0,0 +1,30 @@ +Workspace web に Repository / Objective pages を追加し、Orchestrator worktree の `orchestration` branch に統合した。 + +主な成果: +- Read-only Repository backend APIs を追加: + - `/api/repositories` + - `/api/repositories/local` + - `/api/repositories/local/log` + - `/api/repositories/local/tickets` +- Current workspace root を local Repository として扱う bounded repository summary を追加。 +- Git repository では bounded branch/head/root/dirty/remote/recent log summary を返す。 +- Non-Git workspace では `git.status = unavailable` と bounded diagnostics に degrade。 +- Git log summary は recent commit hash/subject/author/timestamp に限定し、diff/patch/file content/blame/config は読まない。 +- Remote URL summary は URL-scheme userinfo を redact。 +- Read-only Ticket Kanban を Ticket state ごとに grouping し、workspace-local Ticket fallback diagnostic を含めた。 +- Objective list summaries を filesystem Objective records から追加。 +- Static SPA に hash-navigation Repository / Objectives pages を追加。 +- Sidebar Repository/Objectives links を新 pages に接続。 +- Repository page に summary, Git summary/log, diagnostics, read-only Ticket Kanban を表示。 +- Objective page に title/state/updated_at/summary と detail placeholder links を表示。 +- Ticket / Objective canonical authority remains filesystem read-through records; mutation API / DB canonical migration は追加していない。 + +統合・検証: +- Merge commit: `7ee702b1 merge: repository objective pages` +- Implementation commit: `ceb1ee3b feat: add repository objective pages` +- Reviewer final verdict: approve +- Validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p yoi-workspace-server`, `cargo check -p yoi-workspace-server`, `deno task check`, `deno task build`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`。 + +範囲外 / follow-up notes: +- Repository CRUD/API, Objective edit/detail mutation, full Git browser/diff/file views, drag/drop Kanban, and write APIs were not implemented。 +- Reviewer noted possible follow-ups: keep `#/objectives` sidebar link visible even on objective empty/error states, and further tighten remote URL sanitization for query-param or SCP-like token forms if needed。 \ No newline at end of file diff --git a/.yoi/tickets/00001KVNGJPRG/thread.md b/.yoi/tickets/00001KVNGJPRG/thread.md index 626f5957..71349bb6 100644 --- a/.yoi/tickets/00001KVNGJPRG/thread.md +++ b/.yoi/tickets/00001KVNGJPRG/thread.md @@ -30,4 +30,385 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: `wait_do_not_parallelize_with_sidebar` + +Reason: +- Ticket body is implementation-ready in isolation, but it is directly coupled to the currently active sidebar navigation work `00001KVNG9B9Z`。 +- Relations show this Ticket is related to `00001KVNG9B9Z` with note: “Sidebar navigation should link to repository and objective pages”。 +- `00001KVNG9B9Z` is already `inprogress`, has a live Coder Pod, and is expected to change `web/workspace/src/routes/+page.svelte` / sidebar component structure。 +- Starting this Ticket in parallel now would likely produce overlapping route/layout/sidebar changes and non-trivial semantic merge work, rather than independent implementation。 +- Host/Worker API work `00001KVNEKH9Q` just landed, further increasing the need to base the page/navigation work on current SPA state。 + +Decision: +- Do not create a worktree or spawn Coder for `00001KVNGJPRG` yet。 +- Leave Ticket `queued` and record an orchestration `do_not_parallelize` note with `00001KVNG9B9Z`。 +- Re-route this Ticket after sidebar work lands, or if sidebar implementation report shows a stable component boundary that makes repository/objective pages non-conflicting。 + +No implementation side effects were performed for this Ticket。 + +--- + + + +## Decision + +Routing decision: `implementation_ready_after_sidebar_landed` + +Reason: +- This Ticket was previously left queued due `do_not_parallelize` with `00001KVNG9B9Z` because repository/objective pages and sidebar navigation both touched the Workspace SPA navigation/layout surface。 +- `00001KVNG9B9Z` is now `closed` and integrated (`613f4126 merge: workspace sidebar navigation`), so the sequencing blocker is cleared。 +- Related bootstrap Ticket `00001KVMFFYVX` is also closed/integrated。 +- Ticket body has concrete backend/API/frontend requirements, acceptance criteria, and validation requirements。 +- Relations are `related` only; no blocking relation remains。 +- Orchestrator worktree is clean on `orchestration` at `eb2e5907`; target worktree / branch is not present。 + +IntentPacket: + +Intent: +- Add Repository and Objective pages reachable from the Workspace sidebar, using filesystem read-through Ticket/Objective authority and bounded read-only Repository/Git summaries。 + +Binding decisions / invariants: +- Ticket / Objective canonical authority remains existing filesystem records; do not migrate canonical writes to SQLite or add mutation APIs。 +- Repository page is read-only initial slice for current workspace root as local Repository。 +- Git info and log summaries must be bounded; do not expose full diffs, file contents, blame, or secret-like config。 +- Repository Ticket Kanban is read-only and grouped by Ticket state; no drag/drop or state mutation。 +- Objective list uses existing filesystem read-through `/api/objectives` data, with detail links/placeholders as practical。 +- Frontend remains static SPA; no SSR/business authority。 +- Sidebar links should use the now-landed navigation/component structure。 +- API failures, non-Git repo, and empty state must be section-local。 + +Requirements / acceptance criteria: +- Add read-only Repository list/detail/summary API or minimal workspace repository summary。 +- For Git repository: bounded branch/head/root/dirty/remote/recent log summary。 +- For non-Git repository: safe `kind = local` / git unavailable diagnostic。 +- Add bounded Git log summary API returning recent N commit hash/subject/author/timestamp only。 +- Add Repository Ticket Kanban read model grouped by Ticket state, with safe fallback to workspace-local tickets when target metadata is absent。 +- Add Repository page showing summary, Git summary/log, and Ticket Kanban。 +- Add Objective list page showing objective title/state/updated_at/summary and detail link/placeholder if feasible。 +- Sidebar repositories/objectives links navigate to these pages。 +- Deno check/build and backend validation pass。 + +Implementation latitude: +- Use SvelteKit static SPA client-side routing/hash/router approach or simple stateful navigation if current skeleton lacks route generation; keep static output compatible。 +- Backend API names may be pragmatic, e.g. `/api/repositories`, `/api/repositories/{id}`, `/api/repositories/{id}/log`, `/api/repositories/{id}/tickets`。 +- Current workspace root can be the only repository for this slice。 +- Ticket Kanban fallback can group all workspace tickets by state with diagnostic that target metadata is not yet available。 +- Keep UI simple and avoid broad design-system churn。 + +Escalate if: +- Implementing repository APIs would require reading file contents/diffs or unsafe Git config/secrets。 +- Objective detail requires mutation or schema changes。 +- Static SPA route approach conflicts with SvelteKit adapter/static constraints。 +- Sidebar/navigation architecture from `00001KVNG9B9Z` cannot support pages without broad rewrite。 + +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` +- `git diff --check` +- `cargo run -p yoi -- ticket doctor` +- `nix build .#yoi --no-link`。 + +--- + + + +## State changed + +Previously recorded `do_not_parallelize` sequencing reason is cleared because sidebar navigation Ticket `00001KVNG9B9Z` is closed and integrated. Human queue authorization remains applicable, and the Ticket has concrete implementation requirements, so Orchestrator accepts implementation. + +--- + + + +## Implementation report + +Implementation start report: + +- Created child implementation worktree: + - `/home/hare/Projects/yoi/.worktree/00001KVNGJPRG-repository-objective-pages` +- Created branch: + - `impl/00001KVNGJPRG-repository-objective-pages` +- Base commit: + - `0f7e78c1 ticket: accept repository objective pages` +- 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-00001KVNGJPRG` +- Coder scope: + - read: `/home/hare/Projects/yoi` + - write: `/home/hare/Projects/yoi/.worktree/00001KVNGJPRG-repository-objective-pages` + +Next action: +- Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff。 + +--- + + + +## Implementation report + +Coder implementation report received from `yoi-coder-00001KVNGJPRG`. + +Implementation commit: +- `ceb1ee3b feat: add repository objective pages` + +Changed areas: +- Backend repository read APIs: + - Added `crates/workspace-server/src/repositories.rs`。 + - Added bounded local repository/Git summary and log reader。 + - Added endpoints: + - `/api/repositories` + - `/api/repositories/local` + - `/api/repositories/local/log` + - `/api/repositories/local/tickets` + - Added read-only Ticket Kanban grouping by Ticket state with workspace-local fallback diagnostic。 + - Added Objective list summaries from existing filesystem Objective records。 +- Frontend Workspace SPA: + - Added hash-navigation pages for Repository and Objectives。 + - Wired sidebar Repository/Objectives links to the new pages。 + - Added Repository summary, Git summary/log, diagnostics, and read-only Ticket Kanban UI。 + - Added Objective list with title/state/updated_at/summary and detail placeholder links。 +- Tests: + - Extended workspace-server API tests for repository endpoints and Objective summary。 + - Added Git URL sanitization/log parsing unit tests。 + +Coder validation reported: +- `cargo fmt --all`: passed +- `cargo fmt --check`: passed +- `cargo test -p yoi-workspace-server`: passed +- `cargo check -p yoi-workspace-server`: passed +- `cd web/workspace && deno task check`: passed +- `cd web/workspace && deno task build`: passed +- `git diff --check`: passed +- `cargo run -p yoi -- ticket doctor`: passed +- `nix build .#yoi --no-link`: passed after staging newly added `repositories.rs` so Git-backed flake source filtering included it。 + +Generated artifact cleanup: +- Coder removed `web/workspace/build` and `web/workspace/.svelte-kit`。 +- Orchestrator additionally removed ignored `web/workspace/node_modules/` before Reviewer routing。 + +Orchestrator evidence checked: +- Worktree clean on `impl/00001KVNGJPRG-repository-objective-pages` at `ceb1ee3b`。 +- Diff `0f7e78c1..HEAD` touches 8 expected backend/frontend files, about 1228 insertions / 142 deletions。 +- `git diff --check 0f7e78c1..HEAD`: passed。 +- No generated frontend artifacts remain in child worktree status。 + +Next action: +- Route to read-only Reviewer Pod for acceptance review。 + +--- + + + +## Comment + +Review routing: + +Spawned read-only Reviewer Pod: +- `yoi-reviewer-00001KVNGJPRG-r1` + +Review target: +- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVNGJPRG-repository-objective-pages` +- Branch: `impl/00001KVNGJPRG-repository-objective-pages` +- Baseline: `0f7e78c1` +- Implementation commit: `ceb1ee3b feat: add repository objective pages` +- Diff: `0f7e78c1..ceb1ee3b` + +Reviewer scope: +- read-only `/home/hare/Projects/yoi` + +Reviewer focus: +- Read-only bounded Repository APIs and Git summaries。 +- No diffs/file contents/blame/secret-like config exposure。 +- Sanitized remote URL summary。 +- Non-Git degradation。 +- Read-only Ticket Kanban grouped by state。 +- Objective list uses filesystem read-through records。 +- Static SPA navigation/pages and section-local error handling。 +- Generated artifact / Nix source filtering hygiene。 + +Orchestrator will wait for reviewer verdict before integration。 + +--- + + + +## Review: approve + +Verdict: `approve` + +確認範囲: +- Ticket authority: + - `.yoi/tickets/00001KVNGJPRG/item.md` + - `.yoi/tickets/00001KVNGJPRG/thread.md` +- Diff `0f7e78c1..ceb1ee3b`: + - `crates/workspace-server/src/lib.rs` + - `crates/workspace-server/src/records.rs` + - `crates/workspace-server/src/repositories.rs` + - `crates/workspace-server/src/server.rs` + - `web/workspace/src/lib/workspace-sidebar/ObjectivesNavSection.svelte` + - `web/workspace/src/lib/workspace-sidebar/RepositoriesNavSection.svelte` + - `web/workspace/src/lib/workspace-sidebar/types.ts` + - `web/workspace/src/routes/+page.svelte` + +Blocking issues: +- None found。 + +Acceptance verification: +- Repository APIs are read-only `GET` routes only。 +- Git commands use fixed argument vectors through `Command::new("git").arg("-C").arg(workspace_root).args(args)`; no shell interpolation。 +- Git summary/log is bounded: + - log limit clamps to `1..=50` + - command output capped at `4096` + - fields capped at `240`, hash at `40` + - no diff/patch/file content/blame/config commands are used。 +- Remote URL summary strips URL-scheme userinfo and marks `redacted`。 +- Non-Git workspaces return `git.status = "unavailable"` plus diagnostics rather than failing the Repository page model。 +- Ticket Kanban reads filesystem Ticket summaries and groups by state; no Ticket mutation route/UI was added。 +- Objective list continues to use filesystem read-through `/api/objectives`; no Objective mutation/API authority added。 +- Frontend remains static SPA/hash navigation; no SSR/business authority introduced。 +- Nix build succeeds with the new Rust source file。 + +Non-blocking concerns: +- `ObjectivesNavSection.svelte` only shows the “All objectives” sidebar link when objectives are successfully loaded and non-empty. In an empty/error state, the page is still reachable from hero links, but the sidebar itself does not provide a stable top-level Objectives link. Consider keeping `#/objectives` visible in all non-loading states in a follow-up。 +- Remote sanitization removes URL-scheme userinfo, but does not attempt query-param or SCP-like token redaction. Acceptable for the initial bounded summary, but worth tightening if remote URLs may include local secret-bearing config。 + +Reviewer validation: +- `cargo fmt --check`: passed +- `git diff --check 0f7e78c1..HEAD`: passed +- `cargo test -p yoi-workspace-server`: passed (`8 passed`) +- `cargo check -p yoi-workspace-server`: passed +- `cd web/workspace && deno task check`: passed (`svelte-check found 0 errors and 0 warnings`) +- `cd web/workspace && deno task build`: passed +- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`) +- `nix build .#yoi --no-link`: passed + +Generated artifacts: +- Reviewer did not clean generated ignored artifacts after validation。 +- Ignored generated artifacts present: + - `target/` + - `web/workspace/.svelte-kit/` + - `web/workspace/build/` + - `web/workspace/node_modules/` +- No untracked non-ignored files。 + +Worktree status: +- Clean tracked/non-ignored state on `impl/00001KVNGJPRG-repository-objective-pages` at `ceb1ee3b`。 + +--- + + + +## Implementation report + +Reviewer r1 approvalを受けて、実装ブランチを Orchestrator worktree の `orchestration` branch に統合した。 + +統合: +- Merge commit: `7ee702b1 merge: repository objective pages` +- Merged branch: `impl/00001KVNGJPRG-repository-objective-pages` +- Implementation commit: + - `ceb1ee3b feat: add repository objective pages` + +Orchestrator worktree validation: +- `cargo fmt --check`: passed +- `git diff --check HEAD^1..HEAD`: passed +- `cargo test -p yoi-workspace-server`: passed (`8 passed`) +- `cargo check -p yoi-workspace-server`: passed +- `cd web/workspace && rm -rf node_modules .svelte-kit build && deno task check`: passed (`svelte-check found 0 errors and 0 warnings`) +- `cd web/workspace && deno task build`: passed +- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`) +- `nix build .#yoi --no-link`: passed + +Validation generated ignored frontend artifacts in Orchestrator worktree: +- `web/workspace/node_modules/` +- `web/workspace/.svelte-kit/` +- `web/workspace/build/` + +These were removed after validation. Final Orchestrator worktree status after validation cleanup is clean on `orchestration` at `7ee702b1`。 + +--- + + + +## State changed + +Reviewer approval、Orchestrator worktree への統合、workspace-server tests/check、Deno check/build、Ticket doctor、Nix build が完了したため `done` に遷移する。 + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Workspace web に Repository / Objective pages を追加し、Orchestrator worktree の `orchestration` branch に統合した。 + +主な成果: +- Read-only Repository backend APIs を追加: + - `/api/repositories` + - `/api/repositories/local` + - `/api/repositories/local/log` + - `/api/repositories/local/tickets` +- Current workspace root を local Repository として扱う bounded repository summary を追加。 +- Git repository では bounded branch/head/root/dirty/remote/recent log summary を返す。 +- Non-Git workspace では `git.status = unavailable` と bounded diagnostics に degrade。 +- Git log summary は recent commit hash/subject/author/timestamp に限定し、diff/patch/file content/blame/config は読まない。 +- Remote URL summary は URL-scheme userinfo を redact。 +- Read-only Ticket Kanban を Ticket state ごとに grouping し、workspace-local Ticket fallback diagnostic を含めた。 +- Objective list summaries を filesystem Objective records から追加。 +- Static SPA に hash-navigation Repository / Objectives pages を追加。 +- Sidebar Repository/Objectives links を新 pages に接続。 +- Repository page に summary, Git summary/log, diagnostics, read-only Ticket Kanban を表示。 +- Objective page に title/state/updated_at/summary と detail placeholder links を表示。 +- Ticket / Objective canonical authority remains filesystem read-through records; mutation API / DB canonical migration は追加していない。 + +統合・検証: +- Merge commit: `7ee702b1 merge: repository objective pages` +- Implementation commit: `ceb1ee3b feat: add repository objective pages` +- Reviewer final verdict: approve +- Validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p yoi-workspace-server`, `cargo check -p yoi-workspace-server`, `deno task check`, `deno task build`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`。 + +範囲外 / follow-up notes: +- Repository CRUD/API, Objective edit/detail mutation, full Git browser/diff/file views, drag/drop Kanban, and write APIs were not implemented。 +- Reviewer noted possible follow-ups: keep `#/objectives` sidebar link visible even on objective empty/error states, and further tighten remote URL sanitization for query-param or SCP-like token forms if needed。 + +--- + + + +## Implementation report + +Post-close cleanup completed。 + +- Stopped child Pods and reclaimed scope: + - `yoi-coder-00001KVNGJPRG` + - `yoi-reviewer-00001KVNGJPRG-r1` +- Removed ignored frontend validation artifacts from child worktree before worktree removal: + - `web/workspace/node_modules/` + - `web/workspace/.svelte-kit/` + - `web/workspace/build/` +- Removed implementation worktree: + - `/home/hare/Projects/yoi/.worktree/00001KVNGJPRG-repository-objective-pages` +- Deleted implementation branch: + - `impl/00001KVNGJPRG-repository-objective-pages` +- Orchestrator worktree remains clean on `orchestration` at `4b1f1e59`。 + +Root/original workspace was not used for merge/validation/cleanup operations。 + --- diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index e8a5a282..1e538396 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -6,12 +6,17 @@ pub mod hosts; pub mod records; +pub mod repositories; pub mod server; pub mod store; pub use records::{ LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary, }; +pub use repositories::{ + GitCommitSummary, GitRemoteSummary, GitRepositorySummary, LocalRepositoryReader, + RepositoryLogRead, RepositorySummary, +}; pub use server::{AuthConfig, ServerConfig, WorkspaceApi, build_router, serve}; pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord}; @@ -33,6 +38,8 @@ pub enum Error { MissingFrontmatter(String), #[error("unknown local host `{0}`")] UnknownHost(String), + #[error("unknown local repository `{0}`")] + UnknownRepository(String), #[error("store error: {0}")] Store(String), } diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index fe3e0d12..2f32122b 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -8,6 +8,7 @@ use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug}; use crate::{Error, Result}; const DETAIL_BODY_LIMIT: usize = 64 * 1024; +const SUMMARY_BODY_LIMIT: usize = 240; #[derive(Debug, Clone)] pub struct LocalProjectRecordReader { @@ -201,6 +202,7 @@ pub struct ObjectiveSummary { pub title: String, pub state: String, pub updated_at: Option, + pub summary: String, pub linked_tickets: Vec, pub record_source: String, } @@ -233,13 +235,14 @@ struct ObjectiveFrontmatter { fn read_objective_summary(path: &Path, id: &str) -> Result { validate_project_id(id)?; let raw = fs::read_to_string(path.join("item.md"))?; - let (frontmatter, _) = split_frontmatter(&raw, id)?; + let (frontmatter, body) = split_frontmatter(&raw, id)?; let meta: ObjectiveFrontmatter = serde_yaml::from_str(frontmatter)?; Ok(ObjectiveSummary { id: id.to_string(), title: meta.title, state: meta.state, updated_at: meta.updated_at, + summary: summarize_body(body), linked_tickets: meta.linked_tickets, record_source: "local_yoi_objective".to_string(), }) @@ -259,6 +262,20 @@ fn validate_project_id(id: &str) -> Result<()> { validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string())) } +fn summarize_body(body: &str) -> String { + let summary = body + .lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.starts_with('#')) + .unwrap_or_default(); + let (summary, truncated) = truncate_body(summary, SUMMARY_BODY_LIMIT); + if truncated { + format!("{summary}…") + } else { + summary + } +} + fn truncate_body(body: &str, limit: usize) -> (String, bool) { if body.len() <= limit { return (body.to_string(), false); diff --git a/crates/workspace-server/src/repositories.rs b/crates/workspace-server/src/repositories.rs new file mode 100644 index 00000000..a31a45a1 --- /dev/null +++ b/crates/workspace-server/src/repositories.rs @@ -0,0 +1,336 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde::{Deserialize, Serialize}; + +use crate::hosts::RuntimeDiagnostic; + +const LOCAL_REPOSITORY_ID: &str = "local"; +const MAX_COMMAND_OUTPUT: usize = 4096; +const DEFAULT_LOG_LIMIT: usize = 10; +const MAX_LOG_LIMIT: usize = 50; +const MAX_FIELD_LEN: usize = 240; + +#[derive(Debug, Clone)] +pub struct LocalRepositoryReader { + workspace_root: PathBuf, +} + +impl LocalRepositoryReader { + pub fn new(workspace_root: impl Into) -> Self { + Self { + workspace_root: workspace_root.into(), + } + } + + pub fn list(&self, workspace_display_name: &str) -> Vec { + vec![self.summary(workspace_display_name)] + } + + pub fn summary(&self, workspace_display_name: &str) -> RepositorySummary { + let git = inspect_git(&self.workspace_root); + RepositorySummary { + id: LOCAL_REPOSITORY_ID.to_string(), + display_name: workspace_display_name.to_string(), + kind: "local".to_string(), + workspace_root: self.workspace_root.clone(), + record_authority: "local_workspace_root".to_string(), + git, + } + } + + pub fn recent_log(&self, requested_limit: Option) -> RepositoryLogRead { + let limit = requested_limit + .unwrap_or(DEFAULT_LOG_LIMIT) + .clamp(1, MAX_LOG_LIMIT); + git_log(&self.workspace_root, limit) + } + + pub fn is_local_repository_id(id: &str) -> bool { + id == LOCAL_REPOSITORY_ID + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RepositorySummary { + pub id: String, + pub display_name: String, + pub kind: String, + pub workspace_root: PathBuf, + pub record_authority: String, + pub git: GitRepositorySummary, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GitRepositorySummary { + pub status: String, + pub root: Option, + pub branch: Option, + pub head: Option, + pub dirty: Option, + pub dirty_scope: String, + pub remote: Option, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GitRemoteSummary { + pub name: String, + pub url: String, + pub redacted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GitCommitSummary { + pub hash: String, + pub subject: String, + pub author_name: String, + pub author_email: String, + pub timestamp: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RepositoryLogRead { + pub limit: usize, + pub items: Vec, + pub diagnostics: Vec, +} + +fn inspect_git(workspace_root: &Path) -> GitRepositorySummary { + let mut diagnostics = Vec::new(); + let root = match git_stdout(workspace_root, &["rev-parse", "--show-toplevel"]) { + Ok(root) => PathBuf::from(root.trim()), + Err(message) => { + diagnostics.push(diagnostic( + "git_unavailable", + "info", + format!("Workspace root is not available as a Git repository: {message}"), + )); + return GitRepositorySummary { + status: "unavailable".to_string(), + root: None, + branch: None, + head: None, + dirty: None, + dirty_scope: "tracked_changes_only".to_string(), + remote: None, + diagnostics, + }; + } + }; + + let branch = git_stdout(workspace_root, &["branch", "--show-current"]) + .ok() + .map(|value| truncate_field(value.trim(), MAX_FIELD_LEN)) + .filter(|value| !value.is_empty()) + .or_else(|| Some("detached".to_string())); + let head = match git_stdout(workspace_root, &["rev-parse", "--verify", "HEAD"]) { + Ok(value) => Some(truncate_field(value.trim(), 40)), + Err(message) => { + diagnostics.push(diagnostic( + "git_head_unavailable", + "warn", + format!("Git HEAD summary is unavailable: {message}"), + )); + None + } + }; + let dirty = match git_stdout( + workspace_root, + &["status", "--porcelain=v1", "--untracked-files=no"], + ) { + Ok(value) => Some(!value.trim().is_empty()), + Err(message) => { + diagnostics.push(diagnostic( + "git_status_unavailable", + "warn", + format!("Git dirty status is unavailable: {message}"), + )); + None + } + }; + let remote = match git_stdout(workspace_root, &["remote", "get-url", "origin"]) { + Ok(value) => { + let (url, redacted) = sanitize_remote_url(value.trim()); + Some(GitRemoteSummary { + name: "origin".to_string(), + url, + redacted, + }) + } + Err(_) => { + diagnostics.push(diagnostic( + "git_origin_remote_missing", + "info", + "No origin remote is configured or visible through the bounded Git summary." + .to_string(), + )); + None + } + }; + + GitRepositorySummary { + status: "available".to_string(), + root: Some(root), + branch, + head, + dirty, + dirty_scope: "tracked_changes_only".to_string(), + remote, + diagnostics, + } +} + +fn git_log(workspace_root: &Path, limit: usize) -> RepositoryLogRead { + let mut diagnostics = Vec::new(); + if let Err(message) = git_stdout(workspace_root, &["rev-parse", "--show-toplevel"]) { + diagnostics.push(diagnostic( + "git_unavailable", + "info", + format!("Recent Git log is unavailable for this local repository: {message}"), + )); + return RepositoryLogRead { + limit, + items: Vec::new(), + diagnostics, + }; + } + + match git_stdout( + workspace_root, + &[ + "log", + "--no-show-signature", + "--date=iso-strict", + "--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%s%x1e", + "-n", + &limit.to_string(), + ], + ) { + Ok(output) => RepositoryLogRead { + limit, + items: parse_log(output.as_str()), + diagnostics, + }, + Err(message) => { + diagnostics.push(diagnostic( + "git_log_unavailable", + "warn", + format!("Recent Git log is unavailable: {message}"), + )); + RepositoryLogRead { + limit, + items: Vec::new(), + diagnostics, + } + } + } +} + +fn parse_log(output: &str) -> Vec { + output + .split('\u{1e}') + .filter_map(|record| { + let record = record.trim_matches('\n'); + if record.is_empty() { + return None; + } + let mut fields = record.split('\u{1f}'); + Some(GitCommitSummary { + hash: truncate_field(fields.next()?, 40), + author_name: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN), + author_email: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN), + timestamp: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN), + subject: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN), + }) + }) + .collect() +} + +fn git_stdout(workspace_root: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(workspace_root) + .args(args) + .output() + .map_err(|error| truncate_field(&error.to_string(), MAX_FIELD_LEN))?; + command_stdout(output) +} + +fn command_stdout(output: Output) -> Result { + if output.status.success() { + return Ok(truncate_output( + String::from_utf8_lossy(&output.stdout).as_ref(), + )); + } + let stderr = truncate_output(String::from_utf8_lossy(&output.stderr).as_ref()); + if stderr.trim().is_empty() { + Err(format!("git exited with status {}", output.status)) + } else { + Err(stderr.trim().to_string()) + } +} + +fn sanitize_remote_url(raw: &str) -> (String, bool) { + let bounded = truncate_field(raw, MAX_FIELD_LEN); + let Some(separator) = bounded.find("://") else { + return (bounded, false); + }; + let scheme_end = separator + 3; + let after_scheme = &bounded[scheme_end..]; + let Some(at_index) = after_scheme.find('@') else { + return (bounded, false); + }; + let host_and_path = &after_scheme[(at_index + 1)..]; + (format!("{}{}", &bounded[..scheme_end], host_and_path), true) +} + +fn truncate_output(value: &str) -> String { + truncate_field(value, MAX_COMMAND_OUTPUT) +} + +fn truncate_field(value: &str, limit: usize) -> String { + if value.len() <= limit { + return value.to_string(); + } + let mut end = limit; + while !value.is_char_boundary(end) { + end -= 1; + } + value[..end].to_string() +} + +fn diagnostic(code: &str, severity: &str, message: String) -> RuntimeDiagnostic { + RuntimeDiagnostic { + code: code.to_string(), + severity: severity.to_string(), + message, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitizes_userinfo_from_url_remotes() { + assert_eq!( + sanitize_remote_url("https://token@example.com/org/repo.git"), + ("https://example.com/org/repo.git".to_string(), true) + ); + assert_eq!( + sanitize_remote_url("git@example.com:org/repo.git"), + ("git@example.com:org/repo.git".to_string(), false) + ); + } + + #[test] + fn parses_bounded_git_log_records() { + let parsed = parse_log( + "0123456789abcdef\u{1f}Alice\u{1f}a@example.test\u{1f}2026-01-01T00:00:00+00:00\u{1f}Subject\u{1e}\n", + ); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].hash, "0123456789abcdef"); + assert_eq!(parsed[0].subject, "Subject"); + } +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 6c2ea706..0f1a2649 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1,7 +1,7 @@ use std::path::{Component, Path, PathBuf}; use std::sync::Arc; -use axum::extract::{Path as AxumPath, State}; +use axum::extract::{Path as AxumPath, Query, State}; use axum::http::header::CONTENT_TYPE; use axum::http::{StatusCode, Uri}; use axum::response::{IntoResponse, Response}; @@ -11,7 +11,10 @@ use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; use crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary}; -use crate::records::{LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail}; +use crate::records::{ + LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary, +}; +use crate::repositories::{LocalRepositoryReader, RepositoryLogRead, RepositorySummary}; use crate::store::{ControlPlaneStore, RunSummary, WorkspaceRecord}; use crate::{Error, Result}; @@ -95,6 +98,19 @@ impl WorkspaceApi { self.config.local_runtime_data_dir.clone(), ) } + + fn local_repository_reader(&self) -> LocalRepositoryReader { + LocalRepositoryReader::new(self.config.workspace_root.clone()) + } + + fn workspace_display_name(&self) -> String { + self.config + .workspace_root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("workspace") + .to_string() + } } pub fn build_router(api: WorkspaceApi) -> Router { @@ -104,6 +120,13 @@ pub fn build_router(api: WorkspaceApi) -> Router { .route("/api/tickets/{id}", get(get_ticket)) .route("/api/objectives", get(list_objectives)) .route("/api/objectives/{id}", get(get_objective)) + .route("/api/repositories", get(list_repositories)) + .route("/api/repositories/{repository_id}", get(repository_detail)) + .route("/api/repositories/{repository_id}/log", get(repository_log)) + .route( + "/api/repositories/{repository_id}/tickets", + get(repository_tickets), + ) .route("/api/runs", get(list_runs)) .route("/api/hosts", get(list_hosts)) .route("/api/workers", get(list_workers)) @@ -164,6 +187,58 @@ pub struct RuntimeListResponse { pub diagnostics: Vec, } +#[derive(Debug, Serialize, Deserialize)] +pub struct RepositoryListResponse { + pub workspace_id: String, + pub items: Vec, + pub source: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RepositoryDetailResponse { + pub workspace_id: String, + pub item: RepositorySummary, + pub source: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RepositoryLogResponse { + pub workspace_id: String, + pub repository_id: String, + pub limit: usize, + pub items: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RepositoryTicketsResponse { + pub workspace_id: String, + pub repository_id: String, + pub limit: usize, + pub columns: Vec, + pub invalid_records: Vec, + pub record_authority: String, + pub source: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TicketKanbanColumn { + pub state: String, + pub items: Vec, +} + +#[derive(Debug, Deserialize)] +struct LogQuery { + limit: Option, +} + +#[derive(Debug, Deserialize)] +struct TicketKanbanQuery { + limit: Option, +} + async fn get_workspace(State(api): State) -> ApiResult> { let schema_version = api.store.schema_version().await?; let stored = api.store.get_workspace(api.workspace_id()).await?; @@ -249,6 +324,80 @@ async fn get_objective( Ok(Json(api.records.objective(&id)?)) } +async fn list_repositories( + State(api): State, +) -> ApiResult> { + let reader = api.local_repository_reader(); + let items = reader.list(&api.workspace_display_name()); + Ok(Json(RepositoryListResponse { + workspace_id: api.config.workspace_id, + items, + source: "local_workspace_root".to_string(), + diagnostics: Vec::new(), + })) +} + +async fn repository_detail( + State(api): State, + AxumPath(repository_id): AxumPath, +) -> ApiResult> { + ensure_local_repository(&repository_id)?; + let reader = api.local_repository_reader(); + Ok(Json(RepositoryDetailResponse { + workspace_id: api.config.workspace_id.clone(), + item: reader.summary(&api.workspace_display_name()), + source: "local_workspace_root".to_string(), + })) +} + +async fn repository_log( + State(api): State, + AxumPath(repository_id): AxumPath, + Query(query): Query, +) -> ApiResult> { + ensure_local_repository(&repository_id)?; + let RepositoryLogRead { + limit, + items, + diagnostics, + } = api.local_repository_reader().recent_log(query.limit); + Ok(Json(RepositoryLogResponse { + workspace_id: api.config.workspace_id, + repository_id, + limit, + items, + diagnostics, + })) +} + +async fn repository_tickets( + State(api): State, + AxumPath(repository_id): AxumPath, + Query(query): Query, +) -> ApiResult> { + ensure_local_repository(&repository_id)?; + let limit = query.limit.unwrap_or(api.config.max_records).min(200); + let ProjectRecordList { + items, + invalid_records, + record_authority, + } = api.records.list_tickets(limit)?; + Ok(Json(RepositoryTicketsResponse { + workspace_id: api.config.workspace_id, + repository_id, + limit, + columns: ticket_kanban_columns(items), + invalid_records, + record_authority, + source: "workspace_local_ticket_fallback".to_string(), + diagnostics: vec![RuntimeDiagnostic { + code: "repository_ticket_target_metadata_absent".to_string(), + severity: "info".to_string(), + message: "Ticket target Repository metadata is not available yet; Kanban groups all workspace-local Tickets by state as a read-only fallback.".to_string(), + }], + })) +} + async fn list_runs( State(api): State, ) -> ApiResult>> { @@ -308,6 +457,60 @@ fn workers_response(api: WorkspaceApi) -> ApiResult Result<()> { + if LocalRepositoryReader::is_local_repository_id(repository_id) { + Ok(()) + } else { + Err(Error::UnknownRepository(repository_id.to_string())) + } +} + +fn ticket_kanban_columns(items: Vec) -> Vec { + let mut columns = vec![ + TicketKanbanColumn { + state: "planning".to_string(), + items: Vec::new(), + }, + TicketKanbanColumn { + state: "ready".to_string(), + items: Vec::new(), + }, + TicketKanbanColumn { + state: "queued".to_string(), + items: Vec::new(), + }, + TicketKanbanColumn { + state: "inprogress".to_string(), + items: Vec::new(), + }, + TicketKanbanColumn { + state: "done".to_string(), + items: Vec::new(), + }, + TicketKanbanColumn { + state: "closed".to_string(), + items: Vec::new(), + }, + TicketKanbanColumn { + state: "other".to_string(), + items: Vec::new(), + }, + ]; + for item in items { + let index = match item.state.as_str() { + "planning" => 0, + "ready" => 1, + "queued" => 2, + "inprogress" => 3, + "done" => 4, + "closed" => 5, + _ => 6, + }; + columns[index].items.push(item); + } + columns +} + async fn static_or_spa_fallback(State(api): State, uri: Uri) -> Response { if uri.path().starts_with("/api/") || uri.path() == "/api" { return ( @@ -407,9 +610,10 @@ impl From for ApiError { impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = match &self.0 { - Error::InvalidRecordId(_) | Error::MissingFrontmatter(_) | Error::UnknownHost(_) => { - StatusCode::NOT_FOUND - } + Error::InvalidRecordId(_) + | Error::MissingFrontmatter(_) + | Error::UnknownHost(_) + | Error::UnknownRepository(_) => StatusCode::NOT_FOUND, Error::Ticket(_) => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; @@ -468,6 +672,44 @@ mod tests { let objectives = get_json(app.clone(), "/api/objectives").await; assert_eq!(objectives["items"][0]["id"], "00000000001J3"); + assert_eq!(objectives["items"][0]["summary"], "Objective body."); + + let repositories = get_json(app.clone(), "/api/repositories").await; + assert_eq!(repositories["items"][0]["id"], "local"); + assert_eq!(repositories["items"][0]["kind"], "local"); + + let repository_detail = get_json(app.clone(), "/api/repositories/local").await; + assert_eq!(repository_detail["item"]["id"], "local"); + + 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["limit"], 3); + + let repository_tickets = get_json(app.clone(), "/api/repositories/local/tickets").await; + assert_eq!(repository_tickets["repository_id"], "local"); + let ready_column = repository_tickets["columns"] + .as_array() + .unwrap() + .iter() + .find(|column| column["state"] == "ready") + .unwrap(); + assert_eq!(ready_column["items"][0]["id"], "00000000001J2"); + assert_eq!( + repository_tickets["diagnostics"][0]["code"], + "repository_ticket_target_metadata_absent" + ); + + let unknown_repository_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/repositories/nope") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unknown_repository_response.status(), StatusCode::NOT_FOUND); let hosts = get_json(app.clone(), "/api/hosts").await; assert_eq!(hosts["items"][0]["host_id"], "local-local-test"); diff --git a/web/workspace/src/lib/workspace-sidebar/ObjectivesNavSection.svelte b/web/workspace/src/lib/workspace-sidebar/ObjectivesNavSection.svelte new file mode 100644 index 00000000..4d22690f --- /dev/null +++ b/web/workspace/src/lib/workspace-sidebar/ObjectivesNavSection.svelte @@ -0,0 +1,167 @@ + + + + + diff --git a/web/workspace/src/lib/workspace-sidebar/RepositoriesNavSection.svelte b/web/workspace/src/lib/workspace-sidebar/RepositoriesNavSection.svelte new file mode 100644 index 00000000..85223908 --- /dev/null +++ b/web/workspace/src/lib/workspace-sidebar/RepositoriesNavSection.svelte @@ -0,0 +1,109 @@ + + + + + diff --git a/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte new file mode 100644 index 00000000..0b81f0b6 --- /dev/null +++ b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte @@ -0,0 +1,157 @@ + + + + + diff --git a/web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte b/web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte new file mode 100644 index 00000000..ecc85e69 --- /dev/null +++ b/web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte @@ -0,0 +1,124 @@ + + + + + diff --git a/web/workspace/src/lib/workspace-sidebar/types.ts b/web/workspace/src/lib/workspace-sidebar/types.ts new file mode 100644 index 00000000..f461829c --- /dev/null +++ b/web/workspace/src/lib/workspace-sidebar/types.ts @@ -0,0 +1,158 @@ +export type ExtensionPoint = { + status: string; + note: string; +}; + +export type WorkspaceResponse = { + workspace_id: string; + display_name: string; + record_authority: string; + extension_points: { + event_stream: ExtensionPoint; + host_worker_bridge: ExtensionPoint; + }; +}; + +export type Diagnostic = { + code: string; + severity: string; + message: string; +}; + +export type Host = { + host_id: string; + label: string; + kind: string; + status: string; + observed_at: string; + last_seen_at: string; + capabilities: { + local_pod_inspection: string; + workspace_root: string; + os: string; + arch: string; + max_workers: number; + }; + diagnostics: Diagnostic[]; +}; + +export type Worker = { + worker_id: string; + host_id: string; + label: string; + pod_name: string; + role?: string; + profile?: string; + workspace_root?: string; + state: string; + status: string; + last_seen_at?: string; + implementation: { kind: string; pod_name: string }; + diagnostics: Diagnostic[]; +}; + +export type ListResponse = { + workspace_id: string; + limit: number; + items: T[]; + source: string; + diagnostics: Diagnostic[]; +}; + +export type RepositorySummary = { + id: string; + display_name: string; + kind: string; + workspace_root: string; + record_authority: string; + git: GitRepositorySummary; +}; + +export type GitRepositorySummary = { + status: string; + root?: string | null; + branch?: string | null; + head?: string | null; + dirty?: boolean | null; + dirty_scope: string; + remote?: GitRemoteSummary | null; + diagnostics: Diagnostic[]; +}; + +export type GitRemoteSummary = { + name: string; + url: string; + redacted: boolean; +}; + +export type GitCommitSummary = { + hash: string; + subject: string; + author_name: string; + author_email: string; + timestamp: string; +}; + +export type RepositoryDetailResponse = { + workspace_id: string; + item: RepositorySummary; + source: string; +}; + +export type RepositoryLogResponse = { + workspace_id: string; + repository_id: string; + limit: number; + items: GitCommitSummary[]; + diagnostics: Diagnostic[]; +}; + +export type TicketSummary = { + id: string; + title: string; + state: string; + priority?: string | null; + updated_at?: string | null; + queued_by?: string | null; + queued_at?: string | null; + record_source?: string; +}; + +export type TicketKanbanColumn = { + state: string; + items: TicketSummary[]; +}; + +export type RepositoryTicketsResponse = { + workspace_id: string; + repository_id: string; + limit: number; + columns: TicketKanbanColumn[]; + invalid_records: InvalidProjectRecord[]; + record_authority: string; + source: string; + diagnostics: Diagnostic[]; +}; + +export type ObjectiveSummary = { + id: string; + title: string; + state: string; + updated_at?: string | null; + summary: string; + linked_tickets?: string[]; + record_source?: string; +}; + +export type InvalidProjectRecord = { + label: string; + reason: string; +}; + +export type ObjectiveListResponse = { + workspace_id: string; + limit: number; + items: ObjectiveSummary[]; + invalid_records: InvalidProjectRecord[]; + record_authority: string; +}; diff --git a/web/workspace/src/routes/+page.svelte b/web/workspace/src/routes/+page.svelte index 9b1c1a4a..32f89cbb 100644 --- a/web/workspace/src/routes/+page.svelte +++ b/web/workspace/src/routes/+page.svelte @@ -1,64 +1,32 @@ @@ -110,174 +182,406 @@ /> -
-
-

Local / single-workspace bootstrap

-

Yoi Workspace Control Plane

-

- Static SPA shell for reading canonical .yoi project records - and the local Host / Worker execution view through bounded backend APIs. - Ticket and Objective lifecycle authority stays in the existing local record - workflow. -

-
+
+ -
-

Workspace

- {#if workspace} -
-
-
ID
-
{workspace.workspace_id}
-
-
-
Name
-
{workspace.display_name}
-
-
-
Record authority
-
{workspace.record_authority}
-
-
-
Host / Worker bridge
-
{workspace.extension_points.host_worker_bridge.status}
-
-
- {:else if loadError} -

{loadError}

- {:else} -

Waiting for /api/workspace

- {/if} -
- -
-
-

Read API surface

-
    - {#each endpoints as endpoint} -
  • {endpoint.path} — {endpoint.label}
  • - {/each} -
-
- -
-

Reserved seams

+
+
+

Local / single-workspace bootstrap

+

Yoi Workspace Control Plane

- Event streams remain represented as extension-point state in the backend - response. Hosts and Workers are read-only local observations; no - scheduler, lifecycle control, or hosted multi-tenant behavior is - implemented in this slice. + Static SPA shell for reading canonical .yoi project records, + bounded local Repository summaries, and the local Host / Worker execution + view. Ticket and Objective lifecycle authority stays in the existing local + record workflow.

-
-
+ + -
-
-

Hosts

- {#if hosts} - {#if hosts.items.length === 0} -

No local Hosts are visible.

- {:else} -
- {#each hosts.items as host} -
-
- {host.label} - {host.status} + {#if route.page === 'repository'} +
+
+

Repository summary

+ {#if repository} +
+
+
ID
+
{repository.id}
+
+
+
Kind
+
{repository.kind}
+
+
+
Workspace root
+
{repository.workspace_root}
+
+
+
Record authority
+
{repository.record_authority}
+
+
+
Git
+
{repository.git.status}
+
+
+ {:else if repositoryError} +

{repositoryError}

+ {:else} +

Waiting for /api/repositories/local

+ {/if} +
+ +
+

Git summary

+ {#if repository} + {#if repository.git.status === 'available'} +
+
+
Root
+
{repository.git.root ?? 'unknown'}
-
-
-
ID
-
{host.host_id}
-
-
-
Kind
-
{host.kind}
-
-
-
Local Pod inspection
-
{host.capabilities.local_pod_inspection}
-
-
-
Platform
-
{host.capabilities.os} / {host.capabilities.arch}
-
-
+
+
Branch
+
{repository.git.branch ?? 'unknown'}
+
+
+
HEAD
+
{shortHash(repository.git.head)}
+
+
+
Dirty
+
{repository.git.dirty === null || repository.git.dirty === undefined ? 'unknown' : repository.git.dirty ? 'yes' : 'no'} {repository.git.dirty_scope}
+
+
+
Remote
+
+ {#if repository.git.remote} + {repository.git.remote.name} · {repository.git.remote.url} + {#if repository.git.remote.redacted}credentials redacted{/if} + {:else} + not configured + {/if} +
+
+
+ {:else} +

Git metadata is unavailable for this local Repository.

+ {/if} + {:else if repositoryError} +

{repositoryError}

+ {:else} +

Waiting for Git summary…

+ {/if} +
+
+ +
+

Recent Git log

+ {#if repositoryLog} + {#if repositoryLog.items.length === 0} +

No recent commits are available from the bounded Git log API.

+ {:else} +
+ + + + + + + + + + + {#each repositoryLog.items as commit (commit.hash)} + + + + + + + {/each} + +
CommitSubjectAuthorTimestamp
{shortHash(commit.hash)}{commit.subject}{commit.author_name} {commit.author_email}{commit.timestamp}
+
+ {/if} + {:else if repositoryLogError} +

{repositoryLogError}

+ {:else} +

Waiting for /api/repositories/local/log

+ {/if} +
+ +
+

Repository Ticket Kanban

+

+ Read-only grouping of canonical Ticket records. No drag/drop or lifecycle mutation is exposed. +

+ {#if repositoryTickets} +
+ {#each repositoryTickets.columns as column (column.state)} +
+

{column.state} {column.items.length}

+ {#if column.items.length === 0} +

No tickets.

+ {:else} +
    + {#each column.items as ticket (ticket.id)} +
  • + {ticket.title} + {ticket.id} · updated {formatDate(ticket.updated_at)} +
  • + {/each} +
+ {/if}
{/each}
- {/if} - {:else if loadError} -

{loadError}

- {:else} -

Waiting for /api/hosts

- {/if} -
- -
-

Workers

- {#if workers} - {#if workers.items.length === 0} -

No local Workers are visible.

+ {:else if repositoryTicketsError} +

{repositoryTicketsError}

{:else} -
- - - - - - - - - - - - {#each workers.items as worker} - - - - - - - - {/each} - -
WorkerHostStateWorkspaceImplementation
- {worker.label} - {#if worker.role || worker.profile} - {worker.role ?? 'role unknown'} / {worker.profile ?? 'profile unknown'} - {/if} - {worker.host_id}{worker.state} · {worker.status}{worker.workspace_root ?? 'unknown'}{worker.implementation.kind}: {worker.implementation.pod_name}
-
+

Waiting for /api/repositories/local/tickets

{/if} - {:else if loadError} -

{loadError}

- {:else} -

Waiting for /api/workers

- {/if} -
-
- - {#if hosts || workers} - {@const diagnostics = diagnosticsFor(hosts?.diagnostics, workers?.diagnostics)} - {#if diagnostics.length > 0} -
-

Diagnostics

-
    - {#each diagnostics as diagnostic} -
  • - {diagnostic.severity} - {diagnostic.code} - {diagnostic.message} -
  • - {/each} -
+ + {@const repositoryDiagnostics = diagnosticsFor(repository?.git.diagnostics, repositoryLog?.diagnostics, repositoryTickets?.diagnostics)} + {#if repositoryDiagnostics.length > 0} +
+

Repository diagnostics

+
    + {#each repositoryDiagnostics as diagnostic} +
  • + {diagnostic.severity} + {diagnostic.code} + {diagnostic.message} +
  • + {/each} +
+
+ {/if} + {:else if route.page === 'objectives'} +
+

Objectives

+

+ Objectives are read from canonical filesystem records through /api/objectives. +

+ {#if objectives} + {#if objectives.items.length === 0} +

No Objective records are present.

+ {:else} +
+ {#each objectives.items as objective (objective.id)} +
+
+ {objective.title} + {objective.state} +
+

{objective.summary || 'No summary text is available.'}

+
+
+
ID
+
{objective.id}
+
+
+
Updated
+
{formatDate(objective.updated_at)}
+
+
+
Linked tickets
+
{objective.linked_tickets?.length ? objective.linked_tickets.join(', ') : 'none'}
+
+
+

Detail placeholder

+
+ {/each} +
+ {/if} + {#if objectives.invalid_records.length > 0} +

{objectives.invalid_records.length} invalid objective record(s) hidden.

+ {/if} + {:else if objectivesError} +

{objectivesError}

+ {:else} +

Waiting for /api/objectives

+ {/if} +
+ + {#if route.objectiveId} +
+

Objective detail

+

+ Selected Objective {route.objectiveId}. This slice keeps detail navigation as a + static SPA placeholder; canonical Objective content remains in the filesystem record. +

+
+ {/if} + {:else} +
+

Workspace

+ {#if workspace} +
+
+
ID
+
{workspace.workspace_id}
+
+
+
Name
+
{workspace.display_name}
+
+
+
Record authority
+
{workspace.record_authority}
+
+
+
Host / Worker bridge
+
{workspace.extension_points.host_worker_bridge.status}
+
+
+ {:else if workspaceError} +

{workspaceError}

+ {:else} +

Waiting for /api/workspace

+ {/if} +
+ +
+
+

Read API surface

+
    + {#each endpoints as endpoint} +
  • {endpoint.path} — {endpoint.label}
  • + {/each} +
+
+ +
+

Reserved seams

+

+ Event streams remain represented as extension-point state in the backend + response. Hosts and Workers are read-only local observations; no + scheduler, lifecycle control, or hosted multi-tenant behavior is + implemented in this slice. +

+
+
+ +
+
+

Hosts

+ {#if hosts} + {#if hosts.items.length === 0} +

No local Hosts are visible.

+ {:else} +
+ {#each hosts.items as host} +
+
+ {host.label} + {host.status} +
+
+
+
ID
+
{host.host_id}
+
+
+
Kind
+
{host.kind}
+
+
+
Local inspection
+
{host.capabilities.local_pod_inspection}
+
+
+
Platform
+
{host.capabilities.os} / {host.capabilities.arch}
+
+
+
+ {/each} +
+ {/if} + {:else if hostsError} +

{hostsError}

+ {:else} +

Waiting for /api/hosts

+ {/if} +
+ +
+

Workers

+ {#if workers} + {#if workers.items.length === 0} +

No local Workers are visible.

+ {:else} +
+ + + + + + + + + + + + {#each workers.items as worker} + + + + + + + + {/each} + +
WorkerHostStateWorkspaceImplementation
+ {worker.label} + {#if worker.role || worker.profile} + {worker.role ?? 'role unknown'} / {worker.profile ?? 'profile unknown'} + {/if} + {worker.host_id}{worker.state} · {worker.status}{worker.workspace_root ?? 'unknown'}{worker.implementation.kind}
+
+ {/if} + {:else if workersError} +

{workersError}

+ {:else} +

Waiting for /api/workers

+ {/if} +
+
+ + {#if hosts || workers} + {@const diagnostics = diagnosticsFor(hosts?.diagnostics, workers?.diagnostics)} + {#if diagnostics.length > 0} +
+

Diagnostics

+
    + {#each diagnostics as diagnostic} +
  • + {diagnostic.severity} + {diagnostic.code} + {diagnostic.message} +
  • + {/each} +
+
+ {/if} + {/if} {/if} - {/if} -
+ +