Skip to content

feat(webapp): dashboard agent — Watch - #4525

Open
kathiekiwi wants to merge 98 commits into
feat/dashboard-agent-uifrom
feat/dashboard-agent-flows-watch
Open

feat(webapp): dashboard agent — Watch#4525
kathiekiwi wants to merge 98 commits into
feat/dashboard-agent-uifrom
feat/dashboard-agent-flows-watch

Conversation

@kathiekiwi

@kathiekiwi kathiekiwi commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #4529, which is stacked on #4418. Merge those first.

Watch is the agent noticing something later: you ask it to tell you when a condition holds, and it answers when it does — or when it can't any more.

What's inside

  • Watches — condition, cadence and window, evaluated by a cron; one identity per chat so a repeat ask doesn't create a second watch.
  • Delivery — in-chat card, email alert, and the investigation that runs when a watch fires.
  • Submissions — a durable ledger keyed by (chatId, clientRequestId), so a retried submission replays instead of duplicating.
  • Watch token — a dedicated delayed-execution credential, accepted only by the watch endpoints and re-checked against the user's live access on every tick.

How to review

GUIDEBOOK.md — local setup and a walkthrough of all 15 scenarios.

@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 59378cf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 27 packages
Name Type
@trigger.dev/core Patch
@trigger.dev/build Patch
trigger.dev Patch
@trigger.dev/python Patch
@trigger.dev/redis-worker Patch
@trigger.dev/schema-to-json Patch
@trigger.dev/sdk Patch
@internal/cache Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/metrics-pipeline Patch
@trigger.dev/rbac Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/run-store Patch
@internal/schedule-engine Patch
@trigger.dev/sso Patch
@internal/testcontainers Patch
@internal/tracing Patch
@internal/tsql Patch
@internal/dashboard-agent Patch
@internal/sdk-compat-tests Patch
@trigger.dev/react-hooks Patch
@trigger.dev/rsc Patch
@trigger.dev/database Patch
@trigger.dev/otlp-importer Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 81d20184-f68a-4c79-b295-234357b3f4d1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Added watch creation and configuration for runs, queues, errors, and health reports. Added scheduled checks, lifecycle handling, wake notifications, automatic investigations, unread tracking, and cross-browser activity polling. Added email, Slack, and webhook alert delivery with subscription management. Added dashboard-agent tools, APIs, persistence, worker tasks, scenario tooling, documentation, and extensive unit and integration coverage.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the Watch feature and review guide but omits the required issue, checklist, testing, changelog, and screenshots sections. Complete the repository template by adding the issue reference, checklist, testing steps, changelog entry, and screenshots or an explicit not-applicable note.
Docstring Coverage ⚠️ Warning Docstring coverage is 64.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: adding the dashboard agent Watch feature.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/dashboard-agent-flows-watch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kathiekiwi
kathiekiwi marked this pull request as ready for review August 7, 2026 10:20
devin-ai-integration[bot]

This comment was marked as resolved.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx (1)

141-168: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

A reload requested after a write can reuse an older in-flight request.

loadHistory returns the in-flight promise when one exists. The callers at Line 417 (submitWatch) and Line 501 (cancelWatch) run right after a server write. If a history fetch started before that write, the caller receives the older response, and the new watch chip or the removal does not appear until the next reload.

Consider queuing a follow-up request when one is already in flight.

