Skip to content

Commit 87d3bd6

Browse files
authored
Merge pull request #16 from alicoding/goal/0017-realtime-surfaces-audit
goal 0017: real-time surfaces audit — direct-UI/service mutations now emit mill-data-changed
2 parents de0acd3 + 1ea7a63 commit 87d3bd6

35 files changed

Lines changed: 1320 additions & 407 deletions

docs/SPEC.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,15 @@ an implicit `FINAL`.
107107
ever show stale state the user would have to manually refresh?" and
108108
close that gap (goal 0017). Same family as the §1 thesis (no gap
109109
between what you see and what's real) — applied to *time*, not just
110-
structure.
110+
structure. **Including Mill's own UI mutations, not just external
111+
ones**: goal 0017's audit found the event layer's emit side lived in
112+
ONE place (`mcpsvc`, MCP-authored writes only) — a direct-UI create/
113+
edit/delete through `CompositionService`/`ConfigureService`/
114+
`GuardrailService` emitted nothing at all, so it only ever reached the
115+
tab that made the change, never another open surface. Fixed by giving
116+
every direct-mutation service its own `dataevent.Emit` call (one
117+
shared package, `internal/services/dataevent`) rather than treating
118+
MCP as the sole live-sync source.
111119
- **Scope filter, learned from the screenshot-to-clipboard tangent**: before
112120
any capability goes into Mill, check whether the OS (or an existing
113121
launcher like Alfred/Raycast) already does it simply and well. If yes,

docs/goals/BACKLOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,4 @@ live-review material, interleaved during owner reviews, not a lane.**
133133
- [x] [0006 — Trigger-aware Workflows list](0006-trigger-aware-workflows-list.md) — 2026-08-10
134134
- [x] [0007 — Resource-inventory redesign](0007-resource-inventory-redesign.md) — 2026-08-10 (owner recognition test passed live: "like an addition")
135135
12. [x] [0016 — Keymap system](archive/0016-keymap-system.md) — delivered 2026-08-10 (command registry, Settings rebinding, ⌘W→tab, Run=⌘↩; 127/127)
136-
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)
136+
- [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)

