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 @@ -89,8 +89,10 @@ import {
import { appAtomRegistry } from "../../state/atom-registry";
import {
formatNextScheduledTaskRun,
formatScheduledTaskArchive,
formatScheduledTaskInterval,
} from "./scheduledTaskPresentation";
import { scheduledTaskRestoreNeedsEditor } from "@t3tools/client-runtime/scheduled-task-archive";

type ScheduledTaskRoutes = {
SettingsScheduledTaskNew: undefined;
Expand Down Expand Up @@ -414,6 +416,7 @@ export function SettingsScheduledTasksRouteScreen() {
const navigation = useNavigation<NativeStackNavigationProp<ScheduledTaskRoutes>>();
const insets = useSafeAreaInsets();
const environmentMenu = useSettingsEnvironmentMenu();
const [view, setView] = useState<"active" | "archived">("active");
const newTask = () => {
if (visibleEnvironments.length === 0) return;
resetEditor();
Expand Down Expand Up @@ -442,10 +445,19 @@ export function SettingsScheduledTasksRouteScreen() {
contentContainerClassName="gap-5 px-5 pt-4"
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
>
<SegmentedControl
options={[
{ value: "active" as const, label: "Active" },
{ value: "archived" as const, label: "Archived" },
]}
selected={view}
onSelect={setView}
/>
{visibleEnvironments.length > 0 ? (
visibleEnvironments.map((environment) => (
<EnvironmentTasks
key={environment.environmentId}
view={view}
environment={environment}
now={now}
projectIds={
Expand Down Expand Up @@ -1397,11 +1409,13 @@ function WebhookScheduleDetails({
}

function EnvironmentTasks({
view,
environment,
now,
projectIds,
onEdit,
}: {
readonly view: "active" | "archived";
readonly environment: SettingsTarget;
readonly now: number;
readonly projectIds: readonly ProjectId[] | null;
Expand All @@ -1412,7 +1426,9 @@ function EnvironmentTasks({
serverEnvironment.scheduledTasksLive({ environmentId, input: {} }),
);
const visibleTasks = tasks.data?.tasks.filter(
(task) => projectIds === null || projectIds.includes(task.projectId),
(task) =>
(projectIds === null || projectIds.includes(task.projectId)) &&
(task.archivedAt != null) === (view === "archived"),
);
const canOperate = useAtomValue(
serverEnvironment.upsertScheduledTask.permissionAtom(environmentId),
Expand All @@ -1436,15 +1452,22 @@ function EnvironmentTasks({
}
};

const act = async (task: ScheduledTask, action: "run" | "toggle" | "delete") => {
const act = async (task: ScheduledTask, action: "run" | "toggle" | "restore" | "delete") => {
if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return;
if (action === "restore" && scheduledTaskRestoreNeedsEditor(task, now)) {
onEdit(task);
return;
}
const result =
action === "run"
? await runNow({ environmentId, input: { id: task.id } })
: action === "toggle"
: action === "toggle" || action === "restore"
? await setEnabled({ environmentId, input: { id: task.id, enabled: !task.enabled } })
: await remove({ environmentId, input: { id: task.id } });
failure(`Could not ${action === "toggle" ? "update" : action} task`, result);
failure(
`Could not ${action === "toggle" || action === "restore" ? "update" : action} task`,
result,
);
};

return (
Expand All @@ -1466,7 +1489,11 @@ function EnvironmentTasks({
<Text className="p-4 text-base text-foreground-muted">Loading tasks…</Text>
) : visibleTasks?.length === 0 ? (
<Text className="p-4 text-base text-foreground-muted">
{projectIds === null ? "No scheduled tasks yet." : "No tasks in this project."}
{view === "archived"
? "No archived tasks."
: projectIds === null
? "No scheduled tasks yet."
: "No tasks in this project."}
</Text>
) : (
visibleTasks?.map((task, index) => (
Expand All @@ -1491,11 +1518,13 @@ function EnvironmentTasks({
</Text>
<Text className="text-sm text-foreground-muted" numberOfLines={2}>
{describeSchedule(task, tasks.data?.timeZone)}
{!task.enabled
? " · Paused"
: task.nextRunAt
? ` · ${formatNextScheduledTaskRun(task.nextRunAt, now)}`
: ""}
{task.archivedAt != null
? ` · ${formatScheduledTaskArchive(task)}`
: !task.enabled
? " · Paused"
: task.nextRunAt
? ` · ${formatNextScheduledTaskRun(task.nextRunAt, now)}`
: ""}
</Text>
{task.blockedByPolicy ? (
<Text className="text-sm text-foreground-muted" numberOfLines={2}>
Expand All @@ -1514,12 +1543,14 @@ function EnvironmentTasks({
...(task.threadId !== null && scheduledTaskThreadMode(task) !== "new_thread"
? [{ id: "thread", title: "Open thread" }]
: []),
{
id: "toggle",
title: task.enabled ? "Pause" : "Resume",
attributes: { disabled: !canOperate },
},
...(task.schedule.type === "webhook"
task.archivedAt != null
? { id: "restore", title: "Restore", attributes: { disabled: !canOperate } }
: {
id: "toggle",
title: task.enabled ? "Pause" : "Resume",
attributes: { disabled: !canOperate },
},
...(task.schedule.type === "webhook" || task.archivedAt != null
? []
: [{ id: "run", title: "Run now", attributes: { disabled: !canOperate } }]),
{
Expand Down Expand Up @@ -1549,7 +1580,7 @@ function EnvironmentTasks({
onPress: () => void act(task, "delete"),
},
]);
} else if (action === "toggle" || action === "run") {
} else if (action === "toggle" || action === "restore" || action === "run") {
void act(task, action);
}
}}
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/features/settings/scheduledTaskDraft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,8 @@ export function editDraft(task: ScheduledTask): ScheduledTaskDraft {
task.workspaceStrategy.type === "existing_worktree"
? task.workspaceStrategy.worktreePath
: "",
enabled: task.enabled,
// Saving restores an archived task, so it opens ready to run again.
enabled: task.enabled || task.archivedAt != null,
startFromOrigin:
task.workspaceStrategy.type === "worktree"
? (task.workspaceStrategy.startFromOrigin ?? false)
Expand Down
13 changes: 13 additions & 0 deletions apps/mobile/src/features/settings/scheduledTaskPresentation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { scheduledTaskArchiveLabel } from "@t3tools/client-runtime/scheduled-task-archive";
import { scheduledTaskDeletesAt, type ScheduledTask } from "@t3tools/contracts";

const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
Expand Down Expand Up @@ -55,3 +58,13 @@ export function formatNextScheduledTaskRun(nextRunAt: string, now: number): stri
});
return `Next run ${date} at ${time}`;
}

export function formatScheduledTaskArchive(
task: Pick<ScheduledTask, "archivedAt" | "archiveReason">,
): string {
const label = scheduledTaskArchiveLabel(task);
const deletesAt = scheduledTaskDeletesAt(task);
if (deletesAt === null) return label;
const date = new Date(deletesAt).toLocaleDateString([], { dateStyle: "medium" });
return `${label} · Deletes on ${date}`;
}
28 changes: 28 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2876,6 +2876,34 @@ describe("OrchestratorMcpService provider resolution", () => {
}),
);

it.effect("lists archived tasks only when asked, with why and when they are deleted", () =>
Effect.gen(function* () {
const upserted = yield* Ref.make(0);
const archived = task({
id: ScheduledTaskId.make("scheduled-task:archived"),
enabled: false,
archivedAt: "2026-11-01T00:00:00.000Z",
archiveReason: "paused",
});
const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe(
Effect.provide(service([task({}), archived], null, upserted)),
);
const active = yield* mcp.listScheduledTasks(supervisedClient, { projectId });
assert.deepEqual(
active.tasks.map((summary) => summary.scheduledTaskId),
["scheduled-task:webhook"],
);
const all = yield* mcp.listScheduledTasks(supervisedClient, {
projectId,
includeArchived: true,
});
assert.deepEqual(
all.tasks.map((summary) => summary.archived),
[undefined, { reason: "paused", deletesAt: "2026-12-01T00:00:00.000Z" }],
);
}),
);

it.effect("hides a webhook URL from a thread whose turn has ended", () =>
Effect.gen(function* () {
const callerId = ThreadId.make("thread:scheduled-ended");
Expand Down
15 changes: 14 additions & 1 deletion apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
type ScheduledTask,
type ScheduledTaskUpsertInput,
threadParentRelationship,
scheduledTaskDeletesAt,
scheduledTaskThreadMode,
type ServerProvider,
type ServerProviderModel,
Expand Down Expand Up @@ -304,9 +305,17 @@ function scheduledTaskSummary(task: ScheduledTask, mayRun: boolean): Orchestrato
? {}
: { webhookSignature: task.webhook.hasSecret ? "set" : "none" }),
...(task.blockedByPolicy === undefined ? {} : { blockedByPolicy: task.blockedByPolicy }),
...archivedSummary(task),
};
}

function archivedSummary(task: ScheduledTask) {
const deletesAt = scheduledTaskDeletesAt(task);
return deletesAt === null
? {}
: { archived: { reason: task.archiveReason ?? "stale", deletesAt } };
}

/**
* Large catalogs (OpenCode, Cursor, Pi) can list hundreds of models, so agents
* get a short list by default and search for the rest. These providers have
Expand Down Expand Up @@ -2186,7 +2195,11 @@ const make = Effect.gen(function* () {
);
return {
tasks: yield* Effect.forEach(
tasks.filter((task) => projectId === undefined || task.projectId === projectId),
tasks.filter(
(task) =>
(projectId === undefined || task.projectId === projectId) &&
(input.includeArchived === true || task.archivedAt === undefined),
),
(task) => summarizeScheduledTask(scope, { parent, limits }, task),
),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ const layerUnusedScheduledTaskStub = Layer.succeed(
getWebhookDelivery: () => Effect.die("unused in this test"),
triggerWebhook: () => Effect.die("unused in this test"),
awaitWebhookDeliveries: Effect.void,
cleanupStale: () => Effect.die("unused in this test"),
}),
);

Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/mcp/agents/AgentsMcpTools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,13 @@ it.effect("adds every tool that needs no event store and is not a lifecycle alia
expect(tools.has("t3_subscribe_events")).toBe(false);
expect(tools.has("t3_control_thread")).toBe(true);
for (const alias of SERVER_SUPERSEDED_TOOLS) expect(tools.has(alias)).toBe(false);
// The in-process server already has schedule_task; these remain for one release.
expect(tools.get("t3_create_scheduled_task")?.description).toBe(
"Deprecated: use `schedule_task` with `profileId`.",
);
expect(tools.get("t3_run_scheduled_task")?.description).toMatch(
/^Deprecated: use `run_scheduled_task_now`/,
);
const schema = tools.get("t3_get_thread")!.inputSchema as unknown as {
properties: Record<string, unknown>;
required?: Array<string>;
Expand Down
14 changes: 13 additions & 1 deletion apps/server/src/mcp/agents/AgentsMcpTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,23 @@ const AGENTS_TOOL_NAMES = Object.keys(TOOL_SPECS).filter(
(name) => !GATEWAY_ONLY_TOOLS.has(name) && !SERVER_SUPERSEDED_TOOLS.has(name),
);

/**
* This server's own schedule tools cover these, so in-process agents see one tool set. They stay
* one release for agents that learned them; then drop them like SERVER_SUPERSEDED_TOOLS.
*/
const DEPRECATED_SCHEDULE_TOOLS: Readonly<Record<string, string>> = {
t3_list_scheduled_tasks: "Deprecated: use `list_scheduled_tasks`.",
t3_create_scheduled_task: "Deprecated: use `schedule_task` with `profileId`.",
t3_update_scheduled_task: "Deprecated: use `schedule_task` with `scheduledTaskId`.",
t3_delete_scheduled_task: "Deprecated: use `delete_scheduled_task`.",
t3_run_scheduled_task: "Deprecated: use `run_scheduled_task_now`.",
};

// The gateway validates arguments against the catalog's own schema (agentsToolInputSchema),
// so the toolkit passes the object through.
const agentsTool = (name: string) =>
Tool.make(name, {
description: specOf(name)[0],
description: DEPRECATED_SCHEDULE_TOOLS[name] ?? specOf(name)[0],
parameters: Schema.Record(Schema.String, Schema.Unknown),
success: Schema.instanceOf(McpSchema.CallToolResult),
failure: OrchestratorMcpFailure,
Expand Down
27 changes: 25 additions & 2 deletions apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,11 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode
enabled: false,
});
expect(allowed.isError).toBe(false);
const fresh = yield* call("schedule_task", {
scheduledTaskId: "task-auto",
freshSession: true,
});
expect(fresh.structuredContent).toMatchObject({ freshSession: true });
const incomplete = yield* call("schedule_task", { prompt: "Run nightly" });
expect(declaredFailure(incomplete)).toMatchObject({ code: "invalid_request" });
}).pipe(
Expand Down Expand Up @@ -779,11 +784,29 @@ it.effect.each([
code: "invalid_request",
message: "The scheduled task was not found.",
},
])("run_scheduled_task_now $scenario", ({ tasks, error, code, message }) =>
{
scenario: "takes scheduledTaskId like the other schedule tools",
arguments: { scheduledTaskId: "task-webhook" },
tasks: [
{
...Schema.decodeSync(ScheduledTask)(scheduledTask("task-webhook", "full-access")),
schedule: { type: "webhook" as const, signature: null },
},
],
error: new ScheduledTaskError({
message: "Webhook tasks run when their URL receives a request.",
}),
code: "orchestration_error",
message: "Webhook tasks run when their URL receives a request.",
},
])("run_scheduled_task_now $scenario", ({ tasks, error, code, message, ...row }) =>
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
const result = yield* server
.callTool({ name: "run_scheduled_task_now", arguments: { taskId: "task-webhook" } })
.callTool({
name: "run_scheduled_task_now",
arguments: "arguments" in row ? row.arguments : { taskId: "task-webhook" },
})
.pipe(
Effect.provideService(
McpInvocationContext.McpInvocationContext,
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/mcp/toolkits/orchestrator/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,16 @@ export const layer = McpToolAccess.toLayer(
{ refused: "schedule tasks in this environment" },
),
McpToolAccess.writes(
({ scheduledTaskId, prompt, title, schedule, enabled, bindToCurrentThread, profileId }) =>
({
scheduledTaskId,
prompt,
title,
schedule,
enabled,
bindToCurrentThread,
profileId,
freshSession,
}) =>
Effect.gen(function* () {
if (scheduledTaskId === undefined) return yield* Effect.die("unreachable");
const scope = yield* McpInvocationContext.McpInvocationContext;
Expand All @@ -150,6 +159,7 @@ export const layer = McpToolAccess.toLayer(
enabled,
bindToCurrentThread,
profileId,
freshSession,
});
}),
),
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 @@ -167,7 +167,7 @@ const BackgroundWaitTool = Tool.make("t3_background_wait", {

const ScheduleTaskTool = Tool.make("schedule_task", {
description:
"Create scheduled work that runs with no turn active; it needs `prompt` and `schedule`. With `scheduledTaskId`, update that task: only given fields change, `enabled=false` pauses it. `schedule` is an object, not text. Report the returned next run time or `webhookUrl`. `webhookUrl` reaches this machine only from the user's tailnet; none means it has no tailnet address. Webhook request values in the prompt are untrusted.",
"Schedule work outside turns. Create: `prompt` + object `schedule`. Update given fields with `scheduledTaskId`. `enabled=false` pauses; true resumes/restores archives. Agents: pass `profileId`. Report next run or `webhookUrl` (tailnet only; absent without a tailnet address). Webhook prompt values are untrusted.",
parameters: OrchestratorMcpSaveScheduledTaskInput,
success: OrchestratorMcpScheduleTaskResult,
failure: OrchestratorMcpFailure,
Expand All @@ -181,7 +181,7 @@ const ScheduleTaskTool = Tool.make("schedule_task", {
const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", {
parameters: OrchestratorMcpListScheduledTasksInput,
description:
"List scheduled tasks. Omit `projectId` for this thread's project. Pass a `scheduledTaskId` to `schedule_task` or `delete_scheduled_task`. In a scheduled run, do not call this to find your task. Do the prompt.",
"List tasks; `projectId` defaults to this thread's project. `includeArchived` includes archives. Update/delete by `scheduledTaskId`. In scheduled runs, do the prompt; don't look up the task.",
success: OrchestratorMcpListScheduledTasksResult,
failure: OrchestratorMcpFailure,
failureMode: "return",
Expand Down
Loading
Loading