# Plugin Runtime Описание системы плагинов Verstak Desktop: discovery, lifecycle, capabilities, states. ## Plugin Discovery ### Discovery Directories Plugins ищутся через единый resolver `internal/core/plugin.ResolveDiscoveryDirs`. Порядок приоритета: | Путь | Назначение | Коммитится | |---|---|---| | `VERSTAK_PLUGIN_DIR` | Override для тестов/dev; можно передать несколько путей через OS path separator | Нет | | `./plugins/` | Dev plugins относительно текущей рабочей директории/repo | Нет (`.gitignore`) | | `/plugins/` | Packaged plugins рядом с desktop binary | Зависит от дистрибутива | | `~/.config/verstak/plugins/` | User-installed plugins | Нет (user home) | Resolver нормализует пути, удаляет дубликаты и передает discovery только канонический список директорий. Отсутствующие директории просто пропускаются на этапе scanning. Discovery сканирует **все** resolved директории в указанном порядке. Если один и тот же `plugin.id` найден несколько раз, применяется правило **first plugin wins**: первый найденный plugin загружается, последующие plugins с тем же id пропускаются. Конфликт логируется и возвращается как discovery warning с двумя путями: путь пропущенного duplicate и путь уже загруженного winner. ### ./plugins/ как Dev/Install Target Директория `./plugins/` от текущей рабочей директории используется как: - **Dev target** — `install-dev-plugins.sh` коприрует сюда собранные пакеты из `verstak-official-plugins/dist/`. - **Local override** — при запуске desktop из repo позволяет быстро проверять packaged bundles. В packaged-сборке bundled plugins должны лежать в `plugins/` рядом с executable. Для тестов и локальных сценариев можно задать `VERSTAK_PLUGIN_DIR=/path/to/plugins`. Директория **не коммитится**. Каждый разработчик устанавливает плагины через `install-dev-plugins.sh`. ### Discovery Process 1. `DiscoverPlugins(dirs []string)` сканирует каждую директорию. 2. Для каждой поддиректории читает `plugin.json`. 3. `ValidateManifest()` проверяет: `schemaVersion == 1`, `id`, `name`, `version`, `apiVersion`, минимум 1 `provides`, минимум 1 `permissions`. 4. Дубликаты `id` отбрасываются с warning. Подробнее о формате manifest см. [Plugin Manifest Format](#plugin-manifest-format). ## Plugin Lifecycle States ``` discovered │ ├─ disabled — plugin.json найден, но plugin отключён │ ├─ loading — plugin начинает загрузку │ ├─ loaded — все required и optional capabilities разрешены │ ├─ degraded — required capabilities разрешены, но не хватает optional │ ├─ missing-required-capability — не хватает хотя бы одной required capability │ ├─ failed — ошибка при регистрации capabilities (дубликат, panic) │ ├─ incompatible — schemaVersion или apiVersion не поддерживаются │ └─ (Скрыто) — discovered используется как промежуточный статус в discovery ``` ### Определения статусов | Статус | Условие | Поведение | |---|---|---| | `discovered` | plugin.json прочитан и валиден | Промежуточный, до capability resolution | | `disabled` | Plugin отключён пользователем | Не загружается | | `loaded` | Все capabilities разрешены | Полная функциональность | | `degraded` | Required OK, но не хватает optional | Работает, часть функций недоступна | | `missing-required-capability` | Не хватает required capability | Не загружается, показать ошибку | | `failed` | Ошибка регистрации capabilities | Не загружается | | `incompatible` | Неподдерживаемая schemaVersion/apiVersion | Не загружается | ## Required / Optional Capabilities ### Правило - **`requires`** — жёсткая зависимость. Если ни один plugin не предоставляет требуемый capability, плагин получает `missing-required-capability` и **не загружается**. - **`optionalRequires`** — мягкая зависимость. Если capability нет, плагин переходит в `degraded`, но **продолжает работать**. ### Регистрация core capabilities Core capabilities регистрируются в `main.go` ДО plugin discovery: ```go coreCaps := []string{ "verstak/core/plugin-manager/v1", "verstak/core/capability-registry/v1", "verstak/core/contribution-registry/v1", "verstak/core/permissions/v1", "verstak/core/events/v1", "verstak/core/files/v1", "verstak/core/workbench/v1", } capRegistry.Register("verstak-desktop", coreCaps) // Vault capability — регистрируется отдельно после vault initialization capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"}) ``` Это гарантирует, что любой plugin с `requires: ["verstak/core/plugin-manager/v1"]` будет загружен. ### Plugin capability resolution ``` foreach plugin: 1. зарегистрировать plugin.provides в capability registry 2. проверить plugin.requires — если есть missing → missing-required-capability 3. проверить plugin.optionalRequires — если есть missing → degraded 4. иначе → loaded 5. зарегистрировать plugin.contributes в contribution registry ``` ### Capability конфликт Два plugin не могут предоставлять один и тот же capability. При попытке повторной регистрации — ошибка и статус `failed` для второго плагина. ## Plugin Manifest Format Файл `plugin.json` в корне директории плагина. ### Обязательные поля | Поле | Тип | Описание | |---|---|---| | `schemaVersion` | int | Должен быть `1` | | `id` | string | Уникальный идентификатор (regex: `[a-zA-Z0-9.-]+`) | | `name` | string | Человекочитаемое имя | | `version` | string | Semver (напр. `"0.1.0"`) | | `apiVersion` | string | API версии plugin | | `provides` | string[] | Список capabilities (мин. 1) | | `permissions` | string[] | Список запрашиваемых permissions (мин. 1) | ### Опциональные поля | Поле | Тип | Описание | |---|---|---| | `description` | string | Описание плагина | | `source` | string | `"official"`, `"local"`, `"third-party"` | | `icon` | string | Иконка (emoji или имя) | | `requires` | string[] | Жёзкие capability-зависимости | | `optionalRequires` | string[] | Мягкие capability-зависимости | | `frontend` | object | `{ "entry": "path/to/index.js", "style": "path/to/style.css" }` | | `backend` | object | `{ "type": "go", "entry": { "linux-amd64": "...", ... }, "healthCheck": {...} }` | | `migrations` | object | `{ "path": "migrations/" }` | | `contributes` | object | UI contributions (см. ниже) | | `sync` | object | `{ "namespaces": [...], "participate": bool }` | ### Пример ```json { "schemaVersion": 1, "id": "verstak.platform-test", "name": "Platform Test", "version": "0.1.0", "apiVersion": "0.1.0", "provides": ["verstak/platform-test/v1"], "requires": ["verstak/core/plugin-manager/v1"], "optionalRequires": ["verstak/core/vault/v1", "verstak/core/sync/v1"], "permissions": ["vault.read", "events.publish", "ui.register", "workbench.open"], "frontend": { "entry": "frontend/dist/index.js" }, "contributes": { "views": [{ "id": "my.view", "title": "My View", "component": "MyPanel" }], "commands": [{ "id": "my.cmd", "title": "Run", "handler": "run" }] } } ``` ## Contribution Points Плагины могут регистрировать UI contributions через поле `contributes` в `plugin.json`. ### Реализованные contribution points (Milestone 5a) | Тип | Поле manifest | Описание | Frontend host | |---|---|---|---| | Боковая панель | `sidebarItems` | Элементы в sidebar слева | ✅ Sidebar.svelte (из ContributionRegistry) | | Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (PluginBundleHost — real frontend bundle) | | Панели настроек | `settingsPanels` | Панели в Plugin Manager | ✅ PluginManager.svelte (кнопка Settings, открывает modal) | | Команды | `commands` | Команды для command palette | ✅ ContributionRegistry (UI command palette не реализован) | | Open/edit providers | `openProviders` | Провайдеры viewer/editor для Workbench routing | ✅ ContributionRegistry + минимальный Workbench host | ### Планируемые contribution points | Тип | Поле manifest | Статус | |---|---|---| | Действия над файлами | `fileActions` | Registry готов, UI не реализован | | Действия над заметками | `noteActions` | Registry готов, UI не реализован | | Контекстное меню | `contextMenuEntries` | Registry готов, UI не реализован | | Провайдеры поиска | `searchProviders` | Registry готов, UI не реализован | | Провайдеры активности | `activityProviders` | Registry готов, UI не реализован | | Элементы status bar | `statusBarItems` | Registry готов, UI не реализован | ### Структура contribution points в manifest ```json { "contributes": { "sidebarItems": [ { "id": "mypanel.sidebar", "title": "My Panel", "icon": "📌", "view": "mypanel.view", "position": 100 } ], "views": [ { "id": "mypanel.view", "title": "My Panel View", "icon": "📌", "component": "MyPanelComponent" } ], "settingsPanels": [ { "id": "mypanel.settings", "title": "My Settings", "component": "MySettingsPanel" } ], "commands": [ { "id": "mypanel.cmd", "title": "Do Something", "icon": "⚡", "handler": "doSomething" } ] } } ``` ### Contribution lifecycle 1. Plugin `Register(pluginID, contributions)` — все contributions регистрируются 2. `Unregister(pluginID)` — удаляет все contributions указанного plugin 3. Reload: `Unregister → Register` (предотвращает дублирование) 4. Disable plugin → `Unregister` (contributions исчезают из UI) 5. Enable plugin → `Register` при следующем Reload 6. Registry idempotent: Register удаляет старые записи перед добавлением новых ## Bundled Frontend Plugin API Bundled frontend plugins получают API от host через `createPluginAPI(pluginId)`. Обычный plugin code не передает `pluginId` в методы API: scope закрепляется в host при mount компонента. Это защищает нормальный cooperative path от случайного доступа к чужому namespace. Текущая модель безопасности честно ограничена: - bundled frontend plugins исполняются в общем JS-контексте приложения; - проверки permissions/capabilities сейчас являются contract/policy checks, а не полноценной security boundary; - malicious JS в общем контексте теоретически может обойти frontend wrapper; - настоящая изоляция будет только после отдельного sidecar/sandbox milestone. ## Workbench Open/Edit Routing Files and Notes plugins do not import or embed a concrete editor plugin. They call `api.workbench.openResource(request)` or `api.workbench.editResource(request)`. The backend requires the source plugin to be enabled, loaded/degraded, and to declare `workbench.open`. This is a policy/contract check, not a security boundary. `OpenResourceRequest`: ```ts type OpenResourceRequest = { kind: "vault-file"; path: string; mode?: "view" | "edit"; mime?: string; extension?: string; context?: { sourcePluginId?: string; sourceView?: "files" | "notes" | string; isInsideNotesFolder?: boolean; notesScopePath?: string; notesMode?: boolean; }; }; ``` Routing contexts are fixed as `generic-text`, `generic-markdown`, and `notes-markdown`. `.md`/`.markdown` inside canonical `Notes/` folders uses `notes-markdown`; markdown outside Notes uses `generic-markdown`; ordinary text uses `generic-text`. Milestone 6b derives context from request fields; future Files/Notes integrations can centralize canonical Notes folder auto-detection in the Workbench helper. `contributes.openProviders` extends the existing contribution registry: ```json { "contributes": { "openProviders": [ { "id": "verstak.platform-test.markdown-diagnostic", "title": "Platform Test Markdown Diagnostic", "priority": 100, "component": "MarkdownDiagnosticProvider", "supports": [ { "kind": "vault-file", "extensions": [".md", ".markdown"], "contexts": ["generic-markdown", "notes-markdown"] }, { "kind": "vault-file", "mime": ["text/plain"], "extensions": [".txt", ".log"], "contexts": ["generic-text"] } ] } ] } } ``` Selection uses enabled loaded/degraded provider plugins, resource kind, extension/mime, context, user preference, priority, then deterministic `pluginId/providerId` fallback. If nothing matches, Workbench returns `status: "no-provider"` and shows the fallback view instead of a core editor. Draft app-global preferences are `defaultTextEditorProvider`, `defaultMarkdownEditorProvider`, and `defaultNotesMarkdownEditorProvider`. Vault-scoped and per-extension overrides are deferred. ### API methods `settings` - `settings.read()` — читает весь settings namespace текущего plugin. - `settings.read(key)` — читает один ключ. - `settings.write(key, value)` — обновляет один ключ и пишет namespace обратно. - `settings.writeAll(settings)` — заменяет settings namespace. - Backend требует plugin exists, enabled, status `loaded`/`degraded` и permission `storage.namespace`. `capabilities` - `capabilities.list()` — возвращает текущий capability registry. - `capabilities.get(name)` — возвращает `{ available, name, pluginId, status }`. - `capabilities.has(name)` — boolean wrapper над `get`. - Backend требует, чтобы plugin был enabled/loaded и декларировал dependency на `verstak/core/capability-registry/v1` в `requires` или `optionalRequires`. `commands` - `commands.register(commandId, handler)` — регистрирует bundled frontend handler. Возвращает `Promise`. - `commands.execute(commandId, args)` — backend сначала проверяет plugin status, permission `commands.register` и что command объявлен в `contributes.commands` именно этим plugin. Затем frontend registry вызывает зарегистрированный handler. - Если command объявлен в manifest, но handler не зарегистрирован, API возвращает понятную ошибку `declared-but-unhandled`. - Handler registry очищается при component unmount, reload/disable flow и `api.dispose()`. `events` - `events.subscribe(eventName, handler)` — frontend-local subscription с backend validation permission `events.subscribe`. Возвращает `Promise`. - `events.publish(eventName, payload)` — backend проверяет `events.publish`, затем событие dispatch'ится в bundled frontend event bus. - Handler получает envelope `{ name, pluginId, payload, timestamp }`. - Subscriptions очищаются при component unmount, reload/disable flow и `api.dispose()`. `files` - `files.list(relativeDir)` — list directory using a vault-relative path. - `files.metadata(relativePath)` — returns file/folder/symlink metadata. - `files.readText(relativePath)` — reads a UTF-8 regular file, with a size limit. - `files.writeText(relativePath, content, options)` — atomically writes text via temp-file-and-rename. `options.createIfMissing` and `options.overwrite` control conflicts. - `files.createFolder(relativePath)` — creates one folder when the parent exists. - `files.move(from, to, options)` — moves a file or folder; rejects moving a folder into itself and conflicts unless `options.overwrite` is true. - `files.trash(relativePath)` — moves a file/folder into internal `.verstak/trash/files//...` and returns trash metadata. - Backend requires plugin exists, enabled, status `loaded`/`degraded`, open vault, and `files.read`, `files.write`, or `files.delete`. - All paths are canonical vault-relative slash paths. Backslashes, POSIX absolute paths, Windows drive paths, UNC/network paths, `..`, null bytes, symlink traversal, and public access to `.verstak/` are rejected. - `.verstak` is reserved case-insensitively: `.verstak`, `.Verstak`, and any first path segment with that spelling are internal-only. - `files.metadata` may report a final symlink as `type: "symlink"`, but `files.list` through a symlink directory and all read/write/move/trash operations through symlinks are forbidden in Milestone 6a. - Files API is text-only for read/write in Milestone 6a. `readText` is limited to UTF-8 regular files up to 2 MB. Binary streaming, watcher, restore, external editor integration, and Files UI plugin are deferred. `dispose` - `dispose()` вызывается host'ом при cleanup. Plugin code обычно не вызывает его напрямую. Он удаляет зарегистрированные command handlers и event subscriptions. ### Runtime boundaries | Layer | Current status | |---|---| | Bundled frontend runtime | Functional for settings, capabilities, commands, events and text Files API | | Backend validation | Checks plugin exists, enabled/loaded state, permissions and declarations | | Security boundary | Not implemented; bundled plugins share the desktop frontend JS context | | Sidecar/RPC/sandbox | Not implemented | ### Error boundary - Ошибка в plugin view/settings placeholder не роняет shell - ViewContainer показывает "⚠️ Plugin UI failed" fallback - Error канал: `console.error` + видимый fallback в UI ## Frontend Bundle Contract ### Регистрация компонентов Плагин регистрирует frontend компоненты через глобальную функцию `window.VerstakPluginRegister`: ```javascript window.VerstakPluginRegister('plugin.id', { components: { 'ComponentName': { mount: function(containerEl, props, api) { // containerEl — div, созданный PluginBundleHost // api — ограниченный VerstakPluginAPI containerEl.innerHTML = '

