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 @@ -87,6 +87,7 @@ const DAYS = [
] as const;

function describeSchedule(task: ScheduledTask): string {
if (task.schedule.type === "webhook") return "On webhook";
if (task.schedule.type === "interval") return formatScheduledTaskInterval(task.schedule.everyMs);
const days = task.schedule.weekdays?.length ? repeatLabel(task.schedule.weekdays) : "Every day";
return `${days} at ${formatTime(task.schedule.timeOfDay)}`;
Expand Down Expand Up @@ -1024,6 +1025,8 @@ 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 @@ -1048,7 +1051,7 @@ function EnvironmentTasks({
</Pressable>
<ControlPillMenu
actions={[
{ id: "edit", title: "Edit" },
...(task.schedule.type === "webhook" ? [] : [{ id: "edit", title: "Edit" }]),
{ id: "toggle", title: task.enabled ? "Pause" : "Resume" },
{ id: "run", title: "Run now" },
{ id: "delete", title: "Delete", attributes: { destructive: true } },
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/settings/scheduledTaskDraft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = {
};

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"
? {
...DEFAULT_SCHEDULE,
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ describe("RPC authorization scopes", () => {
);
});

it("keeps webhook delivery logs, which hold request bodies, behind operate scope", () => {
for (const method of [
WS_METHODS.scheduledTasksListWebhookDeliveries,
WS_METHODS.scheduledTasksGetWebhookDelivery,
WS_METHODS.scheduledTasksRotateWebhookToken,
]) {
expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationOperateScope);
}
});

it("allows relay status reads without granting relay installation access", () => {
expect(requiredScopeForRpcMethod(WS_METHODS.cloudGetRelayClientStatus)).toBe(
AuthRelayReadScope,
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope,
[WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope,
[WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope,
[WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope,
// Delivery logs hold request bodies, so they need the same scope as the URL.
[WS_METHODS.scheduledTasksListWebhookDeliveries]: AuthOrchestrationOperateScope,
[WS_METHODS.scheduledTasksGetWebhookDelivery]: AuthOrchestrationOperateScope,
[WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope,
[WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope,
[WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,
Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
failEnvironmentInternal,
} from "./auth/http.ts";
import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
import { WEBHOOK_ROUTE_PREFIX } from "./scheduledTasks/ScheduledTaskService.ts";
import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";

const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
Expand Down Expand Up @@ -381,9 +382,9 @@ const UNTRACED_REQUEST_PATHS: ReadonlySet<string> = new Set([OTLP_TRACES_PROXY_P
// ignored, as in routing.
const untracedRequestsLayer = Layer.succeed(HttpMiddleware.TracerDisabledWhen)((request) => {
const queryIndex = request.url.indexOf("?");
return UNTRACED_REQUEST_PATHS.has(
queryIndex === -1 ? request.url : request.url.slice(0, queryIndex),
);
const path = queryIndex === -1 ? request.url : request.url.slice(0, queryIndex);
// Webhook URLs carry their secret token in the path, so they never reach a trace.
return UNTRACED_REQUEST_PATHS.has(path) || path.startsWith(`${WEBHOOK_ROUTE_PREFIX}/`);
});

export const withUntracedRequests = Layer.provide(untracedRequestsLayer);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ function scheduledTaskSummary(task: ScheduledTask): OrchestratorMcpScheduledTask
schedule: task.schedule,
nextRunAt: task.nextRunAt,
lastRunStatus: task.lastRunStatus,
...(task.webhook === undefined ? {} : { webhookUrl: task.webhook.url ?? task.webhook.path }),
};
}

Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,8 @@ function scheduledTaskFromUpsert(input: ScheduledTaskUpsertInput): ScheduledTask
title: input.title,
prompt: input.prompt,
enabled: input.enabled,
schedule: input.schedule,
schedule:
input.schedule.type === "webhook" ? { type: "webhook", signature: null } : input.schedule,
projectId: input.projectId,
threadId: input.threadId ?? null,
workspaceStrategy: input.workspaceStrategy,
Expand Down Expand Up @@ -471,6 +472,10 @@ const unusedScheduledTaskStubLayer = Layer.succeed(
setEnabled: () => Effect.die("ScheduledTaskService.setEnabled is unused in this test"),
delete: () => Effect.die("ScheduledTaskService.delete is unused in this test"),
runNow: () => Effect.die("ScheduledTaskService.runNow is unused in this test"),
rotateWebhookToken: () => Effect.die("unused in this test"),
listWebhookDeliveries: () => Effect.die("unused in this test"),
getWebhookDelivery: () => Effect.die("unused in this test"),
triggerWebhook: () => Effect.die("unused in this test"),
}),
);

Expand Down Expand Up @@ -628,6 +633,10 @@ describe("orchestrator MCP toolkit", () => {
all.filter((candidate) => candidate.id !== input.id),
).pipe(Effect.as({ id: input.id })),
runNow: () => Effect.die("ScheduledTaskService.runNow is unused in this test"),
rotateWebhookToken: () => Effect.die("unused in this test"),
listWebhookDeliveries: () => Effect.die("unused in this test"),
getWebhookDelivery: () => Effect.die("unused in this test"),
triggerWebhook: () => Effect.die("unused in this test"),
}),
);
const testLayer = Layer.merge(
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/mcp/toolkits/orchestrator/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ const TaskCancelTool = Tool.make("task_cancel", {

export const ScheduleTaskTool = Tool.make("schedule_task", {
description:
"Create persistent recurring work in the app scheduler, which runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text: {type:'interval', everyMs:3600000} means hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} means weekday mornings. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true); 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. Report the returned schedule and nextRunAt after success.",
"Create persistent recurring work in the app scheduler, which runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text: {type:'interval', everyMs:3600000} means hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} means weekday mornings; {type:'webhook'} runs on each request to a generated URL (returned as webhookUrl), and its prompt may use {{body.path}}, {{headers.name}}, {{query.name}}, {{body}} or {{request}} placeholders, which are the only request data the run sees. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true); 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. Report the returned schedule and nextRunAt after success.",
parameters: OrchestratorMcpScheduleTaskInput,
success: OrchestratorMcpScheduleTaskResult,
failure: OrchestratorMcpFailure,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import Migration0053 from "./Migrations/053_PullRequestFilesViewed.ts";
import Migration0054 from "./Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts";
import Migration0055 from "./Migrations/055_OrchestrationV2.ts";
import Migration0056 from "./Migrations/056_RemoveRedundantProjectionIndexes.ts";
import Migration0057 from "./Migrations/057_ScheduledTaskWebhooks.ts";

/**
* Migration loader with all migrations defined inline.
Expand Down Expand Up @@ -140,6 +141,7 @@ export const migrationEntries = [
// Preserve this migration's schema. Future V2 schema changes need new migrations.
[55, "OrchestrationV2", Migration0055],
[56, "RemoveRedundantProjectionIndexes", Migration0056],
[57, "ScheduledTaskWebhooks", Migration0057],
] as const;

export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => {
Effect.sync(() => {
assert.deepStrictEqual(
migrationEntries.map(([id]) => id),
Array.from({ length: 56 }, (_, index) => index + 1),
Array.from({ length: 57 }, (_, index) => index + 1),
);
}),
);
Expand All @@ -28,6 +28,7 @@ layer("055_OrchestrationV2", (it) => {
[54, "ProjectionThreadsAutoSettleDisabledAt"],
[55, "OrchestrationV2"],
[56, "RemoveRedundantProjectionIndexes"],
[57, "ScheduledTaskWebhooks"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);

Expand All @@ -50,6 +51,7 @@ layer("055_OrchestrationV2", (it) => {
{ migration_id: 54, name: "ProjectionThreadsAutoSettleDisabledAt" },
{ migration_id: 55, name: "OrchestrationV2" },
{ migration_id: 56, name: "RemoveRedundantProjectionIndexes" },
{ migration_id: 57, name: "ScheduledTaskWebhooks" },
]);

const tables = yield* sql<{ readonly name: string }>`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { assert, it } from "@effect/vitest";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as SqlClient from "effect/sql/SqlClient";

import { runMigrations } from "../Migrations.ts";

const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));

layer("057_ScheduledTaskWebhooks", (it) => {
it.effect("keeps existing scheduled tasks and adds webhook storage", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* runMigrations({ toMigrationInclusive: 56 });
yield* sql`INSERT INTO scheduled_tasks ${sql.insert({
task_id: "existing",
title: "task",
prompt: "Run",
enabled: 1,
schedule_json: '{"type":"interval","everyMs":60000}',
project_id: "project",
thread_id: null,
workspace_strategy_json: '{"type":"root"}',
model_selection_json: '{"instanceId":"codex","model":"gpt-5"}',
runtime_mode: "full-access",
interaction_mode: "default",
created_by: "user",
creation_source: "web",
created_at: "2026-10-01T00:00:00.000Z",
updated_at: "2026-10-01T00:00:00.000Z",
next_run_at: null,
last_run_at: null,
last_run_status: "never",
last_run_error: null,
run_count: 0,
})}`;
yield* runMigrations({ toMigrationInclusive: 57 });

const rows = yield* sql<{
task_id: string;
webhook_token: string | null;
}>`SELECT task_id, webhook_token FROM scheduled_tasks`;
assert.deepEqual(rows, [{ task_id: "existing", webhook_token: null }]);
const deliveries = yield* sql<{
count: number;
}>`SELECT COUNT(*) AS count FROM scheduled_task_webhook_deliveries`;
assert.equal(deliveries[0]?.count, 0);
}),
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/sql/SqlClient";

export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

// Kept out of schedule_json so they never decode into the read model.
yield* sql`ALTER TABLE scheduled_tasks ADD COLUMN webhook_token TEXT`;
yield* sql`ALTER TABLE scheduled_tasks ADD COLUMN webhook_secret TEXT`;

yield* sql`
CREATE TABLE IF NOT EXISTS scheduled_task_webhook_deliveries (
delivery_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
received_at TEXT NOT NULL,
method TEXT NOT NULL,
query TEXT NOT NULL,
headers_json TEXT NOT NULL,
body TEXT NOT NULL,
body_bytes INTEGER NOT NULL,
body_truncated INTEGER NOT NULL,
outcome TEXT NOT NULL,
signature_verified INTEGER NOT NULL,
missing_fields_json TEXT NOT NULL,
rendered_prompt TEXT,
error TEXT
)
`;

yield* sql`
CREATE INDEX IF NOT EXISTS idx_scheduled_task_webhook_deliveries_task
ON scheduled_task_webhook_deliveries(task_id, received_at)
`;
});
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe("V2 preview upgrade", () => {
[53, "PullRequestFilesViewed"],
[54, "ProjectionThreadsAutoSettleDisabledAt"],
[56, "RemoveRedundantProjectionIndexes"],
[57, "ScheduledTaskWebhooks"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);
assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports);
Expand Down Expand Up @@ -116,6 +117,7 @@ describe("V2 preview upgrade", () => {
[53, "PullRequestFilesViewed"],
[54, "ProjectionThreadsAutoSettleDisabledAt"],
[56, "RemoveRedundantProjectionIndexes"],
[57, "ScheduledTaskWebhooks"],
]);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
);
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/provider/T3OrchestrationInstructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept
- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent, not the target for starting another delegated review round.
- \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work.
- For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review.
- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule. By default runs return to the current thread; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling, report the returned cadence and next run time.
- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the prompt may use \`{{body.path}}\`-style placeholders). By default runs return to the current thread; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling, report the returned cadence and next run time.

### Choose the workspace before starting a new thread

Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/scheduledTasks/Schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function nextScheduledRunAt(
schedule: ScheduledTaskSchedule,
from: DateTime.DateTime,
): DateTime.DateTime | null {
if (schedule.type === "webhook") return null;
if (schedule.type === "interval") {
// Persisted rows created before the one-minute floor remain readable, but
// they must not retain their old high-frequency execution rate.
Expand Down Expand Up @@ -55,6 +56,8 @@ export function isSameSchedule(a: ScheduledTaskSchedule, b: ScheduledTaskSchedul
if (a.type === "interval") {
return b.type === "interval" && a.everyMs === b.everyMs;
}
// Webhook tasks never have a next run, whatever their signature settings.
if (a.type === "webhook") return b.type === "webhook";
if (b.type !== "fixed_time") return false;
// The contract accepts padded and unpadded hours ("9:00" and "09:00"), so
// compare the parsed time — string equality would treat a format-only edit
Expand Down Expand Up @@ -93,6 +96,7 @@ export function isMissedFixedTimeRun(
}

function describeSchedule(schedule: ScheduledTaskSchedule): string {
if (schedule.type === "webhook") return "On webhook";
if (schedule.type === "interval") {
const minutes = schedule.everyMs / MINUTE_MS;
if (Number.isInteger(minutes)) {
Expand Down
Loading
Loading