Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,15 @@ an implicit `FINAL`.
ever show stale state the user would have to manually refresh?" and
close that gap (goal 0017). Same family as the §1 thesis (no gap
between what you see and what's real) — applied to *time*, not just
structure.
structure. **Including Mill's own UI mutations, not just external
ones**: goal 0017's audit found the event layer's emit side lived in
ONE place (`mcpsvc`, MCP-authored writes only) — a direct-UI create/
edit/delete through `CompositionService`/`ConfigureService`/
`GuardrailService` emitted nothing at all, so it only ever reached the
tab that made the change, never another open surface. Fixed by giving
every direct-mutation service its own `dataevent.Emit` call (one
shared package, `internal/services/dataevent`) rather than treating
MCP as the sole live-sync source.
- **Scope filter, learned from the screenshot-to-clipboard tangent**: before
any capability goes into Mill, check whether the OS (or an existing
launcher like Alfred/Raycast) already does it simply and well. If yes,
Expand Down
2 changes: 1 addition & 1 deletion docs/goals/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,4 @@ live-review material, interleaved during owner reviews, not a lane.**
- [x] [0006 — Trigger-aware Workflows list](0006-trigger-aware-workflows-list.md) — 2026-08-10
- [x] [0007 — Resource-inventory redesign](0007-resource-inventory-redesign.md) — 2026-08-10 (owner recognition test passed live: "like an addition")
12. [x] [0016 — Keymap system](archive/0016-keymap-system.md) — delivered 2026-08-10 (command registry, Settings rebinding, ⌘W→tab, Run=⌘↩; 127/127)
13. [ ] [0017 — Real-time surfaces audit](0017-realtime-surfaces-audit.md) (product value locked in SPEC §1: never make the user refresh; audit every surface for stale state, fix via the existing event layer)
- [x] [0017 — Real-time surfaces audit](archive/0017-realtime-surfaces-audit.md) — delivered 2026-08-12 (root cause: only mcpsvc emitted mill-data-changed; gave CompositionService/ConfigureService/GuardrailService their own dataevent.Emit, fixed App.tsx's list/mcpserver misrouting, added a lists/decisions/mcpServers/execEnvs shared store, wired run/workflow/guardrail-rule subscribers across WorkflowRunsPanel/Home/ActivityRunsExplorer/CompositionView/useGuardrailBadges/ReviewView)
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,55 @@ everywhere live; watch a run complete on its Runs tab without
reopening; have an MCP author change something and see it in the open
window — with no manual refresh anywhere, and any remaining poll
justified in writing.

## Delivered 2026-08-12

All P0/P1/P2 items from the audit's fix list, implemented:

- **P0-1** `CompositionService` now emits `mill-data-changed
{entity:"workflow"}` after Create/Update/Delete/UpdateAttributes
(compositionservice.go) and, via the shared `mutateWorkflow` choke
point, Publish/PublishExistingVersion/RestoreVersionToDraft/
SetWorkflowDisabled/SnapshotDraft (compositionservice_versioning.go)
— `ImportWorkflow` inherits it for free (delegates to CreateWorkflow).
- **P0-2** `ConfigureService` now emits for request/list/mcpserver/
decision (new)/execenv (new) CRUD, including List row mutations
(AddListRow/UpdateListRow/DeleteListRow) — `UpdateWorkflowAttributes`
inherits `workflow`'s emit via its delegation to
`CompositionService.UpdateAttributes`. Lists CRUD split out of
configureservice.go into configurelist.go (500-line limit).
- **P0-3** `GuardrailService` emits a new `guardrail-rule` entity after
rule CRUD.
- **P0-4** App.tsx's `mill-data-changed` handler no longer misroutes
`list`/`mcpserver` to `refreshRequests()`+`refreshWorkflows()` — each
entity now routes to its own store's refresher.
- **Shared emit helper**: `internal/services/dataevent` (new package)
is the ONE place `EventName`/`Changed`/`Emit` are defined — mcpsvc's
old locally-owned `DataChanged`/`DataChangedEventName`/
`emitDataChanged` were migrated onto it, removing the duplication the
audit's root cause created.
- **P1-1** `frontend/src/shared/configureEntityStore.ts` (new file,
mirrors store.ts's workflows/requests pattern) backs
ConfigureLists/ConfigureDecisions/ConfigureMCPServers/ConfigureExecEnv
— each switched from its own local `useState` + fetch to the shared
store. ConfigureAttributes switched to store.ts's existing shared
`workflows`.
- **P1-2/P1-3/P1-4** WorkflowRunsPanel, HomeView (covers HomeMostUsed),
and ActivityRunsExplorer each subscribe to
`mill-data-changed{entity:"run"}` and refresh their run list/metrics.
- **P2** CompositionView's `refreshArmed`, `useGuardrailBadges`, and
ReviewView (added a `guardrail-pending-changed` subscription
alongside its existing 2s poll, kept as the documented fallback) all
wired.
- **Proofs**: Go unit tests per service
(`*_dataevent_test.go` in compositionsvc/configuresvc/guardrailsvc,
using `dataevent.TestHook` — the seam added since `application.Get()`
is always nil under `go test`); a new e2e spec,
`e2e/realtime-cross-surface.spec.ts`, proves the flagship two-surface
scenario both ways (an MCP-authored `import_list` reaching an open
Configure > Lists tab; a direct-UI workflow create in one browser
window reaching a canvas picker open in a second window) — neither
page ever reloads.
- **Left as-is, per the audit's own verdict** (unchanged): the in-flight
run 1s polls (DBOS has no per-step event), QuickPanel's
refresh-on-summon, hover-preview/EntityRefField mount-fetches.
126 changes: 126 additions & 0 deletions frontend/e2e/realtime-cross-surface.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import type { Page } from '@playwright/test'
import { test, expect } from './fixtures/server'
import { connectMCPClient } from './mcpTestClient'
import { clickRowAction } from './inventoryRow'

// Goal 0017's flagship scenario, direct from the audit's own root
// cause: before this goal, ONLY mcpsvc emitted mill-data-changed --
// ConfigureService/CompositionService/GuardrailService emitted
// NOTHING, so a direct-UI or MCP-authored mutation never reached an
// already-open OTHER surface (only the exact tab that made the change
// ever refreshed itself, via its own local refetch call). This spec
// proves the fix at the two surfaces the audit named as the P0s:
// Configure's inventories (list/mcpserver were actively MISROUTED to
// refreshRequests()+refreshWorkflows(), App.tsx:242-244 before the
// fix) and a canvas entity picker seeing a workflow created elsewhere.

// Normalizes to write-gate ON + per-write approval OFF (unattended) --
// same local helper canvas-live-sync.spec.ts already uses (kept local
// there too, not promoted to mcpTestClient.ts, since that module's own
// enableMCPWritesWithApprovalRequired deliberately leaves approval ON
// for specs that want to exercise the approval banner instead).
async function enableUnattendedMCPWrites(page: Page): Promise<void> {
await page.goto('/')
await page.getByRole('button', { name: 'Settings' }).click()
const writeCheckbox = page.getByTestId('mcp-write-enabled-checkbox')
await expect(writeCheckbox).toBeEnabled()
if (!(await writeCheckbox.isChecked())) {
await writeCheckbox.click()
await expect(writeCheckbox).toBeChecked()
}
const approvalCheckbox = page.getByTestId('mcp-write-approval-checkbox')
await expect(approvalCheckbox).toBeEnabled()
if (await approvalCheckbox.isChecked()) {
await approvalCheckbox.click()
await expect(approvalCheckbox).not.toBeChecked()
}
}

async function restoreMCPWriteDefaults(page: Page): Promise<void> {
await page.goto('/')
await page.getByRole('button', { name: 'Settings' }).click()
const approvalCheckbox = page.getByTestId('mcp-write-approval-checkbox')
if (await approvalCheckbox.count() && !(await approvalCheckbox.isChecked())) {
await approvalCheckbox.click()
await expect(approvalCheckbox).toBeChecked()
}
const writeCheckbox = page.getByTestId('mcp-write-enabled-checkbox')
if (await writeCheckbox.isChecked()) {
await writeCheckbox.click()
await expect(writeCheckbox).not.toBeChecked()
}
}

test('Configure > Lists open: an MCP-authored import_list appears live, no reload (P0-2/P1-1)', async ({ page }, testInfo) => {
await enableUnattendedMCPWrites(page)

await page.getByRole('link', { name: 'Configure' }).click()
await page.getByRole('tab', { name: 'Lists' }).click()
await expect(page.getByTestId('configure-lists')).toBeVisible()

const label = 'E2E cross-surface list'
const row = page.locator('[data-testid="inventory-row"][data-entity="list"]', { has: page.getByText(label, { exact: true }) })
await expect(row).toHaveCount(0)

const client = await connectMCPClient(testInfo.parallelIndex)
try {
const result = await client.callTool({
name: 'import_list',
arguments: { json: JSON.stringify({ label, description: '', columns: [{ Key: 'k', Label: 'K', Type: 'text' }] }) },
})
if (result.isError) throw new Error(`import_list failed: ${JSON.stringify(result.content)}`)

// No page.reload() -- ConfigureLists.tsx now reads the shared
// configureEntityStore (shared/configureEntityStore.ts), which
// App.tsx's mill-data-changed{entity:"list"} handler refreshes.
// Before the fix, 'list' routed to refreshRequests()+
// refreshWorkflows() -- neither of which touches this page's own
// (then-local) state at all, so this row would never have appeared
// without navigating away and back.
await expect(row).toBeVisible({ timeout: 10_000 })
} finally {
await client.close()
}

await clickRowAction(page, row, 'Delete')
await restoreMCPWriteDefaults(page)
})

test('a direct-UI workflow create in one window reaches a canvas picker open in another (P0-1)', async ({ page }) => {
// Two real pages against the SAME worker server -- the actual "two
// open surfaces" the goal names, not one page simulating it. Neither
// one drives the other; both independently subscribe to the same
// backend's mill-data-changed broadcast.
const page2 = await page.context().newPage()
try {
await page.goto('/')
await page.getByRole('link', { name: 'Configure' }).click()
await page.getByRole('tab', { name: 'Attributes' }).click()
const select = page.getByTestId('attributes-workflow-select')
await expect(select).toBeVisible()

const label = 'E2E cross-surface workflow'
await expect(select.locator('option', { hasText: label })).toHaveCount(0)

// A genuinely direct-UI create (the Workflows page's own "New
// workflow" button + Save, no MCP involved at all) on the SECOND
// page -- proves CompositionService.CreateWorkflow's own new
// dataevent.Emit call (compositionservice.go), not mcpsvc's.
await page2.goto('/')
await page2.getByRole('link', { name: 'Workflows' }).click()
await page2.getByTestId('new-workflow').click()
await page2.locator('[role="tabpanel"]:not([hidden])').last().getByLabel('Label').fill(label)
await page2.locator('[role="tabpanel"]:not([hidden])').last().getByTestId('save-workflow').click()

// No page.reload() on page (the first page/window) -- ConfigureAttributes.tsx
// now reads shared/store.ts's workflows store, refreshed by
// App.tsx's mill-data-changed{entity:"workflow"} handler.
await expect(select.locator('option', { hasText: label })).toHaveCount(1, { timeout: 10_000 })

await page2.getByRole('link', { name: 'Workflows' }).click()
const row = page2.locator('[data-testid="inventory-row"][data-entity="workflow"]', { has: page2.getByText(label, { exact: true }) })
await clickRowAction(page2, row, 'Delete')
} finally {
await page2.close()
}
})
10 changes: 5 additions & 5 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 14 additions & 6 deletions frontend/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import PlaceholderView from "../views/PlaceholderView";
import { CapabilitiesService, ExecutionService, SettingsService } from '../shared/bindings'
import type { BuildInfo } from '../shared/bindings'
import { refreshKeybindings, refreshNodeTypes, refreshRequests, refreshWorkflows, useAppStore } from "../shared/store";
import { refreshDecisions, refreshExecEnvs, refreshLists, refreshMCPServers } from "../shared/configureEntityStore";
import { dispatchCommandForEvent } from "../shared/commands";
import { WorkTabShell } from "./WorkTabShell";
import { AppSidebar } from "./AppSidebar";
Expand Down Expand Up @@ -230,17 +231,24 @@ function App() {
// it's the only one of the two sources that fires headlessly;
// Composition Run-button clicks push directly from their own handler,
// since they already resolve synchronously in the browser.
// Live sync for MCP-driven authoring (docs/adr/0025): when an
// external LLM changes data through Mill's MCP server, the open
// window refreshes it immediately -- §1's what-you-see-is-what-I-see
// thesis running in both directions. One coarse refresh per entity
// kind; the stores are cheap to re-fetch at Mill's scale.
// Live sync (docs/adr/0025 + goal 0017): every direct-mutation
// service now emits this, not just mcpsvc -- one refresher per
// entity kind, each routed to its own store (shared/store.ts's
// workflows/requests, shared/configureEntityStore.ts's lists/
// decisions/mcpServers/execEnvs). Was previously misrouted for
// 'list'/'mcpserver' (refreshRequests()+refreshWorkflows(), neither
// of which holds either); 'decision'/'execenv' are new entity
// strings. 'guardrail-rule' has no shared-store consumer here --
// useGuardrailBadges/the Guardrails section subscribe to it directly.
useEffect(() => {
return Events.On('mill-data-changed', (evt) => {
const entity = (evt.data as { entity?: string })?.entity
if (entity === 'workflow' || entity === 'run') void refreshWorkflows()
if (entity === 'request') void refreshRequests()
if (entity === 'list' || entity === 'mcpserver') { void refreshRequests(); void refreshWorkflows() }
if (entity === 'list') void refreshLists()
if (entity === 'mcpserver') void refreshMCPServers()
if (entity === 'decision') void refreshDecisions()
if (entity === 'execenv') void refreshExecEnvs()
})
}, [])

Expand Down
15 changes: 15 additions & 0 deletions frontend/src/composition/CompositionView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Events } from '@wailsio/runtime'
import { Button, Heading, Label, Stack, Text } from '@primer/react'
import { PlusIcon, UploadIcon, WorkflowIcon } from '@primer/octicons-react'
import { CompositionService, ExecutionService, TriggerService } from '../shared/bindings'
Expand Down Expand Up @@ -72,6 +73,20 @@ function CompositionView() {
refreshArmed()
}, [refreshArmed])

// goal 0017 P2: a Publish/disable/delete elsewhere (another tab, an
// MCP author) can arm or disarm a workflow's trigger listener --
// armedWorkflows used to only refresh from THIS page's own Publish
// button/mount, so that badge could silently go stale for a change
// made anywhere else. refreshWorkflows() already runs on the same
// event (App.tsx), but the list's own store update doesn't imply
// TriggerService's separately-tracked armed-set changed too.
useEffect(() => {
return Events.On('mill-data-changed', (evt) => {
const entity = (evt.data as { entity?: string })?.entity
if (entity === 'workflow') refreshArmed()
})
}, [refreshArmed])

// The row-level Publish CTA (docs/goals/0006, decision 2): publishing
// is what's actually blocking a configured-but-not-live trigger from
// arming (TriggerService.Sync's own gate), so this is the same
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/composition/WorkflowRunsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Events } from '@wailsio/runtime'
import { Button, IconButton, Label, type LabelProps, Select, Stack, Text } from '@primer/react'
import { DataTable, type Column } from '@primer/react/experimental'
import { BugIcon, CheckCircleIcon, XCircleIcon, ClockIcon, XIcon, ShieldIcon, ShieldXIcon, StopIcon } from '@primer/octicons-react'
Expand Down Expand Up @@ -99,6 +100,22 @@ function WorkflowRunsPanel({ workflowId, attrs, initialRunId, onInitialRunConsum
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflowId])

// goal 0017 P1-2: this tab's base run list used to update only on
// mount/workflow-switch -- a run started elsewhere (another tab, a
// headless trigger, an MCP author's run_workflow) never appeared here
// without reopening the tab. mill-data-changed{entity:"run"} is
// already emitted for every run kind (executionsvc's own run-start/
// debug-tool paths); the in-flight-run detail poll above stays --
// DBOS has no per-step event, so polling an already-open run's own
// step-by-step progress is still the honest only-path.
useEffect(() => {
return Events.On('mill-data-changed', (evt) => {
const entity = (evt.data as { entity?: string })?.entity
if (entity === 'run') refreshRuns()
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflowId])

useEffect(() => {
if (!initialRunId) return
setSelectedRunID(initialRunId)
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/composition/useGuardrailBadges.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useEffect } from 'react'
import { Events } from '@wailsio/runtime'
import { GuardrailService } from '../shared/bindings'
import type { CanvasNode, CanvasState } from './canvasStore'

Expand Down Expand Up @@ -37,5 +38,18 @@ export function useGuardrailBadges(workflowId: string | undefined, nodes: Canvas
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflowId, nodeFingerprint])

// goal 0017 P2: a policy guardrail rule changed in Configure >
// Guardrails (another tab, or an external MCP author once that
// surface exists) must re-run this canvas's verdicts too -- the
// nodeFingerprint-keyed effect above only notices a NODE edit, never
// a rule edit elsewhere, so a canvas left open could show a stale
// ask/deny badge after its governing rule changed underneath it.
useEffect(() => {
return Events.On('mill-data-changed', (evt) => {
const entity = (evt.data as { entity?: string })?.entity
if (entity === 'guardrail-rule') refresh()
})
}, [refresh])

return refresh
}
Loading
Loading