Phase 6: Discord and desktop notifications as channel plugins, settings as Schemas #11

Merged
alex merged 12 commits from refactor/phase-6-channels into refactor/phase-5-execution 2026-10-06 18:16:10 +02:00
Owner

Phase 6 of the DDD/CQRS refactor: Discord and desktop notifications become plugins, settings become Schemas, and plugins get their own settings and secrets. Stacked on #10.

What changes

Plugins can be configured. A plugin declares its settings and secrets as Schemas on @Plugin({ settings, secrets }) and reads them with ctx.settings(schema) / ctx.secrets(schema). They are saved per plugin (plugin_settings.settings, plugin_secrets), and secrets are sealed with AES-256-GCM using a key file in the data dir (secrets.key, 0600). The plugin's card has a Configure form drawn from the Schemas. Secrets never come back to the browser; only their last four characters are shown as a hint. Saving restarts the plugin, which replaces restartDiscord.

Channels are a contribution kind. A channel is class X extends Channel (or PromiseChannel) with @ChannelType. It receives ChannelEvents (question asked or answered, plan questions open, notification created) in order from its own inbox, and can keep a long-lived connection (run), report a status and offer a Test button; both show on the plugin's card. A channel works with Kanbot only through its context:

  • board: reads of tasks, questions, plans, reviews, notifications, attachments and standups, plus answering, commenting and attaching. Each needs a permission (board:read, board:answer, board:comment), and writes are recorded as origin plugin:<id>.
  • store: a key-value store, replacing discord_messages (migrated).
  • webSocket: checked against the network permission.
  • host.notify.

