Refactor: the plugin SDK as classes with decorators; Redmine as RedminePlugin #8
Loading…
Reference in a new issue
No description provided.
Delete branch "refactor/plugin-classes"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The plugin SDK as classes, so extending Kanbot means writing a class.
@Plugin({ id, version, permissions, locales, contributes: [RedmineTracker] }) class RedminePlugin extends KanbotPlugin, with optionalonActivate/onDeactivaterun in the plugin's own scope.@TrackerType({ type, label, icon, config, secrets }) class RedmineTracker extends Tracker, with Effect-returning methods;PromiseTrackerlets someone write async/await instead, and the host adapts it. Channels, agent runtimes, workflow node types and agent tools follow the same pattern in their phases.ContributionKindclass (TrackerKindnow) that validates, instantiates and registers its contributions into the core's port; the host only asks the kinds. A new extension point is a new kind, not an edit to the host.kanbot-plugin.jsonis generated from the decorators bynpm run gen:plugins(esbuild bundles each plugin to read them); the host decodes it at boot without running the plugin, then checks the class says the same. A plugin without@Plugin, a contribution without its decorator, or a stale manifest marks that plugin failed and leaves the rest running.experimentalDecorators(the standard ones are not compiled by Vite 8/rolldown/vitest), with the SDK's own metadata registry and noreflect-metadata. Both decorators throw if a build compiled them in the TC39 shape, since a misconfigured build otherwise drops method decorators silently.nuxt.config.tsenables them for Nitro's tsconfig and its esbuild transform;packages/andplugins/have a tsconfig for Vite and vitest.RedminePlugin+RedmineTracker; the tracker contract kit takes the class; the SDK has a README, "Writing a plugin".Behaviour changes: a tracker contribution now requires a
networkpermission; the manifest'scontributesis a list of{ kind, type, … }entries and listslocales.Checked: 397 tests, lint, typecheck, Storybook and the Nuxt build; the built server lists the Redmine plugin active and serves its connection form from the
@TrackerTypeSchemas.Part of the DDD/CQRS refactor; the PRs are stacked, each based on the one before, so merge them in order: Phase 0 → U0 → Phase 1 → Phase 2 → Phase 3 → Phase 4 → structure → plugin classes → UI pages.
CI: the Forgejo job has not run on these branches (every run was cancelled before a runner picked it up; no runner is registered for the repo). The same steps (
npm ci, lint, typecheck, tests, Storybook build) pass innode:24-bookwormlocally.🤖 Generated with Claude Code
S1 of the class-based plugin SDK (plan: Interlude R/S, S). A plugin is now a class extending KanbotPlugin with a @Plugin(meta) decorator; what it contributes are classes too, each with the decorator of its kind: a tracker extends Tracker (Effect) or PromiseTracker (async/await, a thrown TrackerError is the failure) and carries @TrackerType with its type, label, icon and connection form Schemas. Legacy decorators (experimentalDecorators), no reflect-metadata: metadata.ts keeps what each decorator declared in a WeakMap by class (readPlugin, readContribution), dependencies arrive through constructors (the PluginContext), and manifestOf() derives the manifest. definePlugin goes. The tracker Schemas move one per file under tracker/. The host asks kinds, not kinds of thing: ContributionKind (kinds/) knows its base class and decorator, validates a class against the manifest and its permissions, claims and registers instances into its core port and withdraws them; TrackerKind serves the Trackers port from its registry. The host takes { manifest, plugin } sources: it decodes kanbot-plugin.json as before (permissions readable without running anything) and, before anything runs, checks the class says the same (noMetadata, undecorated, unknownKind, needsPermission, undeclared, notProvided, manifestStale), so a build that lost the decorators fails loudly. Activation builds the plugin, runs onActivate in its scope, then each contribution through its kind; a kind's refusal (trackerTaken) or a throw marks only that plugin failed. gen-plugins bundles each plugin's entry with esbuild (plugins/tsconfig.json), reads the decorators and writes kanbot-plugin.json (contributes as { kind, type, … } entries, locales listed) along with the catalogues and bundled.generated.ts; --check fails when stale. esbuild becomes a declared devDependency (the version already installed). Toolchain (S0): nitro.typescript.tsConfig and nitro.esbuild.options.tsconfigRaw both set experimentalDecorators, plus packages/tsconfig.json and plugins/tsconfig.json for Vite and vitest. test/plugins/decorators.test.ts reads every decorated member of the Redmine plugin and probes a method decorator, so a toolchain regression shows. Both decorators also throw when called in the TC39 shape. Redmine is RedminePlugin + RedmineTracker over the split client, mapping, connection and messages files. trackerContract takes the tracker class and the plugin class; the host tests use made-up plugins written as classes, including a PromiseTracker. The lint baseline loses trackerContract.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>The board's list, a task's page and the custom fields in use are read through `TaskQueries.{list,detail,fields}` (packages/contracts/src/board), answered by the Board's new read-side port `TaskReads` (over today's repository in TaskReadsLegacy) and the `getTaskDetail` query, which names a missing task as TaskNotFound. The views in contracts/src/board/views mirror shared/types field for field (TaskView, TaskListItemView, TaskDetailView and the parts of a detail: events, questions, attachments, reviews, time entries, memory proposals, the plan report and the workflow state as stored, with the fields older rows lack optional). The app keeps consuming the shared types: the api module hands the decoded views out under those names. A task's project and workflow cross as declared JSON (EmbeddedProject, EmbeddedWorkflow) until their own contexts' Schemas land. useTaskList, useTaskFields and useTaskDetail are useRpcQuery queries, one per file, under the keys and refresh conditions the pages had ('tasks' on task.*, project.* and runner.changed; `task-<id>` on the task's messages, runner.changed and launchers.changed); the board refreshes only the projects' counts itself now. useTaskActions gets planAnswer; useReviewActions and useTimeEntryCommands hold what the task page still posted by hand. /api/tasks/fields is gone. /api/tasks/[id] stays for the Electron shell but answers through the query, so it leaves LEGACY_API_ROUTES (14 entries). test/rpc/taskQueries.test.ts checks each view equals the repository's output. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>packages/core/src/execution holds what a session is told and allowed: the AgentSession and Question Schemas, the Session*/Question*/Plan/Review/SentBack events, the pure rules (readOnlyViolation, planFirstViolation, mutatingGitCommand, classifyCommand, decideToolUse), Kanbot's tools as AgentTools over a ToolHost port, and the prompts as pure functions with {{tool:name}} tokens. Its ports — AgentRuntimes, SessionStore, QuestionStore, Workspace, TaskBoard, LiveSessions, WorkflowGate — are served by the adapters in server/kanbot/execution and by the new agent_sessions table (migration 0010, dual-written with the task's session id and metrics). server/kanbot/execution runs the sessions: ExecutionService forks each task, review and plan session into a FiberMap keyed by task, builds the SessionSpec (tools bound with JSON Schema, the gate over the rules and approvals, ask over the question row), turns the runtime's events into the same timeline lines the runner wrote, and tells the workflow as before. Claude Code runs behind the AgentRuntimes port as a legacy adapter over the SDK until the claude-code plugin serves it. server/agent/runner.ts, plan.ts and prompt.ts are shims over the service, so every caller and mock keeps working; the characterisation suite's expectations are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>Both tracks defined the AgentRuntimes port the same way; the Execution context now sits above the plugin host in the layer stack and takes the runtimes from RuntimeKind, so the interim adapter and its private Claude runtime go. Tests put a scripted runtime in front through startKanbot({ runtimes }), a layer that wraps the plugins' AgentRuntimes (test/support/scriptedRuntime.ts); without an override the sessions run on the claude-code plugin over the fake SDK, which the deliverAnswer characterisation test relies on. The plugin list also names a plugin's agent runtimes, and labels the two new permissions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>A setting had to be added in four places (the defaults and the per-field merge in getSettings, the zod schema and setSetting in PUT /api/settings, the app's defaultSettings) or it silently dropped. Now each section is a Schema in the Settings context (packages/core/src/settings), its defaults in SETTINGS_DEFAULTS beside it (plain data, so the app shows them without loading Effect), and reading is decoding the stored rows with it (readSettings): a field or section never stored reads back as its default, a row that does not decode as its section's default. The legacy fixes are decode steps: groomingModel becomes planningModel, this machine's launchers and the KANBOT_* numbers apply only where nothing was stored, and the columns workflows provide are added by the server's shim. getSettings, getSettingsRow, publicSettings and setSetting stay as that synchronous shim, so the legacy readers and the characterisation tests are unchanged; the old merge is kept in test/settings as the reference every stored shape must still read back as. Changing settings is the ChangeSettings command over the new Settings RPC group (get, update a patch of whole sections): checked by the Schemas, text trimmed and the board's own columns kept as PUT did, and one SettingsChanged (LocaleChanged for the language) per section that really changed. They are projected to the messages screens already handle (board.changed, launchers.changed, runner.changed, sync.changed, locale.changed) plus settings.changed, which the app's single settings query listens for; a consumer restarts the standup timer and lets the scheduler start what a higher limit allows. PUT /api/settings goes; GET stays for Electron. The app reads one shared copy (useAppSettings on useRpcQuery) and writes through useSettingsCommands, whose answer replaces that copy, so the refreshNuxtData('app-settings') workaround and defaultSettings() go. The tour finishes through the same command. Discord is a plugin now: its section, types and form leave the settings (the Integrations section goes; the tour and the menu point at Plugins). At boot, before the plugins start, a stored `discord` row becomes the Discord plugin's settings and secret (and an off bot an off plugin), then the row is dropped, so it happens once. The legacy bot reads the plugin's config until its plugin replaces it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>