Files
kandev/docs/specs/plugins/system-design/plugins-04.md
T
2026-08-24 11:09:03 +00:00

18 KiB

status, system, requirements, created, owners
status system requirements created owners
draft plugins
REQ-PLUGINS-PLUGINS-001
2026-04-26
cfl

Plugin System System Design Part 4

Purpose and boundaries

This design preserves the technical source detail for REQ-PLUGINS-PLUGINS-001 during migration.

Requirement mapping

Requirement Design section
REQ-PLUGINS-PLUGINS-001 Migrated source detail

Migrated source detail

Scenarios

  • GIVEN an operator with a release tarball URL for a Slack notification plugin, WHEN the operator calls POST /api/plugins/install with {"url": "..."}, THEN kandev downloads the tarball, verifies checksums.txt, validates the manifest, extracts it to ~/.kandev/plugins/kandev-plugin-slack/1.0.0/, spawns the platform-matched binary, completes the go-plugin handshake, and the plugin appears in GET /api/plugins with status active.

  • GIVEN an operator with a plugin tarball on their local machine, WHEN the operator uploads it via POST /api/plugins/install (multipart package), THEN kandev runs the same verify → validate → extract → spawn pipeline and the plugin reaches active without any URL ever being contacted.

  • GIVEN the operator has entered a valid URL or selected a package in the Settings > Plugins install dialog, WHEN the operator submits the install, THEN the primary Install action is disabled, marked busy, and shows an animated loading indicator with the installing label until the install and post-install loading pipeline settles. WHEN the pipeline succeeds, THEN the dialog closes as usual. WHEN it fails, THEN the indicator stops, the action becomes available for retry, and the existing inline error remains visible.

  • GIVEN an operator who extracted a plugin package directly into ~/.kandev/plugins/<id>/<version>/ on the host filesystem (no install call), WHEN the operator clicks Sync in Settings > Plugins (POST /api/plugins/sync), THEN kandev finds the unrecorded manifest.yaml, validates it, and registers the plugin with status disabled — never spawning it automatically. The plugin then appears in the list, and the operator can enable it explicitly like any other plugin.

  • GIVEN an active Slack plugin subscribed to task.state_changed, WHEN a task moves to done, THEN kandev calls the plugin's DeliverEvent gRPC method with the event over the go-plugin unix socket. The plugin formats a Slack message and calls the Slack API, then returns EventAck{}.

  • GIVEN a Jira sync plugin with a registered jira-webhooks webhook, WHEN Jira POSTs a webhook to https://kandev.example.com/api/plugins/kandev-plugin-jira/webhooks/jira-webhooks, THEN kandev converts the HTTP request into a WebhookRequest and calls the plugin's HandleWebhook gRPC method. The plugin parses the Jira event, calls Host.SetState to record the linked task, and returns a WebhookResponse that kandev relays back as the HTTP response.

  • GIVEN an active plugin subprocess that crashes, WHEN kandev detects the process exit, THEN kandev immediately attempts a restart with backoff, marks the plugin error while buffering events (up to 100 or 5 minutes), and creates an inbox item "Plugin kandev-plugin-slack is unreachable". WHEN a subsequent restart attempt succeeds and the handshake completes, THEN status returns to active and buffered events are delivered in order, with the persisted failure diagnostic cleared.

  • GIVEN a plugin in error with a persisted last_error, WHEN the operator opens Settings > Plugins, THEN the row shows the diagnostic and an Enable action. WHEN the operator clicks Enable and the plugin starts successfully, THEN status returns to active, the diagnostic fields are cleared, and normal delivery resumes. WHEN the retry fails, THEN status remains error, the client refetches the authoritative plugin record, and the new diagnostic replaces the previous one in the row and detail views.

  • GIVEN restart exhaustion is entering its final unhealthy callback, WHEN an operator concurrently clicks Enable, THEN the callback and Enable both complete and the replacement process is either started or reports its own result; neither operation waits forever on the other.

  • GIVEN a persisted diagnostic containing an unbroken long token, WHEN the operator opens the plugin row or detail view on a phone, THEN the diagnostic wraps inside its container and the page has no horizontal overflow. Recovery actions have a minimum 44 CSS-pixel phone target.

  • GIVEN a plugin whose event ring buffer is full, WHEN additional events are dropped, THEN kandev logs one warning for the first drop and suppresses further warnings for that plugin for one minute while counting them. WHEN the next warning is emitted, THEN it includes the aggregate number of drops since the previous warning.

  • GIVEN a plugin whose manifest declares secrets: false, WHEN the plugin calls Host.RevealSecret, THEN kandev's server interceptor returns gRPC status PermissionDenied with message capability 'secrets' not declared, before the handler runs.

  • GIVEN two plugins (Slack and Jira), WHEN the Jira plugin calls Host.EmitEvent with event_name: "sync-completed", THEN it is published as plugin.kandev-plugin-jira.sync-completed and the Slack plugin (subscribed to plugin.kandev-plugin-jira.*) receives it via DeliverEvent and posts a sync summary to Slack.

  • GIVEN a plugin with state, WHEN the plugin calls Host.SetState with scope: "task", scope_id: "task_xyz", key: "jira_issue_id", value: "PROJ-123", THEN the state is persisted in SQLite. A subsequent Host.GetState call with the same scope/key returns "PROJ-123".

  • GIVEN a plugin whose manifest declares api_read: ["sessions"], WHEN the plugin calls ListSessionCodeStats, THEN kandev returns per-session committed and peak-pending line counts (plus, via ListSessions, each session's acp_session_id) computed from the service layer, without the plugin opening the kandev database file.

  • GIVEN a plugin whose manifest does not declare tasks in api_read, WHEN the plugin calls ListTasks, THEN kandev returns gRPC status PermissionDenied with message capability 'api_read:tasks' not declared, before the handler runs.

  • GIVEN a plugin with api_read: ["tasks"] and more tasks than one page, WHEN it calls ListTasks with Page{limit: 50} and then again with the returned PageInfo.next_cursor, THEN the second call returns the next page and has_more is false once the last page is reached.

  • GIVEN a plugin with api_read: ["tasks"], WHEN it calls ListTasks without include_ephemeral, THEN quick-chat ephemeral tasks are excluded from the results.

  • GIVEN an active plugin registers app-status-bar-left or app-status-bar-right, WHEN Kandev switches between desktop/tablet and phone, THEN the plugin receives the exact slot props for the active bar or Status drawer presentation, and only that presentation is mounted.

  • GIVEN a user has moved a registered status contribution, WHEN the plugin disables and later enables or Kandev restarts, THEN its deterministic plugin/slot/ordinal identity restores the saved position; the original slot remains its default side rather than overriding user order.

  • GIVEN a plugin whose manifest declares api_write: ["tasks"], WHEN it calls CreateTask, THEN kandev creates the task through the task service (firing task.created), stamps source = "plugin:<id>", and returns the task; WHEN a plugin without api_write:tasks calls CreateTask, THEN kandev returns gRPC PermissionDenied with capability 'api_write:tasks' not declared.

  • GIVEN a plugin whose manifest declares api_write: ["messages"], WHEN it calls SendMessage on a task, THEN kandev delivers the prompt to the task's session through the orchestrator (queueing if running, resuming/starting otherwise), records a user message with source = "plugin:<id>", and returns the target session and a queued/sent/started status; a plugin lacking api_write:messages is denied.

  • GIVEN a SendMessage whose dispatch fails after the user message was recorded, THEN kandev deletes the recorded message and returns an error, so a failed delivery leaves no durable user message and a retry can't duplicate the prompt.

  • GIVEN an active plugin declares connection.save with workspace scope, WHEN a signed-in user invokes host.api.invokeAction("connection.save", ...), THEN Kandev authenticates the caller, authorizes the workspace, bounds the payload, and passes verified context separately to Plugin.HandleAction; the public webhook endpoint is not invoked.

  • GIVEN a provider operation owns an AbortSignal, WHEN its registration is replaced, unloaded, or its request is superseded, THEN invokeAction propagates cancellation to the authenticated HTTP request and backend action context.

  • GIVEN a manifest-owned repository and review provider, WHEN it registers native task-link actions and a review panel, THEN Kandev renders those contributions in applicable desktop and mobile task/review surfaces and removes them, including in-flight work, when the plugin disables.

  • GIVEN a registered review provider publishes normalized task status, WHEN a linked task renders on desktop or mobile, THEN Kandev automatically mounts the same topbar control, composer CI chip, hover popover/mobile drawer, review summary, comment summary, and checks anatomy used by GitHub, refreshes through the provider lifecycle, and requires no provider-owned visual slot or second poller.

  • GIVEN a compatible code-host review provider, WHEN its panel renders normalized detail through host.ui.ChangeRequestDetail, THEN GitHub and the provider share the same header, collapsible sections, add-to-context controls, scroll ownership, loading/error treatment, and desktop/mobile geometry; only data, capabilities, and callbacks remain provider-owned.

  • GIVEN a plugin-owned # reference source returns a pull-request candidate, WHEN the user submits the selected reference, THEN Kandev authorizes it again against the live plugin and rejects a disabled, stale, tampered, or cross-workspace candidate before queueing message metadata.

  • GIVEN a saved or open plugin task panel and a plugin reload whose import or initialize() takes longer than 500 milliseconds, WHEN registration eventually succeeds with the same panel identity, THEN the panel remains in the layout and renders the reloaded component without being closed or deleted.

  • GIVEN an open plugin task panel and a successfully initialized replacement version that no longer registers that panel identity, WHEN initialization completes, THEN the obsolete panel closes exactly once.

  • GIVEN a plugin task panel focused on a phone, WHEN the plugin is disabled or uninstalled, THEN its picker row disappears, its component unmounts, and the session focuses Chat instead of leaving an unavailable panel selected.

  • GIVEN several plugins register mobile-enabled task panels, WHEN a user opens the task workspace on a phone, THEN the fixed bottom navigation exposes one touch-sized Panels entry whose picker lists every panel without shrinking the other navigation targets or causing document-level horizontal overflow.

  • GIVEN a plugin task-menu action is invoked from the phone kanban, WHEN its visible(context) or run(context) callback executes, THEN context.presentation is "mobile"; the same action invoked from desktop receives "desktop".

  • GIVEN a plugin registers a "primary" task-menu action, WHEN a user opens a desktop sidebar or phone task-sheet menu, THEN the same action is present with the correct presentation. GIVEN the plugin unregisters while the menu is open, THEN its action disappears without a reload.

  • GIVEN any plugin registers "task-row-metadata", WHEN sidebar or /tasks rows render, THEN it receives { taskId, workspaceId, workflowStepId, surface }. GIVEN the slot is empty, THEN the host renders no metadata wrapper or extra spacing.

  • GIVEN a plugin has per-user state and deleting those rows returns an error, WHEN an operator uninstalls it, THEN the request fails, the package and plugin record remain installed for retry, and no successful-uninstall response is emitted. WHEN cleanup later succeeds and uninstall is retried, THEN all users' rows, the package, and the record are removed.

  • GIVEN a plugin registers a component for "task-card-tags", WHEN any kanban card renders, THEN that component mounts in its own row (distinct from the title row hosting "task-card-indicators"), receiving exactly { taskId, workspaceId, workflowStepId } for that card. GIVEN no plugin is registered for the slot, WHEN a card renders, THEN no extra DOM node or empty-row spacing appears. GIVEN two plugins register for "task-card-tags", WHEN a card renders, THEN both render, in registration order. GIVEN a "task-card-tags" component throws during render, WHEN that card renders, THEN the card's title and its other slot components (e.g. "task-card-indicators") still render, isolated by the existing per-registration error boundary.

  • GIVEN a plugin registers a component for "sidebar-workspace-actions", WHEN the sidebar's New Task row renders with the rail expanded and a workspace active, THEN that component mounts in the row's action cluster after the built-in Quick Terminal and Quick Chat icons, receiving { workspaceId, workspaceLabel, presentation: "desktop" } for the active workspace, and the flex row keeps the label and every registered action reachable without overlap. GIVEN no plugin is registered for the slot, WHEN the row renders, THEN no plugin action markup or extra row spacing appears. GIVEN the desktop rail is collapsed or no workspace is active, WHEN the desktop row renders, THEN no "sidebar-workspace-actions" markup renders, matching the built-in icons' own visibility. GIVEN a "sidebar-workspace-actions" component throws during render, WHEN the row renders, THEN Quick Terminal and Quick Chat still render and function, isolated by the existing per-registration error boundary. GIVEN the same plugin is active on a phone, WHEN the shared navigation sheet opens, THEN the component mounts with presentation: "mobile" and its control remains reachable with a 44px touch target.

Out of scope

  • Remote / operator-hosted plugin tier. The earlier base_url registration model, where an operator ran and managed a plugin process themselves and kandev only knew its address, is removed. Every plugin backend in v1 is a binary kandev spawns and supervises locally. A remote tier (kandev talking gRPC to a plugin process it does not spawn) may return as future work if a real need emerges.
  • Plugin JS sandboxing. Native UI plugins (see "Frontend plugin runtime") run in the kandev origin with full app-store access. Isolating plugin JS in a worker, realm, or comparable sandbox is future work; v1 relies on only loading active, operator-installed plugins served same-origin.
  • In-process backend plugins. Plugin backends remain out-of-process — no Go plugin loading via plugin.Open, no WASM, no shared-memory communication. (This is distinct from the frontend bundle, which does load into the SPA.)
  • Plugin marketplace or registry. Out of scope for this spec: this spec covers install-by-URL/upload as a manual, single-plugin action. The discoverable, curated catalog (central registry, one-click install, star ranking, third-party sources) is a sibling feature specified in marketplace.md and built on top of this spec's install pipeline.
  • Mandatory package signing. checksums.txt.sig verification is supported when present, but signing is optional in v1 — an unsigned package installs with a warning rather than being rejected. Requiring signatures is future work.
  • Agent tools in the base v1 runtime. The original parallel tools[] plus InvokeTool path remains removed. Plugin-contributed agent tools are a separate extension specified in Plugin-Contributed Agent Tools and feed through Kandev's existing MCP surface rather than a second discovery or invocation protocol.
  • Hot reload. Upgrading a plugin requires a new install (new version directory); there is no in-place manifest or binary swap on a running process.
  • Multi-instance plugins. Each plugin ID maps to exactly one supervised subprocess.
  • Event admission rate limiting. No per-plugin rate limits in v1. Misbehaving plugins can be disabled manually. Diagnostic log aggregation is still required for bounded event-buffer overflow warnings.
  • Plugin database namespaces. Plugins do not get their own SQLite schemas. KV state is sufficient for v1.
  • Broader write surface. The Host data API writes cover task create/update (conservative field mask) and sending a message to a task session. Deleting or archiving tasks, writing sessions/workspaces/workflows/repositories, a wider task-update mask, and delivery-mode/interrupt control on SendMessage are out of scope for now. See "Host data API".
  • Per-session code-stats precomputation. SessionCodeStats is computed on demand per request in v1; a materialized or cached aggregation is future work.
  • New plugin contribution hooks or storage contracts. This hardening does not add registration methods, change the plugin_user_state schema, alter user-state HTTP routes or WS payloads, or broaden the rich-text wrappers.
  • A general mobile navigation redesign. The bounded Panels picker applies only to plugin-contributed task panels and reuses the existing task-mobile picker pattern.
  • Workspace-scoped plugin data access. v1 reads are global to the instance with a reserved scoping hook; per-plugin or per-user workspace restriction is future work (see ADR 0043 open decisions).