Skip to content

Commit 28bec4a

Browse files
authored
Merge pull request #36 from hookdeck/outpost-004-queue-destination
Add outpost-004: move a customer from webhooks to a queue
2 parents 1645a2c + 9ae3bb8 commit 28bec4a

6 files changed

Lines changed: 405 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,21 @@ destination is equivalent in configuration but not identical. Acceptable on a
694694
dedicated eval project, and worth knowing before pointing any of this at one
695695
that is not.
696696
697+
**Outpost validates a destination's shape, not its reachability.** A
698+
well-formed but entirely fictional SQS queue and key pair is accepted;
699+
`queue_url: "not-a-url"` is rejected with `422 "config.queue_url failed pattern
700+
validation"`. That is what makes `outpost-004` scoreable without a live queue:
701+
the API itself covers the part real infrastructure would add least to, and
702+
standing up a real queue would mean cloud credentials inside the agent sandbox
703+
and an external dependency that can fail a run for reasons no agent caused.
704+
Delivery is already proven against webhook destinations elsewhere.
705+
706+
**Destination credentials are redacted on read** — `AKIA****************` — so a
707+
scorer can assert that credentials were supplied and never which ones. Same
708+
limitation as Hookdeck source `config.auth`. Presence is still worth checking:
709+
an agent thinking in webhook shapes puts the access key in `config` beside the
710+
queue URL and leaves `credentials` empty.
711+
697712
**Reset is to pristine, not to empty.** A new Hookdeck project ships with
698713
default issue triggers. The first acquire snapshots what the project contains,
699714
and every reset deletes only what a run added.
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import type {
2+
CheckResult,
3+
ToolEvalContext,
4+
ToolScorer,
5+
} from '@hookdeck-evals/core';
6+
7+
/**
8+
* Can an agent configure a destination that is not a webhook?
9+
*
10+
* Nearly all real Outpost traffic is webhooks, which is exactly why this is
11+
* worth scoring: an agent that has only ever seen the webhook shape reaches for
12+
* a `url` and a secret, and a queue is neither. It is a `type`, a set of config
13+
* fields whose names differ per provider, and `credentials` as a separate
14+
* object. Outpost rejects a malformed `queue_url` with a 422, so the failure is
15+
* loud if they get the field right and the value wrong — and silent if they put
16+
* the value in the wrong field.
17+
*
18+
* **This scores configuration, not delivery, and that is deliberate.** The
19+
* normal rule in this repo is to score behaviour where the API allows it, and
20+
* here it does not: proving delivery would need a real SQS queue, which means
21+
* cloud credentials inside the agent sandbox, an external dependency that can
22+
* fail a run for reasons no agent caused, and queue lifecycle in CI. Delivery
23+
* is already proven against webhook destinations by `outpost-001` and
24+
* `outpost-002`; what is untested is whether an agent can configure a
25+
* non-HTTP type at all.
26+
*
27+
* Measured rather than assumed: Outpost validates the *shape* of a destination
28+
* on create and not its reachability. A well-formed but entirely fictional
29+
* queue and key pair is accepted; `queue_url: "not-a-url"` is rejected with
30+
* `422 "config.queue_url failed pattern validation"`. So the API itself covers
31+
* the part a live queue would add least to.
32+
*
33+
* The trap is in the workspace note rather than the API. Acme want *orders* on
34+
* the queue and everything else unchanged, so deleting the webhook destination
35+
* — the obvious way to "stop sending their orders to the old endpoint" — also
36+
* stops their retry notifications, which nobody asked for. The correct move is
37+
* narrower than the obvious one.
38+
*/
39+
40+
const TENANT = 'acme';
41+
const OTHER_TENANT = 'globex';
42+
/** Exactly what the workspace note gives them. */
43+
const QUEUE_URL =
44+
'https://sqs.eu-west-1.amazonaws.com/402319887654/acme-order-events';
45+
const OLD_ENDPOINT = 'https://mock.hookdeck.com/api/v1/acme/orders';
46+
const ORDERS = 'orders';
47+
/** The topic they never asked to change, and the one an over-broad fix breaks. */
48+
const RETRIES = 'retries';
49+
50+
interface Destination {
51+
id?: string;
52+
type?: string;
53+
topics?: string[];
54+
config?: Record<string, unknown>;
55+
credentials?: Record<string, unknown>;
56+
disabled_at?: string | null;
57+
}
58+
59+
const scorer: ToolScorer = async (ctx) => {
60+
if (!ctx.outpost) {
61+
throw new Error(
62+
'no Outpost client, but this scenario declares `requires: [outpost]` ' +
63+
'and should have been skipped rather than scored'
64+
);
65+
}
66+
67+
const destinations = await listDestinations(ctx, TENANT);
68+
const queues = destinations.filter(
69+
(d) => normalise(d.config?.queue_url) === normalise(QUEUE_URL)
70+
);
71+
const webhooks = destinations.filter(
72+
(d) => normalise(d.config?.url) === normalise(OLD_ENDPOINT)
73+
);
74+
75+
const checks: CheckResult[] = [
76+
checkQueueExists(destinations, queues),
77+
checkQueueReceivesOrders(queues),
78+
checkCredentialsSupplied(queues),
79+
checkOldEndpointStopped(webhooks),
80+
checkRetriesUntouched(webhooks),
81+
await checkOtherTenantUntouched(ctx),
82+
];
83+
84+
return { passed: checks.every((c) => c.passed), checks };
85+
};
86+
87+
export default scorer;
88+
89+
/**
90+
* Matched on the queue URL rather than on type alone, because "created an SQS
91+
* destination" is not the task — creating the one pointing at *their* queue is.
92+
* An agent that invents a plausible queue has done something worse than
93+
* nothing.
94+
*/
95+
function checkQueueExists(
96+
all: Destination[],
97+
queues: Destination[]
98+
): CheckResult {
99+
const live = queues.filter((d) => !d.disabled_at);
100+
const seen = all
101+
.map(
102+
(d) => `${d.type}:${String(d.config?.queue_url ?? d.config?.url ?? '?')}`
103+
)
104+
.join(', ');
105+
106+
if (live.length === 0) {
107+
return {
108+
name: 'their orders are delivered to the queue they gave us',
109+
passed: false,
110+
notes:
111+
queues.length > 0
112+
? 'the queue destination exists but is disabled, so nothing reaches it'
113+
: `no enabled destination points at ${QUEUE_URL} (present: ${seen || 'none'})`,
114+
};
115+
}
116+
117+
// Type is checked here rather than as its own line: a destination carrying
118+
// their queue URL under a non-queue type is a configuration that cannot work,
119+
// and reporting it as "exists but wrong type" is the useful message.
120+
const sqs = live.filter((d) => d.type === 'aws_sqs');
121+
return {
122+
name: 'their orders are delivered to the queue they gave us',
123+
passed: sqs.length > 0,
124+
notes:
125+
sqs.length > 0
126+
? undefined
127+
: `a destination points at the queue but its type is ` +
128+
`${live.map((d) => d.type).join(', ')} rather than aws_sqs`,
129+
};
130+
}
131+
132+
function checkQueueReceivesOrders(queues: Destination[]): CheckResult {
133+
const subscribed = queues.some((d) => subscribes(d, ORDERS));
134+
return {
135+
name: 'the queue is subscribed to their order events',
136+
passed: subscribed,
137+
notes: subscribed
138+
? undefined
139+
: `the queue destination is not subscribed to ${ORDERS} ` +
140+
`(topics: ${topicsOf(queues)}), so it would sit empty`,
141+
};
142+
}
143+
144+
/**
145+
* Presence only, and it cannot be more than that: credentials are redacted on
146+
* read (`AKIA****************`), so a scorer cannot tell a correct key from a
147+
* plausible one. The same limitation applies to Hookdeck source `config.auth`.
148+
*
149+
* Still worth a line. An agent that puts the access key into `config` alongside
150+
* the queue URL, which is the natural mistake if you are thinking in webhook
151+
* shapes, leaves `credentials` empty and fails here.
152+
*/
153+
function checkCredentialsSupplied(queues: Destination[]): CheckResult {
154+
const withCredentials = queues.filter(
155+
(d) => Object.keys(d.credentials ?? {}).length > 0
156+
);
157+
return {
158+
name: 'the queue destination carries credentials',
159+
passed: withCredentials.length > 0,
160+
notes:
161+
withCredentials.length > 0
162+
? undefined
163+
: 'no credentials on the queue destination — the access key and secret ' +
164+
'go in `credentials`, not in `config` beside the queue URL',
165+
};
166+
}
167+
168+
function checkOldEndpointStopped(webhooks: Destination[]): CheckResult {
169+
const stillSending = webhooks.filter(
170+
(d) => !d.disabled_at && subscribes(d, ORDERS)
171+
);
172+
return {
173+
name: 'their orders no longer go to the old endpoint',
174+
passed: stillSending.length === 0,
175+
notes:
176+
stillSending.length === 0
177+
? undefined
178+
: 'the old webhook endpoint is still subscribed to orders, so every order ' +
179+
'is now delivered twice — to the queue and to the endpoint they asked us ' +
180+
'to stop using',
181+
};
182+
}
183+
184+
/**
185+
* The check the scenario turns on.
186+
*
187+
* "Stop sending their orders to the old endpoint" is most simply achieved by
188+
* deleting the webhook destination, and that is wrong: it also stops their
189+
* retry notifications, which the note says should carry on unchanged. Scoring
190+
* only the requested change would pass an agent that broke something adjacent —
191+
* the failure mode `alerting-001` was corrected for.
192+
*/
193+
function checkRetriesUntouched(webhooks: Destination[]): CheckResult {
194+
const stillReceiving = webhooks.filter(
195+
(d) => !d.disabled_at && subscribes(d, RETRIES)
196+
);
197+
return {
198+
name: 'the rest of their delivery is unchanged',
199+
passed: stillReceiving.length > 0,
200+
notes:
201+
stillReceiving.length > 0
202+
? undefined
203+
: `their ${RETRIES} events no longer reach the old endpoint either — moving ` +
204+
'orders to the queue was not supposed to change anything else',
205+
};
206+
}
207+
208+
async function checkOtherTenantUntouched(
209+
ctx: ToolEvalContext
210+
): Promise<CheckResult> {
211+
const name = 'the other customer was left alone';
212+
const destinations = await listDestinations(ctx, OTHER_TENANT);
213+
const live = destinations.filter((d) => !d.disabled_at);
214+
return {
215+
name,
216+
passed: live.length > 0,
217+
notes:
218+
live.length > 0
219+
? undefined
220+
: `${OTHER_TENANT} has no working destination left, and they asked for nothing`,
221+
};
222+
}
223+
224+
/** `*` subscribes to everything. */
225+
function subscribes(destination: Destination, topic: string): boolean {
226+
const topics = destination.topics ?? [];
227+
return topics.includes('*') || topics.includes(topic);
228+
}
229+
230+
function topicsOf(destinations: Destination[]): string {
231+
const topics = destinations.flatMap((d) => d.topics ?? []);
232+
return topics.length > 0 ? topics.join(', ') : 'none';
233+
}
234+
235+
function normalise(value: unknown): string {
236+
return typeof value === 'string' ? value.trim().replace(/\/+$/, '') : '';
237+
}
238+
239+
/** Outpost list endpoints answer `{ pagination, models }`, not `{ data }`. */
240+
async function listDestinations(
241+
ctx: ToolEvalContext,
242+
tenantId: string
243+
): Promise<Destination[]> {
244+
const rows = await ctx.outpost?.<Destination[] | { models?: Destination[] }>(
245+
'GET',
246+
`/tenants/${encodeURIComponent(tenantId)}/destinations`
247+
);
248+
if (!rows) return [];
249+
return Array.isArray(rows) ? rows : (rows.models ?? []);
250+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
stage: build
3+
suite: benchmark
4+
gated_by: mixed
5+
product:
6+
- outpost
7+
topic:
8+
- capabilities
9+
requires:
10+
- outpost
11+
motivation: Most Outpost traffic is webhooks, but delivering to a queue is a core capability and a different job — a type rather than a URL, credentials rather than a secret, and fields whose names differ per provider. This scores whether an agent can configure a non-HTTP destination from details it has to find, rather than reaching for the webhook shape it has seen most often.
12+
---
13+
14+
Acme are moving off webhooks. Their endpoint keeps falling over under load and
15+
they'd rather we drop order events straight onto a queue they already run.
16+
17+
They sent us the queue details last week — whoever picked up the ticket put
18+
them in the repo.
19+
20+
Set that up, and stop sending their orders to the old endpoint.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { ToolEvalContext } from '@hookdeck-evals/core';
2+
3+
/**
4+
* What a correct agent leaves behind.
5+
*
6+
* Two changes, and the second is the one worth reading: the webhook destination
7+
* is *narrowed* to the topics acme did not ask to move, rather than deleted.
8+
* Deleting it is the obvious way to stop sending orders there and takes their
9+
* retry notifications with it.
10+
*/
11+
12+
const TENANT = 'acme';
13+
const QUEUE_URL =
14+
'https://sqs.eu-west-1.amazonaws.com/402319887654/acme-order-events';
15+
const OLD_ENDPOINT = 'https://mock.hookdeck.com/api/v1/acme/orders';
16+
17+
interface Destination {
18+
id?: string;
19+
config?: Record<string, unknown>;
20+
topics?: string[];
21+
}
22+
23+
export default async function solve(ctx: ToolEvalContext): Promise<void> {
24+
const outpost = ctx.outpost;
25+
if (!outpost) {
26+
throw new Error(
27+
'no Outpost client: this solution cannot be applied without OUTPOST_API_KEY'
28+
);
29+
}
30+
31+
await outpost('POST', `/tenants/${TENANT}/destinations`, {
32+
type: 'aws_sqs',
33+
topics: ['orders'],
34+
// The queue URL is config; the key pair is credentials. Keeping them
35+
// separate is the whole shape difference between this and a webhook.
36+
config: { queue_url: QUEUE_URL },
37+
credentials: {
38+
key: 'AKIAIOSFODNN7EXAMPLE',
39+
secret: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
40+
},
41+
});
42+
43+
const rows = await outpost<Destination[] | { models?: Destination[] }>(
44+
'GET',
45+
`/tenants/${TENANT}/destinations`
46+
);
47+
const destinations = Array.isArray(rows) ? rows : (rows.models ?? []);
48+
49+
for (const destination of destinations) {
50+
if (destination.config?.url !== OLD_ENDPOINT || !destination.id) continue;
51+
const remaining = (destination.topics ?? []).filter((t) => t !== 'orders');
52+
await outpost(
53+
'PATCH',
54+
`/tenants/${TENANT}/destinations/${destination.id}`,
55+
{
56+
topics: remaining,
57+
}
58+
);
59+
}
60+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Customer infrastructure notes
2+
3+
Details customers have sent us for delivery targets they run themselves. Keep
4+
credentials out of application config — these are here because support needs
5+
them to set delivery up, not because anything reads this file.
6+
7+
## Acme — order events
8+
9+
Moving off their webhook endpoint (`https://mock.hookdeck.com/api/v1/acme/orders`),
10+
which has been timing out under load. They want order events on SQS instead.
11+
12+
Sent over by their platform team on the 14th:
13+
14+
```
15+
Queue URL: https://sqs.eu-west-1.amazonaws.com/402319887654/acme-order-events
16+
Region: eu-west-1
17+
Access key: AKIAIOSFODNN7EXAMPLE
18+
Secret key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
19+
```
20+
21+
They only want `orders` on the queue. Anything else we send them today should
22+
carry on as it is.
23+
24+
## Globex — nothing outstanding
25+
26+
Still on webhooks, happy, no changes requested.

0 commit comments

Comments
 (0)