Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
} from "@t3tools/contracts";
import {
MAX_WEBHOOK_DELIVERY_AGE_MINUTES,
MAX_WEBHOOK_FILTERS,
resolveEnvironmentMachineKind,
} from "@t3tools/contracts";
import type { MenuAction } from "@react-native-menu/menu";
Expand All @@ -24,7 +25,12 @@ import {
} from "@t3tools/client-runtime/state/runtime";
import {
DEFAULT_WEBHOOK_PROMPT,
emptyWebhookFilterDraft,
parseMaxDeliveryAge,
parseWebhookFilterDrafts,
removeWebhookFilterDraft,
WEBHOOK_FILTER_OPS,
type WebhookFilterDraft,
} from "@t3tools/client-runtime/scheduled-task-webhook";
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useFocusEffect, useNavigation, usePreventRemove } from "@react-navigation/native";
Expand Down Expand Up @@ -222,6 +228,84 @@ function SelectRow(props: {
);
}

function WebhookFilterRows(props: {
readonly filters: ReadonlyArray<WebhookFilterDraft>;
readonly disabled: boolean;
readonly onChange: (filters: ReadonlyArray<WebhookFilterDraft>) => void;
}) {
const update = (index: number, patch: Partial<WebhookFilterDraft>) =>
props.onChange(
props.filters.map((filter, at) => (at === index ? { ...filter, ...patch } : filter)),
);
return (
<>
{props.filters.map((filter, index) => (
<View key={filter.id}>
<View className="flex-row items-center gap-3 border-t border-border-subtle px-4 pt-3">
<Text className="min-w-0 flex-1 text-sm text-foreground-muted">Filter {index + 1}</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Remove filter ${index + 1}`}
disabled={props.disabled}
hitSlop={8}
onPress={() => props.onChange(removeWebhookFilterDraft(props.filters, filter.id))}
className="active:opacity-70"
>
<SymbolView name="trash" size={16} tintColorClassName="accent-danger-foreground" />
</Pressable>
</View>
<FormField
label="Path"
value={filter.path}
placeholder="body.action"
disabled={props.disabled}
onChange={(path) => update(index, { path })}
/>
<SelectRow
label="Test"
value={WEBHOOK_FILTER_OPS.find((option) => option.op === filter.op)?.label ?? ""}
actions={WEBHOOK_FILTER_OPS.map((option) => ({
id: option.op,
title: option.label,
state: option.op === filter.op ? ("on" as const) : undefined,
}))}
onSelect={(id) => {
const known = WEBHOOK_FILTER_OPS.find((option) => option.op === id);
if (known) update(index, { op: known.op });
}}
/>
{filter.op === "exists" ? null : (
<FormField
label={filter.op === "in" ? "Values, comma-separated" : "Value"}
value={filter.value}
placeholder={filter.op === "in" ? "opened, reopened" : "opened"}
disabled={props.disabled}
onChange={(value) => update(index, { value })}
/>
)}
</View>
))}
{props.filters.length < MAX_WEBHOOK_FILTERS ? (
<Pressable
accessibilityRole="button"
disabled={props.disabled}
onPress={() => props.onChange([...props.filters, emptyWebhookFilterDraft()])}
className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70"
>
<Text className="min-w-0 flex-1 text-lg text-foreground">Add filter</Text>
<SymbolView name="plus" size={16} tintColorClassName="accent-icon" />
</Pressable>
) : null}
{props.filters.length > 0 ? (
<Text className="px-4 pb-3 text-sm text-foreground-muted">
Runs only when every filter matches. Other requests are logged as filtered. Separate “is
one of” values with commas; a value can't contain a comma.
</Text>
) : null}
</>
);
}

function PickerRow(props: {
readonly label: string;
readonly value: string;
Expand Down Expand Up @@ -627,6 +711,11 @@ function TaskForm({
);
return;
}
const parsedFilters = parseWebhookFilterDrafts(draft.schedule.filters);
if (draft.schedule.mode === "webhook" && "error" in parsedFilters) {
Dialog.alert("Invalid filter", parsedFilters.error);
return;
}
if (
!draft.title.trim() ||
!draft.prompt.trim() ||
Expand Down Expand Up @@ -964,6 +1053,13 @@ function TaskForm({
setDraft({ ...draft, schedule: { ...draft.schedule, maxDeliveryAgeMinutes } })
}
/>
<WebhookFilterRows
filters={draft.schedule.filters}
disabled={saving}
onChange={(filters) =>
setDraft({ ...draft, schedule: { ...draft.schedule, filters } })
}
/>
</>
) : (
<>
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/features/settings/scheduledTaskDraft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,23 @@ describe("scheduleDraftForTask", () => {
expect(saved).toEqual({ type: "webhook", signature, maxDeliveryAgeMinutes: null });
expect(saved?.type === "webhook" && saved.signature && "secret" in saved.signature).toBe(false);
});

it("round-trips webhook filters and refuses an invalid one", () => {
const filters = [
{ path: "headers.x-github-event", op: "equals", value: "pull_request" },
{ path: "body.label.name", op: "in", value: ["needs review", "agent:fix"] },
] as const;
const draft = scheduleDraftForTask({ schedule: { type: "webhook", signature: null, filters } });
// Untouched rows are left out, so the server keeps whatever is stored now.
expect(scheduleFromDraft(draft)).not.toHaveProperty("filters");
expect(scheduleFromDraft({ ...draft, filters: [] })).toMatchObject({ filters: [] });
expect(scheduleFromDraft({ ...draft, filters: draft.filters.slice(1) })).toMatchObject({
filters: filters.slice(1),
});
expect(
scheduleFromDraft({ ...draft, filters: [{ id: "f1", path: "", op: "exists", value: "" }] }),
).toBeNull();
});
});

describe("hasScheduledTaskDraftChanges", () => {
Expand Down
22 changes: 21 additions & 1 deletion apps/mobile/src/features/settings/scheduledTaskDraft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import type {
} from "@t3tools/contracts";

import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
import { parseMaxDeliveryAge } from "@t3tools/client-runtime/scheduled-task-webhook";
import {
parseMaxDeliveryAge,
parseWebhookFilterDrafts,
webhookFilterDrafts,
webhookFilterDraftsChanged,
type WebhookFilterDraft,
} from "@t3tools/client-runtime/scheduled-task-webhook";
import {
resolveProjectSettings,
type LegacyProjectSettingsFields,
Expand Down Expand Up @@ -50,6 +56,9 @@ export type ScheduleDraft = {
readonly intervalMinutes: string;
readonly signature: ScheduledTaskWebhookSignature | null;
readonly maxDeliveryAgeMinutes: string;
readonly filters: ReadonlyArray<WebhookFilterDraft>;
/** The filter rows the form opened with. */
readonly savedFilters: ReadonlyArray<WebhookFilterDraft>;
};

export const DEFAULT_SCHEDULE: ScheduleDraft = {
Expand All @@ -59,6 +68,8 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = {
intervalMinutes: "15",
signature: null,
maxDeliveryAgeMinutes: "",
filters: [],
savedFilters: [],
};

export function scheduleDraftForTask(task: Pick<ScheduledTask, "schedule">): ScheduleDraft {
Expand Down Expand Up @@ -86,6 +97,8 @@ export function scheduleDraftForTask(task: Pick<ScheduledTask, "schedule">): Sch
task.schedule.maxDeliveryAgeMinutes == null
? ""
: String(task.schedule.maxDeliveryAgeMinutes),
filters: webhookFilterDrafts(task.schedule.filters),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
savedFilters: webhookFilterDrafts(task.schedule.filters),
};
}
}
Expand All @@ -94,6 +107,8 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche
if (draft.mode === "webhook") {
const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes);
if (maxDeliveryAgeMinutes === undefined) return null;
const parsedFilters = parseWebhookFilterDrafts(draft.filters);
if (!("filters" in parsedFilters)) return null;
// No secret is sent, so the server keeps the stored one.
return {
type: "webhook",
Expand All @@ -106,6 +121,10 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche
prefix: draft.signature.prefix,
},
maxDeliveryAgeMinutes,
// Only edited filters are sent; the server keeps the stored ones otherwise.
...(webhookFilterDraftsChanged(draft.savedFilters, draft.filters)
? { filters: parsedFilters.filters }
: {}),
};
}
if (draft.mode === "interval") {
Expand Down Expand Up @@ -160,6 +179,7 @@ function draftSignature(draft: ScheduledTaskDraft): string {
[...draft.schedule.weekdays].sort((a, b) => a - b),
draft.schedule.intervalMinutes,
draft.schedule.maxDeliveryAgeMinutes,
draft.schedule.filters.map(({ path, op, value }) => [path, op, value]),
draft.workspace,
draft.baseRef,
draft.checkoutPath,
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/mcp/toolkits/orchestrator/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", {

export const ScheduleTaskTool = Tool.make("schedule_task", {
description:
"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. 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}}. 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.",
"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. 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.",
parameters: OrchestratorMcpScheduleTaskInput,
success: OrchestratorMcpScheduleTaskResult,
failure: OrchestratorMcpFailure,
Expand All @@ -143,7 +143,7 @@ const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", {

const UpdateScheduledTaskTool = Tool.make("update_scheduled_task", {
description:
"Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run.",
"Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run. A webhook schedule without filters keeps the stored filters; pass filters to replace them, or filters:[] to clear them.",
parameters: OrchestratorMcpUpdateScheduledTaskInput,
success: OrchestratorMcpScheduleTaskResult,
failure: OrchestratorMcpFailure,
Expand Down
Loading
Loading