17 KiB
spec, created, status
| spec | created | status |
|---|---|---|
| docs/specs/workspaces/requirements/repository-sets.md | 2026-08-17 | completed |
Implementation Plan: Repository Sets
Overview
Add a workspace-scoped repository_sets entity with an ordered membership table, expose it over the
existing repository REST/WebSocket surface and boot payload, then teach the shared task-creation
repository picker to apply a set as rows and to save the current selection as a new set. Manage sets
from the existing workspace Repositories settings tab rather than a new tab.
The feature is additive: with no sets defined, every existing surface renders and behaves exactly as
today. That is why it ships without a runtime feature flag. The registry in
apps/backend/internal/runtimeflags/registry.go holds five entries, all guarding genuinely risky or
mode-changing subsystems (office, auth, two Claude turn behaviors, dev mode); a new picker control
that is invisible until a user creates a set does not meet that bar, and adding a flag would mean a
retired identity to carry forever.
Two deliberate model choices, both from the spec:
- Sets carry no branch. The user's ask is explicitly that branches stay a per-task decision.
Storing a branch would duplicate
task_repositories.base_branchand go stale against the repository's real refs. - A real table with FK memberships, not a JSON column.
office_projects.repositoriesstores a JSON[]stringof config-style names with no validation that the entries exist (apps/backend/internal/office/projects/service.go:148). Sets must resolve to selectablerepositories.idvalues, so membership is a table with a foreign key and a uniqueness contract.
Backend
Persistence and models
- Add
repository_setsandrepository_set_itemsDDL toapps/backend/internal/task/repository/sqlite/base_schema.go, as a newinitRepositorySetsSchemastep appended to thestepsslice ininitSchema(base_schema.go:16-48) after the core schema, sorepositoriesandworkspacesexist. Model the tables ontask_repositories(base_schema.go:354-367):ON DELETE CASCADEon both foreign keys,UNIQUE(workspace_id, name)on sets andUNIQUE(repository_set_id, repository_id)on items, plus a unique index on(workspace_id, LOWER(name))so the case-insensitive name rule the service enforces has a database backstop as well. - Add the same tables idempotently in
runMigrations()(apps/backend/internal/task/repository/sqlite/base_migrations.go:124) viaMigrateLogger.Apply("repository_sets.table", …)and"repository_set_items.table", plus"idx_repository_sets_workspace_id"and"idx_repository_set_items_set_id". Migrations run after schema init, so the indexes are safe there; no existing table gains a column, so there is nothing to sequence against anADD COLUMN. - Add
RepositorySetandRepositorySetItemtoapps/backend/internal/task/models/models.go, next toRepository(:1426) andTaskRepository(:978). - Add
RepositorySetRepositorytoapps/backend/internal/task/repository/interface.gobesideRepositoryEntityRepository(:370-420), implemented in a newapps/backend/internal/task/repository/sqlite/repository_set.go: create, get, get-by-name, list-by-workspace, update, delete, and aReplaceRepositorySetItemsthat rewrites the whole membership list with contiguous positions in one transaction. Reads joinrepositoriesand filterrepositories.deleted_at IS NULL AND repositories.workspace_id = repository_sets.workspace_id. List uses one batched query for items across the returned sets rather than one query per set. - Extend both repository soft-delete paths,
DeleteRepositoryandDeleteRepositoryIfNoActiveTaskSessions(apps/backend/internal/task/repository/sqlite/repository_entity.go:143,:199), to delete the repository'srepository_set_itemsrows inside the existing transaction, exactly as they already deleterepository_secret_bindings. - Add
RepositorySetDTO/RepositorySetItemDTOand aFromRepositorySetmapper toapps/backend/internal/task/dto/dto.go, besideRepositoryDTO(:48) andFromRepository(:605).
Service, authorization, and events
- Add
apps/backend/internal/task/service/service_repository_sets.gowithCreateRepositorySet,GetRepositorySet,ListRepositorySets,UpdateRepositorySet, andDeleteRepositorySet, keepingservice_resources.gounder its file-size limit rather than growing it. - Every method resolves the workspace and runs the workspace-access check used by the repository
methods in
service_resources.go(internal/task/service/service_access.go); item-route methods resolve the set, then authorize against the set's workspace. - Validate before any write: trimmed name of 1 to 100 characters; case-insensitive name uniqueness
within the workspace, reported as a conflict naming the existing set; non-empty
repository_ids; no duplicate ids; and every id resolving to a live repository in the same workspace. Return typed errors so the handler can map400/404/409/422. - Name, description, and membership changes for one request commit together.
- Add
RepositorySetCreated|Updated|Deleted = "repository_set.created|updated|deleted"toapps/backend/internal/events/types.gonear the repository events (:125-127), the matchingActionRepositorySet*constants toapps/backend/pkg/websocket/actions.gonear:233, and the threeb.subscribelines toapps/backend/internal/gateway/websocket/task_notifications.gonear:60-62. Publish from the service with a payload carryingidandworkspace_idso the existingextractWorkspaceIDrouting (task_notifications.go:316) reachesBroadcastToWorkspacewith no new routing branch.
HTTP and WebSocket surface
- Add
apps/backend/internal/task/handlers/repository_set_handlers.gofollowingrepository_handlers.go(RegisterRepositorySetRoutes+registerHTTP+registerWS), with the five REST routes from the spec and the fiverepository_set.*dispatcher actions. Collection routes use:idfor the workspace, matching the sibling repository routes (repository_handlers.go:38-65); gin will not accept a second wildcard name at that position. - Register it from
registerTaskRoutesinapps/backend/internal/backendapp/helpers.go:1064, next toRegisterRepositoryRoutes. - Extend
apps/backend/internal/task/handlers/route_registration_test.goso the new paths and WS actions are pinned like every other route group.
Boot payload
- Add
repositorySetsForStatenext torepositoriesForState(apps/backend/internal/backendapp/boot_state_routes.go:202) emitting the workspace-keyed shape the store expects (itemsByWorkspaceId/loadingByWorkspaceId/loadedByWorkspaceId), and call it fromrouteContextBootData(:143),tasksPageBootData(:77), andaddHomeKanbanRouteState(apps/backend/internal/backendapp/boot_state.go:319) so every surface that can open the create dialog hydrates sets. Include the key in the empty-workspace early returns so the shape is never absent, and add it to therouteDatamaps (boot_state_routes.go:80-89,:145-150). - A listing failure is non-fatal:
logBootErrorand an empty list, as the neighbours do. - Add the two tables to the workspace-scoped delete list in
apps/backend/internal/backendapp/e2e_reset.go(the FK cascade covers the database, but that list is explicit).
Frontend
Data layer
- Add
RepositorySet/RepositorySetItemtypes toapps/web/lib/types/http.tsbesideRepository(:245). - Add
listRepositorySets,createRepositorySet,updateRepositorySet,deleteRepositorySettoapps/web/lib/api/domains/workspace-api.tsbesidelistRepositories(:28). - Add a
repositorySetsslice toapps/web/lib/state/slices/workspace/mirroring therepositoriesshape intypes.ts:20-24, withsetRepositorySets,upsertRepositorySet,removeRepositorySet, andinvalidateRepositorySets. - Add
apps/web/hooks/domains/workspace/use-repository-sets.tsmodelled onuse-repositories.ts:28(store read, lazy fetch,{ sets, isLoading, refresh }). - Add
apps/web/lib/ws/handlers/repository-sets.tsand register it inapps/web/lib/ws/router.ts(registerWsHandlers,:39) sorepository_set.created|updated|deletedupdate the slice. This is the firstrepository-family WS handler on the web side; repositories themselves have none, which is why sets need their own rather than extending an existing file.
Applying a set in the picker
- Put the applier in
RepoChipsRow(apps/web/components/task-create-dialog-repo-chips.tsx:65), not inWorkspaceRepoChips. Both the create dialog and the new-subtask form renderRepoChipsRow(apps/web/components/task/new-subtask-form-parts.tsx:240), while Quick Chat rendersWorkspaceRepoChipsdirectly (apps/web/components/quick-chat/quick-chat-setup.tsx:135) - so that placement gives exactly the surfaces the spec asks for with no gating code. - Add a pure
apps/web/components/task-create-dialog-repository-sets.tsholdingapplyRepositorySet(rows, set, repositories, nextKey): skip members already present, skip members missing from the live repository list, consume a single blank placeholder row, append the rest with empty branches, and report the skipped counts. Keeping it pure is what makes the apply semantics unit-testable without rendering the dialog. - Allocate row keys with the taken-key loop already used by
addRemoteRepo(apps/web/components/task-create-dialog-repositories-state.ts:58-68).useRepositoriesState's plain counter (:16) would otherwise hand out a key that an injected row already holds. - Apply through
fs.setRepositories. No submit or payload change is needed:buildRepositoriesPayload(apps/web/components/task-create-dialog-helpers.ts:279) already handles N rows. - Render the control next to
add-repository(apps/web/components/task-create-dialog-workspace-repo-chips.tsx:134-143) as a new component file, passed down as a node soWorkspaceRepoChipskeeps its current responsibilities. Hide it when the workspace has no sets or whenfs.useRemote/fs.noRepositoryis on; disable it with visible reason text whengetMultiRepoExecutorDisabledReason(apps/web/components/task-create-dialog-multi-repo-guard.ts:20) returns one. - Verify the interaction with
useRepositoryAutoSelectEffect(apps/web/components/task-create-dialog-repository-autopick.ts:23): itscanReplaceEmptyRepositoryPlaceholderguard (:148) only fires with exactly one blank row, so an applied set of two or more is not clobbered, but a set applied before user settings load can race thedeferbranch (:94). Gate the control onuserSettingsLoaded. - Mobile parity (
/mobile-parity): on phone widths the set list is a bottom sheet, not a hover dropdown, sized for touch; the disabled reason is visible text, not a tooltip.
Saving the current selection as a set
- Add Save as set to the same control: a small dialog taking a name and optional description,
submitting the form's currently selected workspace repository ids in row order, deduped, ignoring
blank rows and branches. It reuses
createRepositorySetand leaves the in-progress task untouched. Surface the409name conflict inline in the dialog.
Managing sets in settings
- Add a Repository sets
SettingsSectiontoapps/web/app/settings/workspace/workspace-repositories-client.tsx:559(below the existing Repositories section), with the list, create/edit/delete controls, and member reordering in a new component file. The client is already 624 lines against an 800-effective-line cap, so the section and its hooks live in their own files. - No new settings tab: sets belong with repositories, and a new tab would mean touching
apps/web/lib/settings/workspace-settings-tabs.ts:48,apps/web/src/settings-routes.tsx:357-420, the discovery catalog, and four tests for a section that has one natural home. - Read-only for the Improve workspace, matching
isImproveWorkspacehandling on that page.
Internationalization
Every string goes through t(). Add keys to apps/web/src/locales/en/ and to pt-pt, zh-cn,
zh-hk, zh-tw; generate the Traditional pair with cd apps/web && pnpm run i18n:zh-hant. No
em dashes in any catalog. Repository set names are user data and are never translated. Verify with
cd apps/web && pnpm run i18n:check and pnpm run i18n:ratchet.
Testing
| Layer | Coverage |
|---|---|
| Store | repository_set_test.go (new file): full-column round trip, name uniqueness, item uniqueness, contiguous positions after replace, soft-deleted-repository exclusion, cross-workspace member rejection, workspace cascade. Fixture style from apps/backend/internal/office/repository/sqlite/projects_test.go:16-70. |
| Schema | Extend the replay test (sqlite/schema_replay_test.go:12) and the Postgres schema test (postgres_schema_test.go:26). |
| Service | New service_repository_sets_test.go: each validation error code, published event types (pattern at service_test.go:248), workspace authorization. |
| Handlers | New repository_set_handlers_test.go over an httptest router; route + WS action pinning in route_registration_test.go. |
| Boot | Extend boot_state_routes_test.go for the new collection and its empty-workspace shape. |
| Web unit | task-create-dialog-repository-sets.test.ts (apply semantics: skip-present, skip-missing, placeholder consumption, key collisions, idempotence), the new store slice, the API client, the WS handler, use-repository-sets.test.ts, and the settings section. |
| E2E | apps/web/e2e/tests/task/create-task-repository-sets.spec.ts and mobile-create-task-repository-sets.spec.ts: apply a set and assert two repo-chip rows and the created task's repository ids, apply twice for idempotence, save a selection as a set, and settings CRUD. Follow subtask.spec.ts:929 for the multi-repo assertion shape and use causal-waits.ts primitives. |
Verification commands
# backend
cd apps/backend && make fmt
cd apps/backend && go test ./internal/task/... ./internal/backendapp/... ./internal/events/... ./internal/gateway/websocket/...
cd apps/backend && make lint
# frontend
cd apps/web && pnpm run typecheck
cd apps/web && pnpm vitest run components/task-create-dialog-repository-sets.test.ts lib/state/slices/workspace hooks/domains/workspace
cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet
cd apps && pnpm --filter @kandev/web lint
cd apps/web && pnpm e2e:raw --grep "repository set"
Risks
- Autopick race. The repository auto-select effect writes
repositorieson open and after user settings load. Applying a set inside that window can be overwritten. Mitigated by gating onuserSettingsLoadedand by a unit test that applies a set while the placeholder row is present. - Row-key collision. Injecting rows via
setRepositorieswhileuseRepositoriesState's counter sits at zero produces duplicate React keys and broken uncontrolled inputs. Mitigated by reusing the existing taken-key loop and pinning it in a test. - Soft-delete drift. Because repository deletion is soft, a membership row can outlive its repository. Mitigated by both deleting items in the delete transaction and filtering on read; the store test asserts the filter independently of the transaction.
- Boot-payload field whitelisting. The boot mappers are explicit camelCase whitelists
(
boot_state_routes.go:631-634), so a DTO field not listed is silently absent from hydration. The boot test asserts the hydrated shape, not just the status code. - File-size caps.
workspace-repositories-client.tsx(624 lines) and the task-create dialog modules are close to their limits; every addition lands in a new file.
Dependency order
| Wave | Tasks |
|---|---|
| 1 | task-01 persistence contracts |
| 2 | task-02 service and events |
| 3 | task-03 HTTP and WebSocket surface |
| 4 | task-04 boot payload and web data layer |
| 5 | task-05 apply a set in the picker, task-07 settings management (parallel-safe) |
| 6 | task-06 save selection as a set |
| 7 | task-08 end-to-end coverage |
| 8 | task-09 public documentation |
| 9 | task-10 final verification |
Task status
| Task | Status |
|---|---|
| task-01-persistence-contracts | done |
| task-02-service-and-events | done |
| task-03-http-ws-surface | done |
| task-04-boot-and-web-data-layer | done |
| task-05-apply-set-in-picker | done |
| task-06-save-selection-as-set | done |
| task-07-settings-management | done |
| task-08-end-to-end-coverage | done |
| task-09-public-documentation | done |
| task-10-final-verification | done |