Skip to content

Commit 93c4a02

Browse files
committed
feat(webapp): a consented watch investigation conducts itself
The wake seeded the card and said it had started looking, then nothing ran it — the findings were left to a turn only the user could start. The watcher now reports a delivered consented wake to the webapp, which mints the same delegated user-actor token a turn gets and sends a `watch.investigate` action into the chat; the agent conducts a real investigating turn on that card and delivers the findings as its own message. Best-effort throughout: nothing here can retry or invalidate the wake.
1 parent 4aa9abc commit 93c4a02

15 files changed

Lines changed: 1451 additions & 62 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
When you ask a watch to investigate bad news, it now does the whole thing on its own: the chat tells you the watch resolved, and the findings arrive right after in their own message, with the investigation card. You no longer have to come back and ask.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { getWatch, isTerminalWatchStatus } from "@internal/dashboard-agent-db";
2+
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
3+
import { z } from "zod";
4+
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
5+
import {
6+
kickWatchInvestigation,
7+
watchWantsInvestigation,
8+
} from "~/services/dashboardAgentWatchInvestigate.server";
9+
import { authorizeWatchEnvironment } from "~/services/dashboardAgentWatches.server";
10+
import {
11+
bearerToken,
12+
verifyWatchTokenFromRequest,
13+
} from "~/services/dashboardAgentWatchToken.server";
14+
import { logger } from "~/services/logger.server";
15+
16+
/**
17+
* `POST /api/v1/dashboard-agent/watches/:watchId/investigate` — the watcher task
18+
* tells us it has delivered the wake for a watch whose creator pre-approved an
19+
* investigation, so the agent can be sent off to actually conduct it.
20+
*
21+
* Same security model as the fired callback next door: the TOKEN only names a
22+
* watch, the ROW is the authority on what happened, and the watch's initiating USER
23+
* is re-authorized against the row's immutable project/environment before anything
24+
* is minted — an investigation must never outlive the access the watch was created
25+
* with. The caller's body is ignored entirely, so a replay can only ever re-ask for
26+
* what the row already says; the agent then dedupes on the action's stable id.
27+
*
28+
* Nothing here can be steered by the model: the consent, the outcome, the user and
29+
* the environment all come off the row.
30+
*/
31+
32+
const ParamsSchema = z.object({ watchId: z.string().min(1) });
33+
34+
export async function action({ request, params }: ActionFunctionArgs) {
35+
if (request.method.toUpperCase() !== "POST") {
36+
return json({ error: "Method not allowed" }, { status: 405 });
37+
}
38+
39+
const parsedParams = ParamsSchema.safeParse(params);
40+
if (!parsedParams.success) return json({ error: "Invalid params" }, { status: 400 });
41+
const { watchId } = parsedParams.data;
42+
43+
const token = bearerToken(request);
44+
if (!token) {
45+
return json(
46+
{ error: "Invalid or missing access token", code: "unauthorized" },
47+
{ status: 401 }
48+
);
49+
}
50+
51+
const claims = await verifyWatchTokenFromRequest(token);
52+
if (!claims) {
53+
return json(
54+
{ error: "Invalid or missing access token", code: "unauthorized" },
55+
{ status: 401 }
56+
);
57+
}
58+
59+
if (claims.watchId !== watchId) {
60+
return json({ error: "Not allowed for this watch", code: "watch_mismatch" }, { status: 403 });
61+
}
62+
63+
const watch = await getWatch(dashboardAgentDb, { id: watchId });
64+
if (!watch) {
65+
return json({ error: "Watch not found", code: "not_found" }, { status: 404 });
66+
}
67+
68+
// The row decides, on all three counts: it has to have resolved, the user has to
69+
// have consented, and the outcome has to be one the consent covers.
70+
if (!isTerminalWatchStatus(watch.status)) {
71+
return json(
72+
{ error: `This watch is ${watch.status}`, code: "not_resolved", status: watch.status },
73+
{ status: 409 }
74+
);
75+
}
76+
77+
if (!watchWantsInvestigation(watch)) {
78+
// Good news, neutral news, or no consent — the wake was the whole delivery.
79+
return json({ ok: true, investigating: false });
80+
}
81+
82+
const authorization = await authorizeWatchEnvironment({
83+
userId: watch.userId,
84+
organizationId: watch.organizationId,
85+
projectId: watch.projectId,
86+
environmentId: watch.environmentId,
87+
});
88+
89+
if (!authorization.ok) {
90+
logger.info("Dashboard agent watch resolved, but access was revoked; no investigation", {
91+
watchId,
92+
});
93+
return json(
94+
{ error: "Access to this environment was revoked", code: "access_revoked" },
95+
{ status: 403 }
96+
);
97+
}
98+
99+
// Best-effort, and deliberately not an error to the caller: the wake has already
100+
// been delivered and marked by the time we are called, so a failed kick must not
101+
// make the watcher retry anything (§6). A turn that never starts — or one that
102+
// dies mid-investigation — is caught by the stale-investigation sweep, which
103+
// settles the card instead of leaving a spinner.
104+
try {
105+
await kickWatchInvestigation({ watch, environment: authorization.environment });
106+
} catch (error) {
107+
logger.error("Dashboard agent watch investigation could not be started", { watchId, error });
108+
return json({ ok: true, investigating: false, code: "kick_failed" });
109+
}
110+
111+
return json({ ok: true, investigating: true });
112+
}

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { $replica } from "~/db.server";
33
import { findProjectBySlug } from "~/models/project.server";
44
import {
55
dashboardAgentApiOrigin,
6+
dashboardAgentEnvironmentName,
67
mintDashboardAgentUserActorToken,
78
resolveDashboardAgentRepoSnapshot,
89
} from "~/services/dashboardAgent.server";
@@ -36,17 +37,6 @@ const FORWARDED_HEADERS = [
3637
"x-trigger-branch",
3738
];
3839

