Skip to content

Commit d3ea16e

Browse files
authored
fix: live-sync completeness -- run lifecycle emits + hotkey/keybinding vocabulary (goal 0057) (#113)
* fix: live-sync completeness -- run lifecycle emits + hotkey/keybinding entity vocabulary (goal 0057) Closes the three run-lifecycle emit gaps (ResolveApproval, CancelRun's ENQUEUED path, RedriveRun's fork path) and adds the missing "hotkey"/ "keybinding" dataevent entities so hotkey and command-keybinding mutations emit mill-data-changed, with live subscriptions added to every verified fetch-once frontend surface (CommandPalette, QuickPanel, hotkeyCapture.ts's useComboCapture, App.tsx's central router). * test: mcpsvc emit coverage completes goal 0057 box 3 * test: seed-lifecycle + AI-provider emit coverage -- goal 0057 box 3 complete
1 parent 4aede29 commit d3ea16e

16 files changed

Lines changed: 820 additions & 4 deletions

frontend/src/app/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ function App() {
253253
if (entity === 'decision') void refreshDecisions()
254254
if (entity === 'execenv') void refreshExecEnvs()
255255
if (entity === 'aiprovider') void refreshAIProviders()
256+
if (entity === 'keybinding') void refreshKeybindings()
256257
})
257258
}, [])
258259

frontend/src/app/CommandPalette.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
44
import { Dialog, Text } from '@primer/react'
55
import { FilteredActionList } from '@primer/react/experimental'
66
import { CommandPaletteIcon, PencilIcon, PlayIcon, TabIcon, XIcon } from '@primer/octicons-react'
7+
import { Events } from '@wailsio/runtime'
78
import { ExecutionService, RunKind, TriggerService } from '../shared/bindings'
89
import { COMMANDS } from '../shared/commands'
910
import { generateSamplePayload } from '../shared/configSchema'
@@ -149,6 +150,17 @@ export function CommandPalette() {
149150
TriggerService.ListHotkeys().then((combos) => setHotkeyCombos(combos ?? {})).catch(() => {})
150151
}, [paletteOpen])
151152

153+
// Live sync while the palette stays open: a hotkey assigned/cleared
154+
// elsewhere (Composition's NodeInspector, another open tab) refreshes
155+
// the inline combo hints without waiting for the palette to be
156+
// closed and reopened.
157+
useEffect(() => {
158+
return Events.On('mill-data-changed', (evt) => {
159+
const entity = (evt.data as { entity?: string })?.entity
160+
if (entity === 'hotkey') TriggerService.ListHotkeys().then((combos) => setHotkeyCombos(combos ?? {})).catch(() => {})
161+
})
162+
}, [])
163+
152164
// Runs a workflow through the exact same RPC + RunKind CompositionView's
153165
// own list-row Run button uses (ExecutionService.RunWorkflow,
154166
// RunKind.RunKindTest -- docs/adr/0008's single execution path), and

frontend/src/app/QuickPanel.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,11 @@ export function QuickPanel() {
191191
// this window happens to be open updates the jump rows without
192192
// waiting for the next show. Scoped to just the entity kinds this
193193
// panel actually renders (workflow/run for frecency+the row list,
194-
// request/list/mcpserver for the Configure jump rows); 'run' also
195-
// refreshes frecency since a new run changes MostUsed's ranking.
194+
// request/list/mcpserver for the Configure jump rows, hotkey/
195+
// keybinding for the inline combo hints); 'run' also refreshes
196+
// frecency since a new run changes MostUsed's ranking. hotkey/
197+
// keybinding are ALSO refetched on every show (focusAndReset above)
198+
// -- this closes the gap while the panel stays open between shows.
196199
useEffect(() => {
197200
return Events.On('mill-data-changed', (evt) => {
198201
const entity = (evt.data as { entity?: string })?.entity
@@ -201,6 +204,8 @@ export function QuickPanel() {
201204
if (entity === 'request') void refreshRequests()
202205
if (entity === 'list') void refreshLists()
203206
if (entity === 'mcpserver') void refreshMCPServers()
207+
if (entity === 'hotkey') refreshHotkeyCombos()
208+
if (entity === 'keybinding') void refreshKeybindings()
204209
})
205210
}, [])
206211

frontend/src/composition/hotkeyCapture.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect, useMemo, useState } from 'react'
22
import { useTranslation } from 'react-i18next'
3+
import { Events } from '@wailsio/runtime'
34
import { SettingsService, TriggerService } from '../shared/bindings'
45
import { comboKey, describeCombo, formatCombo, keyFromEventCode, modsFromEvent, reservedByMacOS } from '../shared/keybinding'
56
import { refreshKeybindings, useAppStore } from '../shared/store'
@@ -29,6 +30,12 @@ interface ComboCaptureAdapter {
2930
currentBinding: () => Promise<string | null>
3031
assign: (mods: string[], key: string) => Promise<string>
3132
unassign: () => Promise<void>
33+
// Which mill-data-changed entity carries this adapter's own combo --
34+
// "hotkey" (a workflow trigger) or "keybinding" (a command override).
35+
// Lets useComboCapture refetch currentBinding when the SAME target
36+
// changes from elsewhere (another open tab/window's recorder), not
37+
// just after this hook instance's own assign/unassign call.
38+
entity: 'hotkey' | 'keybinding'
3239
}
3340

3441
// Extracted from the now-retired RunbookView.tsx (docs/SPEC.md §2.2's
@@ -58,6 +65,21 @@ function useComboCapture(enabled: boolean, adapter: ComboCaptureAdapter, onChang
5865
// eslint-disable-next-line react-hooks/exhaustive-deps
5966
}, [enabled])
6067