docs/goals/0017-realtime-surfaces-audit.md renamed to docs/goals/archive/0017-realtime-surfaces-audit.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,55 @@ everywhere live; watch a run complete on its Runs tab without
7070
reopening; have an MCP author change something and see it in the open
7171
window — with no manual refresh anywhere, and any remaining poll
7272
justified in writing.
73+
74+
## Delivered 2026-08-12
75+
76+
All P0/P1/P2 items from the audit's fix list, implemented:
77+
78+
- **P0-1** `CompositionService` now emits `mill-data-changed
79+
{entity:"workflow"}` after Create/Update/Delete/UpdateAttributes
80+
(compositionservice.go) and, via the shared `mutateWorkflow` choke
81+
point, Publish/PublishExistingVersion/RestoreVersionToDraft/
82+
SetWorkflowDisabled/SnapshotDraft (compositionservice_versioning.go)
83+
`ImportWorkflow` inherits it for free (delegates to CreateWorkflow).
84+
- **P0-2** `ConfigureService` now emits for request/list/mcpserver/
85+
decision (new)/execenv (new) CRUD, including List row mutations
86+
(AddListRow/UpdateListRow/DeleteListRow) — `UpdateWorkflowAttributes`
87+
inherits `workflow`'s emit via its delegation to
88+
`CompositionService.UpdateAttributes`. Lists CRUD split out of
89+
configureservice.go into configurelist.go (500-line limit).
90+
- **P0-3** `GuardrailService` emits a new `guardrail-rule` entity after
91+
rule CRUD.
92+
- **P0-4** App.tsx's `mill-data-changed` handler no longer misroutes
93+
`list`/`mcpserver` to `refreshRequests()`+`refreshWorkflows()` — each
94+
entity now routes to its own store's refresher.
95+
- **Shared emit helper**: `internal/services/dataevent` (new package)
96+
is the ONE place `EventName`/`Changed`/`Emit` are defined — mcpsvc's
97+
old locally-owned `DataChanged`/`DataChangedEventName`/
98+
`emitDataChanged` were migrated onto it, removing the duplication the
99+
audit's root cause created.
100+
- **P1-1** `frontend/src/shared/configureEntityStore.ts` (new file,
101+
mirrors store.ts's workflows/requests pattern) backs
102+
ConfigureLists/ConfigureDecisions/ConfigureMCPServers/ConfigureExecEnv
103+
— each switched from its own local `useState` + fetch to the shared
104+
store. ConfigureAttributes switched to store.ts's existing shared
105+
`workflows`.
106+
- **P1-2/P1-3/P1-4** WorkflowRunsPanel, HomeView (covers HomeMostUsed),
107+
and ActivityRunsExplorer each subscribe to
108+
`mill-data-changed{entity:"run"}` and refresh their run list/metrics.
109+
- **P2** CompositionView's `refreshArmed`, `useGuardrailBadges`, and
110+
ReviewView (added a `guardrail-pending-changed` subscription
111+
alongside its existing 2s poll, kept as the documented fallback) all
112+
wired.
113+
- **Proofs**: Go unit tests per service
114+
(`*_dataevent_test.go` in compositionsvc/configuresvc/guardrailsvc,
115+
using `dataevent.TestHook` — the seam added since `application.Get()`
116+
is always nil under `go test`); a new e2e spec,
117+
`e2e/realtime-cross-surface.spec.ts`, proves the flagship two-surface
118+
scenario both ways (an MCP-authored `import_list` reaching an open
119+
Configure > Lists tab; a direct-UI workflow create in one browser
120+
window reaching a canvas picker open in a second window) — neither
121+
page ever reloads.
122+
- **Left as-is, per the audit's own verdict** (unchanged): the in-flight
123+
run 1s polls (DBOS has no per-step event), QuickPanel's
124+
refresh-on-summon, hover-preview/EntityRefField mount-fetches.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import type { Page } from '@playwright/test'
2+
import { test, expect } from './fixtures/server'
3+
import { connectMCPClient } from './mcpTestClient'
4+
import { clickRowAction } from './inventoryRow'
5+
6+
// Goal 0017's flagship scenario, direct from the audit's own root
7+
// cause: before this goal, ONLY mcpsvc emitted mill-data-changed --
8+
// ConfigureService/CompositionService/GuardrailService emitted
9+
// NOTHING, so a direct-UI or MCP-authored mutation never reached an
10+
// already-open OTHER surface (only the exact tab that made the change
11+
// ever refreshed itself, via its own local refetch call). This spec
12+
// proves the fix at the two surfaces the audit named as the P0s:
13+
// Configure's inventories (list/mcpserver were actively MISROUTED to
14+
// refreshRequests()+refreshWorkflows(), App.tsx:242-244 before the
15+
// fix) and a canvas entity picker seeing a workflow created elsewhere.
16+
17+
// Normalizes to write-gate ON + per-write approval OFF (unattended) --
18+
// same local helper canvas-live-sync.spec.ts already uses (kept local
19+
// there too, not promoted to mcpTestClient.ts, since that module's own
20+
// enableMCPWritesWithApprovalRequired deliberately leaves approval ON
21+
// for specs that want to exercise the approval banner instead).
22+
async function enableUnattendedMCPWrites(page: Page): Promise<void> {
23+
await page.goto('/')
24+
await page.getByRole('button', { name: 'Settings' }).click()
25+
const writeCheckbox = page.getByTestId('mcp-write-enabled-checkbox')
26+
await expect(writeCheckbox).toBeEnabled()
27+
if (!(await writeCheckbox.isChecked())) {
28+
await writeCheckbox.click()
29+
await expect(writeCheckbox).toBeChecked()
30+
}
31+
const approvalCheckbox = page.getByTestId('mcp-write-approval-checkbox')
32+
await expect(approvalCheckbox).toBeEnabled()
33+
if (await approvalCheckbox.isChecked()) {
34+
await approvalCheckbox.click()
35+
await expect(approvalCheckbox).not.toBeChecked()
36+
}
37+
}
38+
39+
async function restoreMCPWriteDefaults(page: Page): Promise<void> {
40+
await page.goto('/')
41+
await page.getByRole('button', { name: 'Settings' }).click()
42+
const approvalCheckbox = page.getByTestId('mcp-write-approval-checkbox')
43+
if (await approvalCheckbox.count() && !(await approvalCheckbox.isChecked())) {
44+
await approvalCheckbox.click()
45+
await expect(approvalCheckbox).toBeChecked()
46+
}
47+
const writeCheckbox = page.getByTestId('mcp-write-enabled-checkbox')
48+
if (await writeCheckbox.isChecked()) {
49+
await writeCheckbox.click()
50+
await expect(writeCheckbox).not.toBeChecked()
51+
}
52+
}
53+
54+
test('Configure > Lists open: an MCP-authored import_list appears live, no reload (P0-2/P1-1)', async ({ page }, testInfo) => {
55+
await enableUnattendedMCPWrites(page)
56+
57+
await page.getByRole('link', { name: 'Configure' }).click()
58+
await page.getByRole('tab', { name: 'Lists' }).click()
59+
await expect(page.getByTestId('configure-lists')).toBeVisible()
60+
61+
const label = 'E2E cross-surface list'
62+
const row = page.locator('[data-testid="inventory-row"][data-entity="list"]', { has: page.getByText(label, { exact: true }) })
63+
await expect(row).toHaveCount(0)
64+
65+
const client = await connectMCPClient(testInfo.parallelIndex)
66+
try {
67+
const result = await client.callTool({
68+
name: 'import_list',
69+
arguments: { json: JSON.stringify({ label, description: '', columns: [{ Key: 'k', Label: 'K', Type: 'text' }] }) },
70+
})
71+
if (result.isError) throw new Error(`import_list failed: ${JSON.stringify(result.content)}`)
72+
73+
// No page.reload() -- ConfigureLists.tsx now reads the shared
74+
// configureEntityStore (shared/configureEntityStore.ts), which
75+
// App.tsx's mill-data-changed{entity:"list"} handler refreshes.
76+
// Before the fix, 'list' routed to refreshRequests()+
77+
// refreshWorkflows() -- neither of which touches this page's own
78+
// (then-local) state at all, so this row would never have appeared
79+
// without navigating away and back.
80+
await expect(row).toBeVisible({ timeout: 10_000 })
81+
} finally {
82+
await client.close()
83+
}
84+
85+
await clickRowAction(page, row, 'Delete')
86+
await restoreMCPWriteDefaults(page)
87+
})
88+
89+
test('a direct-UI workflow create in one window reaches a canvas picker open in another (P0-1)', async ({ page }) => {
90+
// Two real pages against the SAME worker server -- the actual "two
91+
// open surfaces" the goal names, not one page simulating it. Neither
92+
// one drives the other; both independently subscribe to the same
93+
// backend's mill-data-changed broadcast.
94+
const page2 = await page.context().newPage()
95+
try {
96+
await page.goto('/')
97+
await page.getByRole('link', { name: 'Configure' }).click()
98+
await page.getByRole('tab', { name: 'Attributes' }).click()
99+
const select = page.getByTestId('attributes-workflow-select')
100+
await expect(select).toBeVisible()
101+
102+
const label = 'E2E cross-surface workflow'
103+
await expect(select.locator('option', { hasText: label })).toHaveCount(0)
104+
105+
// A genuinely direct-UI create (the Workflows page's own "New
106+
// workflow" button + Save, no MCP involved at all) on the SECOND
107+
// page -- proves CompositionService.CreateWorkflow's own new
108+
// dataevent.Emit call (compositionservice.go), not mcpsvc's.
109+
await page2.goto('/')
110+
await page2.getByRole('link', { name: 'Workflows' }).click()
111+
await page2.getByTestId('new-workflow').click()
112+
await page2.locator('[role="tabpanel"]:not([hidden])').last().getByLabel('Label').fill(label)
113+
await page2.locator('[role="tabpanel"]:not([hidden])').last().getByTestId('save-workflow').click()
114+
115+
// No page.reload() on page (the first page/window) -- ConfigureAttributes.tsx
116+
// now reads shared/store.ts's workflows store, refreshed by
117+
// App.tsx's mill-data-changed{entity:"workflow"} handler.
118+
await expect(select.locator('option', { hasText: label })).toHaveCount(1, { timeout: 10_000 })
119+
120+
await page2.getByRole('link', { name: 'Workflows' }).click()
121+
const row = page2.locator('[data-testid="inventory-row"][data-entity="workflow"]', { has: page2.getByText(label, { exact: true }) })
122+
await clickRowAction(page2, row, 'Delete')
123+
} finally {
124+
await page2.close()
125+
}
126+
})

frontend/package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/app/App.tsx

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

frontend/src/composition/CompositionView.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2+
import { Events } from '@wailsio/runtime'
23
import { Button, Heading, Label, Stack, Text } from '@primer/react'
34
import { PlusIcon, UploadIcon, WorkflowIcon } from '@primer/octicons-react'
45
import { CompositionService, ExecutionService, TriggerService } from '../shared/bindings'
@@ -72,6 +73,20 @@ function CompositionView() {
7273
refreshArmed()
7374
}, [refreshArmed])
7475