Hello from plugin!

'; }, unmount: function(containerEl) { // Очистка при смене view containerEl.innerHTML = ''; } } } }); ``` ### VerstakPluginAPI API объект передаётся в `mount()` и содержит plugin-scoped методы текущего bundled runtime. Это реальный runtime contract для cooperative bundled plugins, но не sandbox/security boundary. | Свойство | Статус | Описание | |---|---|---| | `api.pluginId` | ✅ Работает | ID плагина | | `api.settings.read(key?)` | ✅ Работает | Читает plugin-scoped settings через backend bridge | | `api.settings.write(key, value)` | ✅ Работает | Пишет один settings key через backend bridge | | `api.settings.writeAll(settings)` | ✅ Работает | Заменяет settings namespace плагина | | `api.capabilities.list()` | ✅ Работает | Возвращает capability registry | | `api.capabilities.get(id)` | ✅ Работает | Возвращает capability entry/status | | `api.capabilities.has(id)` | ✅ Работает | Boolean wrapper над `get` | | `api.commands.register(id, handler)` | ✅ Работает | Регистрирует bundled frontend handler для объявленной command | | `api.commands.execute(id, args)` | ✅ Работает | Валидирует declaration/permission/backend state и вызывает bundled handler | | `api.events.publish(type, payload)` | ✅ Работает | Валидирует permission и публикует во frontend event bus | | `api.events.subscribe(type, handler)` | ✅ Работает | Валидирует permission и подписывает handler на frontend event bus | | `api.files.list(relativeDir)` | ✅ Работает | Список vault-relative директории, `.verstak` скрыта | | `api.files.metadata(relativePath)` | ✅ Работает | Metadata для файла/папки/symlink без чтения содержимого | | `api.files.readText(relativePath)` | ✅ Работает | Читает UTF-8 regular file до 2 MB | | `api.files.writeText(relativePath, content, options)` | ✅ Работает | Atomic text write с явным create/overwrite policy | | `api.files.createFolder(relativePath)` | ✅ Работает | Создаёт vault-relative folder | | `api.files.move(from, to, options)` | ✅ Работает | Move file/folder с conflict и path-policy checks | | `api.files.trash(relativePath)` | ✅ Работает | Перемещает в internal trash, permanent delete нет | | `api.workbench.openResource(request)` | ✅ Работает | Routes vault resources to `openProviders` | | `api.workbench.editResource(request)` | ✅ Работает | Same routing, forcing `mode: "edit"` | | `api.dispose()` | ✅ Работает | Очищает command handlers и event subscriptions текущего API instance | Ограничения: - permissions/capabilities checks являются contract/policy checks; - bundled frontend plugins исполняются в общем JS-контексте; - malicious JS не изолирован; - sidecar process lifecycle, RPC transport и sandbox enforcement ещё не реализованы. - Files paths are slash-only vault-relative contract paths; backslashes, Windows absolute paths, UNC paths, `.verstak` variants, traversal and symlink operations are rejected by backend policy checks. ### Загрузка бандла 1. `PluginBundleHost` получает pluginId и componentId 2. Вызывает `App.GetPluginFrontendInfo(pluginId)` — получает entry/style/rootPath 3. Вызывает `App.GetPluginAssetContent(pluginId, entry)` — получает JS контент 4. Выполняет контент через `new Function(content)` — bundle вызывает `VerstakPluginRegister` 5. Находит компонент по componentId и вызывает `mount(container, props, api)` 6. При смене view — вызывает `unmount(container)` для старого компонента ### Безопасность asset path | Правило | Проверка | |---|---| | Нет абсолютных путей | Пути, начинающиеся с `/` или `\`, отклоняются | | Нет path traversal | Пути, содержащие `..`, отклоняются | | Нет выхода за root | После `filepath.Join` проверяется, что путь внутри plugin root | | Только существующие файлы | `os.ReadFile` возвращает ошибку если файл не существует | ### manifest frontend config ```json { "frontend": { "entry": "frontend/dist/index.js", "style": "frontend/style.css" } } ``` ## Reload `ReloadPlugins()` в `internal/api/app.go` позволяет перезагрузить plugins без перезапуска приложения: 1. Unregister all non-core capabilities. 2. Re-register core capabilities + vault + workspace (если открыт). 3. Re-scan discovery directories. 4. For each plugin: re-run capability resolution. 5. **Unregister contributions** before re-registering (предотвращает дубли). 6. Register contributions for loaded/degraded plugins (disabled/failed — не регистрируются). 7. Update plugins list. Frontend вызывает это при нажатии "Reload" в Plugin Manager. ## Vault Core Capability - `verstak/core/vault/v1` — регистрируется в `main.go` после остальных core capabilities, когда vault инициализирован. - Vault layout: `/VerstakVault/.verstak/` с подпапками (см. ниже). - Plugin namespace paths: `plugin-data/`, `plugin-settings/`, `plugin-cache/`. - Vault events: `vault.created`, `vault.opened`, `vault.closed`, `vault.error`. - Vault status: `not-created`, `closed`, `open`, `error`. - Path traversal protection через `ResolveSafePath`. ### Vault Directory Layout ``` / VerstakVault/ ← vault root (создаётся CreateVault) .verstak/ vault.json ← VaultMeta (schemaVersion, vaultId, createdAt, app) plugin-data/ ← per-plugin data namespaces / plugin-settings/ ← per-plugin settings namespaces / plugin-cache/ ← per-plugin cache namespaces / trash/ ← soft-deleted items logs/ ← vault-scoped logs ``` ### Vault API | Метод | Описание | |---|---| | `CreateVault(path)` | Создаёт `VerstakVault/` с `.verstak/` layout и `vault.json`. Публикует `vault.created`. | | `OpenVault(path)` | Открывает существующий vault, валидирует `vault.json`. Публикует `vault.opened`. | | `CloseVault()` | Закрывает vault, сбрасывает path/meta. Публикует `vault.closed`. | | `GetVaultStatus()` | Возвращает текущий статус: `not-created`, `closed`, `open`, `error`. | | `GetVaultPath()` | Возвращает путь к vault root. | | `GetVaultMeta()` | Возвращает `VaultMeta` (vaultId, schemaVersion, timestamps). | | `ResolveSafePath(rel)` | Безопасно резолвит относительный путь внутри vault. Блокирует path traversal. | | `GetPluginDataPath(id)` | Возвращает (и создаёт) `plugin-data//`. | | `GetPluginSettingsPath(id)` | Возвращает (и создаёт) `plugin-settings//`. | | `GetPluginCachePath(id)` | Возвращает (и создаёт) `plugin-cache//`. | ### Vault Events | Event | Когда публикуется | Payload | |---|---|---| | `vault.created` | После успешного `CreateVault` | `path`, `vaultId` | | `vault.opened` | После успешного `OpenVault` | `path`, `vaultId` | | `vault.closed` | После `CloseVault` | `vaultId` | | `vault.error` | При ошибках операций | `error` | ### Vault Status Flow ``` not-created ──CreateVault──▶ open ──CloseVault──▶ closed │ │ └──OpenVault─────────┘ ``` ## Файлы реализации | Файл | Назначение | |---|---| | `internal/core/plugin/plugin.go` | Manifest, ValidateManifest, DiscoverPlugins, Status | | `internal/core/capability/registry.go` | CapabilityRegistry | | `internal/core/contribution/registry.go` | ContributionRegistry | | `internal/core/permissions/registry.go` | PermissionsRegistry | | `internal/core/events/bus.go` | EventBus | | `internal/api/app.go` | Wails API, ReloadPlugins | | `internal/core/vault/vault.go` | Vault service: CreateVault, OpenVault, CloseVault, ResolveSafePath, plugin namespace paths | || `internal/core/vault/vault_test.go` | Vault tests: layout creation, open/close, path traversal, events | || `internal/core/storage/api.go` | Plugin storage API: settings/data/cache JSON with namespace isolation | || `internal/core/storage/api_test.go` | Storage tests: write/read, path traversal, atomic write | || `internal/core/appsettings/manager.go` | App settings manager: Load/Save/Update, recent vaults, defaults | || `internal/core/appsettings/manager_test.go` | App settings tests: defaults, corrupt config, recent dedup | || `internal/core/pluginstate/manager.go` | Vault plugin state: enable/disable, desired plugins, missing-installed | || `internal/core/pluginstate/manager_test.go` | Plugin state tests: enable/disable, persist, corrupt, missing | --- ## App Settings App settings хранятся **локально** (НЕ внутри vault) в `~/.config/verstak/config.json`. ### Поле | Назначение ---|--- `currentVaultPath` | Путь к текущему vault `recentVaults` | Список недавних vault (max 10, без дублей) `theme` | Тема (dark/light) `devMode` | Режим разработки `userPluginsDir` | Директория пользовательских плагинов `windowState` | Состояние окна (размеры, максимизация) `lastOpenedAt` | Время последнего запуска ### Правила - Если config отсутствует — создаётся с defaults - Если config битый — backup + создание defaults с понятной ошибкой - `currentVaultPath` при запуске проверяется и vault открывается автоматически - Secrets НЕ хранятся в app settings --- ## Vault Plugin State Vault plugin state хранится **внутри vault** в `.verstak/plugins.json`. ### Структура ```json { "schemaVersion": 1, "enabledPlugins": ["verstak.platform-test"], "disabledPlugins": [], "desiredPlugins": [ { "id": "verstak.platform-test", "version": "0.1.0", "source": "official" } ], "updatedAt": "2026-06-17T..." } ``` ### Поле | Назначение ---|--- `enabledPlugins` | Плагины, которые активны в этом vault `disabledPlugins` | Плагины, которые явно отключены `desiredPlugins` | Плагины, которые нужны этому vault (для будущей синхронизации) `updatedAt` | Время последнего обновления ### Правила - Enabled/disabled состояние относится к конкретному vault - Disabled plugin не регистрирует provides/contributions - Plugin settings остаются в `.verstak/plugin-settings//settings.json` - Отсутствие `plugins.json` → создаётся с defaults - Битый `plugins.json` → backup + defaults с понятной ошибкой - App settings НЕ хранятся внутри vault - Plugin packages НЕ хранятся в vault settings ### Installed vs Enabled - **Installed** — plugin package существует в discovery directory - **Enabled** — plugin активен в vault plugin state - **Disabled** — plugin установлен, но отключен в vault - **Missing installed** — plugin listed в `desiredPlugins`, но package отсутствует локально ### Missing Installed Plugins Состояние для будущей синхронизации: - `desiredPlugins` может содержать plugin, которого нет локально - Plugin Manager показывает "Missing installed plugin" - Auto-install пока НЕ делается - Показывается подсказка: "Install official plugin package" ## UI Layout ``` ┌─────────────────────────────────────────────────────┐ │ App.svelte │ │ ┌──────────┬──────────────────────────────────────┐ │ │ │ Sidebar │ Content area │ │ │ │ │ │ │ │ │ Verstak │ PluginManager | ViewContainer │ │ │ │ │ │ │ │ │ 🧩 Plugin│ (padding: 1.5rem) │ │ │ │ Manager│ │ │ │ │ │ │ │ │ │ Plugins │ │ │ │ │ 📌 item1 │ │ │ │ │ 📌 item2 │ │ │ │ │ │ │ │ │ │ ● Vault │ │ │ │ └──────────┴──────────────────────────────────────┘ │ └─────────────────────────────────────────────────────┘ ``` - **Sidebar** (220px): навигация (Plugin Manager), plugin sidebar items, vault status - **Content**: Plugin Manager или ViewContainer в зависимости от выбранного view - **Vault Selection**: полноэкранный экран, показывается когда vault не открыт ## Milestone 4b — UI Completion (2026-06-17) Сделано: - VaultSelection.svelte: исправлен flow (CreateVault → OpenVault → SetCurrentVault) - Sidebar.svelte: полная навигация с отступами, plugin sidebar items, vault status - App.svelte: обработка `verstak:nav` событий, global reset стилей - PluginManager.svelte: исправлены отступы header Проверки: - `go test ./...` — 52 PASS - `./scripts/check.sh` — ✅ - `./scripts/smoke-platform.sh` — ✅ (enable/disable/plugins.json) - `./scripts/build.sh` — ✅ ## Workspace / Cases Core Capability Workspace — центральная модель Верстака вокруг "дел". Это НЕ notes/files — это фундамент. ### Ноды | Поле | Тип | Описание | |------|-----|----------| | `id` | UUID | Стабильный идентификатор | | `parentId` | string | ID родителя (пусто для root) | | `type` | space/case/folder | Тип ноды | | `title` | string | Название | | `status` | active/sleeping/archived | Жизненный цикл | | `tags` | string[] | Теги | | `order` | int | Порядок среди siblings | | `createdAt` | RFC3339Nano | Создан | | `updatedAt` | RFC3339Nano | Обновлён | ### Хранение `/.verstak/workspace.json` — атомарная запись (temp + rename). ### API - `GetWorkspaceTree()` — полное дерево - `CreateWorkspaceNode(parentID, type, title)` — создать - `RenameWorkspaceNode(id, title)` — переименовать - `MoveWorkspaceNode(id, newParentID)` — переместить - `ArchiveWorkspaceNode(id)` — архивировать - `SetCurrentWorkspaceNode(id)` — выбрать текущую - `GetCurrentWorkspaceNode()` — получить текущую ### Capability `verstak/core/workspace/v1` — регистрируется только когда vault открыт и workspace инициализирован. ### Правила - Root node создаётся при создании vault - Порядок children стабилен (sort by order) - Нельзя переместить ноду в себя или в своего потомка - Архивирование — soft delete (status = archived) - Corrupt JSON → backup + defaults ### Типы нод | Тип | Назначение | |-----|-----------| | `space` | Рабочее пространство (root) | | `case` | Дело | | `folder` | Папка | НЕ добавляются: note, file, action, secret, worklog, link — это плагины. ### Lifecycle Events **Planned (not yet implemented in runtime):** - `workspace.node.created` - `workspace.node.renamed` - `workspace.node.moved` - `workspace.node.archived` - `workspace.node.selected` - `workspace.error` ### UI WorkspaceTree в sidebar: - Дерево с expand/collapse - Создание case/folder - Выбор текущей ноды - Индикатор статуса (active/archived/sleeping) --- ## Build Scripts В `verstak-desktop/scripts/` есть два скрипта: ### `build.sh` — локальная детерминированная сборка - Собирает **только** `verstak-desktop` (core platform). - Не трогает другие репозитории. - **Fail-fast**: любая ошибка (go vet, go test, frontend build, wails build) прерывает сборку. - Проверяет: deps → frontend build → go mod download → go vet → go build → go test → wails build + plugin copy. - Используется в CI и для повседневной работы над core. ### `update-and-build-all.sh` — dev helper для полной пересборки связки - **Не для CI.** Только для разработки, когда нужно быстро собрать всё вместе. - Шаги: 1. `git pull --ff-only` во всех 6 репозиториях 2. Сборка official plugins (frontend npm build + backend go build для каждого плагина) 3. Копирование собранных плагинов в `verstak-desktop/plugins/` 4. Запуск `build.sh` для сборки desktop - Ошибки pull и сборки плагинов не прерывают скрипт (best-effort), но ошибка build.sh прерывает (fail-fast).