68+
// Live sync: this target's combo can change from a DIFFERENT open
69+
// recorder instance (another tab's NodeInspector, the Settings
70+
// Keyboard Shortcuts row, a second window) -- without this, only the
71+
// instance that made the change saw its own onChanged callback, and
72+
// every other mounted recorder for the same target stayed on its
73+
// mount-time snapshot until closed and reopened.
74+
useEffect(() => {
75+
if (!enabled) return
76+
return Events.On('mill-data-changed', (evt) => {
77+
const changed = (evt.data as { entity?: string })?.entity
78+
if (changed === adapter.entity) adapter.currentBinding().then(setBinding).catch(console.error)
79+
})
80+
// eslint-disable-next-line react-hooks/exhaustive-deps
81+
}, [enabled])
82+
6183
// Menu-accelerator suspension (SettingsService.SuspendMenuAccelerators)
6284
// brackets the entire time this hook is "recording", not just the
6385
// keydown listener below -- on macOS, NSMenu's own
@@ -148,6 +170,7 @@ export function useHotkeyCapture(workflowId: string | null, onChanged?: () => vo
148170
currentBinding: () => TriggerService.ListHotkeys().then((list) => (list ?? {})[workflowId ?? ''] ?? null),
149171
assign: (mods, key) => TriggerService.AssignHotkey(workflowId ?? '', mods, key),
150172
unassign: () => TriggerService.UnassignHotkey(workflowId ?? ''),
173+
entity: 'hotkey',
151174
}), [workflowId])
152175
return useComboCapture(workflowId !== null, adapter, onChanged)
153176
}
@@ -199,6 +222,7 @@ export function useCommandKeybindingCapture(commandId: string | null, onChanged?
199222
})
200223
},
201224
unassign: () => SettingsService.ClearKeybinding(commandId ?? '').then(() => { void refreshKeybindings() }),
225+
entity: 'keybinding',
202226
}), [commandId, t])
203227
return useComboCapture(commandId !== null, adapter, onChanged)
204228
}

internal/services/compositionsvc/compositionservice_dataevent_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,46 @@ func TestDataEvent_WorkflowMutations(t *testing.T) {
136136
})
137137
}
138138

139+
// TestDataEvent_SeedLifecycleMutations proves goal 0017's P0-2 for
140+
// ResetWorkflowToSeed/RestoreWorkflow (compositionservice_seedlifecycle.go,
141+
// docs/goals/0037 items 4/5) -- both bypass the mutateWorkflow choke
142+
// point TestDataEvent_WorkflowMutations' other subtests all route
143+
// through, so they need their own direct emit coverage. Reuses
144+
// firstGoldenID/the golden-workflow fixture
145+
// compositionservice_seedlifecycle_test.go's own Reset/Restore tests
146+
// already establish.
147+
func TestDataEvent_SeedLifecycleMutations(t *testing.T) {
148+
t.Run("ResetWorkflowToSeed", func(t *testing.T) {
149+
c := NewCompositionService(servicetest.NewFakeStore())
150+
id := firstGoldenID(t)
151+
if _, err := c.UpdateWorkflow(id, "User's own edit", "", []composition.Node{{ID: "t", NodeTypeID: "trigger-manual"}}, nil); err != nil {
152+
t.Fatalf("UpdateWorkflow: %v", err)
153+
}
154+
155+
got := captureEmits(t)
156+
reset, err := c.ResetWorkflowToSeed(id)
157+
if err != nil {
158+
t.Fatalf("ResetWorkflowToSeed: %v", err)
159+
}
160+
assertEmittedWorkflow(t, *got, reset.ID)
161+
})
162+
163+
t.Run("RestoreWorkflow", func(t *testing.T) {
164+
c := NewCompositionService(servicetest.NewFakeStore())
165+
id := firstGoldenID(t)
166+
if err := c.DeleteWorkflow(id); err != nil {
167+
t.Fatalf("DeleteWorkflow: %v", err)
168+
}
169+
170+
got := captureEmits(t)
171+
restored, err := c.RestoreWorkflow(id)
172+
if err != nil {
173+
t.Fatalf("RestoreWorkflow: %v", err)
174+
}
175+
assertEmittedWorkflow(t, *got, restored.ID)
176+
})
177+
}
178+
139179
// assertEmittedWorkflow fails the test unless got contains at least
140180
// one dataevent.Changed{"workflow", id} pair -- every mutation this
141181
// file tests emits the "workflow" entity, so entity itself isn't a