Discord is plugins/discord. The 1,022-line server/integrations/discord.ts is now about 30 files, all under the size limit. 93 characterisation tests were written against the old bot first and pass unchanged against the plugin; they use a fake Discord REST API and gateway. /api/discord/* is gone.

The OS notification is plugins/host-desktop, a channel using host:notify. The desktop setting and the desktop app's own notifications behave as before.

Settings are Schemas (packages/core/src/settings). Each section has a Schema with defaults, and reading is one decode, so adding a field is one edit (it used to need four). Changing settings is a command (ChangeSettings) that records an event. That event is projected to the live messages screens already handle, and consumers follow up on it (the standup timer, the scheduler). The app reads settings through one shared Settings RPC query, which retires the refreshNuxtData('app-settings') workaround. PUT /api/settings is gone; GET stays for the Electron shell. Discord left AppSettings: an old install's Discord settings are adopted into the plugin once, at boot, and enabled: false turns the plugin off.

Checks

  • 745 tests (583 before), lint, typecheck, Storybook, Nuxt build. LEGACY_API_ROUTES: 8 → 5. Lint baseline: 21 → 20 files.
  • On a copy of the real database, with the Discord row and connections removed first, the old and new settings readers return identical settings for every section.
  • Production build, upgrading a pre-Phase-6 data dir with a (fake) Discord token:
    • the legacy row is adopted and removed;
    • the secret is not stored in clear and secrets.key is -rw-------;
    • Plugins.form returns only the hint WXYZ;
    • the channel reports "not configured" when it has no channel id;
    • a setting changed through Settings.update survives a restart;
    • an old install with Discord off gets the plugin disabled.

Behaviour changes, on purpose

  • A settings section with an invalid stored value reads as that section's defaults; before, the bad value passed through.
  • Saving settings only nudges the scheduler and the sync when the agent limit or the sync interval changed; before, it did on every save.
  • Discord's API and gateway addresses are fixed, so KANBOT_DISCORD_API and KANBOT_DISCORD_GATEWAY are no longer read. A configurable address would be somewhere to send the bot's token.
  • A Discord reply that is only 👍 or ✅ still doesn't approve (as before; the old pattern needed a word). Reactions do approve.
  • Each channel receives events in order; before, every handler fired concurrently.

Not in this PR

  • Delivery to channels follows today's signals, so it is at most once. Durable delivery comes when the signals become events.
  • The packaged desktop app was not rebuilt, and nothing was run on Windows. No Forgejo runner is registered, so CI doesn't run.

🤖 Generated with Claude Code

Phase 6 of the DDD/CQRS refactor: Discord and desktop notifications become plugins, settings become Schemas, and plugins get their own settings and secrets. Stacked on #10. ## What changes **Plugins can be configured.** A plugin declares its settings and secrets as Schemas on `@Plugin({ settings, secrets })` and reads them with `ctx.settings(schema)` / `ctx.secrets(schema)`. They are saved per plugin (`plugin_settings.settings`, `plugin_secrets`), and secrets are sealed with AES-256-GCM using a key file in the data dir (`secrets.key`, 0600). The plugin's card has a Configure form drawn from the Schemas. Secrets never come back to the browser; only their last four characters are shown as a hint. Saving restarts the plugin, which replaces `restartDiscord`. **Channels are a contribution kind.** A channel is `class X extends Channel` (or `PromiseChannel`) with `@ChannelType`. It receives `ChannelEvent`s (question asked or answered, plan questions open, notification created) in order from its own inbox, and can keep a long-lived connection (`run`), report a status and offer a Test button; both show on the plugin's card. A channel works with Kanbot only through its context: - `board`: reads of tasks, questions, plans, reviews, notifications, attachments and standups, plus answering, commenting and attaching. Each needs a permission (`board:read`, `board:answer`, `board:comment`), and writes are recorded as origin `plugin:<id>`. - `store`: a key-value store, replacing `discord_messages` (migrated). - `webSocket`: checked against the `network` permission. - `host.notify`. **Discord is `plugins/discord`.** The 1,022-line `server/integrations/discord.ts` is now about 30 files, all under the size limit. 93 characterisation tests were written against the old bot first and pass unchanged against the plugin; they use a fake Discord REST API and gateway. `/api/discord/*` is gone. **The OS notification is `plugins/host-desktop`**, a channel using `host:notify`. The desktop setting and the desktop app's own notifications behave as before. **Settings are Schemas** (`packages/core/src/settings`). Each section has a Schema with defaults, and reading is one decode, so adding a field is one edit (it used to need four). Changing settings is a command (`ChangeSettings`) that records an event. That event is projected to the live messages screens already handle, and consumers follow up on it (the standup timer, the scheduler). The app reads settings through one shared `Settings` RPC query, which retires the `refreshNuxtData('app-settings')` workaround. `PUT /api/settings` is gone; `GET` stays for the Electron shell. Discord left AppSettings: an old install's Discord settings are adopted into the plugin once, at boot, and `enabled: false` turns the plugin off. ## Checks - 745 tests (583 before), lint, typecheck, Storybook, Nuxt build. `LEGACY_API_ROUTES`: 8 → 5. Lint baseline: 21 → 20 files. - On a copy of the real database, with the Discord row and connections removed first, the old and new settings readers return identical settings for every section. - Production build, upgrading a pre-Phase-6 data dir with a (fake) Discord token: - the legacy row is adopted and removed; - the secret is not stored in clear and `secrets.key` is `-rw-------`; - `Plugins.form` returns only the hint `WXYZ`; - the channel reports "not configured" when it has no channel id; - a setting changed through `Settings.update` survives a restart; - an old install with Discord off gets the plugin disabled. ## Behaviour changes, on purpose - A settings section with an invalid stored value reads as that section's defaults; before, the bad value passed through. - Saving settings only nudges the scheduler and the sync when the agent limit or the sync interval changed; before, it did on every save. - Discord's API and gateway addresses are fixed, so `KANBOT_DISCORD_API` and `KANBOT_DISCORD_GATEWAY` are no longer read. A configurable address would be somewhere to send the bot's token. - A Discord reply that is only 👍 or ✅ still doesn't approve (as before; the old pattern needed a word). Reactions do approve. - Each channel receives events in order; before, every handler fired concurrently. ## Not in this PR - Delivery to channels follows today's signals, so it is at most once. Durable delivery comes when the signals become events. - The packaged desktop app was not rebuilt, and nothing was run on Windows. No Forgejo runner is registered, so CI doesn't run. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
alex added 12 commits 2026-10-02 13:13:20 +02:00
Plugin settings and secrets: declared on @Plugin, read through the context
Some checks failed
CI / check (push) Has been cancelled
e7a8497672
The contract Phase 6 builds on: a plugin declares its settings and secrets as
Schemas on @Plugin and reads them with ctx.settings(schema) / ctx.secrets(schema),
from the PluginConfigs port the host reads as it activates the plugin. The
default layer saves nothing yet; storage and the form come next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The contract (e7a8497) let a plugin declare settings and secrets and read
them through its context, but nothing could save them. This stores them and
lets the UI change them.

- plugin_settings gains a settings JSON column beside `enabled`, and
  plugin_secrets holds the secrets one row per field (migration
  0011_plugin_configs).
- Secrets are sealed with AES-256-GCM (SecretBox) under a random key made
  once into secrets.key in the data dir, mode 0600, each value bound to its
  plugin and field so a copied value does not open elsewhere. One that no
  longer opens reads as unset rather than failing the plugin.
- PluginConfigsSql implements the PluginConfigs port in place of `none`,
  with save (a blank secret keeps the stored one, null forgets it, like a
  connection's) and hints (the last four characters, all the browser sees).
- Plugins.configure checks settings and secrets against the Schemas the
  plugin's @Plugin declares, stores them as the Schema encodes them, and
  restarts the plugin if it is on (PluginHost.reconfigure), which is how it
  reads them: they hold still for an activation. Plugins.form sends the
  Schemas as JSON Schema with the saved values and the secrets' hints, and
  PluginStatus says whether a plugin has anything to configure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Before the bot moves into a plugin, its behaviour is written down against
server/integrations/discord.ts as it is: every kind of question and its
buttons, retiring a message answered in the app, the plan's open questions,
the rich and plain notifications and the quiet kinds, replies, `#id`,
comments with files, reactions, each button and form, the `!` commands,
parseReply (quirks included: a lone emoji is words), and the gateway
(identify, heartbeat, resume, invalid session, giving up on 4004/4014,
reconnecting after anything else).

Discord is faked where the bot meets it: a fetch stub for REST and a
stand-in global WebSocket for the gateway (test/support/fakeDiscord.ts), on
Discord's real addresses, so the port can keep the same expectations and only
bot.ts, which says how the bot is started and read, changes.

Co-Authored-By: Claude Opus 5.5 <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>
A plugin that declares settings or secrets gets a Configure button on its
card in Settings › Plugins, which opens PluginSettingsModal: its settings
and secrets drawn by SchemaForm from the JSON Schema Plugins.form sends,
the saved settings filled in, the saved secrets only as their last
characters (left blank to keep, or all forgotten). Saving goes through
Plugins.configure, which restarts the plugin if it is on. This is where
Discord is configured now that it is a plugin.

The modal takes its form as a prop and emits what to save, so it has a
story and a component test; usePluginSettings loads and saves. The change
to PluginList is the button and the modal only, to leave room for the
channel status the Discord work adds to the same card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One refetch per key for a burst of live messages
Some checks failed
CI / check (push) Has been cancelled
d4bb888d76
Every card on the board now reads its settings through useRpcQuery (the
'app-settings' key), and each component that shares a key registers its own
live listener, so one settings.changed asked for the same refetch once per
card. The first listener schedules it; the rest find it already due. The
board did the same with a debounce before it moved to the shared query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The extension point Discord and the desktop's notifications move onto, before
either moves. In the SDK: `Channel` / `PromiseChannel` with `@ChannelType`,
told what happens as `ChannelEvent`s (question asked or answered, plan
questions open, notification created) carrying ids only, with an optional
`run` (a long-lived connection), `status` and `test`; `ChannelError` says a
failure by catalogue key, as TrackerError does; `channelContract` is the kit.

A channel needs more of Kanbot than a tracker, so the context grows, each
capability behind a permission the manifest declares: `store` (JSON values by
key over the new `plugin_kv` table, into which the migration carries the
Discord bot's message map), `webSocket(url)` (Effect's Socket, checked against
`network` like `http`), `board` (plain Schema views of what a channel reads,
and the answers and comments it gives, written as `plugin:<id>` through the
Execution context's commands, behind `board:read`, `board:answer`,
`board:comment`), and `host.notify` (the OS notification behind `host:notify`,
gated by the setting and skipped in the desktop app, as notify() does).

In the host, ChannelKind gives each channel an inbox drained in its plugin's
scope, so events arrive in order and a slow or failing channel holds up no
other, and forks its `run` there; a kind's `register` may now return what to
run for the plugin's life. The live signals channels follow today
(`task.question`, `task.planned`, `notification.created`) become events, so
delivery stays at most once. Status and Test go over the Plugins group.
AddComment takes its files-only headline as a key, so the line reads in the
language chosen later. Tests can start the app with their own PluginConfigs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
server/integrations/discord.ts becomes plugins/discord: a channel whose
`deliver` posts questions (with the buttons that answer them), retires them
once answered, posts the plan's open questions and the notifications (rich or
plain), and whose `run` is the gateway. It reaches Discord only through the
context (REST over `ctx.http`, the gateway over `ctx.webSocket`, attachments
from the CDN), the board only through `ctx.board`, and keeps which message is
which in `ctx.store` (the migration carried the old map there). Split by the
code-organisation rules: REST, the gateway (heartbeat as a fiber, resume,
backoff from a Schedule that starts over after READY, giving up on 4004/4013/
4014), the message builders, the inbound handlers, parseReply, the map.

Its settings are the agreed Schemas (channelId, planQuestions, screenshots,
richUpdates, mentionUserId; the token as a secret), every field with a
default: with no token or channel it is active but idle. Discord's addresses
are fixed rather than settings: a plugin cannot read KANBOT_DISCORD_API, a
configurable API base would be somewhere to send the bot's token, and the
tests reach a fake Discord by replacing fetch and WebSocket instead.

The characterisation tests pass unchanged against the plugin; only bot.ts,
which says how the bot is started and read, changed (the app is started with
the plugin's config, status and Test come from the Channels service). Its
messages moved to the plugin's catalogue (with the core keys it borrowed, and
the sentences it hard-coded), except `discord.attachedFiles`, which old
timeline lines still name; the tray's Approve button reads `tray.approvePlan`.
startDiscord, restartDiscord and /api/discord/* are gone (saving the plugin's
settings restarts it, which is track B's).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
notify() no longer shells out itself. It records the notification and sends
`notification.created`, which the host-desktop plugin's channel hears like any
other: it reads the notification in the language set now and asks
`ctx.host.notify`, whose host side is today's pushDesktop (osascript,
notify-send), shown only when desktop notifications are on in Settings and not
inside the desktop app, whose shell shows richer ones. Behaviour is the same;
what changed is that the OS is one channel among others, and can be turned off
as a plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What Settings › Integrations showed of the bot (connected as whom,
connecting, the last error, a test message) now sits on the card of whichever
plugin provides a channel, from Plugins.channels and Plugins.testChannel. A
new organism (PluginChannels) reads them through useChannels and re-reads the
status every five seconds while it is on screen, as the old section did,
since a connection's state is not a live message; the molecule it renders
(PluginChannelStatus) takes props only and has its story. PluginList gains one
line to place it, and names the board permissions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge Discord as a plugin (wip/p6-discord) onto settings as Schemas
Some checks failed
CI / check (push) Has been cancelled
240fa0a48f
Channels as a contribution kind, the Discord bot and the desktop notification
as plugins, over the plugin settings and secrets track B stored. The two meet
in the plugin host: Plugins.configure and Plugins.form beside Plugins.channels
and Plugins.testChannel, migrations 0011_plugin_configs then 0012_plugin_kv,
and the Discord characterisation tests now configure the bot the way the
Configure form does (saved through PluginConfigsSql, the plugin restarted).
Unused keys of the old Integrations section and the DiscordStatus type go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: settings as Schemas, plugin settings, channels, the headless tour over RPC
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
05120ca2df
The four-places and refreshNuxtData lessons are obsolete: a setting is its
Schema field and default, read through one decode and one shared query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alex merged commit 83d3f115a1 into refactor/phase-5-execution 2026-10-06 18:16:10 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Overlight/App!11
No description provided.