Skip to content

Commit a50cd98

Browse files
feat(server): webhook filters fan out over arrays with a [] path wildcard
A filter path segment ending in [] resolves to the list of every element's value, so a task can run only when, say, an issue carries a given label: {path: 'body.issue.labels[].name', op: 'equals', value: 'needs more info'}. equals, in and startsWith match when any element does, notEquals only when none equals the value, exists when the list is not empty. Placeholders accept the same syntax and render the list. Before this, filters could only compare scalars and arrays satisfied exists alone, so a GitHub issue_comment hook had to wake the bound thread on every comment in the repository and let the agent discard the rest. Co-Authored-By: Claude Fable 5.1 <[email protected]>
1 parent 49e97d4 commit a50cd98

6 files changed

Lines changed: 157 additions & 11 deletions

File tree

‎apps/server/src/mcp/toolkits/orchestrator/tools.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", {
118118

119119
const ScheduleTaskTool = Tool.make("schedule_task", {
120120
description:
121-
"Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10–3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.",
121+
"Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10–3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. A path segment ending in [] fans out over an array: {path:'body.issue.labels[].name', op:'equals', value:'needs more info'} matches when any label has that name, and notEquals only when none does. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.",
122122
parameters: OrchestratorMcpScheduleTaskInput,
123123
success: OrchestratorMcpScheduleTaskResult,
124124
failure: OrchestratorMcpFailure,
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { assert, describe, it } from "@effect/vitest";
2+
3+
import { findFailedWebhookFilter } from "./webhookFilter.ts";
4+
import type { WebhookRequest } from "./webhookTemplate.ts";
5+
6+
const issueComment: WebhookRequest = {
7+
method: "POST",
8+
path: "/api/hooks/task-1",
9+
query: "",
10+
headers: { "content-type": "application/json", "x-github-event": "issue_comment" },
11+
bodyText: JSON.stringify({
12+
action: "created",
13+
issue: { number: 7, labels: [{ name: "bug" }, { name: "needs more info" }], assignees: [] },
14+
comment: { user: { login: "reporter", type: "User" } },
15+
}),
16+
};
17+
18+
describe("findFailedWebhookFilter", () => {
19+
it("matches a wildcard path when any element matches", () => {
20+
assert.isNull(
21+
findFailedWebhookFilter(
22+
[
23+
{ path: "body.issue.labels[].name", op: "equals", value: "needs more info" },
24+
{ path: "body.issue.labels[].name", op: "in", value: ["needs more info", "triage"] },
25+
{ path: "body.issue.labels[].name", op: "startsWith", value: "needs" },
26+
{ path: "body.issue.labels[].name", op: "exists" },
27+
],
28+
issueComment,
29+
),
30+
);
31+
assert.deepEqual(
32+
findFailedWebhookFilter(
33+
[{ path: "body.issue.labels[].name", op: "equals", value: "wontfix" }],
34+
issueComment,
35+
),
36+
{ index: 0, path: "body.issue.labels[].name" },
37+
);
38+
});
39+
40+
it("fails notEquals on a wildcard path when any element equals the value", () => {
41+
assert.deepEqual(
42+
findFailedWebhookFilter(
43+
[{ path: "body.issue.labels[].name", op: "notEquals", value: "bug" }],
44+
issueComment,
45+
),
46+
{ index: 0, path: "body.issue.labels[].name" },
47+
);
48+
assert.isNull(
49+
findFailedWebhookFilter(
50+
[{ path: "body.issue.labels[].name", op: "notEquals", value: "wontfix" }],
51+
issueComment,
52+
),
53+
);
54+
});
55+
56+
it("treats an empty or missing wildcard list as nothing to match", () => {
57+
assert.deepEqual(
58+
findFailedWebhookFilter(
59+
[{ path: "body.issue.assignees[].login", op: "exists" }],
60+
issueComment,
61+
),
62+
{ index: 0, path: "body.issue.assignees[].login" },
63+
);
64+
assert.isNull(
65+
findFailedWebhookFilter(
66+
[{ path: "body.issue.assignees[].login", op: "notEquals", value: "anyone" }],
67+
issueComment,
68+
),
69+
);
70+
assert.deepEqual(
71+
findFailedWebhookFilter([{ path: "body.issue.number[].x", op: "exists" }], issueComment),
72+
{ index: 0, path: "body.issue.number[].x" },
73+
);
74+
});
75+
76+
it("still compares scalars without a wildcard", () => {
77+
assert.isNull(
78+
findFailedWebhookFilter(
79+
[
80+
{ path: "body.action", op: "equals", value: "created" },
81+
{ path: "body.comment.user.type", op: "notEquals", value: "Bot" },
82+
],
83+
issueComment,
84+
),
85+
);
86+
// A list without a wildcard in the path only satisfies exists.
87+
assert.deepEqual(
88+
findFailedWebhookFilter(
89+
[{ path: "body.issue.labels", op: "equals", value: "bug" }],
90+
issueComment,
91+
),
92+
{ index: 0, path: "body.issue.labels" },
93+
);
94+
});
95+
});

‎apps/server/src/scheduledTasks/webhookFilter.ts‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,7 @@ function scalarText(value: unknown): string | undefined {
1313
return undefined;
1414
}
1515

16-
/** Placeholders render null as missing, so filters treat it the same way. */
17-
function matchesWebhookFilter(filter: ScheduledTaskWebhookFilter, value: unknown): boolean {
18-
if (filter.op === "exists") return value !== undefined && value !== null;
19-
const text = scalarText(value);
20-
if (text === undefined) return false;
16+
function matchesScalar(filter: ScheduledTaskWebhookFilter, text: string): boolean {
2117
switch (filter.op) {
2218
case "equals":
2319
return text === filter.value;
@@ -27,9 +23,29 @@ function matchesWebhookFilter(filter: ScheduledTaskWebhookFilter, value: unknown
2723
return filter.value.includes(text);
2824
case "startsWith":
2925
return text.startsWith(filter.value);
26+
case "exists":
27+
return true;
3028
}
3129
}
3230

31+
/**
32+
* Placeholders render null as missing, so filters treat it the same way. A
33+
* path with a `[]` wildcard resolves to a list: `equals`, `in` and
34+
* `startsWith` match when any element does, `notEquals` only when none equals
35+
* the value, and `exists` when the list has an element.
36+
*/
37+
function matchesWebhookFilter(filter: ScheduledTaskWebhookFilter, value: unknown): boolean {
38+
if (Array.isArray(value) && /\[\]/.test(filter.path)) {
39+
const texts = value.map(scalarText).filter((text): text is string => text !== undefined);
40+
if (filter.op === "notEquals") return texts.every((text) => text !== filter.value);
41+
return texts.some((text) => matchesScalar(filter, text));
42+
}
43+
if (filter.op === "exists") return value !== undefined && value !== null;
44+
const text = scalarText(value);
45+
if (text === undefined) return false;
46+
return matchesScalar(filter, text);
47+
}
48+
3349
/** The first filter the request fails, or null when every filter matches. */
3450
export function findFailedWebhookFilter(
3551
filters: ReadonlyArray<ScheduledTaskWebhookFilter>,

‎apps/server/src/scheduledTasks/webhookTemplate.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,23 @@ describe("renderWebhookPrompt", () => {
165165
);
166166
});
167167

168+
it("renders a [] wildcard as the list of matching values", () => {
169+
const rendered = renderWebhookPrompt(
170+
"Labels: {{body.pull_request.labels[]}} Names: {{body.reviewers[].login}}",
171+
{
172+
...githubPullRequest,
173+
bodyText: JSON.stringify({
174+
pull_request: { labels: ["bug", "ui"] },
175+
reviewers: [{ login: "ada" }, { name: "no login" }, { login: "bob" }],
176+
}),
177+
},
178+
"id",
179+
true,
180+
);
181+
assert.equal(rendered.prompt, 'Labels: [\n "bug",\n "ui"\n] Names: [\n "ada",\n "bob"\n]');
182+
assert.deepEqual(rendered.missing, []);
183+
});
184+
168185
it("does not resolve inherited object properties", () => {
169186
const rendered = renderWebhookPrompt(
170187
"{{body.constructor}}{{body.__proto__}}{{headers.constructor}}{{headers.__proto__}}",

‎apps/server/src/scheduledTasks/webhookTemplate.ts‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,30 @@ function parseBody(request: WebhookRequest): unknown {
8383
}
8484
}
8585

86+
const WILDCARD = /^(.*)\[\]$/;
87+
88+
/**
89+
* Walks `segments` from `root`. A segment ending in `[]` names an array and
90+
* the rest of the path applies to each element, so `labels[].name` resolves
91+
* to the list of every label's name. Elements without the rest of the path
92+
* are dropped; a wildcard on a non-array resolves to undefined.
93+
*/
8694
function lookup(root: unknown, segments: ReadonlyArray<string>): unknown {
8795
let current = root;
88-
for (const segment of segments) {
96+
for (const [index, segment] of segments.entries()) {
8997
if (current === null || typeof current !== "object") return undefined;
90-
if (!Object.hasOwn(current, segment)) return undefined;
91-
current = (current as Record<string, unknown>)[segment];
98+
const wildcard = WILDCARD.exec(segment);
99+
const key = wildcard ? wildcard[1]! : segment;
100+
if (!Object.hasOwn(current, key)) return undefined;
101+
current = (current as Record<string, unknown>)[key];
102+
if (!wildcard) continue;
103+
if (!Array.isArray(current)) return undefined;
104+
const rest = segments.slice(index + 1);
105+
return current.flatMap((element) => {
106+
const value = rest.length === 0 ? element : lookup(element, rest);
107+
if (value === undefined) return [];
108+
return rest.some((part) => WILDCARD.test(part)) && Array.isArray(value) ? value : [value];
109+
});
92110
}
93111
return current;
94112
}

‎packages/contracts/src/scheduledTask.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ const WebhookFilterPath = TrimmedNonEmptyString.check(
109109
Schema.isPattern(/^(body|headers|query)(\.|$)/),
110110
).annotate({
111111
description:
112-
"Request value to test, in placeholder syntax without braces: headers.x-github-event, body.action, query.source.",
112+
"Request value to test, in placeholder syntax without braces: headers.x-github-event, body.action, query.source. A segment ending in [] fans out over an array, so body.issue.labels[].name tests every label's name.",
113113
});
114114

115115
/**
@@ -134,7 +134,7 @@ export const ScheduledTaskWebhookFilter = Schema.Union([
134134
Schema.Struct({ path: WebhookFilterPath, op: Schema.Literal("exists") }),
135135
]).annotate({
136136
description:
137-
"Scalars compare as strings; objects and arrays only satisfy 'exists'. Every op but 'exists' fails when the path is missing.",
137+
"Scalars compare as strings; objects and arrays only satisfy 'exists'. Every op but 'exists' fails when the path is missing. A [] wildcard path yields a list: equals, in and startsWith match when any element does, notEquals when no element equals the value, exists when the list is not empty.",
138138
});
139139
export type ScheduledTaskWebhookFilter = typeof ScheduledTaskWebhookFilter.Type;
140140
export type ScheduledTaskWebhookFilterOp = ScheduledTaskWebhookFilter["op"];

0 commit comments

Comments
 (0)