internal/services/configuresvc/configureservice_dataevent_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package configuresvc
33
import (
44
"testing"
55

6+
"github.com/alicoding/mill/internal/domain/aiprovider"
67
"github.com/alicoding/mill/internal/domain/composition"
78
"github.com/alicoding/mill/internal/domain/decision"
89
"github.com/alicoding/mill/internal/domain/execenv"
@@ -191,6 +192,33 @@ func TestDataEvent_ExecEnvMutations(t *testing.T) {
191192
assertEmitted(t, *got, "execenv", e.ID)
192193
}
193194

195+
// TestDataEvent_AIProviderMutations proves goal 0017's P0-2 for AI
196+
// Providers -- "aiprovider" is a NEW entity string on the wire
197+
// (goal 0031's AI node family).
198+
func TestDataEvent_AIProviderMutations(t *testing.T) {
199+
cfg, _ := newTestConfigureService(t)
200+
201+
got := captureEmits(t)
202+
p, err := cfg.CreateAIProvider("Emit test provider", aiprovider.KindAnthropic, "", "claude")
203+
if err != nil {
204+
t.Fatalf("CreateAIProvider: %v", err)
205+
}
206+
assertEmitted(t, *got, "aiprovider", p.ID)
207+
208+
got = captureEmits(t)
209+
p, err = cfg.UpdateAIProvider(p.ID, "Emit test provider (edited)", aiprovider.KindAnthropic, "", "claude")
210+
if err != nil {
211+
t.Fatalf("UpdateAIProvider: %v", err)
212+
}
213+
assertEmitted(t, *got, "aiprovider", p.ID)
214+
215+
got = captureEmits(t)
216+
if err := cfg.DeleteAIProvider(p.ID); err != nil {
217+
t.Fatalf("DeleteAIProvider: %v", err)
218+
}
219+
assertEmitted(t, *got, "aiprovider", p.ID)
220+
}
221+
194222
// TestDataEvent_UpdateWorkflowAttributes_DelegatesToComposition proves
195223
// goal 0017's "workflow-attributes changes" item: ConfigureService's
196224
// delegate emits "workflow" via CompositionService.UpdateAttributes,

internal/services/dataevent/dataevent.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import "github.com/wailsapp/wails/v3/pkg/application"
1818

1919
// Changed is the live-sync event payload: which kind of entity changed
2020
// (e.g. "workflow", "request", "list", "mcpserver", "decision",
21-
// "execenv", "guardrail-rule", "run") and its ID.
21+
// "execenv", "guardrail-rule", "run", "hotkey", "keybinding") and its
22+
// ID -- "hotkey" carries the workflow ID its combo binds to,
23+
// "keybinding" carries the command ID its combo overrides.
2224
type Changed struct {
2325
Entity string `json:"entity"`
2426
ID string `json:"id"`

internal/services/executionsvc/executionservice.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,12 @@ func (e *ExecutionService) RedriveRun(runID, fromNodeID string) (RunSummary, err
441441
if err != nil {
442442
return RunSummary{}, fmt.Errorf("redrive: %w", err)
443443
}
444+
// The fork enters DBOS via ForkWorkflow, not runWorkflowStart, so the
445+
// latter's own start emit never fires for forkedID -- announce it
446+
// here so an open Runs panel shows the redriven run immediately
447+
// rather than only once it completes (runWorkflow's own completion
448+
// emit still covers that half).
449+
dataevent.Emit("run", forkedID)
444450
if _, err := handle.GetResult(); err != nil {
445451
_ = err // see RunWorkflowDurable's identical comment
446452
}

internal/services/executionsvc/executionservice_cancel.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/alicoding/mill/internal/adapters/execution"
88
"github.com/alicoding/mill/internal/adapters/procexec"
9+
"github.com/alicoding/mill/internal/services/dataevent"
910
)
1011

1112
// Cancellation (docs/adr/0026's Amendment, goal 0004b): DBOS cannot
@@ -101,6 +102,14 @@ func (e *ExecutionService) CancelRun(runID string) error {
101102
// skips run-cancelled itself (see its own comment) so this run
102103
// isn't reported twice.
103104
e.emitSystemEvent(SystemEventRunCancelled, runID, "")
105+
// A run cancelled while still ENQUEUED never reaches runWorkflow
106+
// at all (DBOS never dequeues it), so that function's own
107+
// completion emit never fires -- this is the only live-sync
108+
// signal such a run gets. A run cancelled while already RUNNING
109+
// also gets runWorkflow's unconditional completion emit once its
110+
// step unwinds; a second mill-data-changed for the same run is
111+
// harmless (a refetch, not a state mutation).
112+
dataevent.Emit("run", runID)
104113
}
105114
return err
106115
}

0 commit comments

Comments
 (0)