39-
// The API's env routes key on the canonical env name (dev/staging/prod/preview),
40-
// not the dashboard URL slug (e.g. staging's slug is "stg"). Map from the env
41-
// type so the agent's tools address the right environment. Preview branches
42-
// aren't threaded yet (they'd need the branch on every tool call) — a follow-up.
43-
const ENV_NAME_BY_TYPE: Record<string, string> = {
44-
DEVELOPMENT: "dev",
45-
STAGING: "staging",
46-
PRODUCTION: "prod",
47-
PREVIEW: "preview",
48-
};
49-
5040
export async function action({ request, params }: ActionFunctionArgs) {
5141
const user = await requireUser(request);
5242
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
@@ -79,7 +69,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
7969
where: { projectId: project.id, slug: envParam },
8070
select: { id: true, type: true },
8171
});
82-
const environmentName = runtimeEnv ? ENV_NAME_BY_TYPE[runtimeEnv.type] : undefined;
72+
const environmentName = dashboardAgentEnvironmentName(runtimeEnv?.type);
8373

8474
// When the project has a connected GitHub repo, resolve a signed source-archive
8575
// pointer (code mode). Null otherwise -> the agent stays in assistant mode.
@@ -91,8 +81,18 @@ export async function action({ request, params }: ActionFunctionArgs) {
9181
try {
9282
const parsed = JSON.parse(raw) as {
9383
kind?: string;
94-
payload?: { metadata?: Record<string, unknown> };
84+
payload?: { trigger?: string; metadata?: Record<string, unknown> };
9585
};
86+
// Actions are server business, not the browser's. A wake comes from the
87+
// watcher task and the investigate kick from our own server hop, both writing
88+
// to `.in` with an environment key — nothing arriving through this proxy may
89+
// claim to be one. Refusing here (rather than letting the agent's action schema
90+
// sort it out) keeps "an action was placed by the server" a real boundary: the
91+
// proxy is the one path a browser can reach `.in` through, and it is also where
92+
// a delegated token gets minted.
93+
if (parsed.payload?.trigger === "action") {
94+
return json({ error: "Not allowed" }, { status: 403 });
95+
}
9696
if (parsed.kind === "message" && parsed.payload) {
9797
parsed.payload.metadata = {
9898
...(parsed.payload.metadata ?? {}),

apps/webapp/app/services/dashboardAgent.server.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,21 @@ export function mintDashboardAgentUserActorToken(
5555
});
5656
}
5757

58+
// The API's env routes key on the canonical env name (dev/staging/prod/preview),
59+
// not the dashboard URL slug (e.g. staging's slug is "stg"). Anything handing the
60+
// agent an environment maps from the type through here, so a tool built from an
61+
// injected token and a tool built from a kicked action address the same place.
62+
const ENV_NAME_BY_TYPE: Record<string, string> = {
63+
DEVELOPMENT: "dev",
64+
STAGING: "staging",
65+
PRODUCTION: "prod",
66+
PREVIEW: "preview",
67+
};
68+
69+
export function dashboardAgentEnvironmentName(type: string | undefined): string | undefined {
70+
return type ? ENV_NAME_BY_TYPE[type] : undefined;
71+
}
72+
5873
// The session is created in whatever env DASHBOARD_AGENT_SECRET_KEY belongs to.
5974
// baseURL is the Trigger instance this webapp runs against (its own API origin).
6075
function dashboardAgentConfig() {
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* The second half of a consented watch: making the agent CONDUCT the investigation
3+
* its wake said had started.
4+
*
5+
* The wake seeds the card and says "I've started looking into why", but the wake
6+
* turn deliberately carries no delegated token — it narrates what the check already
7+
* established, it doesn't read. So the reading turn is kicked from here, where a
8+
* token can be minted: one record on the chat's `in` stream with
9+
* `trigger: "action"`, carrying a fresh short-lived delegated token for the watch's
10+
* INITIATING user in the metadata, exactly the way the dashboard's `in` proxy
11+
* injects a turn's token. The agent handles it as its own message.
12+
*
13+
* Security, all of it decided here and none of it model-suppliable:
14+
* - the token is for `watch.userId`, minted against `watch.environmentId` — the
15+
* immutable snapshot the watch was created with, never anything from a request
16+
* body;
17+
* - the caller re-authorizes that user against that environment first (the route
18+
* does, before calling in), so a revoked access investigates nothing;
19+
* - the TTL is the same short one every turn's token gets;
20+
* - the token is never logged and never reaches a browser.
21+
*/
22+
23+
import type { WatchInvestigateAction } from "@internal/dashboard-agent";
24+
import { type Watch } from "@internal/dashboard-agent-db";
25+
import { watchResultNeedsAttention } from "@internal/dashboard-agent-contracts";
26+
import { ApiClient } from "@trigger.dev/core/v3";
27+
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
28+
import {
29+
dashboardAgentApiOrigin,
30+
dashboardAgentEnvironmentName,
31+
mintDashboardAgentUserActorToken,
32+
} from "~/services/dashboardAgent.server";
33+
import { env } from "~/env.server";
34+
35+
/**
36+
* Whether this resolved watch is one the consent covers.
37+
*
38+
* Consent is for ATTENTION outcomes only, and which those are is the contracts'
39+
* mapping to decide — the same call the agent's wake makes, so the two can never
40+
* disagree about what counts as bad news. A watch with no resolution (still active,
41+
* or cancelled) has no outcome to investigate.
42+
*/
43+
export function watchWantsInvestigation(watch: Watch): boolean {
44+
if (!watch.investigateOnAttention) return false;
45+
if (!watch.resolution) return false;
46+
return watchResultNeedsAttention({
47+
kind: watch.spec.kind,
48+
resolution: watch.resolution,
49+
outcome: watch.observedOutcome,
50+
});
51+
}
52+
53+
/**
54+
* The action the agent receives. Stable id, so a retried kick is a no-op.
55+
*
56+
* Typed against the agent's own contract (type-only import — the agent's runtime
57+
* must never enter this bundle), so the two halves can't drift.
58+
*/
59+
export function watchInvestigateAction(watch: Watch): WatchInvestigateAction {
60+
return {
61+
type: "watch.investigate" as const,
62+
id: `watch:${watch.id}:${watch.status}:investigate`,
63+
watchId: watch.id,
64+
identity: watch.identity,
65+
spec: watch.spec,
66+
facts: (watch.lastResult ?? {}) as Record<string, unknown>,
67+
resolution: watch.resolution ?? undefined,
68+
observed: watch.observedOutcome ?? undefined,
69+
note: watch.spec.note,
70+
};
71+
}
72+
73+
/**
74+
* Send the kick. Throws on failure — every caller treats it as best-effort, because
75+
* the wake has already been delivered by the time we get here and nothing about
76+
* this may retry or invalidate it (§6). A turn that dies mid-investigation is
77+
* covered by the stale-investigation sweep, not by retrying this.
78+
*/
79+
export async function kickWatchInvestigation(params: {
80+
watch: Watch;
81+
environment: AuthenticatedEnvironment;
82+
}): Promise<void> {
83+
const { watch, environment } = params;
84+
const accessToken = env.DASHBOARD_AGENT_SECRET_KEY;
85+
if (!accessToken) throw new Error("DASHBOARD_AGENT_SECRET_KEY is not set");
86+
87+
const apiOrigin = dashboardAgentApiOrigin();
88+
// The agent's `clientData` for this record — the watch's own immutable tenancy
89+
// plus the delegated token, which is what turns this into a turn that can read.
90+
const metadata = {
91+
userId: watch.userId,
92+
organizationId: watch.organizationId,
93+
projectId: watch.projectId,
94+
environmentId: watch.environmentId,
95+
projectRef: watch.projectRef ?? environment.project.externalRef,
96+
environmentName: dashboardAgentEnvironmentName(environment.type),
97+
apiOrigin,
98+
userActorToken: await mintDashboardAgentUserActorToken(watch.userId, {
99+
environmentId: watch.environmentId,
100+
}),
101+
};
102+
103+
const apiClient = new ApiClient(apiOrigin, accessToken);
104+
await apiClient.appendToSessionStream(
105+
// Sessions are addressable by externalId, which is the chat id.
106+
watch.chatId,
107+
"in",
108+
JSON.stringify({
109+
kind: "message",
110+
payload: {
111+
chatId: watch.chatId,
112+
trigger: "action",
113+
action: watchInvestigateAction(watch),
114+
metadata,
115+
},
116+
})
117+
);
118+
}

0 commit comments

Comments
 (0)