Skip to content

Commit 6abbd85

Browse files
committed
Merge branch 'feat/dashboard-agent-flows-watch' into feat/agent-storybook-gallery
2 parents 5755c4a + a91af3b commit 6abbd85

11 files changed

Lines changed: 253 additions & 18 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ export function DashboardAgentPanel({
383383
});
384384
}, []);
385385

386-
const dismissWatchCard = useCallback(() => dispatchWatchCard({ type: "dismissed" }), []);
386+
const dismissWatchCard = () => dispatchWatchCard({ type: "dismissed" });
387387

388388
const submitWatch = useCallback(async () => {
389389
const draft = watchCard.draft;

apps/webapp/app/components/dashboard-agent/suggested-prompts/page-mappers.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,12 @@ describe("queueAgentPageContext", () => {
212212
expect(agentPageContextSchema.safeParse(context).success).toBe(true);
213213
});
214214

215+
// A watch the agent proposes off this context is validated against the stored name.
216+
it("names a task queue by its stored name, prefix and all", () => {
217+
const context = queueAgentPageContext(queueLoaderData({ type: "task", name: "send-receipt" }));
218+
expect(context?.page).toMatchObject({ kind: "queue", name: "task/send-receipt" });
219+
});
220+
215221
it("emits no saturation signal when the queue is idle under its limit", () => {
216222
const context = queueAgentPageContext(queueLoaderData({ running: 10, queued: 0 }));
217223
expect(context?.signals).toEqual([]);

apps/webapp/app/components/dashboard-agent/suggested-prompts/page-mappers.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55
import type { AgentPageContext, AgentPageSignal } from "@internal/dashboard-agent-contracts";
66
import { z } from "zod";
7+
import { storedQueueName } from "~/components/queues/queue-name";
78
import { isQueueAtCapacity, OLDEST_WAIT_WARNING_MS } from "~/components/queues/queue-thresholds";
89

910
export const FRESH_FAILURE_WINDOW_MS = 30 * 60_000;
@@ -163,6 +164,7 @@ export const QUEUE_OLDEST_WAIT_WARNING_MS = OLDEST_WAIT_WARNING_MS;
163164
const queueLoaderDataSchema = z.object({
164165
queue: z.object({
165166
name: z.string(),
167+
type: z.string(),
166168
paused: z.boolean().nullish(),
167169
running: z.number(),
168170
queued: z.number(),
@@ -200,7 +202,7 @@ export function queueAgentPageContext(data: unknown): AgentPageContext | undefin
200202
const parsed = queueLoaderDataSchema.safeParse(data);
201203
if (!parsed.success) return undefined;
202204

203-
const { name, paused, running, queued, concurrencyLimit } = parsed.data.queue;
205+
const { name, type, paused, running, queued, concurrencyLimit } = parsed.data.queue;
204206
const { environmentConcurrencyLimit, oldestQueuedAt, loadedAt, ckBreakdown } = parsed.data;
205207
const limit = concurrencyLimit ?? environmentConcurrencyLimit ?? null;
206208
const atCapacity = isQueueAtCapacity({ running, queued, limit });
@@ -217,7 +219,11 @@ export function queueAgentPageContext(data: unknown): AgentPageContext | undefin
217219
signals.push({ kind: "concurrency_saturation", severity: queued >= limit! ? "crit" : "warn" });
218220
}
219221

220-
return { page: { kind: "queue", name, health, paused: Boolean(paused) }, signals };
222+
// The stored name, not the display one: a watch the agent proposes has to validate against it.
223+
return {
224+
page: { kind: "queue", name: storedQueueName({ type, name }), health, paused: Boolean(paused) },
225+
signals,
226+
};
221227
}
222228

223229
export function deploymentsAgentPageContext(): AgentPageContext {

apps/webapp/app/routes/api.v1.orgs.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,33 @@ export const loader = createLoaderPATApiRoute(
4040
}
4141
);
4242

43-
// No org exists yet, so there is nothing to scope the gate to; any authenticated user can create
44-
// an org and becomes its ADMIN. The gate is still declared so a narrowly-capped delegated token
45-
// (which cannot `manage`) is refused rather than inheriting its user's full reach.
43+
// No org exists yet, so there is nothing to scope a route-level gate to; any authenticated user
44+
// can create an org and becomes its ADMIN. A narrowly-capped delegated token (which cannot
45+
// `manage`) is still refused rather than inheriting its user's full reach — but only for
46+
// user-actor tokens, so an ordinary PAT is unaffected.
4647
export const action = createActionPATApiRoute(
4748
{
4849
method: "POST",
4950
body: CreateOrgRequestBody,
50-
authorization: { action: "manage", resource: () => ({ type: "organization" }) },
5151
},
52-
async ({ body, authentication }) => {
52+
async ({ body, authentication, ability }) => {
5353
if (env.ORG_CREATION_API_ENABLED !== "1") {
5454
return json({ error: "Not found" }, { status: 404 });
5555
}
5656

57+
// After the env gate: an install with the API disabled should 404, not 403.
58+
if (authentication.userActor && !ability.can("manage", { type: "organization" })) {
59+
return json(
60+
{
61+
error: "Unauthorized",
62+
code: "unauthorized",
63+
param: "access_token",
64+
type: "authorization",
65+
},
66+
{ status: 403 }
67+
);
68+
}
69+
5770
// Mirror the dashboard: stash companyUrl/companySize as onboarding data and
5871
// derive the org avatar from the company domain's favicon.
5972
const onboardingData: Record<string, string> = {};

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
watchIdentity,
3838
watchOneShotBlockBody,
3939
watchRequestSentence,
40+
watchResolvedBlockBody,
4041
watchSubjectLabel,
4142
type WatchDraft,
4243
type WatchExternalNotification,
@@ -609,8 +610,27 @@ export async function submitDashboardAgentWatch(params: {
609610
unavailable: boolean;
610611
external: WatchExternalNotification;
611612
confirmed?: WatchDraft;
613+
/** The row this settles against, when the caller already read it. */
614+
watch?: Watch | null;
612615
}) => {
613616
const confirmed = args.confirmed ?? draft;
617+
// A retry can settle against a watch that already ran: say what it found, not "watching".
618+
const resolution = args.watch?.status !== "active" ? args.watch?.resolution : null;
619+
if (args.watch && resolution) {
620+
return confirmationMessage({
621+
id: `${WATCH_CONFIRMATION_MESSAGE_ID_PREFIX}${args.watchId}`,
622+
blockId: args.watchId,
623+
body: watchResolvedBlockBody({
624+
watchId: args.watchId,
625+
resolved: {
626+
kind: confirmed.spec.kind,
627+
identity: args.watch.identity,
628+
resolution,
629+
observed: args.watch.observedOutcome,
630+
},
631+
}),
632+
});
633+
}
614634
return confirmationMessage({
615635
id: `${WATCH_CONFIRMATION_MESSAGE_ID_PREFIX}${args.watchId}`,
616636
blockId: args.watchId,
@@ -796,6 +816,8 @@ export async function submitDashboardAgentWatch(params: {
796816
unavailable: boolean;
797817
/** The watch was already there: this call adopted it rather than creating it. */
798818
adopted: boolean;
819+
/** The adopted row. Absent when this call created the watch, so it is active. */
820+
watch?: Watch | null;
799821
}): Promise<SubmitWatchCardResult> => {
800822
// Attached after the watch exists, and a failure here never fails the creation — it is
801823
// said out loud in the confirmation instead, and recorded so a replay repeats it.
@@ -830,6 +852,7 @@ export async function submitDashboardAgentWatch(params: {
830852
return settle({
831853
confirmation: watchingConfirmation({
832854
watchId: args.watchId,
855+
watch: args.watch,
833856
unavailable: args.unavailable,
834857
external,
835858
}),
@@ -851,7 +874,12 @@ export async function submitDashboardAgentWatch(params: {
851874
});
852875
}
853876
// `unavailable` isn't recoverable here: it belonged to the attempt that died.
854-
return settleCreated({ watchId: reserved.id, unavailable: false, adopted: true });
877+
return settleCreated({
878+
watchId: reserved.id,
879+
unavailable: false,
880+
adopted: true,
881+
watch: reserved,
882+
});
855883
}
856884

857885
const result = await create({

apps/webapp/test/contextlessPatRoutes.test.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ const mocks = vi.hoisted(() => ({
1313
authenticatePat: vi.fn(),
1414
createOrganization: vi.fn(),
1515
findManyProjects: vi.fn(),
16+
env: { SESSION_SECRET: "test-session-secret", ORG_CREATION_API_ENABLED: "1" } as {
17+
SESSION_SECRET: string;
18+
ORG_CREATION_API_ENABLED?: string;
19+
},
1620
}));
1721

1822
vi.mock("~/services/rbac.server", () => ({
@@ -25,9 +29,7 @@ vi.mock("~/db.server", () => ({
2529
prisma: { project: { findMany: mocks.findManyProjects } },
2630
$replica: {},
2731
}));
28-
vi.mock("~/env.server", () => ({
29-
env: { SESSION_SECRET: "test-session-secret", ORG_CREATION_API_ENABLED: "1" },
30-
}));
32+
vi.mock("~/env.server", () => ({ env: mocks.env }));
3133
vi.mock("~/models/organization.server", () => ({ createOrganization: mocks.createOrganization }));
3234
vi.mock("~/services/personalAccessToken.server", () => ({
3335
updateLastAccessedAtIfStale: vi.fn(),
@@ -77,6 +79,30 @@ async function createOrg(cap: string[]): Promise<{ status: number; body: any }>
7779
return { status: response.status, body: await response.json() };
7880
}
7981

82+
// An ordinary PAT, paired with an ability that denies everything. Nothing on this route may
83+
// consult it — the route has no org to scope a gate to, and on cloud the plugin returns a
84+
// deny-shaped ability when there is no org context.
85+
async function createOrgWithPat(): Promise<{ status: number; body: any }> {
86+
mocks.authenticatePat.mockImplementation(async () => ({
87+
ok: true,
88+
userId: USER_ID,
89+
tokenId: "pat_1",
90+
lastAccessedAt: new Date(),
91+
ability: { can: () => false, canSuper: () => false },
92+
}));
93+
94+
const response = await action({
95+
request: new Request("https://api.trigger.dev/api/v1/orgs", {
96+
method: "POST",
97+
headers: { Authorization: "Bearer tr_pat_1234", "Content-Type": "application/json" },
98+
body: JSON.stringify({ title: "New Org" }),
99+
}),
100+
params: {},
101+
context: {},
102+
} as any);
103+
return { status: response.status, body: await response.json() };
104+
}
105+
80106
const AGENT_ENVIRONMENT_ID = "env_dev";
81107

82108
async function listProjects(): Promise<{ status: number; body: any }> {
@@ -146,6 +172,29 @@ describe("creating an organization over the API", () => {
146172
expect(mocks.createOrganization).not.toHaveBeenCalled();
147173
});
148174

175+
it("admits an ordinary PAT without consulting its ability", async () => {
176+
const result = await createOrgWithPat();
177+
178+
expect(result.status).toBe(201);
179+
expect(result.body.slug).toBe("new-org");
180+
});
181+
182+
// The env gate runs before the capability gate, so an install with the API disabled tells
183+
// every caller the same thing: the route does not exist. A capped token must not learn from a
184+
// 403 that it would have been the only thing standing in its way.
185+
it("hides the route from a capped token when the API is disabled", async () => {
186+
mocks.env.ORG_CREATION_API_ENABLED = undefined;
187+
188+
try {
189+
const result = await createOrg(["read:all"]);
190+
191+
expect(result.status).toBe(404);
192+
expect(mocks.createOrganization).not.toHaveBeenCalled();
193+
} finally {
194+
mocks.env.ORG_CREATION_API_ENABLED = "1";
195+
}
196+
});
197+
149198
it("still admits a token that carries the universal grant", async () => {
150199
const result = await createOrg(["admin"]);
151200

apps/webapp/test/dashboardAgentInvestigationSweepCard.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ import { liveInvestigation } from "~/components/dashboard-agent/progress-line";
1818
// connection is only stubbed so importing the service doesn't open a pool.
1919
vi.mock("~/services/dashboardAgentDb.server", () => ({ dashboardAgentDb: undefined }));
2020

21-
const { sweepDashboardAgentInvestigations } =
22-
await import("~/services/dashboardAgentInvestigationSweep.server");
21+
import { sweepDashboardAgentInvestigations } from "~/services/dashboardAgentInvestigationSweep.server";
2322

2423
/**
2524
* The transcript half of the sweep, without a container: settling the row is invisible

apps/webapp/test/dashboardAgentWatches.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2300,6 +2300,49 @@ describe("the watch card submit", () => {
23002300
}
23012301
);
23022302

2303+
postgresTest(
2304+
"converging on a watch that already fired confirms the outcome, not 'watching'",
2305+
async ({ prisma, postgresContainer }) => {
2306+
await boot(prisma, postgresContainer.getConnectionUri());
2307+
const seeded = await seed(prisma, "submit-converge-fired");
2308+
await seedChat(seeded);
2309+
2310+
let reservedWatchId = "";
2311+
await expect(
2312+
submit({
2313+
seeded,
2314+
chatId: "chat_1",
2315+
create: async (createParams) => {
2316+
reservedWatchId = createParams.watchId!;
2317+
await createDashboardAgentWatch(createParams);
2318+
throw new Error("died after the watch was created");
2319+
},
2320+
})
2321+
).rejects.toThrow("died after the watch was created");
2322+
2323+
// The watch ran and woke the chat before anyone retried the submit.
2324+
await transitionWatchCondition(ctx.agentDb, {
2325+
id: reservedWatchId,
2326+
resolution: "condition_met",
2327+
observedOutcome: { kind: "run_start", verified: true, status: "EXECUTING", started: true },
2328+
});
2329+
2330+
const retry = await submit({ seeded, chatId: "chat_1" });
2331+
2332+
expect(retry.ok).toBe(true);
2333+
if (!retry.ok) return;
2334+
// Still one row, still the same watch: adoption is not refused.
2335+
expect(retry.watchId).toBe(reservedWatchId);
2336+
expect(await countWatchRows(prisma, "chat_1")).toBe(1);
2337+
2338+
const parts = retry.messages.at(-1)?.parts ?? [];
2339+
const block = (parts[0] as any).data.blocks[0];
2340+
expect(block.outcome).toBe("already_true");
2341+
expect(block.headline).not.toContain("Watching");
2342+
expect(block.lifetime).toBeNull();
2343+
}
2344+
);
2345+
23032346
postgresTest(
23042347
"a refusal that wins the race leaves no live watch behind",
23052348
async ({ prisma, postgresContainer }) => {
@@ -2407,6 +2450,37 @@ describe("the watch card submit", () => {
24072450
expect(await storedMessages(seeded, "chat_1")).toEqual(transcript);
24082451
}
24092452
);
2453+
2454+
postgresTest(
2455+
"a replay repeats the recorded 'Watching' confirmation after the watch has fired",
2456+
async ({ prisma, postgresContainer }) => {
2457+
await boot(prisma, postgresContainer.getConnectionUri());
2458+
const seeded = await seed(prisma, "submit-replay-fired");
2459+
await seedChat(seeded);
2460+
2461+
const first = await submit({ seeded, chatId: "chat_1" });
2462+
expect(first.ok).toBe(true);
2463+
if (!first.ok || !first.watchId) return;
2464+
2465+
await transitionWatchCondition(ctx.agentDb, {
2466+
id: first.watchId,
2467+
resolution: "condition_met",
2468+
});
2469+
2470+
const retry = await submit({ seeded, chatId: "chat_1" });
2471+
2472+
expect(retry.ok).toBe(true);
2473+
if (!retry.ok) return;
2474+
expect(retry.repaired).toBe(true);
2475+
2476+
// The recorded outcome is replayed, never decided again: the append-once
2477+
// confirmation in the transcript says "Watching", so the answer has to as well.
2478+
const parts = retry.messages.at(-1)?.parts ?? [];
2479+
const block = (parts[0] as any).data.blocks[0];
2480+
expect(block.outcome).toBe("watching");
2481+
expect(block.headline).toContain("Watching");
2482+
}
2483+
);
24102484
});
24112485

24122486
describe("appendChatMessageOnce", () => {

internal-packages/dashboard-agent-contracts/src/watch-wording.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,32 @@ export function watchConfirmationBlockBody(args: {
614614
};
615615
}
616616

617+
/**
618+
* The confirmation for a watch that had already resolved before this submission settled
619+
* (a retry adopting a fired or expired row). It states the outcome the watch reached
620+
* rather than claiming something is still being watched.
621+
*/
622+
export function watchResolvedBlockBody(args: { watchId: string; resolved: WatchResolvedInput }): {
623+
type: "watch_result";
624+
outcome: "already_true" | "impossible";
625+
headline: string;
626+
lifetime: null;
627+
detail: null;
628+
followUp: never[];
629+
watchId: string;
630+
} {
631+
const presented = presentResolvedWatch(args.resolved);
632+
return {
633+
type: "watch_result",
634+
outcome: presented.category === "positive" ? "already_true" : "impossible",
635+
headline: presented.headline,
636+
lifetime: null,
637+
detail: null,
638+
followUp: [],
639+
watchId: args.watchId,
640+
};
641+
}
642+
617643
/**
618644
* The one-shot result block: the immediate check answered outright, so no watch was
619645
* created. Nothing is running, so there is no lifetime and no follow-ups.

0 commit comments

Comments
 (0)