Mission Control — automation and the internals (part 5)
Part 5 of the Mission Control page, for anyone working on it: the board automation engine and the events it fires on, the bridge that lets the app's own automation react to a board event, the workflow-engine nodes that poll a board, where the local data mirror lives, and the command surface an agent or script drives.
What it is
This is part 5 of the Mission Control page. It is the how-it-is-built half: the automation engine and the internals behind a board.
Where to find it
Automations are configured against a board inside Mission Control; the rest of this part is reached from code or the command line rather than from a screen.
How it behaves
Board automation engine (Phase 5C)
Phase 5C adds an event-driven automation engine that fires action chains when board data changes. The sync service snapshots board state before each sync, diffs after, and emits typed BoardEvent objects (27 event types: item_created, item_deleted, item_restored, item_archived, item_unarchived, item_name_changed, item_status_changed, item_assigned, item_due_date_passed, item_moved_to_group, item_column_changed, date_approaching, schedule, sprint_started, sprint_ended, comment_added, approval_changed, webhook_received, column_created, column_renamed, column_deleted, link_created, link_deleted, session_completed, group_created, group_deleted, group_renamed). The automation engine matches events against user-defined rules in pm_board_automations and dispatches action chains through the existing Omniscio automation dispatcher.
Condition evaluation: each rule carries a conditionConfig — either { method: 'always' } (fire unconditionally, zero overhead) or { method: 'conditions', root: ConditionGroup } (recursive AND/OR tree of column conditions). After a trigger matches, the engine evaluates the condition tree against the triggering item's column values before dispatching actions. Ten operators: equals, not_equals, contains, not_contains, greater_than, less_than, is_empty, is_not_empty, in_list, not_in_list. Cross-board conditions (scope: 'linked') resolve linked items via connect_boards columns. Safety limits: max 3 nesting depth, max 10 conditions, max 3 cross-board refs, 5-second timeout. On evaluation failure, the rule is skipped (fail-closed — the action does NOT fire). The ConditionBuilder UI (ConditionBuilder.tsx, ConditionGroupNode.tsx, ConditionRuleRow.tsx) renders a visual AND/OR tree editor inside the AutomationBuilder modal.
Safety controls: master toggle (pmAutomationsEnabled, default off), event dedup (SQLite pm_automation_processed_events with 7-day retention), 5-second per-rule cooldown, condition evaluation with safety limits, stop-on-match, and always-persist run records (even on failure).
A due-date checker (pm-due-date-checker) runs every 15 minutes, scans date columns for overdue items, and feeds item_due_date_passed events into the engine. Twelve pm.* action types are defined in ActionType (src/shared/types/automations.ts): pm.move-item, pm.set-column-value, pm.update-linked-status, pm.add-comment, pm.create-item, pm.create-subitem, pm.duplicate-item, pm.notify, pm.spawn-session, pm.log-time, pm.on-result, and pm.if-else (conditional control flow).
CLI routes: GET/POST/PATCH/DELETE /pm/automations — all auth-gated and rate-limited; POST/PATCH are Zod-validated via parseBodyOr400, while GET and DELETE validate params with manual checks only. IPC: 6 channels for CRUD + presets + run history, all Zod-validated with push on mutation. Twenty-five preset templates in four categories — notify (status-done-notify, assigned-to-me-inbox, done-notify-creator, date-approaching-reminder, column-changed-notify), organize (new-item-assign, due-date-overdue-move, bug-triage, review-request-notify, new-item-subtask-checklist, done-duplicate-to-review, moved-in-progress-kickoff), escalate (blocked-create-session, stale-issue-alert, overdue-escalation), automate (sprint-rollover, date-approaching-ai-prep, new-item-ai-plan, recurring-task, approval-done-move, approval-rejected-notify).
Per-rule approval: non-cost-bearing rules default to approval_status = 'approved', while cost-bearing rules (those whose action triggers isCostBearingAction) auto-gate to pending_approval without an explicit parameter. Pending rules are excluded from listActiveBoardAutomations() until approved. A pm-automation-approval inbox source surfaces pending rules in the unified inbox (gated on missionControlEnabled + pmAutomationsEnabled). IPC channels: pm:board-automation:pending-list (cross-board pending query) and pm:board-automation:set-approval (approve/reject).
Feature gating: registered as 'pm-automations' in UNRELEASED_FEATURES with status 'in-development'. The renderer UI (Phase 5F) lives in src/plugins/mission-control/web/features/automations/ (AutomationsPage, AutomationBuilder, PresetGallery, TriggerPicker, ActionPicker, RunHistoryPanel, and more).
- Contract: pm-automation-contract.md
- Key files:
src/main/services/pm/automation/pm-automation-service.ts(engine),src/main/services/pm/automation/pm-condition-evaluator.ts(condition tree evaluator),src/main/services/pm/pm-board-diff.ts(diff),src/main/services/pm/date-scanning/pm-due-date-checker.ts(cron),src/main/services/pm/automation/pm-automation-queries.ts(CRUD),src/main/services/pm/automation/pm-automation-presets.ts(presets),src/main/ipc/pm-automation-handlers.ts(IPC),src/main/services/cli/cli-server-pm-automation-routes.ts(CLI)
PM event channel bridge
Bridges PM board events into the channel automation engine (automations table), adding a fourth trigger type on_pm_event alongside on_message, manual, and on_session_state. This lets users create channel automations like "when a task goes overdue, nudge me in inbox" using the same rules UI as any other automation.
How it works: the PM sync service calls bridgePmEventsToChannelAutomations(events) fire-and-forget after each sync. The bridge checks both automationsEnabled and pmAutomationsEnabled, queries listEnabledPmEventAutomations() (channel automations with triggerType: 'on_pm_event'), matches events against each rule's PmEventConfig (event type + board scope + optional board/column filter), enforces a 5-second per-rule cooldown, deduplicates within a batch using bridge:-prefixed keys, and dispatches through dispatchActionChain() passing the BoardEvent as v2 trigger data so actions can read itemId/boardId from ctx.trigger. Errors are caught per-automation and never propagate to the sync pipeline.
Three v2 actions for PM-triggered automations:
pm.update-linked-status— updates a column value on the triggering item (readsitemIdfromctx.trigger; skips gracefully if no trigger context)pm.add-comment— adds a comment/update to the triggering item (same trigger skip behavior)pm.create-item— creates a new item on a specified board (does NOT require trigger context)
Four preset templates in the channel automation presets: Overdue Task Nudge (pm-overdue-nudge), Status Change Update (pm-status-changed-update), Comment on New Items (pm-new-item-comment, v2), Assignment Notification (pm-assignment-alert).
Architecture notes: the bridge is SEPARATE from the board automation engine (Phase 5C) — board automations use pm_board_automations table, channel bridge uses the automations table with pm_event_config column. The dispatchActionChain function accepts an optional v2TriggerData parameter that populates trigger on the v2 action context.
- Contract: pm-automation-contract.md (§ PM event channel bridge)
- Key files:
src/main/services/pm/pm-event-channel-bridge.ts(bridge),src/main/services/automation/v2/dispatcher.ts(v2TriggerData),src/main/services/automation/v2/actions/pm-update-linked-status.ts,pm-add-comment.ts,pm-create-item.ts(actions),src/main/services/automation/legacy/automation-presets.ts(presets),src/main/db/migrations/20260720203018-add-pm-event-config-to-automations.ts(migration)
Workflow Engine Nodes (Phase 5H)
Phase 5H integrates PM boards into the Workflow Engine with a poll-based trigger and five action nodes. The trigger watcher polls all synced boards every 60 seconds, computing deltas to fire workflows. The actions proxy writes through to the Mission Control backend via pmAuthedFetch.
Trigger node (trigger.pm_item): configurable event filter (item_status_changed, item_assigned, item_created, item_overdue), optional boardId/columnId scoping. First tick establishes a baseline (no false-fires on start). Dedup by pm-trigger:boardId:itemId:event:value. Kill switch: AMC_DISABLE_PM_WORKFLOW_TRIGGER.
Action nodes:
action.pm_create_item— create an item on a board (title, group, column values via JSON)action.pm_transition— set a column value (status transition)action.pm_comment— add an update/comment to an itemaction.pm_assign— assign a person to an item (personsAndTeams column)action.pm_search— search items by name/text (reads from local SQLite mirror)
Architecture: nodes are auto-registered via Vite's import.meta.glob('./nodes/*-node.ts'). Side effects route through WorkflowEnginePorts (never raw DB/fetch from a node). 'pm' is added to WORKFLOW_TRIGGER_SOURCES and renders as "PM Board" in the run-history viewer.
- Contract: pm-workflow-nodes-contract.md
- Key files:
src/main/services/workflow-engine/pm-item-trigger.ts(watcher),src/main/services/workflow-engine/nodes/pm-*-node.ts(6 node files),src/main/services/workflow-engine/ports.ts(port implementations),src/main/services/workflow-engine/types.ts(port interface)
For agents
Local SQLite mirror (Phase 5A)
Phase 5A adds a local SQLite mirror of Mission Control board data for offline access and as the foundation for automation (Phase 5B), CLI (5C), inbox (5D), and UI (5F). Six tables — pm_boards (includes account_id for multi-tenancy scoping), pm_groups, pm_items, pm_columns, pm_column_values, pm_sync_state — cache the data returned by GET /boards/:id/full. These tables are a read cache, not the authority; the Mission Control backend (Postgres) remains the source of truth. All PM queries that read child tables (items, events, embeddings, inbox events) JOIN through pm_boards and filter on b.account_id = ? to enforce tenant isolation.
A background sync service (pm-sync-service) runs on a 5-minute heartbeat, re-fetching any board whose last_sync_at is older than 5 minutes. Each sync writes all tables in a single SQLite transaction (no partial state) and soft-deletes items no longer present in the response. A WebSocket connection to the Mission Control backend's /realtime endpoint listens for board-changed events and triggers an immediate re-sync of the affected board, so changes made in the Mission Control UI appear locally within seconds.
The sync service is gated on missionControlEnabled — if Mission Control is disabled, the service does nothing. If the user is not signed into Omniscio's Firebase auth, pmAuthedFetch calls will fail gracefully (no explicit credential check in the sync entry point). Auth is handled by a main-process token manager (pm-auth) that obtains Firebase ID tokens via getFirebaseIdToken() from global auth, caches them in-process, and retries on 401.
CLI surface (Phase 5B)
Phase 5B added the initial CLI routes under /pm/ on the control server (127.0.0.1:19519) so any agent session can read and write board data programmatically (subsequent phases expanded the surface to 96+ routes across 21+ files). Read endpoints serve from the local SQLite mirror tables; write endpoints proxy through to the Mission Control backend REST API via pm-auth.
Read routes (bearer + read-budgeted 60/min): GET /pm/boards (list), GET /pm/boards/:id (detail with groups + columns), GET /pm/boards/:id/items (items with enriched column values and filters for status/assignee/overdue/group/archived/limit), GET /pm/search?q= (cross-board item search by name or column text).
Write routes (bearer + mutation-limited 10/min, apply-immediately): POST /pm/boards/:id/items (create item), PATCH /pm/items/:id (update name/group), PUT /pm/items/:id/columns/:colId (set cell value), DELETE /pm/items/:id, POST /pm/boards/:id/columns (add column). All writes are proxied to the Mission Control backend and require a valid Mission Control session.
Board create writes the local mirror too (POST /pm/boards): the create is proxied to the Mission Control backend, and on success the returned board is ALSO written to the local pm_boards mirror in the same request, keyed by the backend's id. This is not an optimisation — GET /pm/boards (listBoards) and every :id-scoped board route (getBoard) read local SQLite ONLY, so without the write-through a caller got back a board id that resolved nowhere on this machine and every follow-up call answered "Board not found" until a sync cycle ran. Two guards keep it safe: a create the backend accepts but returns no id for is logged and skipped (never a 5xx over a board that genuinely exists remotely), and a (workspace_id, name) slot already held by a different live local board is logged and skipped rather than tripping the partial unique index — the existing board is never re-keyed or soft-deleted to make room, and sync reconciles it. The backend-success path also emits pm:sync-updated, so the new board appears without waiting for a sync tick, matching what the offline local-fallback path already did. Invariant + locking tests: backend-create-writes-mirror in pm-sync-contract.md.
Workspace sync route (bearer + mutation-limited): POST /pm/workspaces/:id/sync fetches all boards in a workspace from the Mission Control backend and syncs each one into the local mirror. Continues past individual board failures and returns { synced, failed, total }. This is the entry point for initial workspace-level sync — callers no longer need to know individual board IDs.
All routes are feature-gated on missionControlEnabled (404 when off). cli-server-pm-routes.ts is a barrel aggregator that calls 35 registration functions — all PM CLI routes are registered through this single barrel via registerPmCliRoutes(). Mutation routes use Zod schemas via parseBodyOr400; many GET routes pass raw query params without Zod validation. The read queries live alongside the handlers under src/main/services/cli/pm/ (the old single pm-queries.ts is gone). The spoke doc for agents is pm.md.
How it works
Auth is unified with Omniscio's Global Auth (Firebase) — users sign in once via Omniscio and get access to all boards; there is no separate Mission Control login. The plugin sends Firebase ID tokens (obtained from the main process via IPC) as Bearer auth to a separately-hosted backend (amc-back/, a Bun + Elysia + Postgres + Drizzle service; Omniscio does not bundle it). The backend URL defaults to the live Cloud Run target in the plugin (web/lib/runtime-config.ts → getApiUrl()), but is overridable via the VITE_API_URL build env (e.g. a local dev backend) — a fixed deploy target by default, not a user setting. The backend verifies tokens via admin.auth().verifyIdToken() (dual-auth: also accepts legacy JWT during migration). Firebase tokens auto-refresh (1hr expiry, handled by the main process).
Code references
- Isolated app:
src/plugins/mission-control/web/—amc/AmcMissionControlApp.tsx(composition root),lib/api.ts(authedFetch),lib/runtime-config.ts,lib/session-bridge.ts(Firebase auth bridge). Isolated from Omniscio tooling — owntsconfig.json(npm run typecheck:mission-control), scoped Tailwind (tailwind.mission-control.config.js→npm run build:mission-control-css), excluded from Omniscio's tsconfig/eslint/prettier. - Omniscio glue (normal tooling scope):
src/renderer/src/features/mission-control/—MissionControlPanel.tsx(panel host) +vendored-mission-control.d.ts(typecheck boundary: the ambient@mission-control/web/*modules) +mission-control-host-bridge.ts(the host bridge types, reached throughmissionControlHostWindow()). Vite aliases@mission-control/webinelectron.vite.renderer-shared.ts(that one alias is the whole bridge — there is no@mission-control/sharedalias). - Sentinel + registry:
MISSION_CONTROL_PROJECT_ID = '__mission_control__'insrc/shared/virtual-project-ids.ts; theid: 'mission-control'manifest insrc/shared/integrations/mission-control.ts(auto-indexed intointegration-registry.generated.ts) +src/renderer/src/integrations/ui-registry.ts(panelOwnsLayout: true, lazyMissionControlPanel). - Settings:
missionControlEnabledinsrc/shared/types/settings/mission-control-settings.ts(+ the Zod slice). Auth is via Omniscio's global Firebase auth — no Mission Control-specific credential. - IPC:
mission-control:get-firebase-tokenchannel (constant insrc/shared/ipc-channels/mission-control-auth.ts; handler insrc/main/ipc/mission-control-auth-handlers.ts) provides Firebase ID tokens to the plugin. - Backend: a separate Cloud Run service, maintained outside this repo.
Related
- Mission Control — the overview page this continues.
- mc-automations.md — the automation engine on its own page.
- monday-cloud.md — the integration that connects a hosted board service instead.
Last verified 2026-10-06