♻️ Sketch: chain a fresh request instead of reusing the in-flight one
-  const loadHistory = useCallback(async () => {
-    if (historyInFlight.current) return historyInFlight.current;
-    const request = (async () => {
+  const loadHistory = useCallback(async () => {
+    const previous = historyInFlight.current;
+    const request = (async () => {
+      // Wait out an older request, so a reload after a write never reuses its response.
+      if (previous) await previous;
       try {
🧹 Nitpick comments (22)
apps/webapp/app/v3/commonWorker.server.ts (1)

165-174: 🩺 Stability & Availability | 🔵 Trivial

Note the overlap between the visibility timeout and the cron period.

visibilityTimeoutMs is 5 minutes and the cron period is also 5 minutes. If a sweep exceeds the visibility timeout, the message becomes visible again and a second run can start while the first is still working. maxAttempts: 1 limits retries but does not prevent that re-delivery.

The existing dashboardAgent.maintenance entry uses the same values, so this matches current practice. Confirm that sweepDashboardAgentWatches and rearmDashboardAgentWatchBatches are safe to run concurrently, or raise the visibility timeout above the period.

internal-packages/emails/src/index.tsx (1)

136-141: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider using headline in the subject.

The subject interpolates data.identity, which is an internal condition key such as run_finished:run_abc123. The payload also carries headline, the human sentence the panel shows. Reading headline first gives a clearer subject and keeps the email consistent with the in-app wording.

headline is optional, so keep identity as the fallback.

♻️ Proposed subject change
       case "alert-dashboard-agent-watch": {
         return {
-          subject: `[${data.organization}] Watch update: ${data.identity}`,
+          subject: `[${data.organization}] Watch update: ${data.headline ?? data.identity}`,
           component: <AlertDashboardAgentWatchEmail {...data} />,
         };
       }
apps/webapp/app/services/dashboardAgentWatchRunChecks.ts (1)

19-36: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Keep the terminal-status list in sync with ~/v3/taskStatus.

FINAL_STATUSES currently matches FINAL_RUN_STATUSES, but imports should derive from the canonical source when possible. If the module must keep no server-side imports, add a type-level assertion that fails when this list drifts from FINAL_RUN_STATUSES.

apps/webapp/app/services/dashboardAgentWatches.server.ts (1)

930-954: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Hoist the shared TriggerClient if TriggerOptions includes trigger.

TriggerClient is exported from @trigger.dev/sdk, tasks.trigger accepts delay, idempotencyKey, and version, but idempotencyKeyTTL is only accepted by batch trigger options. If the single-task schedule path needs TTL, move it to batch scheduling; otherwise, create one module-level client for each apiOrigin to avoid repeated setup on each watch tick.

Source: Coding guidelines

apps/webapp/app/services/dashboardAgentWatchSweep.server.ts (1)

126-139: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

A rejected authorization promise is cached for the whole sweep.

authorizeOncePerSweep stores the pending promise before it settles. If authorize rejects, for example on a transient database error, every remaining watch in that group reuses the rejected promise. All those rows are then counted as failed in one sweep run instead of being retried independently. The next sweep run recovers them, so the impact is bounded. Consider evicting the entry on rejection.

♻️ Proposed eviction on rejection
     const cached = seen.get(key);
     if (cached) return cached;
-    const pending = authorize(watch);
+    const pending = authorize(watch).catch((error) => {
+      seen.delete(key);
+      throw error;
+    });
     seen.set(key, pending);
     return pending;
apps/webapp/app/services/dashboardAgentWatchToken.server.ts (1)

176-181: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

bearerToken only matches the exact scheme Bearer .

The scheme name in an Authorization header is case-insensitive. A header of bearer <token> leaves the prefix in place, so the extracted value fails the token prefix check and the caller receives 401. Match the scheme case-insensitively and allow repeated whitespace.

♻️ Proposed scheme matching
-  const value = raw.replace(/^Bearer /, "").trim();
+  const value = raw.replace(/^Bearer\s+/i, "").trim();
apps/webapp/app/services/dashboardAgentWatchBatch.server.ts (1)

167-175: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The authorization cache key omits environmentId.

authorizeOnce keys on user, organization, and project. The equivalent helper in apps/webapp/app/services/dashboardAgentWatchSweep.server.ts at Line 132 also includes environmentId. The batch is scoped to a single environment by params, so the two keys agree today. Align the keys so a future change to the row loader cannot silently reuse another environment's authorization.

♻️ Proposed key alignment
-    const key = `${watch.userId}:${watch.organizationId}:${watch.projectId}`;
+    const key = `${watch.userId}:${watch.organizationId}:${watch.projectId}:${watch.environmentId}`;
apps/webapp/app/routes/api.v1.dashboard-agent.watches.$watchId.fired.ts (1)

94-99: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

A failing release masks the enqueue error and strands the claim.

If releaseWatchAlertDispatch throws, the rethrow at Line 98 is never reached and the caller sees the release error instead of the enqueue error. The claim also stays held, so no later attempt can send the alert. Swallow and log the release failure, then rethrow the original error.

♻️ Proposed error handling
     try {
       await enqueueWatchFiredAlert(watch, "fired");
     } catch (error) {
-      await releaseWatchAlertDispatch(dashboardAgentDb, { id: watch.id, terminalStatus: "fired" });
+      await releaseWatchAlertDispatch(dashboardAgentDb, {
+        id: watch.id,
+        terminalStatus: "fired",
+      }).catch((releaseError) =>
+        logger.error("Dashboard agent watch alert claim couldn't be released", {
+          watchId,
+          releaseError,
+        })
+      );
       throw error;
     }
internal-packages/dashboard-agent/src/watch-delivery.ts (1)

176-177: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Detect a lost delivery fence when markWatchDelivered returns null.

markWatchDelivered returns Watch | null. A null result means the claimId fence no longer matches, so another deliverer took the claim after the stale window elapsed. The current code ignores that result and continues to notifyFired and notifyInvestigate. Downstream dedup limits the damage, but the lost fence is invisible in logs.

Log the null result so a duplicate-wake incident is diagnosable.

♻️ Proposed change
-  await deps.store.markWatchDelivered({ id: claimed.id, claimId });
+  const marked = await deps.store.markWatchDelivered({ id: claimed.id, claimId });
+  if (!marked) {
+    // The claim was reclaimed after the stale window, so another deliverer may also
+    // append. The action id dedups the wake; record the race for diagnosis.
+    logger.warn("dashboard-agent watch delivery lost its claim after appending", {
+      watchId: claimed.id,
+      claimId,
+    });
+  }
internal-packages/dashboard-agent/src/watch-narration.ts (1)

50-59: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add an exhaustiveness guard to the category switch.

The switch has no default. If WatchPresentation["category"] gains a member, this function returns undefined at runtime while its declared return type stays string. An exhaustiveness check turns that into a compile error instead. The repository already uses assert-never for this pattern in apps/webapp/app/v3/services/alerts/deliverDashboardAgentWatchAlert.server.ts.

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx (1)

98-120: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider running the promoted-prompt lookup and the activity read concurrently.

This loader runs on every environment-scoped page load. getPromotedDashboardAgentPrompt and readDashboardAgentWakeActivity are both gated on hasDashboardAgentAccess and are independent, but they are awaited one after the other. That adds two serial round trips to a hot path.

Run them with Promise.all to remove one round trip. Keep the per-read failure isolation so a store outage still lets the dashboard load.

♻️ Proposed concurrent read
-  const promotedDashboardAgentPrompt = hasDashboardAgentAccess
-    ? await getPromotedDashboardAgentPrompt({
-        orgFeatureFlags: (project.organization.featureFlags as Record<string, unknown>) ?? {},
-      })
-    : null;
-
-  // One narrow read per page load, so the wake signal reaches a browser that has never opened
-  // the panel — including one whose watch hasn't fired yet. The poll never asks for this.
-  let dashboardAgentActivity: DashboardAgentWakeActivity = {
-    unreadWakes: 0,
-    hasActiveWatches: false,
-  };
-  if (hasDashboardAgentAccess) {
-    try {
-      dashboardAgentActivity = await readDashboardAgentWakeActivity(dashboardAgentDb, {
-        organizationId: project.organization.id,
-        userId: user.id,
-      });
-    } catch (error) {
-      // The dashboard must load even when the agent's store doesn't answer.
-      logger.error("Failed to read dashboard agent wake activity", { error });
-    }
-  }
+  const NO_ACTIVITY: DashboardAgentWakeActivity = { unreadWakes: 0, hasActiveWatches: false };
+
+  // One narrow read per page load, so the wake signal reaches a browser that has never opened
+  // the panel — including one whose watch hasn't fired yet. The poll never asks for this.
+  const [promotedDashboardAgentPrompt, dashboardAgentActivity] = hasDashboardAgentAccess
+    ? await Promise.all([
+        getPromotedDashboardAgentPrompt({
+          orgFeatureFlags: (project.organization.featureFlags as Record<string, unknown>) ?? {},
+        }),
+        readDashboardAgentWakeActivity(dashboardAgentDb, {
+          organizationId: project.organization.id,
+          userId: user.id,
+        }).catch((error) => {
+          // The dashboard must load even when the agent's store doesn't answer.
+          logger.error("Failed to read dashboard agent wake activity", { error });
+          return NO_ACTIVITY;
+        }),
+      ])
+    : [null, NO_ACTIVITY];
apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx (1)

55-62: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the alert-type list into one constant.

The literal list is now repeated on line 57 and line 61. This change had to update both branches by hand. The next alert type carries the same risk: if only the array branch is updated, a single-checkbox submission fails validation while a multi-checkbox submission succeeds.

Declare the values once and reuse them in both branches.

♻️ Proposed single source for the alert types
+const AlertTypeEnum = z.enum([
+  "TASK_RUN",
+  "DEPLOYMENT_FAILURE",
+  "DEPLOYMENT_SUCCESS",
+  "DASHBOARD_AGENT_WATCH",
+]);
+
 const FormSchema = z
   .object({
-    alertTypes: z
-      .array(
-        z.enum(["TASK_RUN", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS", "DASHBOARD_AGENT_WATCH"])
-      )
-      .min(1)
-      .or(
-        z.enum(["TASK_RUN", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS", "DASHBOARD_AGENT_WATCH"])
-      ),
+    alertTypes: z.array(AlertTypeEnum).min(1).or(AlertTypeEnum),
apps/webapp/test/dashboardAgentWatchCardRequestId.test.ts (1)

142-161: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the status of the keyed submit as well.

Line 160 only checks that the response body is not invalid_request. A 500 response with a different error code also passes. Assert the expected status to keep the control case meaningful.

♻️ Proposed change
-      expect(await withKey.json()).not.toMatchObject({ code: "invalid_request" });
+      expect(withKey.status).not.toBe(400);
+      expect(await withKey.json()).not.toMatchObject({ code: "invalid_request" });
internal-packages/dashboard-agent-contracts/src/blocks.test.ts (1)

255-259: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Assert the parsed watch spec, not only the intent kind.

The strict schema could strip or default fields inside intent.spec and this assertion would still pass. Add an assertion on the parsed spec so the round-trip covers the payload.

♻️ Proposed addition
     const strict = viewBlockSchema.parse({ ...body, ...envelope });
     expect(strict.type === "actions" && strict.actions[0].intent.kind).toBe("watch");
+    expect(strict.type === "actions" && strict.actions[0].intent).toMatchObject(watchAction.intent);
apps/webapp/test/dashboardAgentWatchToken.test.ts (1)

12-12: 📐 Maintainability & Code Quality | 🔵 Trivial

Import USER_ACTOR_TOKEN_PREFIX instead of hardcoding "tr_uat_".

@trigger.dev/rbac re-exports USER_ACTOR_TOKEN_PREFIX from @trigger.dev/plugins, so use that constant instead of duplicating the prefix in the watch-token tests and keep the cross-token prefix changes in sync.

[low_effort和low_reward]

apps/webapp/test/dashboardAgentWatches.test.ts (1)

1473-1481: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Narrow activeWatch on watching.

result.ok still includes the watching: false branch, which does not provide watchId or expiresAt. The callers read those for check/token operations, so add the watching guard before returning.

♻️ Proposed narrowing
   async function activeWatch(seeded: Seeded) {
     const result = await create({ seeded });
     if (!result.ok) throw new Error(`watch not created: ${result.code}`);
+    if (!result.watching) throw new Error("expected an active watch");
     return result;
   }

Source: Coding guidelines

internal-packages/dashboard-agent/src/watch-actions.test.ts (1)

505-523: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the missing-userId fallback.

narrateWatchWake has a branch at watch-actions.ts lines 540-547 that runs when clientData.userId is absent. It logs an error and calls persistMessages with the whole transcript. The comment on lines 532-535 states that a wholesale write can drop host-appended blocks, so this branch trades correctness for delivery on purpose.

No test in this file exercises it. A test that sends WAKE with clientData lacking userId, then asserts calls.appendMessage is empty and calls.persistMessages has one entry, pins that deliberate trade-off in place.

Do you want me to generate this test?

internal-packages/dashboard-agent/src/watch-actions.ts (1)

423-438: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The Haiku wake lane emits no telemetry and no cache metrics.

The Sonnet branch on lines 439-455 ends with ...resolved.toAISDKTelemetry(). The Haiku branch on lines 425-438 does not. conductWatchInvestigation (lines 796-818) and the agent's own run (lines 571-582 in dashboard-agent.ts) both record telemetry and call recordPromptCacheUsage.

The comment on lines 371-375 says Haiku handles the common wake and Sonnet only the consented-investigation wake. So the lane with the most traffic is the one with no observability.

Add telemetry to the Haiku branch so wake narrations appear alongside every other model call.

♻️ Proposed change
           maxOutputTokens: HAIKU_WAKE_MAX_OUTPUT_TOKENS,
+          ...resolved.toAISDKTelemetry(),
         })
apps/webapp/app/components/dashboard-agent/WakeBanner.tsx (1)

113-119: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Share one semantic-icon map. Both files declare an identical SEMANTIC_ICON record that maps WatchSemanticIcon to a Heroicons glyph. A new icon added to the contract must then be added in two places, and the two maps can drift.

  • apps/webapp/app/components/dashboard-agent/WakeBanner.tsx#L113-L119: export this map (or move it to a small shared module next to agent-badges) so it is the single definition.
  • apps/webapp/app/components/dashboard-agent/WatchChips.tsx#L38-L44: delete the local copy and import the shared map. This file already imports wakePresentation from WakeBanner.
apps/webapp/app/components/dashboard-agent/WatchCard.tsx (1)

131-138: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Associate the Field label with its controls.

Field renders the label as a plain span. The pickers built from Choice are bare buttons. A screen reader announces "when it finishes" with no indication that it belongs to "Tell me". The numeric inputs carry aria-label, so only the picker rows are affected.

Add a group role and connect it to the label.

♻️ Proposed change
 function Field({ label, children }: { label: string; children: React.ReactNode }) {
+  const labelId = useId();
   return (
     <div className="flex flex-col gap-1">
-      <span className="text-xxs uppercase tracking-wide text-text-faint">{label}</span>
-      <div className="flex flex-wrap items-center gap-1">{children}</div>
+      <span id={labelId} className="text-xxs uppercase tracking-wide text-text-faint">
+        {label}
+      </span>
+      <div role="group" aria-labelledby={labelId} className="flex flex-wrap items-center gap-1">
+        {children}
+      </div>
     </div>
   );
 }
apps/webapp/app/components/dashboard-agent/watch-card.ts (2)

118-121: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

withWindow clamps but does not snap to an offered option.

The module comment at Line 5 states that "the window is always one of the offered options". withWindow only clamps between WATCH_WINDOW_HOURS_OPTIONS[0] and WATCH_MAX_HOURS. A value such as 7 passes through unchanged and is not an offered option. Today the card only passes values from WATCH_WINDOW_HOURS_OPTIONS, so the mismatch is not visible. WatchCard documents a free-text pre-fill path at Line 145, which could pass an arbitrary number.

Snap to the nearest offered option, in the same way clampCadence does.

♻️ Proposed change
 export function withWindow(draft: WatchDraft, maxHours: number): WatchDraft {
-  const clamped = Math.min(Math.max(maxHours, WATCH_WINDOW_HOURS_OPTIONS[0]), WATCH_MAX_HOURS);
+  const clamped = Math.min(Math.max(maxHours, WATCH_WINDOW_HOURS_OPTIONS[0]!), WATCH_MAX_HOURS);
+  const snapped =
+    WATCH_WINDOW_HOURS_OPTIONS.find((option) => option >= clamped) ??
+    WATCH_WINDOW_HOURS_OPTIONS[WATCH_WINDOW_HOURS_OPTIONS.length - 1]!;
-  return { ...draft, spec: { ...draft.spec, maxHours: clamped } as WatchSpec };
+  return { ...draft, spec: { ...draft.spec, maxHours: snapped } as WatchSpec };
 }

157-181: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

watchDraftError runs a Zod parse on every card render.

WatchCard calls watchDraftError(draft) at Line 164 of apps/webapp/app/components/dashboard-agent/WatchCard.tsx, directly in the render body. Each keystroke in the threshold input reparses the spec through watchSpecSchema.

Memoize the result in the card, keyed on draft.

Based on learnings, this repository treats Zod as a boundary validation tool for API handlers and storage reads/writes, not as inline render-time validation inside React components, to avoid per-render schema-parse overhead.

♻️ Proposed change in `WatchCard.tsx`
-  const localError = watchDraftError(draft);
+  const localError = useMemo(() => watchDraftError(draft), [draft]);

Source: Learnings


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f5885caf-6411-4004-a096-446ba10f9f27

📥 Commits

Reviewing files that changed from the base of the PR and between bb036f9 and 89b7522.

⛔ Files ignored due to path filters (2)
  • apps/webapp/test/__snapshots__/dashboardAgentWatchWording.test.ts.snap is excluded by !**/*.snap
  • internal-packages/dashboard-agent/src/__snapshots__/prompt-prefix.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (140)
  • .server-changes/dashboard-agent.md
  • apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx
  • apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx
  • apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx
  • apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx
  • apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx
  • apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx
  • apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx
  • apps/webapp/app/components/dashboard-agent/ReportView.tsx
  • apps/webapp/app/components/dashboard-agent/WakeBanner.tsx
  • apps/webapp/app/components/dashboard-agent/WatchButton.tsx
  • apps/webapp/app/components/dashboard-agent/WatchCard.tsx
  • apps/webapp/app/components/dashboard-agent/WatchChips.tsx
  • apps/webapp/app/components/dashboard-agent/WatchResultBlock.tsx
  • apps/webapp/app/components/dashboard-agent/WatchWakeToast.tsx
  • apps/webapp/app/components/dashboard-agent/chat-layout.test.ts
  • apps/webapp/app/components/dashboard-agent/chat-layout.tsx
  • apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx
  • apps/webapp/app/components/dashboard-agent/demo/fixtures/page-context.ts
  • apps/webapp/app/components/dashboard-agent/list-row.tsx
  • apps/webapp/app/components/dashboard-agent/message-quota.ts
  • apps/webapp/app/components/dashboard-agent/pending-intents.test.ts
  • apps/webapp/app/components/dashboard-agent/pending-intents.ts
  • apps/webapp/app/components/dashboard-agent/report-sparkline.tsx
  • apps/webapp/app/components/dashboard-agent/suggested-prompts/page-prompts.ts
  • apps/webapp/app/components/dashboard-agent/suggested-prompts/prompt-chips.ts
  • apps/webapp/app/components/dashboard-agent/suggested-prompts/resolver.test.ts
  • apps/webapp/app/components/dashboard-agent/suggested-prompts/resolver.ts
  • apps/webapp/app/components/dashboard-agent/suggested-prompts/signal-prompts.ts
  • apps/webapp/app/components/dashboard-agent/tool-labels.ts
  • apps/webapp/app/components/dashboard-agent/turn-error.test.ts
  • apps/webapp/app/components/dashboard-agent/turn-error.ts
  • apps/webapp/app/components/dashboard-agent/view-actions.test.ts
  • apps/webapp/app/components/dashboard-agent/view-catalog.tsx
  • apps/webapp/app/components/dashboard-agent/wake-banner.test.ts
  • apps/webapp/app/components/dashboard-agent/wake-poll.test.ts
  • apps/webapp/app/components/dashboard-agent/wake-poll.ts
  • apps/webapp/app/components/dashboard-agent/watch-activity.test.ts
  • apps/webapp/app/components/dashboard-agent/watch-activity.ts
  • apps/webapp/app/components/dashboard-agent/watch-card.test.ts
  • apps/webapp/app/components/dashboard-agent/watch-card.ts
  • apps/webapp/app/components/dashboard-agent/watch-chips.test.ts
  • apps/webapp/app/components/dashboard-agent/watch-chips.ts
  • apps/webapp/app/components/dashboard-agent/watch-recommendations.ts
  • apps/webapp/app/components/queues/queue-thresholds.ts
  • apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts
  • apps/webapp/app/presenters/v3/dashboardAgent/block-text.ts
  • apps/webapp/app/presenters/v3/dashboardAgent/index.ts
  • apps/webapp/app/presenters/v3/dashboardAgent/watch-wording.ts
  • apps/webapp/app/presenters/v3/reports/ReportPresenter.server.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx
  • apps/webapp/app/routes/api.v1.dashboard-agent.alerts.$channelId.ts
  • apps/webapp/app/routes/api.v1.dashboard-agent.alerts.ts
  • apps/webapp/app/routes/api.v1.dashboard-agent.watches.$watchId.check.ts
  • apps/webapp/app/routes/api.v1.dashboard-agent.watches.$watchId.fired.ts
  • apps/webapp/app/routes/api.v1.dashboard-agent.watches.$watchId.investigate.ts
  • apps/webapp/app/routes/api.v1.dashboard-agent.watches.batch-check.ts
  • apps/webapp/app/routes/api.v1.dashboard-agent.watches.ts
  • apps/webapp/app/routes/resources.dashboard-agent.alerts.$channelId.unsubscribe.tsx
  • apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts
  • apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx
  • apps/webapp/app/services/dashboardAgentAlertContext.server.ts
  • apps/webapp/app/services/dashboardAgentAlertUnsubscribeToken.server.ts
  • apps/webapp/app/services/dashboardAgentWatchAlerts.server.ts
  • apps/webapp/app/services/dashboardAgentWatchBatch.server.ts
  • apps/webapp/app/services/dashboardAgentWatchCheckBase.ts
  • apps/webapp/app/services/dashboardAgentWatchChecks.server.ts
  • apps/webapp/app/services/dashboardAgentWatchChecks.ts
  • apps/webapp/app/services/dashboardAgentWatchErrorChecks.ts
  • apps/webapp/app/services/dashboardAgentWatchHealthChecks.ts
  • apps/webapp/app/services/dashboardAgentWatchInvestigate.server.ts
  • apps/webapp/app/services/dashboardAgentWatchQueueChecks.ts
  • apps/webapp/app/services/dashboardAgentWatchRunChecks.ts
  • apps/webapp/app/services/dashboardAgentWatchSweep.server.ts
  • apps/webapp/app/services/dashboardAgentWatchToken.server.ts
  • apps/webapp/app/services/dashboardAgentWatches.server.ts
  • apps/webapp/app/v3/alertsWorker.server.ts
  • apps/webapp/app/v3/commonWorker.server.ts
  • apps/webapp/app/v3/services/alerts/deliverAlert.server.ts
  • apps/webapp/app/v3/services/alerts/deliverDashboardAgentWatchAlert.server.ts
  • apps/webapp/package.json
  • apps/webapp/seed-watch-scenarios.mts
  • apps/webapp/test/dashboardAgentBodyCap.test.ts
  • apps/webapp/test/dashboardAgentTranscriptStore.test.ts
  • apps/webapp/test/dashboardAgentWakeActivity.test.ts
  • apps/webapp/test/dashboardAgentWatchAlertFanout.test.ts
  • apps/webapp/test/dashboardAgentWatchAlertOwnerScope.test.ts
  • apps/webapp/test/dashboardAgentWatchBatchFairness.test.ts
  • apps/webapp/test/dashboardAgentWatchBatchRecording.test.ts
  • apps/webapp/test/dashboardAgentWatchCardAtomicity.test.ts
  • apps/webapp/test/dashboardAgentWatchCardRequestId.test.ts
  • apps/webapp/test/dashboardAgentWatchChecks.test.ts
  • apps/webapp/test/dashboardAgentWatchCreationReads.test.ts
  • apps/webapp/test/dashboardAgentWatchInvestigate.test.ts
  • apps/webapp/test/dashboardAgentWatchSweepBoundary.test.ts
  • apps/webapp/test/dashboardAgentWatchTenancy.test.ts
  • apps/webapp/test/dashboardAgentWatchToken.test.ts
  • apps/webapp/test/dashboardAgentWatchWording.test.ts
  • apps/webapp/test/dashboardAgentWatches.test.ts
  • apps/webapp/test/reportHealth.test.ts
  • internal-packages/dashboard-agent-contracts/src/blocks.test.ts
  • internal-packages/dashboard-agent-contracts/src/blocks.ts
  • internal-packages/dashboard-agent-contracts/src/contracts.test.ts
  • internal-packages/dashboard-agent-contracts/src/index.ts
  • internal-packages/dashboard-agent-contracts/src/intent.ts
  • internal-packages/dashboard-agent-contracts/src/watch-wording.ts
  • internal-packages/dashboard-agent/GUIDEBOOK.md
  • internal-packages/dashboard-agent/README.md
  • internal-packages/dashboard-agent/src/agent-runtime.ts
  • internal-packages/dashboard-agent/src/compaction.test.ts
  • internal-packages/dashboard-agent/src/compaction.ts
  • internal-packages/dashboard-agent/src/dashboard-agent.eval.ts
  • internal-packages/dashboard-agent/src/dashboard-agent.test.ts
  • internal-packages/dashboard-agent/src/dashboard-agent.ts
  • internal-packages/dashboard-agent/src/eval-policy.ts
  • internal-packages/dashboard-agent/src/index.ts
  • internal-packages/dashboard-agent/src/step-cache.ts
  • internal-packages/dashboard-agent/src/tool-alerts.ts
  • internal-packages/dashboard-agent/src/tool-investigations.ts
  • internal-packages/dashboard-agent/src/tool-schemas.ts
  • internal-packages/dashboard-agent/src/tools.ts
  • internal-packages/dashboard-agent/src/watch-actions.test.ts
  • internal-packages/dashboard-agent/src/watch-actions.ts
  • internal-packages/dashboard-agent/src/watch-batch.ts
  • internal-packages/dashboard-agent/src/watch-delivery.ts
  • internal-packages/dashboard-agent/src/watch-lifecycle.ts
  • internal-packages/dashboard-agent/src/watch-narration.test.ts
  • internal-packages/dashboard-agent/src/watch-narration.ts
  • internal-packages/dashboard-agent/src/watch-task-adapters.ts
  • internal-packages/dashboard-agent/src/watch-tick.test.ts
  • internal-packages/dashboard-agent/src/watch-tick.ts
  • internal-packages/dashboard-agent/src/watch-tools.ts
  • internal-packages/database/prisma/migrations/20260729120000_add_dashboard_agent_watch_alert_type/migration.sql
  • internal-packages/database/prisma/schema.prisma
  • internal-packages/emails/emails/alert-dashboard-agent-watch.tsx
  • internal-packages/emails/src/index.tsx

Comment thread apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx
Comment thread apps/webapp/app/components/dashboard-agent/watch-activity.ts
Comment thread apps/webapp/app/components/dashboard-agent/WatchChips.tsx
Comment thread apps/webapp/app/routes/api.v1.dashboard-agent.alerts.ts Outdated
Comment thread apps/webapp/app/routes/api.v1.dashboard-agent.watches.$watchId.check.ts Outdated
Comment thread internal-packages/dashboard-agent/src/tool-schemas.ts
Comment thread internal-packages/dashboard-agent/src/watch-actions.ts
Comment thread internal-packages/dashboard-agent/src/watch-actions.ts
Comment thread internal-packages/dashboard-agent/src/watch-lifecycle.ts
Comment thread internal-packages/emails/emails/alert-dashboard-agent-watch.tsx
@kathiekiwi
kathiekiwi changed the base branch from feat/dashboard-agent-flows to feat/dashboard-agent-ui August 7, 2026 12:43
@pkg-pr-new

pkg-pr-new Bot commented Aug 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@59378cf

trigger.dev

npm i https://pkg.pr.new/trigger.dev@59378cf

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@59378cf

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@59378cf

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@59378cf

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@59378cf

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@59378cf

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@59378cf

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@59378cf

commit: 59378cf

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Observability map

As of 6630047.

20/100 over 424 measured of 440 entry points (base 19, up 1)

What this PR changed

route base head now failing
/api/v1/dashboard-agent/watches/:watchId/check (suppressed: error-classification) new 50

FIX FIRST

  • /api/v1/projects/:projectRef/envvars (sensitive) - auth-boundary, request-context
  • /auth/sso (sensitive) - auth-boundary, request-context
  • /_app/orgs/:organizationSlug/settings/team (sensitive) - error-classification, auth-scope, request-context

AUDIT 3 of 50 sensitive mutations record an actor. 47 without one.
CONTEXT 21 of 424 entry points name a tenant on a failure path. 324 appear only here, 39 of them sensitive, in the JSON rather than the fix list.

What the score is made of
CHECKS
  error-classification  177 applicable, 101 pass,   0 sole, global without it 12
  auth-boundary          62 applicable,  57 pass,   0 sole, global without it 17
  auth-scope             19 applicable,  17 pass,   0 sole, global without it 20
  request-context       424 applicable,  21 pass, 225 sole, global without it 64
  audit-trail            50 applicable,   3 pass,   0 sole, not in the score

The score and findings here are report-only and never gate the merge. Separately, a required test suite keeps this tool's symbol and route lists in sync with the code they name, and can fail a pull request that renames or removes a symbol they reference, or that adds the first route with a segment they anticipate. Each failure names the list to edit. The rules and their reasons: internal-packages/observability-map/README.md.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@kathiekiwi
kathiekiwi force-pushed the feat/dashboard-agent-flows-watch branch from 712396b to e110e90 Compare August 8, 2026 12:05
@kathiekiwi
kathiekiwi force-pushed the feat/dashboard-agent-ui branch from bd4d4a0 to 887f5b6 Compare August 8, 2026 12:05
@kathiekiwi
kathiekiwi force-pushed the feat/dashboard-agent-flows-watch branch from c0f0058 to e7432a8 Compare August 8, 2026 14:30
@kathiekiwi
kathiekiwi force-pushed the feat/dashboard-agent-ui branch from 887f5b6 to 17a0f07 Compare August 8, 2026 14:30
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

coderabbitai[bot]

This comment was marked as resolved.

kathiekiwi and others added 27 commits August 9, 2026 11:51
create_alert reached for an `alert` envelope the route never sent, so it
always reported undefined, and the 403 branch keyed on `reason` where the
route sends `code` — the "email isn't set up on this instance" wording was
unreachable. The unit fixtures were written against both invented shapes, so
they hid it; they now mirror the route's bodies exactly.

The subscribe path also inlined its deduplication key instead of calling
`watchAlertDeduplicationKey`, the one record of whose channel it is.
Each failed check stored the whole previous `lastResult` under `previous`, so
consecutive failures wrapped one another without bound. The row escapes: an
unverified expiry copies `lastResult` into the wake facts, which the alert and
the webhook body serialise. A failure record is now unwrapped before it is
stored, so `previous` is always the last observation the check really made.
…r_view

The flag was computed inside `ViewBlocks`, which sees the blocks of a single
`render_view` part. A turn that renders the investigation card in one call and
the actions block in another gave each call its own answer, and the duplicate
Watch button came back. It is now computed where every part of the message is
in scope; a card can still add its own offer, never drop the turn's.
The poll deduped fresh wakes against a 50-id localStorage set alone, so inside
the feed's 15-minute window a second browser — or one whose site data was
cleared — toasted wakes the user had already read. The payload's own `unread`
flag now gates the toast as well. A wake landing in an open chat stays unread
until that chat's next read, so it still toasts.
`SimpleTooltip` drops its trigger from the tab order unless `tabbable` is set,
and the chip's label tooltip is the only place its status, cadence and expiry
are written. The cancel tooltip beside it already passed the prop.
The seed script refused a non-local Redis or ClickHouse host outright, but sent
the target's API key to whatever `APP_ORIGIN` named. All three now go through
one guard, which also fixes it for a bracketed IPv6 host — `URL.hostname` hands
back `[::1]`, which the old set membership never matched.
…t read

The column was added nullable and every reader treats NULL as unread, so the
first load after rollout reported every pre-existing chat unread.
… ClickHouse

The chat route now pulls in the watch services, and through them a ClickHouse
client built at import time from an unset env var.
The file the review comment named was missed: it opened a pool per case and never closed one. Adds the sibling files' afterEach and their 30s case timeout.
…that isn't there

The queue's live row read collapsed every non-ok response into "no live row",
so a 401, a 429 or a 5xx reached the model as exists:false — the queue does not
exist. Only a 404 is evidence of absence now; anything else reports
exists:"unknown" with the status, and the prompt says unknown is never missing.
The wake poll's callback closed over `open` from the render that started it, so
once the panel opened the subtraction never applied. The panel's open state now
goes through a ref, like the visible chat already does, rather than adding
`open` to the effect's deps and restarting the poll on every open and close.
…watch

A watch can expire or be cancelled with nothing written back into the
transcript, so asking the summariser for "any watch that is running" preserved
an old confirmation as current state.
The guard compared a slice against its own start, so it could not fail. Move
the decision into takeNavigateIntent and drive it across two commits instead.
The unmount case repeated its neighbour and no query string can reach
unmountTeardown. Guard the pathname tracking that does decide it.
…token

pk_ is browser-shipped and environment-bound. Nothing routes it to the query
API today, so the cap costs no caller anything and the helper stops promising
the wrong thing.
The seeder and its `scenarios:watch` script were only ever a way to reproduce a
watch condition by hand. The guidebook now states the conditions themselves, so
there is nothing left for the kit to be the answer to.
…oduce it

The guidebook was a walkthrough of the scenario kit: a command, some clicks, and
the sentence that came back. It is now a reference of conditions, derived from
the checks rather than from the old prose, so a reader can predict an outcome
without running anything.

Corrects, among others: the queue Investigate rule (a zero or unset concurrency
limit is never saturation), the run-panel Investigate rule (it needs an error
block as well as a failed status), the claim that a backed-up queue never offers
an investigate chip (the page registry offers both), and the claim that every
wake is an LLM call (only attention outcomes and consented investigations are).
…lable checks

- 0003 catches the last_read_at backfill up on databases where 0002 already ran.
- The per-watch check endpoint records a look, not a check, when it read nothing.
- A suggested prompt is consumed once it is sent, not when it is clicked.
- Closing the panel settles the launcher dot instead of waiting for the poll.
- Say why the oldest-age reader's 50-key page cannot under-report.
- Suppress error-classification on the check route, with the reason on the record.
@kathiekiwi
kathiekiwi force-pushed the feat/dashboard-agent-flows-watch branch from 6630047 to 59378cf Compare August 9, 2026 11:57
@kathiekiwi
kathiekiwi force-pushed the feat/dashboard-agent-ui branch 2 times, most recently from 92efdcd to 2481567 Compare August 9, 2026 12:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant