Skip to content

Commit 346c7f3

Browse files
alicodingclaude
andauthored
feat: one notification spine -- durable record, one presence gate, N channels (goal 0171) (#362)
Replaces three independently-drifted presence checks (backend isAway, the browser tab's focus-only predicate, the ungated dock badge) with a single exported SettingsService.IsAway, and two in-memory dedupe Sets with one persisted per-channel delivery record on notificationsvc's NotificationService.Publish -- persist first, then fan out to a registered Channel per delivery destination (desktop banner, dock bounce, browser tab). Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 26d5932 commit 346c7f3

20 files changed

Lines changed: 1054 additions & 146 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+
// This file is automatically generated. DO NOT EDIT
3+
4+
export type {
5+
Event,
6+
Record
7+
} from "./models.js";
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+
// This file is automatically generated. DO NOT EDIT
3+
4+
/**
5+
* Event is what a producer hands to Publish -- Record's input-facing
6+
* counterpart, the same input/full-record split MCPWriteRequest/
7+
* MCPWriteRecord already establish (millmcpservice_approval.go).
8+
*/
9+
export interface Event {
10+
"type": string;
11+
"title": string;
12+
"body": string;
13+
"dedupeKey": string;
14+
"sourceRef"?: string;
15+
16+
/**
17+
* Focused is the producer's own presence reading for the surface
18+
* that originated this event (a frontend's document.hasFocus()),
19+
* threaded through rather than read here since only the caller
20+
* knows it. The zero value (false) fails toward "away" -- a
21+
* backend-originated event with no window to read focus from (a
22+
* run finishing unattended) is exactly the case a channel's
23+
* presence gate should err toward delivering for, not suppressing.
24+
*/
25+
"focused": boolean;
26+
}
27+
28+
/**
29+
* Record is one durable notification, keyed by DedupeKey so publishing
30+
* the same logical event twice (a restart, two independent producers
31+
* for the same underlying item) resolves to the same Record rather than
32+
* a duplicate. Field names follow CloudEvents' own id/type/time/data
33+
* shape (id, type, time here as CreatedAt, data as Title+Body) --
34+
* naming alignment only, the CloudEvents SDK itself is not imported
35+
* (docs/goals/0171's own commodity verdict: adopt the envelope shape,
36+
* defer the SDK).
37+
*/
38+
export interface Record {
39+
"id": string;
40+
"type": string;
41+
"title": string;
42+
"body": string;
43+
"dedupeKey": string;
44+
45+
/**
46+
* SourceRef names the underlying item this notification is about
47+
* (a run ID, an MCP write ID, an update version) -- what a click
48+
* would navigate to, kept separate from DedupeKey since a future
49+
* producer's dedupe key and navigation target could genuinely
50+
* differ even though today every producer sets them equal.
51+
*/
52+
"sourceRef"?: string;
53+
"createdAt": string;
54+
"readAt"?: string | null;
55+
"expiresAt"?: string | null;
56+
57+
/**
58+
* DeliveredChannels names every Channel that has already run
59+
* Deliver for this Record -- the one dedupe mechanism (goal 0171),
60+
* persisted alongside the record itself so it survives a reload
61+
* instead of resetting the way two separate in-memory Sets used to.
62+
* A channel appears here at most once; a Publish call for an
63+
* already-published DedupeKey re-attempts only the channels still
64+
* missing from this list, so a second producer of the same logical
65+
* event (e.g. a second browser tab) still gets ITS OWN channel
66+
* delivered without re-delivering one that already fired.
67+
*/
68+
"deliveredChannels"?: string[] | null;
69+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+
// This file is automatically generated. DO NOT EDIT
3+
4+
import * as NotificationService from "./notificationservice.js";
5+
export {
6+
NotificationService
7+
};
8+
9+
export type {
10+
PublishResult
11+
} from "./models.js";
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+
// This file is automatically generated. DO NOT EDIT
3+
4+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
5+
// @ts-ignore: Unused imports
6+
import * as notification$0 from "../../domain/notification/models.js";
7+
8+
/**
9+
* PublishResult is Publish's return shape: the durable Record (created
10+
* fresh, or the existing one a repeat DedupeKey resolved to) plus which
11+
* channel names newly delivered on THIS call specifically -- distinct
12+
* from Record.DeliveredChannels' cumulative history, since a caller
13+
* (e.g. the browser-tab channel's own frontend half) needs to know
14+
* whether ITS delivery just happened, not the record's full history.
15+
*/
16+
export interface PublishResult {
17+
"record": notification$0.Record;
18+
"delivered": string[] | null;
19+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+
// This file is automatically generated. DO NOT EDIT
3+
4+
/**
5+
* NotificationService is the Wails-bound owner of the notification
6+
* spine: the persisted Record slice and the registered Channel list
7+
* Publish fans out to.
8+
* @module
9+
*/
10+
11+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
12+
// @ts-ignore: Unused imports
13+
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
14+
15+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
16+
// @ts-ignore: Unused imports
17+
import * as notification$0 from "../../domain/notification/models.js";
18+
19+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
20+
// @ts-ignore: Unused imports
21+
import * as $models from "./models.js";
22+
23+
/**
24+
* ListNotifications returns every persisted Record, newest first --
25+
* the read side of the silent-loss fix (docs/goals/0171): an event
26+
* published with no window/tab open is still here once one opens.
27+
*/
28+
export function ListNotifications(): $CancellablePromise<notification$0.Record[] | null> {
29+
return $Call.ByID(1795017620);
30+
}
31+
32+
/**
33+
* MarkRead stamps id's ReadAt to now, a no-op if it's already set --
34+
* idempotent so a duplicate click/retry never overwrites an earlier
35+
* real read time with a later one.
36+
*/
37+
export function MarkRead(id: string): $CancellablePromise<void> {
38+
return $Call.ByID(3359623529, id);
39+
}
40+
41+
/**
42+
* Publish is the notification spine's one entry point (docs/goals/0171
43+
* item 2): PERSISTS FIRST, then fans out. A repeat call for a
44+
* DedupeKey that already has a Record does not create a second one --
45+
* it resolves to the existing Record and re-attempts only the channels
46+
* that haven't delivered it yet, which is what makes Publish safe to
47+
* call from more than one producer for the same logical event (a
48+
* backend origination point and a frontend caller both naming the same
49+
* DedupeKey) without double-delivering through either channel.
50+
*
51+
* The existence check and the insert-if-absent below are one hand-
52+
* written critical section rather than a call to entitystore.Insert:
53+
* Insert acquires its own lock internally, so calling it while already
54+
* holding s.mu (needed to make "check, then insert" atomic) would
55+
* deadlock on Go's non-reentrant sync.Mutex. This is the one place
56+
* entitystore's generic shape does not fit -- Insert is an
57+
* unconditional-append primitive, and Publish needs insert-if-absent.
58+
* entitystore.Persist/Update/Load are still used for everything else
59+
* below.
60+
*/
61+
export function Publish(evt: notification$0.Event): $CancellablePromise<$models.PublishResult> {
62+
return $Call.ByID(2392278483, evt);
63+
}

frontend/bindings/github.com/alicoding/mill/internal/services/settingssvc/settingsservice.ts

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,25 @@ export function GetWorkflowMinutesSaved(workflowID: string): $CancellablePromise
269269
return $Call.ByID(1967529281, workflowID);
270270
}
271271

272+
/**
273+
* IsAway is the ONE presence-gate decision point in the tree (docs/
274+
* goals/0171): present = focused AND recently-active (idle below the
275+
* configured threshold); away = anything else. Exported so every
276+
* notification channel (settingsservice_notifychannels.go) and the
277+
* browser tab (via this same method, bound as an RPC) share this one
278+
* definition instead of each re-deriving their own -- the frontend's
279+
* own focus-only predicate (goal 0132 slice A's shouldNotifyBrowserTab)
280+
* existed only because there was no shared gate to call into; it is
281+
* gone now that there is one. An idletime read error (server mode, or
282+
* a real desktop failing to read the counter) FAILS TOWARD AWAY -- §8's
283+
* fail-safe posture: a truly-away user missing a decision is the
284+
* failure that matters, not a present user seeing one extra
285+
* notification.
286+
*/
287+
export function IsAway(focused: boolean): $CancellablePromise<boolean> {
288+
return $Call.ByID(3618695826, focused);
289+
}
290+
272291
/**
273292
* IsIsolatedData reports whether this instance is running against a
274293
* non-default settings path (MILL_SETTINGS_PATH was set) -- see
@@ -319,20 +338,25 @@ export function MCPAccessAddressInfo(): $CancellablePromise<$models.MCPAddrInfo>
319338
}
320339

321340
/**
322-
* NotifyPendingApproval sends an actionable OS notification AND shows
323-
* the floating approval prompt (docs/goals/0023 item 1) for a new
324-
* pending item (docs/adr/0032 §3), but ONLY when isAway(focused) says
325-
* the user is away -- the single decision point both surfaces share, so
326-
* "notify" and "show the floating prompt" can never disagree about
327-
* presence. focused is the caller's own document.hasFocus() reading
328-
* (App.tsx) -- only the browser context knows that; everything else
329-
* about presence (idle time, the threshold) is resolved in isAway.
341+
* NotifyPendingApproval publishes a durable notification for a new
342+
* pending item (docs/goals/0171) and shows the floating approval
343+
* prompt, but ONLY when IsAway(focused) says the user is away -- the
344+
* single decision point every surface shares, so "notify" and "show
345+
* the floating prompt" can never disagree about presence. focused is
346+
* the caller's own document.hasFocus() reading (App.tsx) -- only the
347+
* browser context knows that; everything else about presence (idle
348+
* time, the threshold) is resolved in IsAway.
330349
*
331-
* kind "mcp-write" gets Approve/Deny action buttons resolving directly
332-
* via ResolveMCPWrite; any other kind (a guardrail/human-review park)
333-
* gets a plain notification whose default click shows+focuses the main
334-
* window instead -- typed input may be required to resolve those, so
335-
* blind approval from a notification isn't offered.
350+
* The desktop banner and dock bounce below now run through Publish's
351+
* channel registry (settingsservice_notifychannels.go) rather than
352+
* calling notify.Send* /dockBounceFn directly -- same observable calls,
353+
* same away verdict, just expressed as registered channels so a future
354+
* channel is one new struct, not a new branch here. kind "mcp-write"
355+
* still gets Approve/Deny action buttons resolving via ResolveMCPWrite
356+
* (desktopBannerChannel.Deliver's own branch); any other kind gets a
357+
* plain notification whose default click shows+focuses the main window
358+
* -- typed input may be required to resolve those, so blind approval
359+
* from a notification isn't offered.
336360
*/
337361
export function NotifyPendingApproval(id: string, description: string, kind: string, focused: boolean): $CancellablePromise<void> {
338362
return $Call.ByID(99139683, id, description, kind, focused);
@@ -639,17 +663,10 @@ export function ShowPanel(): $CancellablePromise<void> {
639663
* (AssignHotkey/CheckConflict) stay scoped to per-workflow bindings and
640664
* have no reason to know the native application menu exists.
641665
*
642-
* Server-mode-safe by construction, not just by nil-guard: every
643-
* menu_*.go in Wails3's own pkg/application (the package that defines
644-
* DefaultApplicationMenu, Menu.Update's native half, etc.) is
645-
* //go:build !server -- calling those symbols unconditionally from this
646-
* package would fail to *compile* under `-tags server`, not just
647-
* misbehave at runtime. applicationMenu (settingsservice_menu_desktop.go
648-
* / settingsservice_menu_server.go) is the same !server/server split
649-
* internal/adapters/hotkey and internal/adapters/launchatlogin already
650-
* use for the identical reason (no native run loop / no native menu
651-
* bar in server mode) -- the server build's applicationMenu always
652-
* returns nil, so both methods below degrade to a safe no-op there.
666+
* The actual native suspend/restore/release calls -- and the
667+
* server-mode no-op degrade -- live in internal/adapters/windowing;
668+
* this only counts concurrent recorders and calls the adapter exactly
669+
* once at the 0->1 and 1->0 transitions.
653670
*/
654671
export function SuspendMenuAccelerators(): $CancellablePromise<void> {
655672
return $Call.ByID(2098787179);

frontend/src/app/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ function App() {
266266

267267
useMillNavigate(setView);
268268

269-
const notifyBrowserTab = useBrowserNotify(buildInfo?.Server === true);
269+
const notifyBrowserTab = useBrowserNotify();
270270

271271
useEffect(() => {
272272
return Events.On('hotkey-activity', (evt) => {

frontend/src/app/browserNotifyPredicate.test.ts

Lines changed: 0 additions & 36 deletions
This file was deleted.

frontend/src/app/browserNotifyPredicate.ts

Lines changed: 0 additions & 18 deletions
This file was deleted.
Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { useCallback, useRef } from 'react'
1+
import { useCallback } from 'react'
22
import { getNotificationPermission, raiseNotification } from '../shared/browserNotify'
3-
import { shouldNotifyBrowserTab } from './browserNotifyPredicate'
3+
import { NotificationService } from '../shared/bindings'
44

55
export interface BrowserNotifyRequest {
66
// Identifies the thing being notified about (a run id, a pending
@@ -15,21 +15,26 @@ export interface BrowserNotifyRequest {
1515
onClick: () => void
1616
}
1717

18-
// docs/goals/0132-remote-access.md SLICE A: the one browser-tab
19-
// notification seam every consumer goes through, never a bespoke
20-
// notifier per event type. A new event that wants this (a finished
21-
// run, an agent action while away) is a new call site with its own
22-
// dedupeKey/title/body/onClick -- never a change here. The parked-
23-
// approval call in App.tsx is this seam's first consumer, not its only
24-
// intended one.
25-
export function useBrowserNotify(isServerMode: boolean) {
26-
const notifiedKeys = useRef<Set<string>>(new Set())
27-
18+
// docs/goals/0171-notification-spine.md: the browser-tab channel's
19+
// client half. The gate (server mode, presence) and the dedupe (has
20+
// this dedupeKey already been delivered through the "browser-tab"
21+
// channel) both live server-side now -- NotificationService.Publish is
22+
// the same one entry point every producer goes through, so this hook
23+
// only decides whether to raise the actual `Notification`, based on
24+
// whether Publish's own response says "browser-tab" newly delivered on
25+
// THIS call. Replaces goal 0132 slice A's local shouldNotifyBrowserTab
26+
// predicate (focus-only, no idle) and its in-memory notifiedKeys Set
27+
// (reset on reload) -- both are gone, not just unused.
28+
export function useBrowserNotify() {
2829
return useCallback((req: BrowserNotifyRequest) => {
29-
const alreadyNotified = notifiedKeys.current.has(req.dedupeKey)
30-
if (!shouldNotifyBrowserTab({ isServerMode, hasFocus: document.hasFocus(), alreadyNotified })) return
3130
if (getNotificationPermission() !== 'granted') return
32-
notifiedKeys.current.add(req.dedupeKey)
33-
raiseNotification(req.title, req.body, req.onClick)
34-
}, [isServerMode])
31+
NotificationService.Publish({
32+
type: 'guardrail', title: req.title, body: req.body,
33+
dedupeKey: req.dedupeKey, sourceRef: req.dedupeKey, focused: document.hasFocus(),
34+
}).then((result) => {
35+
if (result.delivered?.includes('browser-tab')) {
36+
raiseNotification(req.title, req.body, req.onClick)
37+
}
38+
}).catch(() => {})
39+
}, [])
3540
}

0 commit comments

Comments
 (0)