diff --git a/.yoi/tickets/00001KVNKD56W/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVNKD56W/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..8d791a5e --- /dev/null +++ b/.yoi/tickets/00001KVNKD56W/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"id":"orch-plan-20260622-085006-1","ticket_id":"00001KVNKD56W","kind":"accepted_plan","accepted_plan":{"summary":"Align Workspace server SQLite bootstrap/migrations and tests with the canonical `schema-v0.md` artifact: typed repository/ticket-target/worker-link/artifact/audit schema, no canonical runs/hosts/workers/generic JSON/validation result tables, while preserving filesystem Ticket/Objective authority and existing read APIs.","branch":"impl/00001KVNKD56W-workspace-db-schema-v0","worktree":"/home/hare/Projects/yoi/.worktree/00001KVNKD56W-workspace-db-schema-v0","role_plan":"Orchestrator creates a dedicated child worktree and spawns a narrow-scope Coder. Reviewer will be spawned read-only after Coder reports implementation/design alignment commit(s). After approval, Orchestrator integrates into `orchestration`, validates workspace-server schema/tests/Nix if needed, records closure, and cleans only the child worktree/branch."},"author":"yoi-orchestrator","at":"2026-06-22T08:50:06Z"} diff --git a/.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md b/.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md index e785c109..1345998f 100644 --- a/.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md +++ b/.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md @@ -512,3 +512,9 @@ If implementation is included in this Ticket, prefer a small non-breaking migrat - Do not create a full `actors` table in v0. - Do not create `hosts` / `workers` canonical tables in v0. - Do not create a separate `runs` table in v0; use structured Ticket events and TicketWorkerLink relationships. + +## Implementation alignment notes + +The `yoi-workspace-server` SQLite bootstrap migration implements this v0 schema as schema version 2. Fresh databases create the typed tables listed above and deliberately do not create canonical `runs`, `hosts`, `workers`, `actors`, or check/validation result tables. Host and Worker HTTP read APIs remain live runtime views backed by local inspection, not DB tables. + +For databases created by the earlier workspace-server bootstrap, migration version 2 preserves old `workspaces`, `repositories`, `runs`, `artifacts`, `ticket_projections`, and `objective_projections` data by renaming those tables to `legacy_workspaces`, `legacy_repositories`, `legacy_runs`, `legacy_artifacts`, `legacy_ticket_projections`, and `legacy_objective_projections`, then creating the v0 typed tables. Existing legacy workspace rows are copied into the canonical v0 `workspaces` table with `state = active` when the old row had no typed state. The legacy names are compatibility preservation only and are not canonical schema tables or active write authority. diff --git a/.yoi/tickets/00001KVNKD56W/item.md b/.yoi/tickets/00001KVNKD56W/item.md index 66915d68..4c035df6 100644 --- a/.yoi/tickets/00001KVNKD56W/item.md +++ b/.yoi/tickets/00001KVNKD56W/item.md @@ -1,8 +1,8 @@ --- title: 'Workspace DB canonical schema design' -state: 'queued' +state: 'closed' created_at: '2026-06-21T17:24:43Z' -updated_at: '2026-06-22T08:48:39Z' +updated_at: '2026-06-22T09:27:06Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-22T08:48:39Z' diff --git a/.yoi/tickets/00001KVNKD56W/resolution.md b/.yoi/tickets/00001KVNKD56W/resolution.md new file mode 100644 index 00000000..900cb1ac --- /dev/null +++ b/.yoi/tickets/00001KVNKD56W/resolution.md @@ -0,0 +1,36 @@ +Workspace DB canonical schema v0 design を `schema-v0.md` artifact と workspace-server SQLite bootstrap/migrations に整合させ、Orchestrator worktree の `orchestration` branch に統合した。 + +主な成果: +- SQLite schema version を `2` に進め、canonical schema v0 を実装。 +- Typed v0 tables を追加: + - `tickets` + - `ticket_events` + - `ticket_relations` + - `objectives` + - `objective_ticket_links` + - `repositories` + - `ticket_targets` + - `ticket_target_paths` + - `ticket_worker_links` + - `artifacts` + - `audit_events` +- Canonical v0 から top-level `runs`, `hosts`, `workers`, `actors`, validation/CI result tables を除外。 +- Generic `metadata_json`, `payload_json`, `diagnostics_json` のような catch-all payload columns を canonical v0 tables に追加しない方針を維持。 +- `/api/runs` と frontend Runs card/reference を削除し、404 test を追加。 +- Host/Worker APIs は DB authority ではなく live runtime views として維持。 +- Legacy bootstrap tables は non-canonical `legacy_*` preservation tables に demote。 +- Legacy `workspaces` は `legacy_workspaces` に preserve し、active canonical `workspaces` を v0 column set で作り直して既存行を copy。 +- Post-upgrade `upsert_workspace()` for new workspace id が通る regression test を追加。 +- `schema-v0.md` に SQLite version 2 / legacy preservation alignment notes を追加。 + +統合・検証: +- Merge commit: `38bd122d merge: workspace db schema v0` +- Implementation commits: `5149ab70`, `d89ace5b` +- 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`。 + +範囲外: +- Ticket/Objective write authority migration to DB is not implemented。 +- Host/Worker canonical DB tables are not added。 +- Validation/CI result tables and Actor table are not added。 +- Full TicketEvents/TicketWorkerLinks/Artifacts write surfaces are not implemented beyond schema/bootstrap alignment。 \ No newline at end of file diff --git a/.yoi/tickets/00001KVNKD56W/thread.md b/.yoi/tickets/00001KVNKD56W/thread.md index 9fbc7fb2..3f7c50b5 100644 --- a/.yoi/tickets/00001KVNKD56W/thread.md +++ b/.yoi/tickets/00001KVNKD56W/thread.md @@ -30,4 +30,512 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: `implementation_ready` + +Reason: +- Ticket includes a concrete `schema-v0.md` artifact defining the Workspace DB canonical schema target and migration stance。 +- Artifact is precise enough to implement a non-breaking schema/migration alignment slice and/or finish design documentation without inventing semantics。 +- Relations are non-blocking context relations; no blocker relation is recorded。 +- Current Workspace backend has an older bootstrap SQLite schema in `crates/workspace-server/src/store.rs` with `runs`, `ticket_projections`, `objective_projections`, and run-linked artifacts, while `schema-v0.md` explicitly says no separate `Run` entity and Host/Worker are live views. This gives a concrete implementation alignment target。 +- Orchestrator worktree is clean on `orchestration` at `b7c890d3`; target worktree / branch is not present。 +- Current queued Ticket is this Ticket only。 + +Evidence checked: +- Ticket body / thread / artifacts via `TicketShow` and direct read of `artifacts/schema-v0.md`。 +- `TicketRelationQuery(00001KVNKD56W)`: 2 relation records, no blocking relation reported。 +- `TicketOrchestrationPlanQuery(00001KVNKD56W)`: no records。 +- `TicketList(state=queued)`: this Ticket is the only queued Ticket。 +- Orchestrator git state / branch list checked from `/home/hare/Projects/yoi/.worktree/orchestration` only。 +- Bounded code map: + - `crates/workspace-server/src/store.rs` currently owns SQLite migrations and schema versioning。 + - Current store still has bootstrap `runs` and projection-oriented tables; `schema-v0.md` says v0 should not include separate `runs`, Host/Worker canonical DB tables, generic JSON blobs, or validation result tables。 + - Current repository/objective/ticket read APIs are filesystem read-through and must keep working。 + +IntentPacket: + +Intent: +- Turn the Workspace DB canonical schema v0 design into a durable implementation/design artifact and align the current SQLite bootstrap migration where safely non-breaking。 + +Binding decisions / invariants: +- Ticket and Objective filesystem records remain the active authority; do not switch write authority to DB。 +- No generic JSON/payload/metadata columns in the v0 canonical schema。 +- Do not add canonical `hosts` / `workers` DB tables; Host/Worker remain live runtime views。 +- Do not add a top-level `runs` canonical table; execution history belongs to Ticket events, TicketWorkerLinks, and Artifacts。 +- Do not add CI/validation result tables in this Ticket。 +- Do not add Actor table in v0; authorship is embedded typed snapshot fields。 +- Keep existing Workspace APIs/tests working。 +- Non-breaking migration is acceptable; destructive migration of existing user DBs is not required in this slice unless clearly safe and tested。 + +Requirements / acceptance criteria: +- `schema-v0.md` remains consistent with implemented/bootstrap migration semantics。 +- Workspace-server migration/schema tests cover new v0 schema version and expected tables/absence of forbidden tables。 +- Empty/safe tables from minimal implementation guidance may be added: `repositories`, `ticket_targets`, `ticket_target_paths`, `ticket_worker_links`, `artifacts`, `audit_events`, and supporting Ticket/Objective current/event tables if included。 +- Remove or replace obsolete `runs` table/API/store concept if it conflicts with v0, or clearly demote it to non-canonical compatibility only if removal is out of scope。 +- No canonical host/worker tables are added。 +- Existing filesystem read-through APIs for Tickets/Objectives/Repository pages keep passing。 +- Docs/tests make clear that Host/Worker API is live view and Ticket thread/events are execution authority。 + +Implementation latitude: +- If full schema migration is too large, implement the smallest safe migration alignment plus tests and record remaining tables as future follow-up comments。 +- Prefer typed tables/columns over catch-all text/json columns。 +- SQLite schema version can advance if migrations change。 +- `runs` API placeholder may be removed if no longer used; ensure frontend/tests do not depend on it。 +- Keep API surface read-only unless Ticket explicitly requires otherwise; this one does not require write APIs。 + +Escalate if: +- Removing current `runs` table/API would break existing accepted frontend or tests in a way that needs product decision。 +- Existing DB migration compatibility requires a complex destructive migration or data backfill policy。 +- Implementing full TicketEvents/TicketWorkerLinks/Artifacts read/write surfaces grows beyond schema/bootstrap alignment。 +- Schema design conflicts with recent Repository/Objectives pages in a way not anticipated by artifact。 + +Validation plan: +- `cargo fmt --check` +- `cargo test -p yoi-workspace-server` +- `cargo check -p yoi-workspace-server` +- `git diff --check` +- `cargo run -p yoi -- ticket doctor` +- `nix build .#yoi --no-link` if package/source/dependency behavior changes。 +- Deno check/build only if frontend/runs API removal touches frontend。 + +--- + + + +## State changed + +Human authorized queue routing from Workspace Dashboard. Ticket has a concrete schema-v0 design artifact and current workspace-server migration has a clear non-breaking alignment target, so Orchestrator accepts implementation/design alignment. + +--- + + + +## Implementation report + +Implementation start report: + +- Created child implementation worktree: + - `/home/hare/Projects/yoi/.worktree/00001KVNKD56W-workspace-db-schema-v0` +- Created branch: + - `impl/00001KVNKD56W-workspace-db-schema-v0` +- Base commit: + - `f17a458a ticket: accept workspace db schema v0` +- 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-00001KVNKD56W` +- Coder scope: + - read: `/home/hare/Projects/yoi` + - write: `/home/hare/Projects/yoi/.worktree/00001KVNKD56W-workspace-db-schema-v0` + +Next action: +- Wait for Coder implementation/design-alignment report, then spawn Reviewer read-only for the implementation diff。 + +--- + + + +## Implementation report + +Coder implementation report received from `yoi-coder-00001KVNKD56W`. + +Implementation commit: +- `5149ab70 workspace: implement db schema v0 bootstrap` + +Changed areas: +- `.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md` + - Added implementation alignment notes for SQLite schema version 2。 + - Documented legacy preservation/demotion for old bootstrap tables。 +- `crates/workspace-server/src/store.rs` + - Replaced old bootstrap schema with Workspace DB canonical schema v0 tables。 + - Advanced schema version to `2`。 + - Added typed v0 tables: `tickets`, `ticket_events`, `ticket_relations`, `objectives`, `objective_ticket_links`, `repositories`, `ticket_targets`, `ticket_target_paths`, `ticket_worker_links`, `artifacts`, `audit_events`。 + - Removed active/canonical `runs` store concept and `RunSummary`。 + - Added legacy migration handling that renames old bootstrap tables to `legacy_*` preservation tables instead of treating them as canonical。 + - Added tests for fresh v0 schema boundaries and legacy upgrade behavior, including forbidden table absence。 +- `crates/workspace-server/src/server.rs` + - Removed `/api/runs` route。 + - Kept Host/Worker APIs as live runtime views。 + - Added test coverage that `/api/runs` is no longer served。 +- `web/workspace/src/routes/+page.svelte` + - Removed Runs endpoint card/reference。 + +Coder validation reported: +- `cargo fmt --check`: passed +- `cargo test -p yoi-workspace-server`: passed +- `cargo check -p yoi-workspace-server`: passed +- `git diff --check`: passed +- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`) +- `cd web/workspace && deno task check`: passed +- `cd web/workspace && deno task build`: passed + +Not run by Coder: +- `nix build .#yoi --no-link`: not run because no dependency/package/source-filter changes。 + +Orchestrator evidence checked: +- Worktree clean on `impl/00001KVNKD56W-workspace-db-schema-v0` at `5149ab70`。 +- Diff `f17a458a..HEAD` touches expected design/backend/frontend files only。 +- `git diff --check f17a458a..HEAD`: passed。 +- Deno validation generated ignored frontend artifacts; Orchestrator removed `web/workspace/node_modules/`, `.svelte-kit/`, and `build/` before Reviewer routing。 + +Next action: +- Route to read-only Reviewer Pod for acceptance review。 + +--- + + + +## Comment + +Review routing: + +Spawned read-only Reviewer Pod: +- `yoi-reviewer-00001KVNKD56W-r1` + +Review target: +- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVNKD56W-workspace-db-schema-v0` +- Branch: `impl/00001KVNKD56W-workspace-db-schema-v0` +- Baseline: `f17a458a` +- Implementation commit: `5149ab70 workspace: implement db schema v0 bootstrap` +- Diff: `f17a458a..5149ab70` + +Reviewer scope: +- read-only `/home/hare/Projects/yoi` + +Reviewer focus: +- Consistency between `schema-v0.md` and implemented SQLite migration/version 2。 +- Absence of canonical `runs`, `hosts`, `workers`, `actors`, generic JSON/payload/metadata columns, and validation/CI result tables。 +- Legacy table preservation/demotion behavior。 +- Existing read APIs for Tickets/Objectives/Repositories/Hosts/Workers still work。 +- `/api/runs` removal is intentional and test-covered。 +- Design artifact updates remain concise and consistent。 + +Orchestrator will wait for reviewer verdict before integration。 + +--- + + + +## Review: request changes + +Verdict: `request_changes` + +確認範囲: +- Ticket/design authority: + - `.yoi/tickets/00001KVNKD56W/item.md` + - `.yoi/tickets/00001KVNKD56W/thread.md` + - `.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md` +- Implementation diff `f17a458a..5149ab70`: + - `crates/workspace-server/src/store.rs` + - `crates/workspace-server/src/server.rs` + - `web/workspace/src/routes/+page.svelte` + - schema artifact updates +- Target branch/head: + - Branch: `impl/00001KVNKD56W-workspace-db-schema-v0` + - Head: `5149ab703f63` + +Blocking issue: + +1. Legacy-upgraded `workspaces` table remains non-canonical and can reject new workspace inserts。 + +Fresh schema defines canonical `workspaces` as: +- `workspace_id` +- `display_name` +- `state` +- `created_at` +- `updated_at` + +This matches `schema-v0.md`。 + +However, legacy migration does not rebuild or demote the old bootstrap `workspaces` table. It only adds `state`: +- `crates/workspace-server/src/store.rs:198-200` + - `ALTER TABLE workspaces ADD COLUMN state TEXT NOT NULL DEFAULT 'active';` + +Then `create_schema_v0_tables()` is called, but because it uses `CREATE TABLE IF NOT EXISTS workspaces`, the old table is left in place: +- `crates/workspace-server/src/store.rs:218-227` + +The old v1 bootstrap `workspaces` table had extra `local_root TEXT NOT NULL` and `record_authority TEXT NOT NULL` columns. Those remain on upgraded DBs even though they are not part of canonical v0。 + +This is not merely cosmetic: `upsert_workspace()` now inserts only v0 columns: +- `crates/workspace-server/src/store.rs:87-89` + +For an upgraded legacy DB, inserting a new `workspace_id` would fail on old `local_root` / `record_authority` NOT NULL columns because the insert no longer supplies them. Updating the already-existing legacy workspace may pass due conflict/update path, which is why current tests do not catch this。 + +Why this blocks: +- Upgraded canonical schema does not actually match `schema-v0.md`。 +- Legacy handling is not safe enough because active canonical `workspaces` remains partly old schema and incompatible with the new write path。 + +Required fix: +- Rebuild/copy `workspaces` into canonical v0 shape, or otherwise explicitly preserve old workspace data without leaving obsolete NOT NULL columns on the active canonical table。 +- Add tests asserting upgraded `workspaces` column set。 +- Add a post-upgrade insert/upsert test for a new workspace id。 + +Non-blocking concerns: +- Existing tests cover fresh forbidden table absence and legacy demotion for old `runs` / projections / artifact / repository tables, but not upgraded `workspaces` column set or post-upgrade insert; add coverage with the fix。 +- Runs card/reference removal is narrow and `/api/runs` removal is test-covered with 404 assertion。 + +Reviewer validation: +- `cargo fmt --check`: passed +- `git diff --check f17a458a..HEAD`: passed +- `cargo test -p yoi-workspace-server`: passed (`10 passed`) +- `cargo check -p yoi-workspace-server`: passed +- `cd web/workspace && rm -rf node_modules .svelte-kit build && deno task check && deno task build`: passed +- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`) +- `nix build .#yoi --no-link`: passed +- `nix build .#yoi --no-link --print-out-paths`: passed/cached output `/nix/store/i0dr920x6kf217zddbnaqyljh8l1gwri-yoi-0.1.0` + +Generated/ignored artifacts remain in reviewer worktree after validation: +- `target/` +- `web/workspace/node_modules/` +- `web/workspace/.svelte-kit/` +- `web/workspace/build/` + +Worktree status: +- Tracked status clean; only ignored generated artifacts present。 + +--- + + + +## Implementation report + +Coder r1-fix report received from `yoi-coder-00001KVNKD56W`. + +New fix commit: +- `d89ace5b workspace: canonicalize legacy workspaces` + +Branch commits now: +- `5149ab70 workspace: implement db schema v0 bootstrap` +- `d89ace5b workspace: canonicalize legacy workspaces` + +Fix summary: +- `crates/workspace-server/src/store.rs` + - Legacy migration now preserves old non-canonical `workspaces` by renaming it to `legacy_workspaces`。 + - Active canonical v0 `workspaces` is created with only: + - `workspace_id` + - `display_name` + - `state` + - `created_at` + - `updated_at` + - Existing legacy workspace rows are copied into canonical `workspaces`, defaulting `state` to `active` when no typed state existed。 + - Previous legacy demotion for `repositories`, `runs`, `artifacts`, `ticket_projections`, and `objective_projections` is preserved。 + - Added upgrade test assertions for: + - upgraded `workspaces` column set exactly matches v0。 + - `legacy_workspaces` preserves old obsolete columns。 + - old workspace data is copied into canonical table。 + - post-upgrade `upsert_workspace()` succeeds for a new workspace id。 +- `.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md` + - Updated implementation alignment notes to document `legacy_workspaces` preservation and canonical copy behavior。 + +Coder validation reported: +- `cargo fmt --check`: passed +- `cargo test -p yoi-workspace-server`: passed +- `cargo check -p yoi-workspace-server`: passed +- `git diff --check f17a458a..HEAD`: passed +- `cargo run -p yoi -- ticket doctor`: passed + +Not rerun by Coder for the r1 fix: +- Deno check/build and Nix build, because the fix touched only schema artifact and workspace-server store code and Reviewer had validated frontend/Nix before the blocker。 + +Generated artifact cleanup: +- Coder removed reviewer-generated frontend artifacts: + - `web/workspace/node_modules/` + - `web/workspace/.svelte-kit/` + - `web/workspace/build/` + +Orchestrator evidence checked: +- Worktree clean on `impl/00001KVNKD56W-workspace-db-schema-v0` at `d89ace5b`。 +- r1-fix diff `5149ab70..HEAD` touches only `crates/workspace-server/src/store.rs` and `schema-v0.md`。 +- `git diff --check f17a458a..HEAD`: passed。 +- No ignored frontend artifacts remain under `web/workspace`。 + +Next action: +- Route back to Reviewer for r2 verification of legacy `workspaces` canonicalization and final acceptance。 + +--- + + + +## Review: approve + +Verdict: `approve` + +確認範囲: +- r1-fix diff `5149ab70..d89ace5b`: + - `crates/workspace-server/src/store.rs` + - `.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md` +- Overall diff `f17a458a..d89ace5b`: + - `.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md` + - `crates/workspace-server/src/server.rs` + - `crates/workspace-server/src/store.rs` + - `web/workspace/src/routes/+page.svelte` + +Previous blocker verification: +- Resolved。 + +Evidence: +- Non-canonical legacy `workspaces` is now detected by exact v0 column comparison and renamed to `legacy_workspaces`。 +- Canonical v0 `workspaces` is then created with exactly: + - `workspace_id` + - `display_name` + - `state` + - `created_at` + - `updated_at` +- Legacy workspace rows are copied into canonical `workspaces`, with `state` defaulted to `active` when absent。 +- Tests assert: + - upgraded `workspaces` columns exactly match v0。 + - `legacy_workspaces` preserves old obsolete columns。 + - legacy workspace data is readable through canonical `get_workspace`。 + - post-upgrade `upsert_workspace()` succeeds for a new workspace id。 + +Blocking issues: +- None。 + +Non-blocking concerns: +- None requiring changes。 + +Ticket/design fit: +- Fresh schema creates typed v0 tables and does not create canonical top-level `runs`, `hosts`, `workers`, `actors`, or validation/CI result tables。 +- Legacy `runs` is preserved only as `legacy_runs`; it does not masquerade as canonical。 +- Host/Worker APIs remain live runtime views, not DB authority。 +- `/api/runs` remains removed/404-tested。 +- Canonical schema avoids generic `metadata_json`, `payload_json`, `diagnostics_json`, and catch-all payload columns。 +- `schema-v0.md` update is narrow and consistent with migration behavior。 +- No accidental frontend authority/business-logic expansion found。 + +Reviewer validation: +- `cargo fmt --check`: passed +- `git diff --check f17a458a..HEAD`: passed +- `cargo test -p yoi-workspace-server`: passed (`10 passed`) +- `cargo check -p yoi-workspace-server`: passed +- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`) +- `nix build .#yoi --no-link --print-out-paths`: passed, output `/nix/store/fnz7650sshl3md2vdwb4a1q8dc0hf542-yoi-0.1.0` + +Not rerun in r2: +- Deno frontend check/build, because r1-fix diff touches only `store.rs` and `schema-v0.md`; r1 had already validated frontend for original `/api/runs` card removal。 + +Worktree status: +- Clean tracked status on `impl/00001KVNKD56W-workspace-db-schema-v0` at `d89ace5b`。 +- Ignored artifact present: `target/` only; no ignored frontend artifacts under `web/workspace`。 + +--- + + + +## Implementation report + +Reviewer r2 approvalを受けて、実装ブランチを Orchestrator worktree の `orchestration` branch に統合した。 + +統合: +- Merge commit: `38bd122d merge: workspace db schema v0` +- Merged branch: `impl/00001KVNKD56W-workspace-db-schema-v0` +- Implementation commits: + - `5149ab70 workspace: implement db schema v0 bootstrap` + - `d89ace5b workspace: canonicalize legacy workspaces` + +Orchestrator worktree validation: +- `cargo fmt --check`: passed +- `git diff --check HEAD^1..HEAD`: passed +- `cargo test -p yoi-workspace-server`: passed (`10 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 `38bd122d`。 + +--- + + + +## State changed + +Reviewer approval、Orchestrator worktree への統合、workspace-server tests/check、Deno check/build、Ticket doctor、Nix build が完了したため `done` に遷移する。 + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Workspace DB canonical schema v0 design を `schema-v0.md` artifact と workspace-server SQLite bootstrap/migrations に整合させ、Orchestrator worktree の `orchestration` branch に統合した。 + +主な成果: +- SQLite schema version を `2` に進め、canonical schema v0 を実装。 +- Typed v0 tables を追加: + - `tickets` + - `ticket_events` + - `ticket_relations` + - `objectives` + - `objective_ticket_links` + - `repositories` + - `ticket_targets` + - `ticket_target_paths` + - `ticket_worker_links` + - `artifacts` + - `audit_events` +- Canonical v0 から top-level `runs`, `hosts`, `workers`, `actors`, validation/CI result tables を除外。 +- Generic `metadata_json`, `payload_json`, `diagnostics_json` のような catch-all payload columns を canonical v0 tables に追加しない方針を維持。 +- `/api/runs` と frontend Runs card/reference を削除し、404 test を追加。 +- Host/Worker APIs は DB authority ではなく live runtime views として維持。 +- Legacy bootstrap tables は non-canonical `legacy_*` preservation tables に demote。 +- Legacy `workspaces` は `legacy_workspaces` に preserve し、active canonical `workspaces` を v0 column set で作り直して既存行を copy。 +- Post-upgrade `upsert_workspace()` for new workspace id が通る regression test を追加。 +- `schema-v0.md` に SQLite version 2 / legacy preservation alignment notes を追加。 + +統合・検証: +- Merge commit: `38bd122d merge: workspace db schema v0` +- Implementation commits: `5149ab70`, `d89ace5b` +- 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`。 + +範囲外: +- Ticket/Objective write authority migration to DB is not implemented。 +- Host/Worker canonical DB tables are not added。 +- Validation/CI result tables and Actor table are not added。 +- Full TicketEvents/TicketWorkerLinks/Artifacts write surfaces are not implemented beyond schema/bootstrap alignment。 + +--- + + + +## Implementation report + +Post-close cleanup completed。 + +- Stopped child Pods and reclaimed scope: + - `yoi-coder-00001KVNKD56W` + - `yoi-reviewer-00001KVNKD56W-r1` +- Removed ignored frontend validation artifacts from child worktree before worktree removal if present: + - `web/workspace/node_modules/` + - `web/workspace/.svelte-kit/` + - `web/workspace/build/` +- Removed implementation worktree: + - `/home/hare/Projects/yoi/.worktree/00001KVNKD56W-workspace-db-schema-v0` +- Deleted implementation branch: + - `impl/00001KVNKD56W-workspace-db-schema-v0` +- Orchestrator worktree remains clean on `orchestration` at `7a7891d4`。 + +Root/original workspace was not used for merge/validation/cleanup operations。 + --- diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 0f1a2649..f687e977 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -15,7 +15,7 @@ use crate::records::{ LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary, }; use crate::repositories::{LocalRepositoryReader, RepositoryLogRead, RepositorySummary}; -use crate::store::{ControlPlaneStore, RunSummary, WorkspaceRecord}; +use crate::store::{ControlPlaneStore, WorkspaceRecord}; use crate::{Error, Result}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -74,8 +74,7 @@ impl WorkspaceApi { .upsert_workspace(&WorkspaceRecord { workspace_id: config.workspace_id.clone(), display_name, - local_root: config.workspace_root.clone(), - record_authority: "local_yoi_project_records".to_string(), + state: "active".to_string(), created_at: "1970-01-01T00:00:00Z".to_string(), updated_at: "1970-01-01T00:00:00Z".to_string(), }) @@ -127,7 +126,6 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/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)) .route("/api/hosts/{host_id}/workers", get(list_host_workers)) @@ -398,20 +396,6 @@ async fn repository_tickets( })) } -async fn list_runs( - State(api): State, -) -> ApiResult>> { - let limit = api.config.max_records.min(200); - let items = api.store.list_runs(api.workspace_id(), limit).await?; - Ok(Json(RuntimeListResponse { - workspace_id: api.config.workspace_id, - limit, - items, - source: "sqlite_runtime_tables".to_string(), - diagnostics: Vec::new(), - })) -} - async fn list_hosts( State(api): State, ) -> ApiResult>> { @@ -729,6 +713,18 @@ mod tests { let host_workers = get_json(app.clone(), "/api/hosts/local-local-test/workers").await; assert!(host_workers["items"].as_array().unwrap().is_empty()); + let runs_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/runs") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(runs_response.status(), StatusCode::NOT_FOUND); + let runners_response = app .clone() .oneshot( diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 7c0c4ee6..c9ec8b0f 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -8,92 +8,38 @@ use serde::{Deserialize, Serialize}; use crate::{Error, Result}; -const MIGRATIONS: &[Migration] = &[Migration { - version: 1, - name: "bootstrap workspace control plane", - sql: r#" -CREATE TABLE IF NOT EXISTS workspaces ( - workspace_id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - local_root TEXT NOT NULL, - record_authority TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); +const WORKSPACES_V0_COLUMNS: &[&str] = &[ + "workspace_id", + "display_name", + "state", + "created_at", + "updated_at", +]; -CREATE TABLE IF NOT EXISTS repositories ( - repository_id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - local_root TEXT NOT NULL, - role TEXT NOT NULL, - created_at TEXT NOT NULL -); - --- Projection tables are intentionally empty in this bootstrap: `.yoi/tickets` --- and `.yoi/objectives` remain canonical, but the tables reserve a future --- projection/cache seam without migrating authority. -CREATE TABLE IF NOT EXISTS ticket_projections ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - ticket_id TEXT NOT NULL, - title TEXT NOT NULL, - state TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, ticket_id) -); - -CREATE TABLE IF NOT EXISTS objective_projections ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - objective_id TEXT NOT NULL, - title TEXT NOT NULL, - state TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, objective_id) -); - -CREATE TABLE IF NOT EXISTS runs ( - run_id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - subject_kind TEXT NOT NULL, - subject_id TEXT NOT NULL, - status TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS artifacts ( - artifact_id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - run_id TEXT REFERENCES runs(run_id) ON DELETE SET NULL, - path TEXT NOT NULL, - content_type TEXT, - created_at TEXT NOT NULL -); -"#, -}]; +const MIGRATIONS: &[Migration] = &[ + Migration { + version: 1, + name: "workspace db canonical schema v0 bootstrap", + apply: create_schema_v0_tables, + }, + Migration { + version: 2, + name: "align legacy workspace bootstrap with schema v0", + apply: align_legacy_bootstrap_schema, + }, +]; struct Migration { version: i64, name: &'static str, - sql: &'static str, + apply: fn(&Connection) -> Result<()>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkspaceRecord { pub workspace_id: String, pub display_name: String, - pub local_root: PathBuf, - pub record_authority: String, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RunSummary { - pub run_id: String, - pub workspace_id: String, - pub subject_kind: String, - pub subject_id: String, - pub status: String, + pub state: String, pub created_at: String, pub updated_at: String, } @@ -103,7 +49,6 @@ pub trait ControlPlaneStore: Send + Sync { async fn schema_version(&self) -> Result; async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>; async fn get_workspace(&self, workspace_id: &str) -> Result>; - async fn list_runs(&self, workspace_id: &str, limit: usize) -> Result>; } #[derive(Clone)] @@ -148,18 +93,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(|conn| { conn.execute( r#"INSERT INTO workspaces ( - workspace_id, display_name, local_root, record_authority, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) + workspace_id, display_name, state, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(workspace_id) DO UPDATE SET display_name = excluded.display_name, - local_root = excluded.local_root, - record_authority = excluded.record_authority, + state = excluded.state, updated_at = excluded.updated_at"#, params![ record.workspace_id, record.display_name, - record.local_root.to_string_lossy(), - record.record_authority, + record.state, record.created_at, record.updated_at, ], @@ -171,17 +114,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore { async fn get_workspace(&self, workspace_id: &str) -> Result> { self.with_conn(|conn| { conn.query_row( - r#"SELECT workspace_id, display_name, local_root, record_authority, created_at, updated_at + r#"SELECT workspace_id, display_name, state, created_at, updated_at FROM workspaces WHERE workspace_id = ?1"#, params![workspace_id], |row| { Ok(WorkspaceRecord { workspace_id: row.get(0)?, display_name: row.get(1)?, - local_root: PathBuf::from(row.get::<_, String>(2)?), - record_authority: row.get(3)?, - created_at: row.get(4)?, - updated_at: row.get(5)?, + state: row.get(2)?, + created_at: row.get(3)?, + updated_at: row.get(4)?, }) }, ) @@ -189,28 +131,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { .map_err(Error::from) }) } - - async fn list_runs(&self, workspace_id: &str, limit: usize) -> Result> { - self.with_conn(|conn| { - let limit = limit.min(200) as i64; - let mut stmt = conn.prepare( - r#"SELECT run_id, workspace_id, subject_kind, subject_id, status, created_at, updated_at - FROM runs WHERE workspace_id = ?1 ORDER BY updated_at DESC, run_id DESC LIMIT ?2"#, - )?; - let rows = stmt.query_map(params![workspace_id, limit], |row| { - Ok(RunSummary { - run_id: row.get(0)?, - workspace_id: row.get(1)?, - subject_kind: row.get(2)?, - subject_id: row.get(3)?, - status: row.get(4)?, - created_at: row.get(5)?, - updated_at: row.get(6)?, - }) - })?; - rows.collect::>>().map_err(Error::from) - }) - } } fn configure_sqlite(conn: &Connection) -> Result<()> { @@ -246,7 +166,7 @@ fn apply_migrations(conn: &Connection) -> Result<()> { .filter(|migration| migration.version > current) { let tx = conn.unchecked_transaction()?; - tx.execute_batch(migration.sql)?; + (migration.apply)(&tx)?; tx.execute( "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", params![migration.version, migration.name], @@ -256,9 +176,311 @@ fn apply_migrations(conn: &Connection) -> Result<()> { Ok(()) } +fn align_legacy_bootstrap_schema(conn: &Connection) -> Result<()> { + if table_exists(conn, "repositories")? + && column_exists(conn, "repositories", "local_root")? + && !column_exists(conn, "repositories", "uri")? + { + rename_legacy_table(conn, "repositories", "legacy_repositories")?; + } + if table_exists(conn, "runs")? { + rename_legacy_table(conn, "runs", "legacy_runs")?; + } + if table_exists(conn, "artifacts")? + && (column_exists(conn, "artifacts", "run_id")? + || column_exists(conn, "artifacts", "path")? + || !column_exists(conn, "artifacts", "uri")?) + { + rename_legacy_table(conn, "artifacts", "legacy_artifacts")?; + } + if table_exists(conn, "ticket_projections")? { + rename_legacy_table(conn, "ticket_projections", "legacy_ticket_projections")?; + } + if table_exists(conn, "objective_projections")? { + rename_legacy_table( + conn, + "objective_projections", + "legacy_objective_projections", + )?; + } + + let legacy_workspaces = preserve_noncanonical_workspaces(conn)?; + create_schema_v0_tables(conn)?; + if let Some(legacy_table) = legacy_workspaces { + copy_legacy_workspaces(conn, &legacy_table)?; + } + Ok(()) +} + +fn preserve_noncanonical_workspaces(conn: &Connection) -> Result> { + if !table_exists(conn, "workspaces")? { + return Ok(None); + } + let columns = table_columns(conn, "workspaces")?; + if columns + .iter() + .map(String::as_str) + .eq(WORKSPACES_V0_COLUMNS.iter().copied()) + { + return Ok(None); + } + let legacy_table = "legacy_workspaces"; + rename_legacy_table(conn, "workspaces", legacy_table)?; + Ok(Some(legacy_table.to_string())) +} + +fn copy_legacy_workspaces(conn: &Connection, legacy_table: &str) -> Result<()> { + let columns = table_columns(conn, legacy_table)?; + for required_column in ["workspace_id", "display_name", "created_at", "updated_at"] { + if !columns.iter().any(|column| column == required_column) { + return Err(Error::Store(format!( + "cannot migrate legacy workspaces: `{legacy_table}` is missing `{required_column}`" + ))); + } + } + let state_expr = if columns.iter().any(|column| column == "state") { + "COALESCE(NULLIF(state, ''), 'active')" + } else { + "'active'" + }; + conn.execute_batch(&format!( + r#"INSERT OR IGNORE INTO workspaces ( + workspace_id, display_name, state, created_at, updated_at + ) + SELECT workspace_id, display_name, {state_expr}, created_at, updated_at + FROM {legacy_table};"# + ))?; + Ok(()) +} + +fn rename_legacy_table(conn: &Connection, table_name: &str, legacy_name: &str) -> Result<()> { + if table_exists(conn, legacy_name)? { + return Err(Error::Store(format!( + "cannot preserve legacy table `{table_name}` because `{legacy_name}` already exists" + ))); + } + conn.execute_batch(&format!( + "ALTER TABLE {table_name} RENAME TO {legacy_name};" + ))?; + Ok(()) +} + +fn create_schema_v0_tables(conn: &Connection) -> Result<()> { + conn.execute_batch( + r#" +CREATE TABLE IF NOT EXISTS workspaces ( + workspace_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS tickets ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + ticket_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + state TEXT NOT NULL, + priority TEXT, + assignee_kind TEXT, + assignee_key TEXT, + assignee_display TEXT, + body_md TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + closed_at TEXT, + resolution_event_id TEXT +); + +CREATE TABLE IF NOT EXISTS ticket_events ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + event_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, + event_seq INTEGER NOT NULL, + kind TEXT NOT NULL, + activity_id TEXT, + author_kind TEXT NOT NULL, + author_key TEXT NOT NULL, + author_display TEXT NOT NULL, + author_source_kind TEXT, + author_source_key TEXT, + created_at TEXT NOT NULL, + body_md TEXT, + subject_kind TEXT, + subject_id TEXT, + previous_state TEXT, + new_state TEXT, + status TEXT, + artifact_id TEXT, + worker_ref_kind TEXT, + worker_ref_key TEXT, + worker_display TEXT, + host_ref_kind TEXT, + host_ref_key TEXT, + host_display TEXT, + repository_id TEXT, + caused_by_event_id TEXT, + UNIQUE (ticket_id, event_seq) +); + +CREATE TABLE IF NOT EXISTS ticket_relations ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + source_ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, + target_ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, + kind TEXT NOT NULL, + created_at TEXT NOT NULL, + author_kind TEXT NOT NULL, + author_key TEXT NOT NULL, + author_display TEXT NOT NULL, + author_source_kind TEXT, + author_source_key TEXT, + note TEXT, + PRIMARY KEY (source_ticket_id, target_ticket_id, kind) +); + +CREATE TABLE IF NOT EXISTS objectives ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + objective_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + state TEXT NOT NULL, + body_md TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS objective_ticket_links ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + objective_id TEXT NOT NULL REFERENCES objectives(objective_id) ON DELETE CASCADE, + ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, + kind TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (objective_id, ticket_id, kind) +); + +CREATE TABLE IF NOT EXISTS repositories ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + repository_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL, + provider TEXT, + uri TEXT NOT NULL, + default_ref TEXT, + auth_ref_kind TEXT, + auth_ref_key TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS ticket_targets ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, + target_id TEXT NOT NULL, + repository_id TEXT NOT NULL REFERENCES repositories(repository_id) ON DELETE CASCADE, + role TEXT NOT NULL, + intent TEXT NOT NULL, + ref_selector TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (ticket_id, target_id) +); + +CREATE TABLE IF NOT EXISTS ticket_target_paths ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + ticket_id TEXT NOT NULL, + target_id TEXT NOT NULL, + path TEXT NOT NULL, + PRIMARY KEY (ticket_id, target_id, path), + FOREIGN KEY (ticket_id, target_id) REFERENCES ticket_targets(ticket_id, target_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS ticket_worker_links ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, + worker_ref_kind TEXT NOT NULL, + worker_ref_key TEXT NOT NULL, + worker_display TEXT, + role TEXT NOT NULL, + status TEXT NOT NULL, + activity_id TEXT, + assigned_at TEXT, + released_at TEXT, + last_event_id TEXT, + PRIMARY KEY (ticket_id, worker_ref_kind, worker_ref_key, role) +); + +CREATE TABLE IF NOT EXISTS artifacts ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + artifact_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + uri TEXT NOT NULL, + media_type TEXT, + sha256 TEXT, + size_bytes INTEGER, + summary TEXT, + created_at TEXT NOT NULL, + created_by_kind TEXT NOT NULL, + created_by_key TEXT NOT NULL, + created_by_display TEXT NOT NULL, + created_by_source_kind TEXT, + created_by_source_key TEXT, + ticket_id TEXT, + objective_id TEXT, + event_id TEXT, + worker_ref_kind TEXT, + worker_ref_key TEXT, + worker_display TEXT, + repository_id TEXT, + source_kind TEXT, + source_revision TEXT +); + +CREATE TABLE IF NOT EXISTS audit_events ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + audit_event_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + actor_kind TEXT NOT NULL, + actor_key TEXT NOT NULL, + actor_display TEXT NOT NULL, + actor_source_kind TEXT, + actor_source_key TEXT, + action TEXT NOT NULL, + target_kind TEXT NOT NULL, + target_id TEXT, + outcome TEXT NOT NULL, + request_id TEXT, + summary TEXT +); +"#, + )?; + Ok(()) +} + +fn table_exists(conn: &Connection, table_name: &str) -> Result { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1)", + params![table_name], + |row| row.get::<_, bool>(0), + ) + .map_err(Error::from) +} + +fn column_exists(conn: &Connection, table_name: &str, column_name: &str) -> Result { + Ok(table_columns(conn, table_name)? + .iter() + .any(|column| column == column_name)) +} + +fn table_columns(conn: &Connection, table_name: &str) -> Result> { + let mut stmt = conn.prepare(&format!("PRAGMA table_info({table_name})"))?; + let rows = stmt.query_map([], |row| row.get::<_, String>(1))?; + rows.collect::>>() + .map_err(Error::from) +} + #[cfg(test)] mod tests { use super::*; + use std::collections::BTreeSet; #[tokio::test] async fn migrates_sqlite_and_preserves_workspace_record() { @@ -266,30 +488,355 @@ mod tests { let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 1); + assert_eq!(store.schema_version().await.unwrap(), 2); let record = WorkspaceRecord { workspace_id: "local-dev".to_string(), display_name: "Yoi Dev".to_string(), - local_root: dir.path().to_path_buf(), - record_authority: "local_yoi_project_records".to_string(), + state: "active".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-01T00:00:00Z".to_string(), }; store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 1); + assert_eq!(reopened.schema_version().await.unwrap(), 2); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) ); + } + + #[test] + fn fresh_schema_matches_workspace_db_v0_boundaries() { + let conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + apply_migrations(&conn).unwrap(); + + let tables = table_names(&conn); + for expected in [ + "workspaces", + "tickets", + "ticket_events", + "ticket_relations", + "objectives", + "objective_ticket_links", + "repositories", + "ticket_targets", + "ticket_target_paths", + "ticket_worker_links", + "artifacts", + "audit_events", + ] { + assert!( + tables.contains(expected), + "missing expected v0 table {expected}" + ); + } + for forbidden in [ + "runs", + "hosts", + "workers", + "actors", + "validation_results", + "ci_results", + ] { + assert!( + !tables.contains(forbidden), + "fresh v0 schema must not create forbidden table {forbidden}" + ); + } assert!( - reopened - .list_runs("local-dev", 20) - .await - .unwrap() - .is_empty() + !tables.iter().any(|table| table.starts_with("legacy_")), + "fresh v0 schema should not create legacy compatibility tables: {tables:?}" + ); + + assert_columns( + &conn, + "workspaces", + [ + "workspace_id", + "display_name", + "state", + "created_at", + "updated_at", + ], + ); + assert_columns( + &conn, + "repositories", + [ + "workspace_id", + "repository_id", + "name", + "kind", + "provider", + "uri", + "default_ref", + "auth_ref_kind", + "auth_ref_key", + "created_at", + "updated_at", + ], + ); + assert_columns( + &conn, + "ticket_events", + [ + "workspace_id", + "event_id", + "ticket_id", + "event_seq", + "kind", + "activity_id", + "author_kind", + "author_key", + "author_display", + "author_source_kind", + "author_source_key", + "created_at", + "body_md", + "subject_kind", + "subject_id", + "previous_state", + "new_state", + "status", + "artifact_id", + "worker_ref_kind", + "worker_ref_key", + "worker_display", + "host_ref_kind", + "host_ref_key", + "host_display", + "repository_id", + "caused_by_event_id", + ], + ); + assert_columns( + &conn, + "artifacts", + [ + "workspace_id", + "artifact_id", + "kind", + "uri", + "media_type", + "sha256", + "size_bytes", + "summary", + "created_at", + "created_by_kind", + "created_by_key", + "created_by_display", + "created_by_source_kind", + "created_by_source_key", + "ticket_id", + "objective_id", + "event_id", + "worker_ref_kind", + "worker_ref_key", + "worker_display", + "repository_id", + "source_kind", + "source_revision", + ], + ); + + for table in ["workspaces", "repositories", "ticket_events", "artifacts"] { + let columns = table_columns(&conn, table).unwrap(); + for forbidden_column in [ + "payload", + "payload_json", + "metadata", + "metadata_json", + "diagnostics_json", + "run_id", + "local_root", + "record_authority", + ] { + assert!( + !columns.iter().any(|column| column == forbidden_column), + "{table} must not contain obsolete/generic column {forbidden_column}" + ); + } + } + } + + #[tokio::test] + async fn upgrades_legacy_bootstrap_without_canonical_runs_table() { + let conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + conn.execute_batch(LEGACY_BOOTSTRAP_SQL).unwrap(); + conn.execute( + r#"INSERT INTO workspaces ( + workspace_id, display_name, local_root, record_authority, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, + params![ + "legacy-workspace", + "Legacy Workspace", + "/tmp/legacy-workspace", + "local_yoi_project_records", + "2026-01-01T00:00:00Z", + "2026-01-02T00:00:00Z", + ], + ) + .unwrap(); + conn.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (1, 'bootstrap workspace control plane')", + [], + ) + .unwrap(); + + let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); + assert_eq!(store.schema_version().await.unwrap(), 2); + + store + .with_conn(|conn| { + let tables = table_names(conn); + for expected in [ + "workspaces", + "repositories", + "tickets", + "ticket_events", + "ticket_worker_links", + "artifacts", + "audit_events", + "legacy_workspaces", + "legacy_repositories", + "legacy_runs", + "legacy_artifacts", + "legacy_ticket_projections", + "legacy_objective_projections", + ] { + assert!( + tables.contains(expected), + "missing {expected} after upgrade" + ); + } + for forbidden in ["runs", "hosts", "workers", "actors", "validation_results"] { + assert!( + !tables.contains(forbidden), + "upgraded schema must not retain forbidden canonical table {forbidden}" + ); + } + assert_columns( + conn, + "workspaces", + [ + "workspace_id", + "display_name", + "state", + "created_at", + "updated_at", + ], + ); + let legacy_workspace_columns = table_columns(conn, "legacy_workspaces")?; + assert!( + legacy_workspace_columns + .iter() + .any(|column| column == "local_root") + ); + assert!( + legacy_workspace_columns + .iter() + .any(|column| column == "record_authority") + ); + let artifact_columns = table_columns(conn, "artifacts")?; + assert!(artifact_columns.iter().any(|column| column == "uri")); + assert!(!artifact_columns.iter().any(|column| column == "run_id")); + Ok(()) + }) + .unwrap(); + + assert_eq!( + store.get_workspace("legacy-workspace").await.unwrap(), + Some(WorkspaceRecord { + workspace_id: "legacy-workspace".to_string(), + display_name: "Legacy Workspace".to_string(), + state: "active".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-02T00:00:00Z".to_string(), + }) + ); + + let new_record = WorkspaceRecord { + workspace_id: "new-workspace".to_string(), + display_name: "New Workspace".to_string(), + state: "active".to_string(), + created_at: "2026-02-01T00:00:00Z".to_string(), + updated_at: "2026-02-01T00:00:00Z".to_string(), + }; + store.upsert_workspace(&new_record).await.unwrap(); + assert_eq!( + store.get_workspace("new-workspace").await.unwrap(), + Some(new_record) ); } + + fn table_names(conn: &Connection) -> BTreeSet { + let mut stmt = conn + .prepare( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", + ) + .unwrap(); + let rows = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap(); + rows.collect::>>().unwrap() + } + + fn assert_columns(conn: &Connection, table: &str, expected: [&str; N]) { + let columns = table_columns(conn, table).unwrap(); + let expected = expected.map(str::to_string).to_vec(); + assert_eq!(columns, expected, "unexpected columns for {table}"); + } + + const LEGACY_BOOTSTRAP_SQL: &str = r#" +CREATE TABLE workspaces ( + workspace_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + local_root TEXT NOT NULL, + record_authority TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE repositories ( + repository_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + local_root TEXT NOT NULL, + role TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE ticket_projections ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + ticket_id TEXT NOT NULL, + title TEXT NOT NULL, + state TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id) +); +CREATE TABLE objective_projections ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + objective_id TEXT NOT NULL, + title TEXT NOT NULL, + state TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, objective_id) +); +CREATE TABLE runs ( + run_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + subject_kind TEXT NOT NULL, + subject_id TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE artifacts ( + artifact_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + run_id TEXT REFERENCES runs(run_id) ON DELETE SET NULL, + path TEXT NOT NULL, + content_type TEXT, + created_at TEXT NOT NULL +); +"#; }