Skip to content

Commit ceb0cb1

Browse files
committed
feat(watch): the resolution model end to end (TRI-12820)
- contracts: three resolutions (condition_met / window_completed / condition_impossible) + per-kind observed outcomes, and the exhaustive resolved-result mapping (category, tone, semantic icon, headline key) — a new kind fails to compile until every cell is filled; icons follow the outcome, a failed run never wears a success check - wire encoding stays fired|expired; resolution + observation travel in facts - the row freezes resolution + observed outcome + facts in one UPDATE at the terminal transition; delivery never re-reads the source (migration 0007) - run_finished preserves the final status; new queue_depth_above kind (same depth seam, inverted, threshold in the identity); the window boundary gets one final evaluation — only pending/unavailable becomes window_completed - the immediate check no longer creates a row: satisfied/terminal_unsatisfied return a one-shot outcome; the inline-resolution and narration-proof machinery is deleted - watch-presentation.ts: fact-first headlines ('Run abc123 failed', 'email-sends queue drained') with the Watch update micro-label; GOOD_NEWS_KINDS is gone from banner and email; the wake narration speaks resolution language
1 parent 28dd3ac commit ceb0cb1

37 files changed

Lines changed: 3488 additions & 643 deletions

apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ function watchRequestText(spec: WatchSpec): string {
2929
switch (spec.kind) {
3030
case "backlog_drain":
3131
return `Watch this for me — tell me when the ${spec.queue} backlog drains.`;
32+
case "queue_depth_above":
33+
return `Watch this for me — tell me if the ${spec.queue} queue grows past ${spec.threshold}.`;
3234
case "run_start":
3335
return `Watch this for me — tell me when run ${spec.runId} starts.`;
3436
case "run_finished":

apps/webapp/app/components/dashboard-agent/WakeBanner.tsx

Lines changed: 90 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,45 @@
11
/**
22
* The banner above a wake narration.
33
*
4-
* A wake arrives unprompted: nobody typed anything, the watch fired and the chat
5-
* spoke. Rendered as plain assistant prose that reads like an answer to a
6-
* question the user never asked — so the narration gets a banner that says what
7-
* happened before the text does, with the outcome carried by a coloured accent
8-
* and icon (the same rule the run status cells and the watch chips follow: the
9-
* text keeps its colour, the state is the icon's job).
4+
* A wake arrives unprompted: nobody typed anything, the watch resolved and the
5+
* chat spoke. Rendered as plain assistant prose it would read like an answer to a
6+
* question the user never asked — so the narration gets a banner that states the
7+
* FACT before the text does, with the outcome carried by a coloured accent and
8+
* icon (the same rule the run status cells and the watch chips follow: the text
9+
* keeps its colour, the state is the icon's job).
1010
*
11-
* A wake is identified by its message id — `wake:watch:{watchId}:{fired|expired}`,
12-
* written by the agent's `narrateWatchWake` — so no protocol change is needed to
13-
* spot one in the transcript.
11+
* **This component contains no kind-specific wording.** Category, tone, semantic
12+
* icon and headline key come from the exhaustive resolved-result mapping in
13+
* contracts; the final English comes from `watch-presentation.ts`. All this file
14+
* decides is which glyph a semantic icon draws and which frame a tone paints.
15+
*
16+
* A wake is identified by its message id — `wake:watch:{watchId}:{fired|expired}`.
17+
* That two-value suffix is the stable TRANSPORT encoding (§7.5): it is not the
18+
* outcome, it is how the wake is addressed. The outcome comes off the watch row.
1419
*/
15-
import { CheckCircleIcon, ClockIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
20+
import {
21+
CheckCircleIcon,
22+
ClockIcon,
23+
ExclamationCircleIcon,
24+
ExclamationTriangleIcon,
25+
InformationCircleIcon,
26+
} from "@heroicons/react/20/solid";
27+
import type {
28+
WatchObservedOutcome,
29+
WatchResolution,
30+
WatchSemanticIcon,
31+
} from "@internal/dashboard-agent-contracts";
1632
import { cn } from "~/utils/cn";
1733
import { type AgentTone, TONE_ICON_COLOR } from "./agent-badges";
34+
import { presentResolvedWatch, WATCH_PRESENTATION_FALLBACK } from "./watch-presentation";
1835

1936
const WAKE_ID_PREFIX = "wake:watch:";
2037

38+
/**
39+
* The wire encoding in a wake's message id. NOT the resolution — a
40+
* `window_completed` and a `condition_impossible` are both addressed as
41+
* `expired`, and the row is the authority on which one it was.
42+
*/
2143
export type WakeOutcome = "fired" | "expired";
2244

2345
/** The watch fields a banner can use. A `WatchChip` satisfies it. */
@@ -26,21 +48,18 @@ export type WakeWatch = {
2648
kind: string;
2749
note: string;
2850
identity: string;
51+
/** How the watch ended. Absent on a row written before the resolution model. */
52+
resolution?: WatchResolution | null;
53+
/** What the resolving check observed — the other half of the headline. */
54+
observedOutcome?: WatchObservedOutcome | null;
2955
/**
3056
* Why the watch ended, from its last result — `terminal_unsatisfied` when the
31-
* condition became impossible (a cancelled run, a deleted queue). Absent when
32-
* the host doesn't carry it, and the banner falls back to the plain expiry.
57+
* condition became impossible. Only used to reconstruct a resolution for rows
58+
* that predate the `resolution` column.
3359
*/
3460
endedReason?: string | null;
3561
};
3662

37-
/**
38-
* A watch that expired because its condition can never happen now. That IS an
39-
* answer, so it gets its own headline instead of "no answer" — the watcher writes
40-
* this reason when a check comes back `terminal_unsatisfied`.
41-
*/
42-
const TERMINAL_UNSATISFIED = "terminal_unsatisfied";
43-
4463
export type WakeRef = { watchId: string; outcome: WakeOutcome };
4564

4665
/**
@@ -63,44 +82,49 @@ export function findWakeWatch(watches: WakeWatch[] | undefined, watchId: string)
6382
return watches?.find((watch) => watch.id === watchId);
6483
}
6584

66-
// Kinds whose condition being met is good news. `error_recurrence` is the
67-
// inverse: it fires because something broke again.
68-
const GOOD_NEWS_KINDS = new Set(["health_recovery", "backlog_drain", "run_start", "run_finished"]);
69-
70-
function toneFor(outcome: WakeOutcome, kind: string | undefined): AgentTone {
71-
if (outcome === "expired") return "neutral";
72-
if (kind === "error_recurrence") return "error";
73-
if (kind && GOOD_NEWS_KINDS.has(kind)) return "success";
74-
// Fired, but we don't know what for: say so without claiming an outcome.
75-
return "neutral";
85+
/**
86+
* The watch's resolution, falling back to what the transport can prove for a row
87+
* written before the `resolution` column existed. `fired` is unambiguous;
88+
* `expired` splits on the last check's reason, exactly as the old banner did.
89+
*/
90+
export function wakeResolution(
91+
outcome: WakeOutcome,
92+
watch: Pick<WakeWatch, "resolution" | "endedReason"> | undefined
93+
): WatchResolution {
94+
if (watch?.resolution) return watch.resolution;
95+
if (outcome === "fired") return "condition_met";
96+
return watch?.endedReason === "terminal_unsatisfied"
97+
? "condition_impossible"
98+
: "window_completed";
7699
}
77100

78-
/** The banner's headline. Exported for the tests; the component owns the tone. */
79-
export function wakeHeadline(
80-
outcome: WakeOutcome,
81-
watch: Pick<WakeWatch, "endedReason"> | undefined,
82-
tone: AgentTone
83-
): string {
84-
if (outcome === "expired") {
85-
return watch?.endedReason === TERMINAL_UNSATISFIED
86-
? "Watch ended — the condition can no longer happen"
87-
: "Watch expired — no answer";
88-
}
89-
switch (tone) {
90-
case "success":
91-
return "Watch update — all clear";
92-
case "error":
93-
return "Watch update — needs your attention";
94-
default:
95-
return "Watch update — condition met";
96-
}
101+
/**
102+
* What this banner shows. Exported for the tests and for any surface that wants
103+
* the same answer without the markup.
104+
*/
105+
export function wakePresentation(outcome: WakeOutcome, watch: WakeWatch | undefined) {
106+
if (!watch) return WATCH_PRESENTATION_FALLBACK;
107+
return presentResolvedWatch({
108+
kind: watch.kind,
109+
identity: watch.identity,
110+
resolution: wakeResolution(outcome, watch),
111+
observed: watch.observedOutcome ?? null,
112+
});
97113
}
98114

99-
const TONE_ICON: Record<AgentTone, (props: { className?: string }) => JSX.Element> = {
100-
neutral: ClockIcon,
115+
/**
116+
* Semantic icon → glyph. The mapping lives here because the icon SET is this
117+
* app's; which icon a resolved result deserves was decided in contracts, and the
118+
* rule it encodes is that the icon follows the outcome, never the resolution — a
119+
* failed run gets `error`, not the success check its `condition_met` would
120+
* otherwise suggest.
121+
*/
122+
const SEMANTIC_ICON: Record<WatchSemanticIcon, (props: { className?: string }) => JSX.Element> = {
101123
success: CheckCircleIcon,
102-
warning: ExclamationTriangleIcon,
103-
error: ExclamationTriangleIcon,
124+
attention: ExclamationTriangleIcon,
125+
error: ExclamationCircleIcon,
126+
waiting: ClockIcon,
127+
info: InformationCircleIcon,
104128
};
105129

106130
const TONE_FRAME: Record<AgentTone, string> = {
@@ -111,32 +135,39 @@ const TONE_FRAME: Record<AgentTone, string> = {
111135
};
112136

113137
/** What the watch was for: the user's own words, else whatever names it. */
114-
function subline(watch: WakeWatch | undefined): string {
138+
function subline(watch: WakeWatch | undefined): string | null {
115139
const note = watch?.note.trim();
116140
if (note) return note;
117141
if (watch?.identity) return watch.identity;
118142
if (watch?.kind) return watch.kind;
119-
return "The watch woke this chat up on its own.";
143+
return null;
120144
}
121145

122146
export function WakeBanner({
123147
outcome,
124148
watch,
125149
}: {
150+
/** The wire encoding from the wake's message id (§7.5). */
126151
outcome: WakeOutcome;
127-
/** The watch that woke, when the host has it. Absent: kind-agnostic wording. */
152+
/** The watch that woke, when the host has it. Absent: the neutral fallback. */
128153
watch?: WakeWatch;
129154
}) {
130-
const tone = toneFor(outcome, watch?.kind);
131-
const Icon = TONE_ICON[tone];
155+
const presentation = wakePresentation(outcome, watch);
156+
const tone = presentation.tone as AgentTone;
157+
const Icon = SEMANTIC_ICON[presentation.semanticIcon];
158+
const note = subline(watch);
159+
132160
return (
133161
<div
134162
className={cn("flex items-start gap-2 rounded-r-md border-l-2 px-3 py-2", TONE_FRAME[tone])}
135163
>
136164
<Icon className={cn("mt-0.5 size-4 shrink-0", TONE_ICON_COLOR[tone])} />
137165
<div className="min-w-0">
138-
<p className="text-sm font-medium text-text-bright">{wakeHeadline(outcome, watch, tone)}</p>
139-
<p className="truncate text-xs text-text-dimmed">{subline(watch)}</p>
166+
<p className="text-xxs font-medium uppercase tracking-wider text-text-dimmed">
167+
{presentation.label}
168+
</p>
169+
<p className="text-sm font-medium text-text-bright">{presentation.headline}</p>
170+
{note ? <p className="truncate text-xs text-text-dimmed">{note}</p> : null}
140171
</div>
141172
</div>
142173
);

apps/webapp/app/components/dashboard-agent/WatchWakeToast.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ export function showWatchWakesSummaryToast(count: number, onOpenChat: () => void
117117
onOpenChat={onOpenChat}
118118
/>
119119
),
120-
`watch-wakes-summary-${count}-${Date.now()}`
120+
// One id for all summaries: a later poll rewrites the count in place instead
121+
// of stacking a second never-expiring toast on top of the first.
122+
"watch-wakes-summary"
121123
);
122124
}

0 commit comments

Comments
 (0)