Skip to content

Commit 4cfd0a2

Browse files
committed
Merge remote-tracking branch 'origin/feat/dashboard-agent-flows-watch' into feat/agent-storybook-gallery
2 parents 41a2f4e + 44b8128 commit 4cfd0a2

4 files changed

Lines changed: 147 additions & 37 deletions

File tree

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

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -207,26 +207,49 @@ function normalizeWatchSpec(spec: WatchSpec): WatchSpec {
207207
return { ...spec, fingerprint: normalizeErrorFingerprint(spec.fingerprint) };
208208
}
209209

210+
const TASK_QUEUE_PREFIX = "task/";
211+
210212
/**
211-
* Existence check for the thing a spec points at, in this environment. `error_recurrence`
212-
* has nothing to validate: zero occurrences so far is the normal case.
213+
* The queue name as stored, from whatever the caller called it. The model can't tell a
214+
* task queue (`task/<task id>`) from a custom one (a plain name), so both spellings are
215+
* tried and the stored one wins. `null` means no queue by either name.
213216
*/
214-
async function validateWatchTarget(spec: WatchSpec, deps: WatchCheckDeps): Promise<boolean> {
217+
async function resolveQueueName(queue: string, deps: WatchCheckDeps): Promise<string | null> {
218+
const alternative = queue.startsWith(TASK_QUEUE_PREFIX)
219+
? queue.slice(TASK_QUEUE_PREFIX.length)
220+
: `${TASK_QUEUE_PREFIX}${queue}`;
221+
for (const candidate of [queue, alternative]) {
222+
if (candidate.length > 0 && (await deps.queueExists(candidate))) return candidate;
223+
}
224+
return null;
225+
}
226+
227+
/**
228+
* Resolve the thing a spec points at, in this environment, returning the spec the identity
229+
* and the checks will see. `null` means the target doesn't exist. `error_recurrence` has
230+
* nothing to validate: zero occurrences so far is the normal case.
231+
*/
232+
async function resolveWatchTarget(
233+
spec: WatchSpec,
234+
deps: WatchCheckDeps
235+
): Promise<WatchSpec | null> {
215236
switch (spec.kind) {
216237
case "run_start":
217238
case "run_finished":
218239
case "run_failed":
219-
return (await deps.readRun(spec.runId)) !== null;
240+
return (await deps.readRun(spec.runId)) !== null ? spec : null;
220241
case "backlog_drain":
221242
case "queue_depth_above":
222243
case "queue_depth_below":
223244
case "queue_stalled":
224-
case "queue_oldest_age":
225-
return await deps.queueExists(spec.queue);
245+
case "queue_oldest_age": {
246+
const queue = await resolveQueueName(spec.queue, deps);
247+
return queue === null ? null : { ...spec, queue };
248+
}
226249
case "error_recurrence":
227-
return spec.fingerprint.length > 0;
250+
return spec.fingerprint.length > 0 ? spec : null;
228251
case "health_recovery":
229-
return isReportKey(spec.report);
252+
return isReportKey(spec.report) ? spec : null;
230253
}
231254
}
232255

@@ -255,7 +278,7 @@ export async function createDashboardAgentWatch(params: {
255278
const { environment, userId, chatId } = params;
256279
// Normalized before anything reads it: the page cites `error_<fingerprint>` and the tools
257280
// cite the bare one, and only one of the two spellings may reach the identity or the link.
258-
const spec = normalizeWatchSpec(params.spec);
281+
const requestedSpec = normalizeWatchSpec(params.spec);
259282
const now = params.now ?? new Date();
260283
// Creation reads the target on the primary; the polling checks stay on the replica.
261284
const buildCheckDeps = params.deps?.checkDeps ?? watchCreationCheckDeps;
@@ -271,7 +294,10 @@ export async function createDashboardAgentWatch(params: {
271294
};
272295
}
273296

274-
if (!(await validateWatchTarget(spec, checkDeps))) {
297+
// Resolution rewrites the target's name, so the identity, the readers and the wording all
298+
// see the stored one. A spec kept as asked would read the depth of a queue that isn't there.
299+
const spec = await resolveWatchTarget(requestedSpec, checkDeps);
300+
if (spec === null) {
275301
return {
276302
ok: false,
277303
code: "invalid_target",

apps/webapp/test/dashboardAgentWatchQueueName.test.ts

Lines changed: 100 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
/**
22
* The queue detail page presents a task queue's name with the `task/` prefix stripped, but
3-
* `TaskQueue.name` keeps it — so the name the page hands a watch has to be the stored one,
4-
* or every task-queue watch is refused as a missing target.
3+
* `TaskQueue.name` keeps it — and nobody asking for a watch can tell which kind of queue
4+
* they are naming. Creation resolves the name against the environment and rewrites the spec
5+
* to the stored one, so the identity and the depth readers never see the other spelling.
56
*/
67

78
import {
89
createChat,
910
createDashboardAgentDb,
11+
getWatch,
1012
type DashboardAgentDb,
1113
type DashboardAgentDbClient,
1214
} from "@internal/dashboard-agent-db";
@@ -75,13 +77,14 @@ async function boot(prisma: PrismaClient, connectionUri: string) {
7577
afterEach(async () => {
7678
await agentDbClient?.close();
7779
agentDbClient = undefined;
80+
readNames.length = 0;
7881
});
7982

8083
function suffix() {
8184
return Math.random().toString(36).slice(2, 10);
8285
}
8386

84-
/** One org, project and production environment, holding one virtual queue for `send-receipt`. */
87+
/** One org, project and production environment, holding a task queue and a custom one. */
8588
async function seed(prisma: PrismaClient) {
8689
const slug = `queue_name_${suffix()}`;
8790
const user = await prisma.user.create({
@@ -116,6 +119,17 @@ async function seed(prisma: PrismaClient) {
116119
runtimeEnvironmentId: environment.id,
117120
},
118121
});
122+
// A custom queue, stored under the plain name the user gave it.
123+
await prisma.taskQueue.create({
124+
data: {
125+
friendlyId: `queue_${suffix()}`,
126+
name: "worker-1",
127+
orderableName: "worker-1",
128+
type: "NAMED",
129+
projectId: project.id,
130+
runtimeEnvironmentId: environment.id,
131+
},
132+
});
119133

120134
await createChat(ctx.agentDb, {
121135
id: `chat_${suffix()}`,
@@ -141,37 +155,50 @@ function authenticated(seeded: Seeded) {
141155
}
142156

143157
/** Only `queueExists` is real: it is the read the target validation is decided by. */
144-
function checkDeps(seeded: Seeded): WatchCheckDeps {
158+
function checkDeps(seeded: Seeded, readNames: string[]): WatchCheckDeps {
145159
return {
146160
readRun: async () => null,
147161
queueExists: (name: string) => watchQueueExistsOnPrimary(seeded.environment.id, name),
148162
readQueueDepth: async () => ({ depth: 3, source: "live_queue", current: true }),
149-
readQueueOldestAge: async () => ({ ageMs: 1_000, source: "live_queue", current: true }),
163+
readQueueOldestAge: async (name: string) => {
164+
readNames.push(name);
165+
return { ageMs: 1_000, source: "live_queue", current: true };
166+
},
150167
readErrorRecurrence: async () => null,
151168
readHealth: async () => null,
152169
};
153170
}
154171

155-
async function createFor(seeded: Seeded, spec: WatchSpec) {
156-
const chatId = `chat_${suffix()}`;
157-
await createChat(ctx.agentDb, {
158-
id: chatId,
159-
organizationId: seeded.organization.id,
160-
userId: seeded.user.id,
161-
});
172+
/** The names the depth readers were handed, so a spec left un-rewritten is visible. */
173+
const readNames: string[] = [];
174+
175+
async function createFor(seeded: Seeded, spec: WatchSpec, chatId?: string) {
176+
const id = chatId ?? `chat_${suffix()}`;
177+
if (!chatId) {
178+
await createChat(ctx.agentDb, {
179+
id,
180+
organizationId: seeded.organization.id,
181+
userId: seeded.user.id,
182+
});
183+
}
162184
return createDashboardAgentWatch({
163185
environment: authenticated(seeded),
164186
userId: seeded.user.id,
165-
chatId,
187+
chatId: id,
166188
spec,
167189
deps: {
168190
configured: () => true,
169-
checkDeps: () => checkDeps(seeded),
191+
checkDeps: () => checkDeps(seeded, readNames),
170192
scheduleTick: async () => {},
171193
},
172194
});
173195
}
174196

197+
async function persistedQueueName(created: { watchId?: string }) {
198+
const watch = await getWatch(ctx.agentDb, { id: created.watchId! });
199+
return (watch?.spec as { queue: string }).queue;
200+
}
201+
175202
/** The queue as `QueueRetrievePresenter` hands it to the page: the prefix already stripped. */
176203
const PRESENTED = { type: "task", name: "send-receipt" };
177204

@@ -191,13 +218,70 @@ describe("a watch on a task's own queue", () => {
191218
);
192219

193220
postgresTest(
194-
"is refused when the display name reaches the spec instead",
221+
"is created under the stored name when the display name reaches the spec",
195222
async ({ prisma, postgresContainer }) => {
196223
await boot(prisma, postgresContainer.getConnectionUri());
197224
const seeded = await seed(prisma);
198225

199226
const created = await createFor(seeded, queueWatchRecommendation(PRESENTED.name));
200-
expect(created).toMatchObject({ ok: false, code: "invalid_target" });
227+
expect(created).toMatchObject({ ok: true, watching: true });
228+
expect(await persistedQueueName(created as { watchId: string })).toBe("task/send-receipt");
229+
expect(readNames).toEqual(["task/send-receipt"]);
230+
}
231+
);
232+
});
233+
234+
describe("a watch on a custom queue", () => {
235+
postgresTest(
236+
"is created under the plain name when the model adds the `task/` prefix",
237+
async ({ prisma, postgresContainer }) => {
238+
await boot(prisma, postgresContainer.getConnectionUri());
239+
const seeded = await seed(prisma);
240+
241+
const created = await createFor(seeded, queueWatchRecommendation("task/worker-1"));
242+
expect(created).toMatchObject({ ok: true, watching: true });
243+
expect(await persistedQueueName(created as { watchId: string })).toBe("worker-1");
244+
expect(readNames).toEqual(["worker-1"]);
245+
}
246+
);
247+
248+
postgresTest(
249+
"dedupes across both spellings, because the identity sees the stored name",
250+
async ({ prisma, postgresContainer }) => {
251+
await boot(prisma, postgresContainer.getConnectionUri());
252+
const seeded = await seed(prisma);
253+
const chatId = `chat_${suffix()}`;
254+
await createChat(ctx.agentDb, {
255+
id: chatId,
256+
organizationId: seeded.organization.id,
257+
userId: seeded.user.id,
258+
});
259+
260+
const first = await createFor(seeded, queueWatchRecommendation("worker-1"), chatId);
261+
expect(first).toMatchObject({ ok: true, watching: true });
262+
263+
const second = await createFor(seeded, queueWatchRecommendation("task/worker-1"), chatId);
264+
expect(second).toMatchObject({
265+
ok: false,
266+
code: "duplicate",
267+
existingId: (first as { watchId: string }).watchId,
268+
});
201269
}
202270
);
203271
});
272+
273+
describe("a queue that exists under neither spelling", () => {
274+
postgresTest("is refused, either way round", async ({ prisma, postgresContainer }) => {
275+
await boot(prisma, postgresContainer.getConnectionUri());
276+
const seeded = await seed(prisma);
277+
278+
expect(await createFor(seeded, queueWatchRecommendation("worker-9"))).toMatchObject({
279+
ok: false,
280+
code: "invalid_target",
281+
});
282+
expect(await createFor(seeded, queueWatchRecommendation("task/worker-9"))).toMatchObject({
283+
ok: false,
284+
code: "invalid_target",
285+
});
286+
});
287+
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export const WATCH_MAX_QUEUE_AGE_MINUTES = 24 * 60;
4040
const watchQueueNameSchema = z
4141
.string()
4242
.describe(
43-
"The stored queue name, keeping the `task/` prefix for task queues (e.g. `task/send-receipt`) — not the display name."
43+
"The queue name. A task's own queue is `task/<task id>`; a custom queue is its plain name. If unsure, pass the name as shown — the server resolves it."
4444
);
4545

4646
/**

internal-packages/dashboard-agent/src/__snapshots__/prompt-prefix.test.ts.snap

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)