76+
// goal 0017 P2: a Publish/disable/delete elsewhere (another tab, an
77+
// MCP author) can arm or disarm a workflow's trigger listener --
78+
// armedWorkflows used to only refresh from THIS page's own Publish
79+
// button/mount, so that badge could silently go stale for a change
80+
// made anywhere else. refreshWorkflows() already runs on the same
81+
// event (App.tsx), but the list's own store update doesn't imply
82+
// TriggerService's separately-tracked armed-set changed too.
83+
useEffect(() => {
84+
return Events.On('mill-data-changed', (evt) => {
85+
const entity = (evt.data as { entity?: string })?.entity
86+
if (entity === 'workflow') refreshArmed()
87+
})
88+
}, [refreshArmed])
89+
7590
// The row-level Publish CTA (docs/goals/0006, decision 2): publishing
7691
// is what's actually blocking a configured-but-not-live trigger from
7792
// arming (TriggerService.Sync's own gate), so this is the same

frontend/src/composition/WorkflowRunsPanel.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useEffect, useRef, useState } from 'react'
2+
import { Events } from '@wailsio/runtime'
23
import { Button, IconButton, Label, type LabelProps, Select, Stack, Text } from '@primer/react'
34
import { DataTable, type Column } from '@primer/react/experimental'
45
import { BugIcon, CheckCircleIcon, XCircleIcon, ClockIcon, XIcon, ShieldIcon, ShieldXIcon, StopIcon } from '@primer/octicons-react'
@@ -99,6 +100,22 @@ function WorkflowRunsPanel({ workflowId, attrs, initialRunId, onInitialRunConsum
99100
// eslint-disable-next-line react-hooks/exhaustive-deps
100101
}, [workflowId])
101102

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

frontend/src/composition/useGuardrailBadges.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useCallback, useEffect } from 'react'
2+
import { Events } from '@wailsio/runtime'
23
import { GuardrailService } from '../shared/bindings'
34
import type { CanvasNode, CanvasState } from './canvasStore'
45

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

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

0 commit comments

Comments
 (0)