Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ import type { ComposerEditorSelection } from "../../components/ComposerEditor";
import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
import { SegmentedControl } from "../../components/SegmentedControl";
import { ThemedSwitch } from "../../components/ThemedSwitch";
import { webhookAddress } from "@t3tools/client-runtime/webhook-address";
import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { usePreparedConnection } from "../../state/session";
import { buildModelOptions } from "../../lib/modelOptions";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { useProjects, useEnvironmentServerConfig } from "../../state/entities";
Expand All @@ -56,6 +59,7 @@ import { SettingsSection } from "./components/SettingsSection";
import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter";
import {
editDraft,
DEFAULT_WEBHOOK_PROMPT,
scheduledTaskDefaultModel,
scheduleFromDraft,
type ScheduledTaskDraft as Draft,
Expand Down Expand Up @@ -599,7 +603,14 @@ function TaskForm({
environmentUnavailable
)
return;
const schedule = scheduleFromDraft(draft.schedule);
// Signatures are edited on desktop and web; send the task's current one,
// not the copy taken when this form opened, so a newer edit survives.
const liveTask = tasks.data?.tasks.find((task) => task.id === draft.task?.id);
const schedule = scheduleFromDraft(
draft.schedule.mode === "webhook" && liveTask?.schedule.type === "webhook"
? { ...draft.schedule, signature: liveTask.schedule.signature }
: draft.schedule,
);
if (
!draft.title.trim() ||
!draft.prompt.trim() ||
Expand Down Expand Up @@ -811,12 +822,20 @@ function TaskForm({
<SegmentedControl
options={[
{ value: "fixed_time", label: "At a time" },
{ value: "interval", label: "Every interval" },
{ value: "interval", label: "Interval" },
{ value: "webhook", label: "Webhook" },
]}
selected={draft.schedule.mode}
onSelect={(mode) => {
setTimePickerOpen(false);
setDraft({ ...draft, schedule: { ...draft.schedule, mode } });
setDraft({
...draft,
prompt:
mode === "webhook" && !draft.prompt.trim()
? DEFAULT_WEBHOOK_PROMPT
: draft.prompt,
schedule: { ...draft.schedule, mode },
});
}}
/>
</View>
Expand Down Expand Up @@ -890,6 +909,14 @@ function TaskForm({
}}
/>
</>
) : draft.schedule.mode === "webhook" ? (
<WebhookScheduleDetails
environmentId={environmentId}
task={
tasks.data?.tasks.find((task) => task.id === draft.task?.id) ?? draft.task ?? null
}
signatureConfigured={draft.schedule.signature !== null}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium settings/SettingsScheduledTasksRouteScreen.tsx:918

The signature status shown in WebhookScheduleDetails can be stale: changes made on desktop/web while this editor is open are not reflected, so the screen reports an incorrect webhook signature configuration. signatureConfigured is read from the form-open-time draft.schedule while task comes from the live subscription; derive the status from the passed live webhook task instead.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx around line 918:

The signature status shown in `WebhookScheduleDetails` can be stale: changes made on desktop/web while this editor is open are not reflected, so the screen reports an incorrect webhook signature configuration. `signatureConfigured` is read from the form-open-time `draft.schedule` while `task` comes from the live subscription; derive the status from the passed live webhook task instead.

/>
) : (
<>
<FormField
Expand Down Expand Up @@ -945,6 +972,90 @@ function TaskForm({
);
}

function WebhookScheduleDetails({
environmentId,
task,
signatureConfigured,
}: {
readonly environmentId: EnvironmentId;
readonly task: ScheduledTask | null;
readonly signatureConfigured: boolean;
}) {
const rotate = useAtomCommand(serverEnvironment.rotateScheduledTaskWebhookToken, {
label: "scheduled task rotate webhook token",
reportFailure: false,
});
const preparedConnection = usePreparedConnection(environmentId);
const httpBaseUrl =
preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null;
const webhook = task?.schedule.type === "webhook" ? task.webhook : undefined;
// Without T3 Connect, the path is resolved on the address this phone uses.
const resolved = webhook ? webhookAddress(webhook, httpBaseUrl) : null;
return (
<View className="gap-2 border-t border-border-subtle px-4 py-3">
<Text className="text-sm text-foreground-muted">
{
"The prompt can use {{body.a.b}}, {{headers.name}}, {{query.name}}, {{body}} and {{request}}. The filled-in prompt is all the agent sees."
}
</Text>
{task === null || resolved === null ? (
<Text className="text-sm text-foreground-muted">Save the task to get its webhook URL.</Text>
) : (
<>
<Pressable
accessibilityRole="button"
accessibilityLabel="Copy webhook URL"
accessibilityHint="Copies the URL to the clipboard"
// A bare path is not something a sender can call, so only full URLs copy.
disabled={!resolved.copyable}
onPress={() => void tryCopyTextWithHaptic(resolved.address)}
className="gap-1 active:opacity-70"
>
<Text className="text-lg text-foreground">
{resolved.copyable ? "Webhook URL" : "Webhook path"}
</Text>
<Text className="text-sm text-foreground-muted" numberOfLines={2} selectable>
{resolved.address}
</Text>
</Pressable>
{resolved.note !== null ? (
<Text className="text-sm text-foreground-muted">{resolved.note}</Text>
) : null}
<Pressable
accessibilityRole="button"
onPress={() =>
Alert.alert("Rotate URL?", "The current URL stops working immediately.", [
{ text: "Cancel", style: "cancel" },
{
text: "Rotate",
style: "destructive",
onPress: () =>
void rotate({ environmentId, input: { id: task.id } }).then((result) => {
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
Alert.alert(
"Could not rotate URL",
String(squashAtomCommandFailure(result)),
);
}
}),
},
])
}
className="min-h-11 justify-center active:opacity-70"
>
<Text className="text-base text-danger-foreground">Rotate URL</Text>
</Pressable>
</>
)}
{signatureConfigured ? (
<Text className="text-sm text-foreground-muted">
Signature check configured on desktop/web.
</Text>
) : null}
</View>
);
}

function EnvironmentTasks({
environment,
now,
Expand Down Expand Up @@ -1025,8 +1136,6 @@ function EnvironmentTasks({
<Pressable
accessibilityRole="button"
accessibilityLabel={`Edit ${task.title}`}
// Webhook tasks are not editable here yet; saving would drop their URL.
disabled={task.schedule.type === "webhook"}
onPress={() => {
onEdit(task);
}}
Expand All @@ -1051,9 +1160,10 @@ function EnvironmentTasks({
</Pressable>
<ControlPillMenu
actions={[
...(task.schedule.type === "webhook" ? [] : [{ id: "edit", title: "Edit" }]),
{ id: "edit", title: "Edit" },
{ id: "toggle", title: task.enabled ? "Pause" : "Resume" },
{ id: "run", title: "Run now" },
// A webhook task has no request to run without.
...(task.schedule.type === "webhook" ? [] : [{ id: "run", title: "Run now" }]),
{ id: "delete", title: "Delete", attributes: { destructive: true } },
]}
onPressAction={({ nativeEvent }) => {
Expand Down
19 changes: 19 additions & 0 deletions apps/mobile/src/features/settings/scheduledTaskDraft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,25 @@ describe("scheduleDraftForTask", () => {
const schedule = { type: "interval" as const, everyMs: 65_000 };
expect(scheduleFromDraft(scheduleDraftForTask({ schedule }))).toEqual(schedule);
});

it("round-trips a webhook schedule without a signature", () => {
const draft = scheduleDraftForTask({ schedule: { type: "webhook", signature: null } });
expect(draft.mode).toBe("webhook");
expect(scheduleFromDraft(draft)).toEqual({ type: "webhook", signature: null });
});

it("keeps a webhook signature on save without sending a secret", () => {
const signature = {
header: "x-hub-signature-256",
encoding: "hex" as const,
prefix: "sha256=",
};
const saved = scheduleFromDraft(
scheduleDraftForTask({ schedule: { type: "webhook", signature } }),
);
expect(saved).toEqual({ type: "webhook", signature });
expect(saved?.type === "webhook" && saved.signature && "secret" in saved.signature).toBe(false);
});
});

describe("hasScheduledTaskDraftChanges", () => {
Expand Down
38 changes: 31 additions & 7 deletions apps/mobile/src/features/settings/scheduledTaskDraft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
RuntimeMode,
ScheduledTask,
ScheduledTaskUpsertSchedule,
ScheduledTaskWebhookSignature,
} from "@t3tools/contracts";

import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
Expand Down Expand Up @@ -37,38 +38,61 @@ export function scheduledTaskDefaultModel(
}

export type ScheduleDraft = {
readonly mode: "fixed_time" | "interval";
readonly mode: "fixed_time" | "interval" | "webhook";
readonly timeOfDay: string;
readonly weekdays: ReadonlyArray<number>;
readonly intervalMinutes: string;
/** A webhook signature check configured elsewhere; mobile keeps it but does not edit it. */
readonly signature: ScheduledTaskWebhookSignature | null;
};

export const DEFAULT_SCHEDULE: ScheduleDraft = {
mode: "fixed_time",
timeOfDay: "09:00",
weekdays: [1, 2, 3, 4, 5],
intervalMinutes: "15",
signature: null,
};

/** Prompt a new webhook task starts with: the whole request, which the user can narrow down. */
export const DEFAULT_WEBHOOK_PROMPT = "Handle this webhook:\n{{request}}";

export function scheduleDraftForTask(task: Pick<ScheduledTask, "schedule">): ScheduleDraft {
// Webhook tasks cannot be edited here yet; show them as the default schedule.
if (task.schedule.type === "webhook") return DEFAULT_SCHEDULE;
return task.schedule.type === "fixed_time"
? {
switch (task.schedule.type) {
case "fixed_time":
return {
...DEFAULT_SCHEDULE,
timeOfDay: task.schedule.timeOfDay,
weekdays: task.schedule.weekdays?.length
? [...new Set(task.schedule.weekdays)].sort((a, b) => a - b)
: [0, 1, 2, 3, 4, 5, 6],
}
: {
};
case "interval":
return {
...DEFAULT_SCHEDULE,
mode: "interval",
intervalMinutes: String(Math.max(1, task.schedule.everyMs / 60_000)),
};
case "webhook":
return { ...DEFAULT_SCHEDULE, mode: "webhook", signature: task.schedule.signature };
}
}

export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSchedule | null {
if (draft.mode === "webhook") {
// No secret is sent, so the server keeps the stored one.
return {
type: "webhook",
signature:
draft.signature === null
? null
: {
header: draft.signature.header,
Comment thread
juliusmarminge marked this conversation as resolved.
encoding: draft.signature.encoding,
prefix: draft.signature.prefix,
},
};
}
if (draft.mode === "interval") {
const minutes = Number(draft.intervalMinutes);
// Undo floating-point noise from displaying existing millisecond intervals as minutes.
Expand Down
4 changes: 4 additions & 0 deletions packages/client-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@
"types": "./src/textPaste.ts",
"default": "./src/textPaste.ts"
},
"./webhook-address": {
"types": "./src/webhookAddress.ts",
"default": "./src/webhookAddress.ts"
},
"./delayed-status": {
"types": "./src/delayedStatus.ts",
"default": "./src/delayedStatus.ts"
Expand Down
36 changes: 36 additions & 0 deletions packages/client-runtime/src/webhookAddress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vite-plus/test";

import { webhookAddress } from "./webhookAddress.ts";

const path = "/api/hooks/scheduled-task%3Ahook/token";
const endpoint = (url: string | null) => ({ path, url, hasSecret: false });

describe("webhookAddress", () => {
it("uses the T3 Connect URL when the server has one", () => {
expect(webhookAddress(endpoint("https://relay.t3.codes/v1/hooks/k/t/x"), null)).toEqual({
address: "https://relay.t3.codes/v1/hooks/k/t/x",
copyable: true,
note: null,
});
});

it("builds a direct URL on the environment's address without T3 Connect", () => {
const result = webhookAddress(endpoint(null), "https://mac.tail1234.ts.net/");
expect(result.address).toBe(`https://mac.tail1234.ts.net${path}`);
expect(result.copyable).toBe(true);
expect(result.note).toContain("Tailscale");
});

it("says only this computer can call a loopback address", () => {
const result = webhookAddress(endpoint(null), "http://127.0.0.1:3773/");
expect(result.copyable).toBe(true);
expect(result.note).toContain("Only this computer");
});

it("falls back to the path when the address is unknown", () => {
expect(webhookAddress(endpoint(null), null)).toMatchObject({
address: path,
copyable: false,
});
});
});
40 changes: 40 additions & 0 deletions packages/client-runtime/src/webhookAddress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { ScheduledTaskWebhookEndpoint } from "@t3tools/contracts";
import { isLocalLoopbackHost } from "@t3tools/shared/hostClassification";

/**
* Where a sender can call a webhook task, as a client shows it. With T3
* Connect the server returns a public URL; without it the path is resolved on
* the address this client reaches the environment at.
*/
export interface WebhookAddress {
/** The URL to give a sender, or the bare path when no address is known. */
readonly address: string;
/** Whether `address` is a full URL a sender can call. */
readonly copyable: boolean;
/** One line on who can reach `address`; null for a T3 Connect URL. */
readonly note: string | null;
}

export function webhookAddress(
endpoint: ScheduledTaskWebhookEndpoint,
httpBaseUrl: string | null,
): WebhookAddress {
if (endpoint.url !== null) {
return { address: endpoint.url, copyable: true, note: null };
}
if (httpBaseUrl === null) {
return {
address: endpoint.path,
copyable: false,
note: "Link this environment to T3 Connect for a public URL.",
};
}
const url = new URL(endpoint.path, httpBaseUrl);
return {
address: url.href,
copyable: true,
note: isLocalLoopbackHost(url.hostname)
? "Only this computer can call this address. Link T3 Connect for a public URL."
: "Works wherever this environment's address is reachable, for example over Tailscale or your own proxy. Link T3 Connect for a public URL.",
};
}
Loading