diff --git a/AGENTS.md b/AGENTS.md index 380a9202683..02cb16fa5c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,10 @@ - If changing native mobile code, `vp run lint:mobile` must also pass. - Use `vp test` for the built-in Vite+ test command and `vp run test` when you specifically need the `test` package script. +## Pull Requests + +- Open every pull request for this repository against the `tritongpt` branch, not `main`, unless the user explicitly asks for a different base branch. + ## Project Snapshot T3 Code is a minimal web GUI for using coding agents like Codex and Claude. diff --git a/apps/server/package.json b/apps/server/package.json index 33c8b775833..d51f5b2ae52 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -31,7 +31,8 @@ "@opencode-ai/sdk": "^1.17.8", "@pierre/diffs": "catalog:", "effect": "catalog:", - "node-pty": "^1.1.0" + "node-pty": "^1.1.0", + "rrule-es": "1.0.0" }, "devDependencies": { "@effect/vitest": "catalog:", diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 95b2520fd5b..7c451aaf34b 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -13,6 +13,8 @@ import packageJson from "../../package.json" with { type: "json" }; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; +import { AutomationToolkitHandlersLive } from "./toolkits/automations/handlers.ts"; +import { AutomationToolkit } from "./toolkits/automations/tools.ts"; import { PreviewSnapshotToolkitHandlersLive, PreviewStandardToolkitHandlersLive, @@ -179,13 +181,22 @@ export const PreviewToolkitRegistrationLive = Layer.mergeAll( PreviewSnapshotRegistrationLive, ); +const AutomationToolkitRegistrationLive = McpServer.toolkit(AutomationToolkit).pipe( + Layer.provide(AutomationToolkitHandlersLive), +); + +export const ToolkitRegistrationLive = Layer.mergeAll( + PreviewToolkitRegistrationLive, + AutomationToolkitRegistrationLive, +); + const McpTransportLive = McpServer.layerHttp({ name: "TritonAI Harness", version: packageJson.version, path: "/mcp", }).pipe(Layer.provide(McpAuthMiddlewareLive)); -export const layer = PreviewToolkitRegistrationLive.pipe( +export const layer = ToolkitRegistrationLive.pipe( Layer.provideMerge(McpTransportLive), Layer.provide(PreviewAutomationBroker.layer), ); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 0d3f84df42c..a1c5a6306d9 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -3,7 +3,7 @@ import { PreviewAutomationUnavailableError } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -export type McpCapability = "preview"; +export type McpCapability = "preview" | "automations"; export interface McpInvocationScope { readonly environmentId: EnvironmentId; diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 1ee7d278c62..6eb5c25d99e 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -103,7 +103,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( threadId: ThreadId.make(request.threadId), providerSessionId, providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), - capabilities: new Set(["preview"]), + capabilities: new Set(["preview", "automations"]), issuedAt, expiresAt, }; diff --git a/apps/server/src/mcp/toolkits/automations/handlers.test.ts b/apps/server/src/mcp/toolkits/automations/handlers.test.ts new file mode 100644 index 00000000000..1cd005bbdad --- /dev/null +++ b/apps/server/src/mcp/toolkits/automations/handlers.test.ts @@ -0,0 +1,511 @@ +import { expect, it } from "@effect/vitest"; +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + EnvironmentId, + ProjectId, + ProviderInstanceId, + type ScheduledTask, + ScheduledTaskId, + ScheduledTaskRRuleConfig, + ScheduledTaskRunId, + type ScheduledTaskCreateInput, + type ScheduledTaskUpdateInput, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { McpSchema, McpServer } from "effect/unstable/ai"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ScheduledTaskService } from "../../../scheduledTasks/ScheduledTaskService.ts"; +import { AutomationToolkitHandlersLive } from "./handlers.ts"; +import { AutomationToolkit } from "./tools.ts"; + +const projectId = ProjectId.make("project-automation-tools-test"); +const threadId = ThreadId.make("thread-automation-tools-test"); +const otherProjectId = ProjectId.make("project-automation-tools-other"); +const otherThreadId = ThreadId.make("thread-automation-tools-other"); +const taskId = ScheduledTaskId.make("task-automation-tools-test"); +const now = "2026-06-20T00:00:00.000Z"; +const rruleConfigStringSchema = Schema.fromJsonString(ScheduledTaskRRuleConfig); +const encodeRRuleConfig = Schema.encodeSync(rruleConfigStringSchema); +const decodeRRuleConfig = Schema.decodeUnknownSync(rruleConfigStringSchema); + +const invocation = { + environmentId: EnvironmentId.make("environment-automation-tools-test"), + threadId, + providerSessionId: "provider-session-automation-tools-test", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["automations"] as const), + issuedAt: 1, + expiresAt: Number.MAX_SAFE_INTEGER, +}; + +const client = McpSchema.McpServerClient.of({ + clientId: 1, + initializePayload: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "automation-tools-test", version: "1.0.0" }, + }, + getClient: Effect.die("unused"), +}); + +const makeTask = (overrides: Partial = {}): ScheduledTask => ({ + id: taskId, + name: "Automation test task", + kind: "thread", + projectId, + targetThreadId: threadId, + prompt: "Check this thread for stale follow-ups.", + scheduleKind: "once", + scheduleValue: "2026-06-21T16:00:00.000Z", + timezone: "UTC", + status: "active", + modelSelection: null, + runtimeMode: null, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + overlapPolicy: "skip", + catchUp: false, + nextRunAt: "2026-06-21T16:00:00.000Z", + lastRunAt: null, + createdAt: now, + updatedAt: now, + ...overrides, +}); + +const makeThreadShell = ( + id: ThreadId, + shellProjectId: ProjectId, + title = "Automation test thread", +) => ({ + id, + projectId: shellProjectId, + title, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.5" }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, +}); + +type ScheduledTaskServiceTestService = ReturnType; +type ProjectionSnapshotQueryTestShape = Parameters[0]; +type ProjectionSnapshotQueryTestService = ReturnType; + +const makeProjectionSnapshotQuery = ( + overrides: Partial = {}, +): ProjectionSnapshotQueryTestService => + ProjectionSnapshotQuery.of({ + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: (targetThreadId) => + Effect.succeed( + targetThreadId === threadId + ? Option.some(makeThreadShell(threadId, projectId)) + : targetThreadId === otherThreadId + ? Option.some(makeThreadShell(otherThreadId, otherProjectId, "Other thread")) + : Option.none(), + ), + getThreadDetailById: () => Effect.die("unused"), + ...overrides, + }); + +const makeTestLayer = ( + scheduledTasks: ScheduledTaskServiceTestService, + projectionSnapshotQuery = makeProjectionSnapshotQuery(), +) => + McpServer.toolkit(AutomationToolkit).pipe( + Layer.provide(AutomationToolkitHandlersLive), + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(Layer.succeed(ScheduledTaskService, scheduledTasks)), + Layer.provideMerge(Layer.succeed(ProjectionSnapshotQuery, projectionSnapshotQuery)), + ); + +it.effect("creates automations for the calling thread by default", () => { + let capturedCreate: ScheduledTaskCreateInput | null = null; + + const scheduledTasks = ScheduledTaskService.of({ + list: () => Effect.succeed({ tasks: [] }), + create: (input) => { + capturedCreate = input; + return Effect.succeed({ + task: { + id: taskId, + name: input.name, + kind: input.kind, + projectId: input.projectId, + targetThreadId: input.targetThreadId ?? null, + prompt: input.prompt, + scheduleKind: input.scheduleKind, + scheduleValue: input.scheduleValue, + timezone: input.timezone, + status: input.status ?? "active", + modelSelection: input.modelSelection ?? null, + runtimeMode: input.runtimeMode ?? null, + interactionMode: input.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, + overlapPolicy: input.overlapPolicy ?? "skip", + catchUp: input.catchUp ?? false, + nextRunAt: input.scheduleValue, + lastRunAt: null, + createdAt: now, + updatedAt: now, + }, + }); + }, + update: () => Effect.die("unused"), + delete: () => Effect.die("unused"), + pause: () => Effect.die("unused"), + resume: () => Effect.die("unused"), + runNow: () => + Effect.succeed({ + run: { + id: ScheduledTaskRunId.make("run-unused"), + taskId, + scheduledFor: now, + status: "queued", + threadId: null, + messageId: null, + turnId: null, + startedAt: null, + finishedAt: null, + error: null, + resultSummary: null, + createdAt: now, + updatedAt: now, + }, + }), + listRuns: () => Effect.succeed({ runs: [] }), + runDueTasks: () => Effect.void, + reconcileOpenRuns: () => Effect.void, + }); + + return Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ + name: "automation_create", + arguments: { + prompt: "Check this thread for stale follow-ups.", + scheduleKind: "once", + runAt: "2026-06-21T09:00:00-07:00", + }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + expect(result.isError).toBe(false); + expect(capturedCreate).toMatchObject({ + kind: "thread", + projectId, + targetThreadId: threadId, + prompt: "Check this thread for stale follow-ups.", + scheduleKind: "once", + timezone: "UTC", + }); + }), + ).pipe(Effect.provide(makeTestLayer(scheduledTasks))); +}); + +it.effect("hides deleted automations from list results by default", () => { + const activeTask = makeTask(); + const deletedTask = makeTask({ + id: ScheduledTaskId.make("task-automation-tools-deleted"), + status: "deleted", + }); + + const scheduledTasks = ScheduledTaskService.of({ + list: () => Effect.succeed({ tasks: [activeTask, deletedTask] }), + create: () => Effect.die("unused"), + update: () => Effect.die("unused"), + delete: () => Effect.die("unused"), + pause: () => Effect.die("unused"), + resume: () => Effect.die("unused"), + runNow: () => Effect.die("unused"), + listRuns: () => Effect.succeed({ runs: [] }), + runDueTasks: () => Effect.void, + reconcileOpenRuns: () => Effect.void, + }); + + return Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const defaultResult = yield* server + .callTool({ name: "automation_list", arguments: {} }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + expect(defaultResult.isError).toBe(false); + expect(defaultResult.structuredContent).toMatchObject({ + tasks: [expect.objectContaining({ id: taskId, status: "active" })], + }); + + const allResult = yield* server + .callTool({ name: "automation_list", arguments: { status: "all" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + expect(allResult.isError).toBe(false); + expect(allResult.structuredContent).toMatchObject({ + tasks: [ + expect.objectContaining({ id: taskId, status: "active" }), + expect.objectContaining({ id: deletedTask.id, status: "deleted" }), + ], + }); + }), + ).pipe(Effect.provide(makeTestLayer(scheduledTasks))); +}); + +it.effect("only lists automations accessible from the calling chat", () => { + const activeTask = makeTask(); + const otherThreadTask = makeTask({ + id: ScheduledTaskId.make("task-automation-tools-other-thread"), + targetThreadId: otherThreadId, + }); + const otherProjectStandaloneTask = makeTask({ + id: ScheduledTaskId.make("task-automation-tools-other-standalone"), + kind: "standalone", + projectId: otherProjectId, + targetThreadId: null, + }); + const currentProjectStandaloneTask = makeTask({ + id: ScheduledTaskId.make("task-automation-tools-project-standalone"), + kind: "standalone", + targetThreadId: null, + }); + + const scheduledTasks = ScheduledTaskService.of({ + list: () => + Effect.succeed({ + tasks: [ + activeTask, + otherThreadTask, + otherProjectStandaloneTask, + currentProjectStandaloneTask, + ], + }), + create: () => Effect.die("unused"), + update: () => Effect.die("unused"), + delete: () => Effect.die("unused"), + pause: () => Effect.die("unused"), + resume: () => Effect.die("unused"), + runNow: () => Effect.die("unused"), + listRuns: () => Effect.succeed({ runs: [] }), + runDueTasks: () => Effect.void, + reconcileOpenRuns: () => Effect.void, + }); + + return Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ name: "automation_list", arguments: { status: "all" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + expect(result.isError).toBe(false); + expect(result.structuredContent).toMatchObject({ + tasks: [ + expect.objectContaining({ id: activeTask.id }), + expect.objectContaining({ id: currentProjectStandaloneTask.id }), + ], + }); + }), + ).pipe(Effect.provide(makeTestLayer(scheduledTasks))); +}); + +it.effect("rejects mutations for automations outside the calling chat scope", () => { + let deleteCalled = false; + const inaccessibleTask = makeTask({ + id: ScheduledTaskId.make("task-automation-tools-inaccessible-delete"), + targetThreadId: otherThreadId, + }); + + const scheduledTasks = ScheduledTaskService.of({ + list: () => Effect.succeed({ tasks: [inaccessibleTask] }), + create: () => Effect.die("unused"), + update: () => Effect.die("unused"), + delete: () => { + deleteCalled = true; + return Effect.die("delete should not be called"); + }, + pause: () => Effect.die("unused"), + resume: () => Effect.die("unused"), + runNow: () => Effect.die("unused"), + listRuns: () => Effect.succeed({ runs: [] }), + runDueTasks: () => Effect.void, + reconcileOpenRuns: () => Effect.void, + }); + + return Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ name: "automation_delete", arguments: { id: inaccessibleTask.id } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + expect(result.isError).toBe(true); + expect(deleteCalled).toBe(false); + }), + ).pipe(Effect.provide(makeTestLayer(scheduledTasks))); +}); + +it.effect("preserves existing recurrence fields during partial schedule updates", () => { + const existingTask = makeTask({ + scheduleKind: "rrule", + scheduleValue: encodeRRuleConfig({ + frequency: "weekly", + interval: 1, + dtStart: "2026-06-22T16:00:00.000Z", + byDay: ["MO", "WE"], + count: 5, + }), + }); + let capturedUpdate: ScheduledTaskUpdateInput | null = null; + + const scheduledTasks = ScheduledTaskService.of({ + list: () => Effect.succeed({ tasks: [existingTask] }), + create: () => Effect.die("unused"), + update: (input) => { + capturedUpdate = input; + return Effect.succeed({ + task: { + ...existingTask, + scheduleKind: input.patch.scheduleKind ?? existingTask.scheduleKind, + scheduleValue: input.patch.scheduleValue ?? existingTask.scheduleValue, + updatedAt: now, + }, + }); + }, + delete: () => Effect.die("unused"), + pause: () => Effect.die("unused"), + resume: () => Effect.die("unused"), + runNow: () => Effect.die("unused"), + listRuns: () => Effect.succeed({ runs: [] }), + runDueTasks: () => Effect.void, + reconcileOpenRuns: () => Effect.void, + }); + + return Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ + name: "automation_update", + arguments: { + id: existingTask.id, + interval: 2, + }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + expect(result.isError).toBe(false); + expect(capturedUpdate?.patch.scheduleKind).toBe("rrule"); + expect(capturedUpdate?.patch.scheduleValue).toBeDefined(); + const config = decodeRRuleConfig(capturedUpdate!.patch.scheduleValue); + expect(config).toMatchObject({ + frequency: "weekly", + interval: 2, + dtStart: "2026-06-22T16:00:00.000Z", + byDay: ["MO", "WE"], + count: 5, + }); + }), + ).pipe(Effect.provide(makeTestLayer(scheduledTasks))); +}); + +it.effect("infers one-time schedule updates from runAt even for existing recurrence tasks", () => { + const existingTask = makeTask({ + scheduleKind: "rrule", + scheduleValue: encodeRRuleConfig({ + frequency: "weekly", + interval: 1, + dtStart: "2026-06-22T16:00:00.000Z", + }), + }); + let capturedUpdate: ScheduledTaskUpdateInput | null = null; + + const scheduledTasks = ScheduledTaskService.of({ + list: () => Effect.succeed({ tasks: [existingTask] }), + create: () => Effect.die("unused"), + update: (input) => { + capturedUpdate = input; + return Effect.succeed({ + task: { + ...existingTask, + scheduleKind: input.patch.scheduleKind ?? existingTask.scheduleKind, + scheduleValue: input.patch.scheduleValue ?? existingTask.scheduleValue, + updatedAt: now, + }, + }); + }, + delete: () => Effect.die("unused"), + pause: () => Effect.die("unused"), + resume: () => Effect.die("unused"), + runNow: () => Effect.die("unused"), + listRuns: () => Effect.succeed({ runs: [] }), + runDueTasks: () => Effect.void, + reconcileOpenRuns: () => Effect.void, + }); + + return Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ + name: "automation_update", + arguments: { + id: existingTask.id, + runAt: "2026-07-01T09:00:00-07:00", + }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + expect(result.isError).toBe(false); + expect(capturedUpdate?.patch).toMatchObject({ + scheduleKind: "once", + scheduleValue: "2026-07-01T16:00:00.000Z", + }); + }), + ).pipe(Effect.provide(makeTestLayer(scheduledTasks))); +}); diff --git a/apps/server/src/mcp/toolkits/automations/handlers.ts b/apps/server/src/mcp/toolkits/automations/handlers.ts new file mode 100644 index 00000000000..a09a65353a9 --- /dev/null +++ b/apps/server/src/mcp/toolkits/automations/handlers.ts @@ -0,0 +1,378 @@ +import { + type ScheduledTask, + ScheduledTaskError, + ScheduledTaskId, + ScheduledTaskRRuleConfig, + type ScheduledTaskRRuleConfig as ScheduledTaskRRuleConfigType, + type ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ScheduledTaskService } from "../../../scheduledTasks/ScheduledTaskService.ts"; +import { + AutomationToolkit, + type AutomationCreateToolInput, + type AutomationUpdateToolInput, +} from "./tools.ts"; + +type AutomationScheduleInput = Pick< + AutomationUpdateToolInput, + | "scheduleKind" + | "runAt" + | "startAt" + | "frequency" + | "interval" + | "byDay" + | "byMonthDay" + | "count" + | "until" +>; + +const rruleConfigStringSchema = Schema.fromJsonString(ScheduledTaskRRuleConfig); +const encodeRRuleConfigString = Schema.encodeSync(rruleConfigStringSchema); +const decodeRRuleConfigString = Schema.decodeUnknownEffect(rruleConfigStringSchema); + +const toolError = (message: string, cause?: unknown) => + new ScheduledTaskError({ + message, + ...(cause !== undefined ? { cause } : {}), + }); + +const requireAutomationScope = Effect.fn("AutomationToolkit.requireScope")(function* () { + const invocation = yield* McpInvocationContext.McpInvocationContext; + if (!invocation.capabilities.has("automations")) { + return yield* toolError("MCP credential does not grant the automations capability."); + } + return invocation; +}); + +function normalizeIsoDateTime( + value: string, + field: string, +): Effect.Effect { + return DateTime.make(value).pipe( + Option.match({ + onNone: () => Effect.fail(toolError(`${field} must be a valid ISO date-time.`)), + onSome: (dateTime) => Effect.succeed(DateTime.formatIso(DateTime.toUtc(dateTime))), + }), + ); +} + +function hasScheduleInput(input: AutomationScheduleInput): boolean { + return ( + input.scheduleKind !== undefined || + input.runAt !== undefined || + input.startAt !== undefined || + input.frequency !== undefined || + input.interval !== undefined || + input.byDay !== undefined || + input.byMonthDay !== undefined || + input.count !== undefined || + input.until !== undefined + ); +} + +function inferScheduleKind(input: AutomationScheduleInput, existingTask?: ScheduledTask) { + if (input.scheduleKind !== undefined) return input.scheduleKind; + if (input.runAt !== undefined) return "once"; + if ( + input.startAt !== undefined || + input.frequency !== undefined || + input.interval !== undefined || + input.byDay !== undefined || + input.byMonthDay !== undefined || + input.count !== undefined || + input.until !== undefined + ) { + return "rrule"; + } + return existingTask?.scheduleKind ?? "rrule"; +} + +const getExistingScheduleInput = Effect.fn("AutomationToolkit.getExistingScheduleInput")(function* ( + task: ScheduledTask, +) { + if (task.scheduleKind === "once") { + return { + scheduleKind: "once", + runAt: task.scheduleValue, + }; + } + + const config = yield* decodeRRuleConfigString(task.scheduleValue).pipe( + Effect.mapError((cause) => toolError("Existing automation schedule is invalid.", cause)), + ); + return { + scheduleKind: "rrule", + startAt: config.dtStart, + frequency: config.frequency, + interval: config.interval, + byDay: config.byDay, + byMonthDay: config.byMonthDay, + count: config.count, + until: config.until, + }; +}); + +const buildSchedule = Effect.fn("AutomationToolkit.buildSchedule")(function* ( + input: AutomationScheduleInput, + existingTask?: ScheduledTask, +) { + const scheduleKind = inferScheduleKind(input, existingTask); + const existingInput = + existingTask !== undefined && scheduleKind === existingTask.scheduleKind + ? yield* getExistingScheduleInput(existingTask) + : {}; + const mergedInput = { ...existingInput, ...input, scheduleKind }; + if (scheduleKind === "once") { + const runAt = mergedInput.runAt ?? mergedInput.startAt; + if (runAt === undefined) { + return yield* toolError("A one-time automation requires runAt or startAt."); + } + return { + scheduleKind: "once" as const, + scheduleValue: yield* normalizeIsoDateTime(runAt, "runAt"), + }; + } + + if (mergedInput.startAt === undefined) { + return yield* toolError("A recurring automation requires startAt."); + } + if (mergedInput.frequency === undefined) { + return yield* toolError("A recurring automation requires frequency."); + } + const config: ScheduledTaskRRuleConfigType = { + frequency: mergedInput.frequency, + interval: mergedInput.interval ?? 1, + dtStart: yield* normalizeIsoDateTime(mergedInput.startAt, "startAt"), + ...(mergedInput.byDay !== undefined && mergedInput.byDay.length > 0 + ? { byDay: mergedInput.byDay } + : {}), + ...(mergedInput.byMonthDay !== undefined && mergedInput.byMonthDay.length > 0 + ? { byMonthDay: mergedInput.byMonthDay } + : {}), + ...(mergedInput.count !== undefined ? { count: mergedInput.count } : {}), + ...(mergedInput.until !== undefined + ? { until: yield* normalizeIsoDateTime(mergedInput.until, "until") } + : {}), + }; + return { + scheduleKind: "rrule" as const, + scheduleValue: encodeRRuleConfigString(config), + }; +}); + +const getThreadProjectId = Effect.fn("AutomationToolkit.getThreadProjectId")(function* ( + threadId: ThreadId, +) { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const thread = yield* projectionSnapshotQuery + .getThreadShellById(threadId) + .pipe(Effect.mapError((cause) => toolError("Failed to resolve the target thread.", cause))); + if (Option.isNone(thread)) { + return yield* toolError(`Thread '${threadId}' was not found.`); + } + return thread.value.projectId; +}); + +const getInvocationProjectId = Effect.fn("AutomationToolkit.getInvocationProjectId")(function* () { + const invocation = yield* requireAutomationScope(); + return yield* getThreadProjectId(invocation.threadId); +}); + +function isTaskAccessibleFromInvocation( + task: ScheduledTask, + invocation: McpInvocationContext.McpInvocationScope, + invocationProjectId: ScheduledTask["projectId"], +): boolean { + return ( + task.targetThreadId === invocation.threadId || + (task.kind === "standalone" && task.projectId === invocationProjectId) + ); +} + +const requireTaskAccess = Effect.fn("AutomationToolkit.requireTaskAccess")(function* ( + task: ScheduledTask, +) { + const invocation = yield* requireAutomationScope(); + const invocationProjectId = yield* getThreadProjectId(invocation.threadId); + if (!isTaskAccessibleFromInvocation(task, invocation, invocationProjectId)) { + return yield* toolError("Automation is not accessible from this chat."); + } + return task; +}); + +const getAccessibleTask = Effect.fn("AutomationToolkit.getAccessibleTask")(function* ( + id: ScheduledTaskId, +) { + const scheduledTasks = yield* ScheduledTaskService; + const result = yield* scheduledTasks.list(); + const task = result.tasks.find((candidate) => candidate.id === id); + if (!task) { + return yield* toolError(`Automation '${id}' was not found.`); + } + return yield* requireTaskAccess(task); +}); + +const ensureCurrentProject = Effect.fn("AutomationToolkit.ensureCurrentProject")(function* ( + projectId: ScheduledTask["projectId"] | undefined, +) { + const invocationProjectId = yield* getInvocationProjectId(); + if (projectId !== undefined && projectId !== invocationProjectId) { + return yield* toolError("Automations created from chat must stay in the current project."); + } + return invocationProjectId; +}); + +const ensureCurrentThreadTarget = Effect.fn("AutomationToolkit.ensureCurrentThreadTarget")( + function* (targetThreadId: ThreadId | undefined) { + const invocation = yield* requireAutomationScope(); + if (targetThreadId !== undefined && targetThreadId !== invocation.threadId) { + return yield* toolError("Automations created from chat can only target the current thread."); + } + return invocation.threadId; + }, +); + +const resolveCreateTarget = Effect.fn("AutomationToolkit.resolveCreateTarget")(function* ( + input: AutomationCreateToolInput, +) { + const projectId = yield* ensureCurrentProject(input.projectId); + if (input.target === "new_thread") { + return { kind: "standalone" as const, projectId, targetThreadId: null }; + } + + const targetThreadId = yield* ensureCurrentThreadTarget(input.targetThreadId); + return { kind: "thread" as const, projectId, targetThreadId }; +}); + +const buildUpdatePatch = Effect.fn("AutomationToolkit.buildUpdatePatch")(function* ( + input: AutomationUpdateToolInput, + existingTask: ScheduledTask, +) { + const invocation = yield* requireAutomationScope(); + const invocationProjectId = yield* ensureCurrentProject(input.projectId); + const schedule = hasScheduleInput(input) ? yield* buildSchedule(input, existingTask) : null; + const targetPatch = + input.target === "new_thread" + ? { + kind: "standalone" as const, + targetThreadId: null, + projectId: invocationProjectId, + } + : input.target === "current_thread" + ? { + kind: "thread" as const, + targetThreadId: invocation.threadId, + projectId: invocationProjectId, + } + : input.targetThreadId !== undefined + ? { + kind: "thread" as const, + targetThreadId: yield* ensureCurrentThreadTarget(input.targetThreadId), + projectId: invocationProjectId, + } + : input.projectId !== undefined + ? { projectId: invocationProjectId } + : {}; + + return { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.prompt !== undefined ? { prompt: input.prompt } : {}), + ...targetPatch, + ...(schedule !== null ? schedule : {}), + ...(input.timezone !== undefined ? { timezone: input.timezone } : {}), + ...(input.status !== undefined ? { status: input.status } : {}), + ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), + ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), + ...(input.overlapPolicy !== undefined ? { overlapPolicy: input.overlapPolicy } : {}), + ...(input.catchUp !== undefined ? { catchUp: input.catchUp } : {}), + }; +}); + +const handlers = { + automation_list: (input) => + Effect.gen(function* () { + const invocation = yield* requireAutomationScope(); + const invocationProjectId = yield* getThreadProjectId(invocation.threadId); + const scheduledTasks = yield* ScheduledTaskService; + const result = yield* scheduledTasks.list(); + const tasks = result.tasks.filter((task) => { + if (input.status === undefined && task.status === "deleted") { + return false; + } + if (input.currentThreadOnly === true && task.targetThreadId !== invocation.threadId) { + return false; + } + if (input.targetThreadId !== undefined && task.targetThreadId !== input.targetThreadId) { + return false; + } + if (input.status !== undefined && input.status !== "all" && task.status !== input.status) { + return false; + } + if (!isTaskAccessibleFromInvocation(task, invocation, invocationProjectId)) { + return false; + } + return true; + }); + return { tasks }; + }), + automation_create: (input) => + Effect.gen(function* () { + const scheduledTasks = yield* ScheduledTaskService; + const target = yield* resolveCreateTarget(input); + const schedule = yield* buildSchedule(input); + return yield* scheduledTasks.create({ + name: input.name ?? input.prompt.slice(0, 80), + prompt: input.prompt, + timezone: input.timezone ?? "UTC", + status: input.status ?? "active", + modelSelection: input.modelSelection, + interactionMode: input.interactionMode, + overlapPolicy: input.overlapPolicy, + catchUp: input.catchUp, + ...target, + ...schedule, + }); + }), + automation_update: (input) => + Effect.gen(function* () { + const scheduledTasks = yield* ScheduledTaskService; + const task = yield* getAccessibleTask(ScheduledTaskId.make(input.id)); + return yield* scheduledTasks.update({ + id: ScheduledTaskId.make(input.id), + patch: yield* buildUpdatePatch(input, task), + }); + }), + automation_delete: (input) => + Effect.gen(function* () { + yield* getAccessibleTask(ScheduledTaskId.make(input.id)); + const scheduledTasks = yield* ScheduledTaskService; + return yield* scheduledTasks.delete({ id: ScheduledTaskId.make(input.id) }); + }), + automation_pause: (input) => + Effect.gen(function* () { + yield* getAccessibleTask(ScheduledTaskId.make(input.id)); + const scheduledTasks = yield* ScheduledTaskService; + return yield* scheduledTasks.pause({ id: ScheduledTaskId.make(input.id) }); + }), + automation_resume: (input) => + Effect.gen(function* () { + yield* getAccessibleTask(ScheduledTaskId.make(input.id)); + const scheduledTasks = yield* ScheduledTaskService; + return yield* scheduledTasks.resume({ id: ScheduledTaskId.make(input.id) }); + }), + automation_run_now: (input) => + Effect.gen(function* () { + yield* getAccessibleTask(ScheduledTaskId.make(input.id)); + const scheduledTasks = yield* ScheduledTaskService; + return yield* scheduledTasks.runNow({ id: ScheduledTaskId.make(input.id) }); + }), +} satisfies Parameters[0]; + +export const AutomationToolkitHandlersLive = AutomationToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/automations/tools.test.ts b/apps/server/src/mcp/toolkits/automations/tools.test.ts new file mode 100644 index 00000000000..8a9b65e9cd4 --- /dev/null +++ b/apps/server/src/mcp/toolkits/automations/tools.test.ts @@ -0,0 +1,43 @@ +import { expect, it } from "@effect/vitest"; +import { Tool } from "effect/unstable/ai"; + +import { AutomationToolkit } from "./tools.ts"; + +const schemaHasDescription = (schema: unknown): boolean => { + if (!schema || typeof schema !== "object") return false; + const record = schema as Record; + if (typeof record.description === "string" && record.description.length > 0) return true; + return [record.anyOf, record.oneOf, record.allOf] + .filter(Array.isArray) + .some((members) => members.some(schemaHasDescription)); +}; + +it("exports provider-compatible object schemas with described parameters", () => { + for (const tool of Object.values(AutomationToolkit.tools)) { + const schema = Tool.getJsonSchema(tool) as { + readonly type?: unknown; + readonly properties?: Readonly>; + readonly anyOf?: unknown; + readonly oneOf?: unknown; + }; + expect( + tool.description?.length ?? 0, + `${tool.name} should have a useful description`, + ).toBeGreaterThan(40); + expect(schema.type, `${tool.name} must export a top-level object schema`).toBe("object"); + expect(schema.anyOf, `${tool.name} must not export a root anyOf`).toBeUndefined(); + expect(schema.oneOf, `${tool.name} must not export a root oneOf`).toBeUndefined(); + for (const [field, fieldSchema] of Object.entries(schema.properties ?? {})) { + expect( + schemaHasDescription(fieldSchema), + `${tool.name}.${field} should explain what data the agent must pass`, + ).toBe(true); + } + if (tool.name === "automation_create" || tool.name === "automation_update") { + expect( + schema.properties?.runtimeMode, + `${tool.name} must not let chat tools set future run permissions`, + ).toBeUndefined(); + } + } +}); diff --git a/apps/server/src/mcp/toolkits/automations/tools.ts b/apps/server/src/mcp/toolkits/automations/tools.ts new file mode 100644 index 00000000000..4f526781b58 --- /dev/null +++ b/apps/server/src/mcp/toolkits/automations/tools.ts @@ -0,0 +1,268 @@ +import { + IsoDateTime, + ModelSelection, + PositiveInt, + ProjectId, + ProviderInteractionMode, + ScheduledTaskDeleteResult, + ScheduledTaskError, + ScheduledTaskMutationResult, + ScheduledTaskOverlapPolicy, + ScheduledTaskRRuleFrequency, + ScheduledTaskRunNowResult, + ScheduledTasksListResult, + ScheduledTaskScheduleKind, + ScheduledTaskStatus, + ScheduledTaskWeekday, + ThreadId, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { ScheduledTaskService } from "../../../scheduledTasks/ScheduledTaskService.ts"; +import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; + +const dependencies = [ + McpInvocationContext.McpInvocationContext, + ScheduledTaskService, + ProjectionSnapshotQuery, +]; + +const optionalDescribed = (schema: Schema.Schema, description: string) => + Schema.optional(schema.annotate({ description })).annotate({ description }); + +const AutomationText = Schema.String.check(Schema.isTrimmed()).check(Schema.isNonEmpty()); +const AutomationIdText = AutomationText.check(Schema.isMaxLength(256)).annotate({ + description: "Automation id. Use automation_list first if you do not know it.", +}); + +const AutomationTarget = Schema.Literals(["current_thread", "new_thread"]).annotate({ + description: + "Where the automation should deliver future runs. current_thread appends to this chat; new_thread creates a fresh thread in this chat's project for each run.", +}); + +const AutomationStatusFilter = Schema.Literals(["active", "paused", "deleted", "all"]).annotate({ + description: "Filter automations by status. Omit to hide deleted tasks; use all to include them.", +}); + +const AutomationListToolInput = Schema.Struct({ + status: optionalDescribed( + AutomationStatusFilter, + "Filter returned automations by status. Defaults to all non-deleted tasks.", + ), + targetThreadId: optionalDescribed( + ThreadId, + "Only return automations that append to this thread id.", + ), + currentThreadOnly: optionalDescribed( + Schema.Boolean, + "When true, only return automations associated with the chat that is calling this tool.", + ), +}).annotate({ + description: + "List scheduled automations so you can inspect ids, schedules, prompts, status, model choices, and thread targets before editing or deleting them.", +}); + +const AutomationMutationScheduleFields = { + scheduleKind: optionalDescribed( + ScheduledTaskScheduleKind, + "Schedule shape to create or edit. Use once with runAt/startAt for a one-time run; use rrule with frequency/startAt for recurrence.", + ), + runAt: optionalDescribed( + IsoDateTime, + "ISO date-time for a one-time run. Example: 2026-06-21T09:00:00-07:00.", + ), + startAt: optionalDescribed( + IsoDateTime, + "ISO date-time for the first run of a recurring automation, or a fallback value for a one-time automation.", + ), + frequency: optionalDescribed( + ScheduledTaskRRuleFrequency, + "Recurring cadence. Required with scheduleKind rrule; choose daily, weekly, or monthly.", + ), + interval: optionalDescribed(PositiveInt, "Repeat every N frequency units. Defaults to 1."), + byDay: optionalDescribed( + Schema.Array(ScheduledTaskWeekday), + "Weekly days to run, for example ['MO','WE','FR']. Use only with weekly recurrence.", + ), + byMonthDay: optionalDescribed( + Schema.Array(PositiveInt.check(Schema.isLessThanOrEqualTo(31))), + "Calendar month days to run, from 1 through 31. Use only with monthly recurrence.", + ), + count: optionalDescribed(PositiveInt, "Optional number of recurring runs before stopping."), + until: optionalDescribed( + IsoDateTime, + "Optional ISO date-time after which a recurring automation should stop.", + ), +}; + +const AutomationMutableFields = { + name: optionalDescribed( + AutomationText, + "Short human-readable automation title. If omitted on create, T3 Code derives one from the prompt.", + ), + prompt: optionalDescribed( + AutomationText, + "Prompt the assistant should run when the automation fires.", + ), + target: optionalDescribed( + AutomationTarget, + "Delivery target. Defaults to current_thread unless targetThreadId is omitted and new_thread is explicitly requested.", + ), + projectId: optionalDescribed( + ProjectId, + "Project id to run in. Chat-created automations may only use the current thread's project.", + ), + targetThreadId: optionalDescribed( + ThreadId, + "Existing thread id that future runs should append to. Chat-created automations may only target the current thread.", + ), + timezone: optionalDescribed( + AutomationText, + "IANA timezone such as America/Los_Angeles. Omit to use UTC from the tool layer when not supplied by the UI.", + ), + status: optionalDescribed( + ScheduledTaskStatus, + "Automation status. Use active to run normally or paused to create/update without future execution.", + ), + modelSelection: optionalDescribed( + Schema.NullOr(ModelSelection), + "Optional explicit provider instance, model, and thinking/options for this automation. Null clears the override.", + ), + interactionMode: optionalDescribed( + ProviderInteractionMode, + "Optional interaction mode for future runs, usually default.", + ), + overlapPolicy: optionalDescribed( + ScheduledTaskOverlapPolicy, + "How to handle overlapping due runs. The current supported value is skip.", + ), + catchUp: optionalDescribed( + Schema.Boolean, + "Whether missed recurring runs should catch up. Defaults to false.", + ), + ...AutomationMutationScheduleFields, +}; + +export const AutomationCreateToolInput = Schema.Struct({ + ...AutomationMutableFields, + prompt: AutomationText.annotate({ + description: "Prompt the assistant should run when the automation fires.", + }), +}).annotate({ + description: + "Create a scheduled automation scoped to this chat. Defaults to this chat's thread/project unless target:'new_thread' is provided.", +}); +export type AutomationCreateToolInput = typeof AutomationCreateToolInput.Type; + +export const AutomationUpdateToolInput = Schema.Struct({ + id: AutomationIdText, + ...AutomationMutableFields, +}).annotate({ + description: + "Edit an existing scheduled automation. Only provided fields change; omit fields that should stay the same.", +}); +export type AutomationUpdateToolInput = typeof AutomationUpdateToolInput.Type; + +export const AutomationIdToolInput = Schema.Struct({ + id: AutomationIdText, +}).annotate({ + description: "Identifies a scheduled automation by id.", +}); + +const readonlyAutomationTool = (tool: T): T => + tool + .annotate(Tool.Readonly, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.Destructive, false) as T; + +const mutatingAutomationTool = (tool: T): T => + tool.annotate(Tool.Readonly, false).annotate(Tool.Destructive, true) as T; + +export const AutomationListTool = readonlyAutomationTool( + Tool.make("automation_list", { + description: + "List the user's scheduled automations. Use before editing, deleting, pausing, resuming, or answering questions about existing automations.", + parameters: AutomationListToolInput, + success: ScheduledTasksListResult, + failure: ScheduledTaskError, + dependencies, + }).annotate(Tool.Title, "List automations"), +); + +export const AutomationCreateTool = mutatingAutomationTool( + Tool.make("automation_create", { + description: + "Create a scheduled automation that runs a prompt later or on a recurrence. Prefer target current_thread when the user wants this chat to receive future runs, and target new_thread when each run should make a new thread.", + parameters: AutomationCreateToolInput, + success: ScheduledTaskMutationResult, + failure: ScheduledTaskError, + dependencies, + }).annotate(Tool.Title, "Create automation"), +); + +export const AutomationUpdateTool = mutatingAutomationTool( + Tool.make("automation_update", { + description: + "Edit an existing scheduled automation's title, prompt, schedule, target thread/project, status, model, or thinking/options.", + parameters: AutomationUpdateToolInput, + success: ScheduledTaskMutationResult, + failure: ScheduledTaskError, + dependencies, + }).annotate(Tool.Title, "Update automation"), +); + +export const AutomationDeleteTool = mutatingAutomationTool( + Tool.make("automation_delete", { + description: + "Delete a scheduled automation so it will no longer run. Use automation_list first when the id is uncertain.", + parameters: AutomationIdToolInput, + success: ScheduledTaskDeleteResult, + failure: ScheduledTaskError, + dependencies, + }).annotate(Tool.Title, "Delete automation"), +); + +export const AutomationPauseTool = mutatingAutomationTool( + Tool.make("automation_pause", { + description: + "Pause a scheduled automation without deleting it. Future runs stop until automation_resume is called.", + parameters: AutomationIdToolInput, + success: ScheduledTaskMutationResult, + failure: ScheduledTaskError, + dependencies, + }).annotate(Tool.Title, "Pause automation"), +); + +export const AutomationResumeTool = mutatingAutomationTool( + Tool.make("automation_resume", { + description: + "Resume a paused scheduled automation and recompute its next run from the current time.", + parameters: AutomationIdToolInput, + success: ScheduledTaskMutationResult, + failure: ScheduledTaskError, + dependencies, + }).annotate(Tool.Title, "Resume automation"), +); + +export const AutomationRunNowTool = mutatingAutomationTool( + Tool.make("automation_run_now", { + description: + "Run a scheduled automation immediately without waiting for its next scheduled occurrence.", + parameters: AutomationIdToolInput, + success: ScheduledTaskRunNowResult, + failure: ScheduledTaskError, + dependencies, + }).annotate(Tool.Title, "Run automation now"), +); + +export const AutomationToolkit = Toolkit.make( + AutomationListTool, + AutomationCreateTool, + AutomationUpdateTool, + AutomationDeleteTool, + AutomationPauseTool, + AutomationResumeTool, + AutomationRunNowTool, +); diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 2a3d7aff189..47fd18bd98e 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -108,3 +108,4 @@ export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDe export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; +export type ScheduledTaskRepositoryError = PersistenceSqlError | PersistenceDecodeError; diff --git a/apps/server/src/persistence/Layers/ScheduledTasks.ts b/apps/server/src/persistence/Layers/ScheduledTasks.ts new file mode 100644 index 00000000000..2afbd4f7663 --- /dev/null +++ b/apps/server/src/persistence/Layers/ScheduledTasks.ts @@ -0,0 +1,548 @@ +import { ModelSelection, ScheduledTask, ScheduledTaskRun } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + ClaimScheduledTaskRunInput, + DeleteScheduledTaskInput, + GetScheduledTaskInput, + GetScheduledTaskRunByOccurrenceInput, + GetScheduledTaskRunInput, + ListOpenScheduledTaskRunsInput, + ListDueScheduledTasksInput, + ListScheduledTaskRunsInput, + ScheduledTaskRepository, + ScheduledTaskRunRepository, + type ScheduledTaskRepositoryShape, + type ScheduledTaskRunRepositoryShape, +} from "../Services/ScheduledTasks.ts"; + +const ScheduledTaskDbRow = ScheduledTask.mapFields( + Struct.assign({ + modelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), + catchUp: Schema.Number, + }), +); +type ScheduledTaskDbRow = typeof ScheduledTaskDbRow.Type; + +function toScheduledTask(row: ScheduledTaskDbRow): ScheduledTask { + return { + ...row, + catchUp: row.catchUp === 1, + }; +} + +const makeScheduledTaskRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertTaskRow = SqlSchema.void({ + Request: ScheduledTask, + execute: (task) => + sql` + INSERT INTO scheduled_tasks ( + task_id, + name, + kind, + project_id, + target_thread_id, + prompt, + schedule_kind, + schedule_value, + timezone, + status, + model_selection_json, + runtime_mode, + interaction_mode, + overlap_policy, + catch_up, + next_run_at, + last_run_at, + created_at, + updated_at + ) + VALUES ( + ${task.id}, + ${task.name}, + ${task.kind}, + ${task.projectId}, + ${task.targetThreadId}, + ${task.prompt}, + ${task.scheduleKind}, + ${task.scheduleValue}, + ${task.timezone}, + ${task.status}, + ${task.modelSelection !== null ? JSON.stringify(task.modelSelection) : null}, + ${task.runtimeMode}, + ${task.interactionMode}, + ${task.overlapPolicy}, + ${task.catchUp ? 1 : 0}, + ${task.nextRunAt}, + ${task.lastRunAt}, + ${task.createdAt}, + ${task.updatedAt} + ) + ON CONFLICT (task_id) + DO UPDATE SET + name = excluded.name, + kind = excluded.kind, + project_id = excluded.project_id, + target_thread_id = excluded.target_thread_id, + prompt = excluded.prompt, + schedule_kind = excluded.schedule_kind, + schedule_value = excluded.schedule_value, + timezone = excluded.timezone, + status = excluded.status, + model_selection_json = excluded.model_selection_json, + runtime_mode = excluded.runtime_mode, + interaction_mode = excluded.interaction_mode, + overlap_policy = excluded.overlap_policy, + catch_up = excluded.catch_up, + next_run_at = excluded.next_run_at, + last_run_at = excluded.last_run_at, + updated_at = excluded.updated_at + `, + }); + + const getTaskRowById = SqlSchema.findOneOption({ + Request: GetScheduledTaskInput, + Result: ScheduledTaskDbRow, + execute: ({ id }) => + sql` + SELECT + task_id AS "id", + name, + kind, + project_id AS "projectId", + target_thread_id AS "targetThreadId", + prompt, + schedule_kind AS "scheduleKind", + schedule_value AS "scheduleValue", + timezone, + status, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + overlap_policy AS "overlapPolicy", + catch_up AS "catchUp", + next_run_at AS "nextRunAt", + last_run_at AS "lastRunAt", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM scheduled_tasks + WHERE task_id = ${id} + `, + }); + + const listTaskRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ScheduledTaskDbRow, + execute: () => + sql` + SELECT + task_id AS "id", + name, + kind, + project_id AS "projectId", + target_thread_id AS "targetThreadId", + prompt, + schedule_kind AS "scheduleKind", + schedule_value AS "scheduleValue", + timezone, + status, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + overlap_policy AS "overlapPolicy", + catch_up AS "catchUp", + next_run_at AS "nextRunAt", + last_run_at AS "lastRunAt", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM scheduled_tasks + WHERE status != 'deleted' + ORDER BY created_at ASC, task_id ASC + `, + }); + + const listDueTaskRows = SqlSchema.findAll({ + Request: ListDueScheduledTasksInput, + Result: ScheduledTaskDbRow, + execute: ({ now }) => + sql` + SELECT + task_id AS "id", + name, + kind, + project_id AS "projectId", + target_thread_id AS "targetThreadId", + prompt, + schedule_kind AS "scheduleKind", + schedule_value AS "scheduleValue", + timezone, + status, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + overlap_policy AS "overlapPolicy", + catch_up AS "catchUp", + next_run_at AS "nextRunAt", + last_run_at AS "lastRunAt", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM scheduled_tasks + WHERE status = 'active' + AND next_run_at IS NOT NULL + AND next_run_at <= ${now} + ORDER BY next_run_at ASC, task_id ASC + LIMIT 25 + `, + }); + + const softDeleteTaskRow = SqlSchema.void({ + Request: DeleteScheduledTaskInput, + execute: ({ id, updatedAt }) => + sql` + UPDATE scheduled_tasks + SET status = 'deleted', + next_run_at = NULL, + updated_at = ${updatedAt} + WHERE task_id = ${id} + `, + }); + + const upsert: ScheduledTaskRepositoryShape["upsert"] = (task) => + upsertTaskRow(task).pipe( + Effect.mapError(toPersistenceSqlError("ScheduledTaskRepository.upsert:query")), + ); + + const getById: ScheduledTaskRepositoryShape["getById"] = (input) => + getTaskRowById(input).pipe( + Effect.mapError(toPersistenceSqlError("ScheduledTaskRepository.getById:query")), + Effect.map(Option.map(toScheduledTask)), + ); + + const listAll: ScheduledTaskRepositoryShape["listAll"] = () => + listTaskRows(undefined).pipe( + Effect.mapError(toPersistenceSqlError("ScheduledTaskRepository.listAll:query")), + Effect.map((rows) => rows.map(toScheduledTask)), + ); + + const listDue: ScheduledTaskRepositoryShape["listDue"] = (input) => + listDueTaskRows(input).pipe( + Effect.mapError(toPersistenceSqlError("ScheduledTaskRepository.listDue:query")), + Effect.map((rows) => rows.map(toScheduledTask)), + ); + + const softDelete: ScheduledTaskRepositoryShape["softDelete"] = (input) => + softDeleteTaskRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ScheduledTaskRepository.softDelete:query")), + ); + + return { + upsert, + getById, + listAll, + listDue, + softDelete, + } satisfies ScheduledTaskRepositoryShape; +}); + +const makeScheduledTaskRunRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRunRow = SqlSchema.void({ + Request: ScheduledTaskRun, + execute: (run) => + sql` + INSERT INTO scheduled_task_runs ( + run_id, + task_id, + scheduled_for, + status, + thread_id, + message_id, + turn_id, + started_at, + finished_at, + error, + result_summary, + created_at, + updated_at + ) + VALUES ( + ${run.id}, + ${run.taskId}, + ${run.scheduledFor}, + ${run.status}, + ${run.threadId}, + ${run.messageId}, + ${run.turnId}, + ${run.startedAt}, + ${run.finishedAt}, + ${run.error}, + ${run.resultSummary}, + ${run.createdAt}, + ${run.updatedAt} + ) + ON CONFLICT (run_id) + DO UPDATE SET + task_id = excluded.task_id, + scheduled_for = excluded.scheduled_for, + status = excluded.status, + thread_id = excluded.thread_id, + message_id = excluded.message_id, + turn_id = excluded.turn_id, + started_at = excluded.started_at, + finished_at = excluded.finished_at, + error = excluded.error, + result_summary = excluded.result_summary, + updated_at = excluded.updated_at + `, + }); + + const insertRunRow = SqlSchema.findOneOption({ + Request: ScheduledTaskRun, + Result: ScheduledTaskRun, + execute: (run) => + sql` + INSERT INTO scheduled_task_runs ( + run_id, + task_id, + scheduled_for, + status, + thread_id, + message_id, + turn_id, + started_at, + finished_at, + error, + result_summary, + created_at, + updated_at + ) + VALUES ( + ${run.id}, + ${run.taskId}, + ${run.scheduledFor}, + ${run.status}, + ${run.threadId}, + ${run.messageId}, + ${run.turnId}, + ${run.startedAt}, + ${run.finishedAt}, + ${run.error}, + ${run.resultSummary}, + ${run.createdAt}, + ${run.updatedAt} + ) + ON CONFLICT (task_id, scheduled_for) DO NOTHING + RETURNING + run_id AS "id", + task_id AS "taskId", + scheduled_for AS "scheduledFor", + status, + thread_id AS "threadId", + message_id AS "messageId", + turn_id AS "turnId", + started_at AS "startedAt", + finished_at AS "finishedAt", + error, + result_summary AS "resultSummary", + created_at AS "createdAt", + updated_at AS "updatedAt" + `, + }); + + const getRunRowById = SqlSchema.findOneOption({ + Request: GetScheduledTaskRunInput, + Result: ScheduledTaskRun, + execute: ({ id }) => + sql` + SELECT + run_id AS "id", + task_id AS "taskId", + scheduled_for AS "scheduledFor", + status, + thread_id AS "threadId", + message_id AS "messageId", + turn_id AS "turnId", + started_at AS "startedAt", + finished_at AS "finishedAt", + error, + result_summary AS "resultSummary", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM scheduled_task_runs + WHERE run_id = ${id} + `, + }); + + const getRunRowByOccurrence = SqlSchema.findOneOption({ + Request: GetScheduledTaskRunByOccurrenceInput, + Result: ScheduledTaskRun, + execute: ({ taskId, scheduledFor }) => + sql` + SELECT + run_id AS "id", + task_id AS "taskId", + scheduled_for AS "scheduledFor", + status, + thread_id AS "threadId", + message_id AS "messageId", + turn_id AS "turnId", + started_at AS "startedAt", + finished_at AS "finishedAt", + error, + result_summary AS "resultSummary", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM scheduled_task_runs + WHERE task_id = ${taskId} + AND scheduled_for = ${scheduledFor} + `, + }); + + const claimQueuedRunRow = SqlSchema.findOneOption({ + Request: ClaimScheduledTaskRunInput, + Result: ScheduledTaskRun, + execute: ({ id, threadId, messageId, startedAt, updatedAt }) => + sql` + UPDATE scheduled_task_runs + SET status = 'running', + thread_id = ${threadId}, + message_id = ${messageId}, + started_at = ${startedAt}, + updated_at = ${updatedAt} + WHERE run_id = ${id} + AND status = 'queued' + RETURNING + run_id AS "id", + task_id AS "taskId", + scheduled_for AS "scheduledFor", + status, + thread_id AS "threadId", + message_id AS "messageId", + turn_id AS "turnId", + started_at AS "startedAt", + finished_at AS "finishedAt", + error, + result_summary AS "resultSummary", + created_at AS "createdAt", + updated_at AS "updatedAt" + `, + }); + + const listRunRowsByTaskId = SqlSchema.findAll({ + Request: ListScheduledTaskRunsInput, + Result: ScheduledTaskRun, + execute: ({ taskId }) => + sql` + SELECT + run_id AS "id", + task_id AS "taskId", + scheduled_for AS "scheduledFor", + status, + thread_id AS "threadId", + message_id AS "messageId", + turn_id AS "turnId", + started_at AS "startedAt", + finished_at AS "finishedAt", + error, + result_summary AS "resultSummary", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM scheduled_task_runs + WHERE task_id = ${taskId} + ORDER BY scheduled_for DESC, run_id DESC + LIMIT 100 + `, + }); + + const listOpenRunRows = SqlSchema.findAll({ + Request: ListOpenScheduledTaskRunsInput, + Result: ScheduledTaskRun, + execute: () => + sql` + SELECT + run_id AS "id", + task_id AS "taskId", + scheduled_for AS "scheduledFor", + status, + thread_id AS "threadId", + message_id AS "messageId", + turn_id AS "turnId", + started_at AS "startedAt", + finished_at AS "finishedAt", + error, + result_summary AS "resultSummary", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM scheduled_task_runs + WHERE status IN ('queued', 'running') + ORDER BY created_at ASC, run_id ASC + LIMIT 100 + `, + }); + + const upsert: ScheduledTaskRunRepositoryShape["upsert"] = (run) => + upsertRunRow(run).pipe( + Effect.mapError(toPersistenceSqlError("ScheduledTaskRunRepository.upsert:query")), + ); + + const insert: ScheduledTaskRunRepositoryShape["insert"] = (run) => + insertRunRow(run).pipe( + Effect.mapError(toPersistenceSqlError("ScheduledTaskRunRepository.insert:query")), + Effect.map(Option.isSome), + ); + + const getById: ScheduledTaskRunRepositoryShape["getById"] = (input) => + getRunRowById(input).pipe( + Effect.mapError(toPersistenceSqlError("ScheduledTaskRunRepository.getById:query")), + ); + + const getByOccurrence: ScheduledTaskRunRepositoryShape["getByOccurrence"] = (input) => + getRunRowByOccurrence(input).pipe( + Effect.mapError(toPersistenceSqlError("ScheduledTaskRunRepository.getByOccurrence:query")), + ); + + const claimQueued: ScheduledTaskRunRepositoryShape["claimQueued"] = (input) => + claimQueuedRunRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ScheduledTaskRunRepository.claimQueued:query")), + ); + + const listByTaskId: ScheduledTaskRunRepositoryShape["listByTaskId"] = (input) => + listRunRowsByTaskId(input).pipe( + Effect.mapError(toPersistenceSqlError("ScheduledTaskRunRepository.listByTaskId:query")), + ); + + const listOpen: ScheduledTaskRunRepositoryShape["listOpen"] = (input) => + listOpenRunRows(input).pipe( + Effect.mapError(toPersistenceSqlError("ScheduledTaskRunRepository.listOpen:query")), + ); + + return { + upsert, + insert, + getById, + getByOccurrence, + claimQueued, + listByTaskId, + listOpen, + } satisfies ScheduledTaskRunRepositoryShape; +}); + +export const ScheduledTaskRepositoryLive = Layer.effect( + ScheduledTaskRepository, + makeScheduledTaskRepository, +); + +export const ScheduledTaskRunRepositoryLive = Layer.effect( + ScheduledTaskRunRepository, + makeScheduledTaskRunRepository, +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ba1131ee259..f0de6ba21e3 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,7 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration0033 from "./Migrations/033_ScheduledTasks.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +90,7 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "ScheduledTasks", Migration0033], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_ScheduledTasks.ts b/apps/server/src/persistence/Migrations/033_ScheduledTasks.ts new file mode 100644 index 00000000000..3a42391a91d --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_ScheduledTasks.ts @@ -0,0 +1,69 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS scheduled_tasks ( + task_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL, + project_id TEXT NOT NULL, + target_thread_id TEXT, + prompt TEXT NOT NULL, + schedule_kind TEXT NOT NULL, + schedule_value TEXT NOT NULL, + timezone TEXT NOT NULL, + status TEXT NOT NULL, + model_selection_json TEXT, + runtime_mode TEXT, + interaction_mode TEXT NOT NULL, + overlap_policy TEXT NOT NULL, + catch_up INTEGER NOT NULL DEFAULT 0, + next_run_at TEXT, + last_run_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS scheduled_task_runs ( + run_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES scheduled_tasks(task_id), + scheduled_for TEXT NOT NULL, + status TEXT NOT NULL, + thread_id TEXT, + message_id TEXT, + turn_id TEXT, + started_at TEXT, + finished_at TEXT, + error TEXT, + result_summary TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (task_id, scheduled_for) + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_due + ON scheduled_tasks(status, next_run_at) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_project + ON scheduled_tasks(project_id, status, next_run_at) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_scheduled_task_runs_task + ON scheduled_task_runs(task_id, scheduled_for DESC) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_scheduled_task_runs_status + ON scheduled_task_runs(status, scheduled_for) + `; +}); diff --git a/apps/server/src/persistence/Services/ScheduledTasks.ts b/apps/server/src/persistence/Services/ScheduledTasks.ts new file mode 100644 index 00000000000..2caa0a15f3e --- /dev/null +++ b/apps/server/src/persistence/Services/ScheduledTasks.ts @@ -0,0 +1,103 @@ +import { + IsoDateTime, + MessageId, + ScheduledTask, + ScheduledTaskId, + ScheduledTaskRun, + ScheduledTaskRunId, + ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { ScheduledTaskRepositoryError } from "../Errors.ts"; + +export const GetScheduledTaskInput = Schema.Struct({ + id: ScheduledTaskId, +}); +export type GetScheduledTaskInput = typeof GetScheduledTaskInput.Type; + +export const ListDueScheduledTasksInput = Schema.Struct({ + now: IsoDateTime, +}); +export type ListDueScheduledTasksInput = typeof ListDueScheduledTasksInput.Type; + +export const DeleteScheduledTaskInput = Schema.Struct({ + id: ScheduledTaskId, + updatedAt: IsoDateTime, +}); +export type DeleteScheduledTaskInput = typeof DeleteScheduledTaskInput.Type; + +export const ListScheduledTaskRunsInput = Schema.Struct({ + taskId: ScheduledTaskId, +}); +export type ListScheduledTaskRunsInput = typeof ListScheduledTaskRunsInput.Type; + +export const ListOpenScheduledTaskRunsInput = Schema.Struct({}); +export type ListOpenScheduledTaskRunsInput = typeof ListOpenScheduledTaskRunsInput.Type; + +export const GetScheduledTaskRunInput = Schema.Struct({ + id: ScheduledTaskRunId, +}); +export type GetScheduledTaskRunInput = typeof GetScheduledTaskRunInput.Type; + +export const GetScheduledTaskRunByOccurrenceInput = Schema.Struct({ + taskId: ScheduledTaskId, + scheduledFor: IsoDateTime, +}); +export type GetScheduledTaskRunByOccurrenceInput = typeof GetScheduledTaskRunByOccurrenceInput.Type; + +export const ClaimScheduledTaskRunInput = Schema.Struct({ + id: ScheduledTaskRunId, + threadId: ThreadId, + messageId: MessageId, + startedAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type ClaimScheduledTaskRunInput = typeof ClaimScheduledTaskRunInput.Type; + +export interface ScheduledTaskRepositoryShape { + readonly upsert: (task: ScheduledTask) => Effect.Effect; + readonly getById: ( + input: GetScheduledTaskInput, + ) => Effect.Effect, ScheduledTaskRepositoryError>; + readonly listAll: () => Effect.Effect, ScheduledTaskRepositoryError>; + readonly listDue: ( + input: ListDueScheduledTasksInput, + ) => Effect.Effect, ScheduledTaskRepositoryError>; + readonly softDelete: ( + input: DeleteScheduledTaskInput, + ) => Effect.Effect; +} + +export interface ScheduledTaskRunRepositoryShape { + readonly upsert: (run: ScheduledTaskRun) => Effect.Effect; + readonly insert: (run: ScheduledTaskRun) => Effect.Effect; + readonly getById: ( + input: GetScheduledTaskRunInput, + ) => Effect.Effect, ScheduledTaskRepositoryError>; + readonly getByOccurrence: ( + input: GetScheduledTaskRunByOccurrenceInput, + ) => Effect.Effect, ScheduledTaskRepositoryError>; + readonly claimQueued: ( + input: ClaimScheduledTaskRunInput, + ) => Effect.Effect, ScheduledTaskRepositoryError>; + readonly listByTaskId: ( + input: ListScheduledTaskRunsInput, + ) => Effect.Effect, ScheduledTaskRepositoryError>; + readonly listOpen: ( + input: ListOpenScheduledTaskRunsInput, + ) => Effect.Effect, ScheduledTaskRepositoryError>; +} + +export class ScheduledTaskRepository extends Context.Service< + ScheduledTaskRepository, + ScheduledTaskRepositoryShape +>()("t3/persistence/Services/ScheduledTasks/ScheduledTaskRepository") {} + +export class ScheduledTaskRunRepository extends Context.Service< + ScheduledTaskRunRepository, + ScheduledTaskRunRepositoryShape +>()("t3/persistence/Services/ScheduledTasks/ScheduledTaskRunRepository") {} diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 5f8e8f69369..4f4942db1c4 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -9,6 +9,24 @@ For browser work, first call \`preview_status\`. If no automation-capable previe Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable. `; +const T3_CODE_AUTOMATION_TOOL_PLAN_INSTRUCTIONS = ` + +## T3 Code automations + +When the user asks about scheduled automations in Plan Mode, you may use non-mutating \`automation_*\` tools such as \`automation_list\` to inspect existing automations when available. Do not create, update, pause, resume, run, or delete automations while Plan Mode is active; include those actions in the proposed plan instead. + +These tools are scoped to the current chat and its project; do not claim you can manage unrelated automations from another project or thread. +`; + +const T3_CODE_AUTOMATION_TOOL_DEFAULT_INSTRUCTIONS = ` + +## T3 Code automations + +When the user asks to create, edit, pause, resume, run, delete, inspect, or otherwise manage scheduled automations, use the \`automation_*\` tools from the \`t3-code\` MCP server when they are available. + +Use \`automation_list\` before editing or deleting unless the target automation id is already clear. These tools are scoped to the current chat and its project; do not claim you can manage unrelated automations from another project or thread. Use \`automation_create\` with \`target: "current_thread"\` when future runs should append back into this chat, and \`target: "new_thread"\` when future runs should create a fresh thread in this project. For recurring schedules, provide \`scheduleKind: "rrule"\`, \`frequency\`, and \`startAt\`; for one-time schedules, provide \`scheduleKind: "once"\` and \`runAt\`. +`; + export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. @@ -130,6 +148,7 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. ${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} +${T3_CODE_AUTOMATION_TOOL_PLAN_INSTRUCTIONS} `; export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `# Collaboration Mode: Default @@ -144,4 +163,5 @@ The \`request_user_input\` tool is unavailable in Default mode. If you call it w In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. ${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} +${T3_CODE_AUTOMATION_TOOL_DEFAULT_INSTRUCTIONS} `; diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts index 15d17d509b6..ff593082fdf 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; import { isDefaultOpenCodeBinaryPath, diff --git a/apps/server/src/provider/builtInDrivers.test.ts b/apps/server/src/provider/builtInDrivers.test.ts index abfcd58f769..27aa6314b77 100644 --- a/apps/server/src/provider/builtInDrivers.test.ts +++ b/apps/server/src/provider/builtInDrivers.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; import { BUILT_IN_DRIVERS } from "./builtInDrivers.ts"; diff --git a/apps/server/src/provider/opencodeRuntime.test.ts b/apps/server/src/provider/opencodeRuntime.test.ts index c8626d36162..4af9bd9e3f4 100644 --- a/apps/server/src/provider/opencodeRuntime.test.ts +++ b/apps/server/src/provider/opencodeRuntime.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; import { buildOpenCodeServerEnvironment, diff --git a/apps/server/src/scheduledTasks/Schedule.test.ts b/apps/server/src/scheduledTasks/Schedule.test.ts new file mode 100644 index 00000000000..044d42c5814 --- /dev/null +++ b/apps/server/src/scheduledTasks/Schedule.test.ts @@ -0,0 +1,149 @@ +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + ProjectId, + ScheduledTaskId, + ScheduledTaskRRuleConfig, + type ScheduledTask, +} from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { computeNextRunAt, isOverdueByMoreThan } from "./Schedule.ts"; + +const encodeRRuleConfig = Schema.encodeSync(Schema.fromJsonString(ScheduledTaskRRuleConfig)); + +const makeTask = ( + overrides: Pick, +): ScheduledTask => ({ + id: ScheduledTaskId.make("task-1"), + name: "Morning check", + kind: "standalone", + projectId: ProjectId.make("project-1"), + targetThreadId: null, + prompt: "Check in.", + status: "active", + modelSelection: null, + runtimeMode: null, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + overlapPolicy: "skip", + catchUp: false, + nextRunAt: null, + lastRunAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, +}); + +describe("scheduled task schedule calculations", () => { + it.effect("keeps a daily local time stable across DST", () => + Effect.gen(function* () { + const task = makeTask({ + scheduleKind: "rrule", + scheduleValue: encodeRRuleConfig({ + frequency: "daily", + interval: 1, + dtStart: "2026-01-01T17:00:00.000Z", + }), + timezone: "America/Los_Angeles", + }); + + assert.equal( + yield* computeNextRunAt(task, "2026-01-01T00:00:00.000Z", { inclusive: true }), + "2026-01-01T17:00:00.000Z", + ); + assert.equal( + yield* computeNextRunAt(task, "2026-07-01T00:00:00.000Z", { inclusive: true }), + "2026-07-01T16:00:00.000Z", + ); + }), + ); + + it.effect("does not return a one-time run after it has passed", () => + Effect.gen(function* () { + const task = makeTask({ + scheduleKind: "once", + scheduleValue: "2026-01-01T17:00:00.000Z", + timezone: "America/Los_Angeles", + }); + + assert.equal( + yield* computeNextRunAt(task, "2026-01-01T17:00:00.000Z", { inclusive: true }), + "2026-01-01T17:00:00.000Z", + ); + assert.equal( + yield* computeNextRunAt(task, "2026-01-01T17:00:00.000Z", { inclusive: false }), + null, + ); + }), + ); + + it.effect("computes weekly recurrences with byDay filters", () => + Effect.gen(function* () { + const task = makeTask({ + scheduleKind: "rrule", + scheduleValue: encodeRRuleConfig({ + frequency: "weekly", + interval: 1, + dtStart: "2026-01-05T17:00:00.000Z", + byDay: ["MO", "WE"], + }), + timezone: "America/Los_Angeles", + }); + + assert.equal( + yield* computeNextRunAt(task, "2026-01-05T17:00:00.000Z", { inclusive: true }), + "2026-01-05T17:00:00.000Z", + ); + assert.equal( + yield* computeNextRunAt(task, "2026-01-06T00:00:00.000Z", { inclusive: true }), + "2026-01-07T17:00:00.000Z", + ); + }), + ); + + it.effect("computes monthly recurrences with byMonthDay filters", () => + Effect.gen(function* () { + const task = makeTask({ + scheduleKind: "rrule", + scheduleValue: encodeRRuleConfig({ + frequency: "monthly", + interval: 1, + dtStart: "2026-01-10T17:00:00.000Z", + byMonthDay: [15], + }), + timezone: "America/Los_Angeles", + }); + + assert.equal( + yield* computeNextRunAt(task, "2026-01-10T17:00:00.000Z", { inclusive: true }), + "2026-01-15T17:00:00.000Z", + ); + assert.equal( + yield* computeNextRunAt(task, "2026-01-16T00:00:00.000Z", { inclusive: true }), + "2026-02-15T17:00:00.000Z", + ); + }), + ); + + it.effect("detects runs outside the missed-run grace window", () => + Effect.gen(function* () { + assert.equal( + yield* isOverdueByMoreThan( + "2026-01-01T17:00:00.000Z", + "2026-01-01T17:06:00.000Z", + 5 * 60 * 1_000, + ), + true, + ); + assert.equal( + yield* isOverdueByMoreThan( + "2026-01-01T17:00:00.000Z", + "2026-01-01T17:04:00.000Z", + 5 * 60 * 1_000, + ), + false, + ); + }), + ); +}); diff --git a/apps/server/src/scheduledTasks/Schedule.ts b/apps/server/src/scheduledTasks/Schedule.ts new file mode 100644 index 00000000000..49c36da2e1e --- /dev/null +++ b/apps/server/src/scheduledTasks/Schedule.ts @@ -0,0 +1,135 @@ +import { + type ScheduledTask, + ScheduledTaskError, + ScheduledTaskRRuleConfig, + type ScheduledTaskRRuleFrequency, + type ScheduledTaskWeekday, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as DateTime from "effect/DateTime"; +import RRule, { Frequency, Weekday } from "rrule-es"; + +const decodeRRuleConfig = Schema.decodeUnknownEffect( + Schema.fromJsonString(ScheduledTaskRRuleConfig), +); +const isScheduledTaskError = Schema.is(ScheduledTaskError); + +const FREQUENCY_BY_CONFIG: Record = { + daily: Frequency.DAILY, + weekly: Frequency.WEEKLY, + monthly: Frequency.MONTHLY, +}; + +const WEEKDAY_BY_CONFIG: Record = { + MO: Weekday.MO, + TU: Weekday.TU, + WE: Weekday.WE, + TH: Weekday.TH, + FR: Weekday.FR, + SA: Weekday.SA, + SU: Weekday.SU, +}; + +function toScheduledTaskError(message: string, cause?: unknown) { + return new ScheduledTaskError({ + message, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function parseDateTime(value: string, field: string) { + return Effect.try({ + try: () => DateTime.makeUnsafe(value), + catch: (cause) => toScheduledTaskError(`${field} must be a valid ISO date-time.`, cause), + }); +} + +function parseIsoInstant(value: string, field: string) { + return parseDateTime(value, field).pipe(Effect.map(DateTime.toDate)); +} + +function parseEpochMillis(value: string, field: string) { + return parseDateTime(value, field).pipe(Effect.map(DateTime.toEpochMillis)); +} + +function parseRRuleConfig(value: string) { + return decodeRRuleConfig(value).pipe( + Effect.mapError((cause) => + toScheduledTaskError("RRULE schedule value is not a supported recurrence config.", cause), + ), + ); +} + +function makeRRule( + task: Pick, +): Effect.Effect { + return Effect.gen(function* () { + const config = yield* parseRRuleConfig(task.scheduleValue); + const params = { + freq: FREQUENCY_BY_CONFIG[config.frequency], + interval: config.interval, + tzid: task.timezone, + dtStart: yield* parseIsoInstant(config.dtStart, "dtStart"), + ...(config.byDay !== undefined && config.byDay.length > 0 + ? { byDay: config.byDay.map((day) => WEEKDAY_BY_CONFIG[day]) } + : {}), + ...(config.byMonthDay !== undefined && config.byMonthDay.length > 0 + ? { byMonthDay: config.byMonthDay } + : {}), + ...(config.count !== undefined ? { count: config.count } : {}), + ...(config.until !== undefined + ? { until: yield* parseIsoInstant(config.until, "until") } + : {}), + }; + const validationErrors = RRule.validate(params); + if (validationErrors.length > 0) { + return yield* toScheduledTaskError(validationErrors.join("; ")); + } + return RRule.strict(params); + }).pipe( + Effect.mapError((cause) => + isScheduledTaskError(cause) + ? cause + : toScheduledTaskError("Failed to build RRULE schedule.", cause), + ), + ); +} + +export function computeNextRunAt( + task: Pick, + afterIso: string, + options: { readonly inclusive: boolean }, +): Effect.Effect { + return Effect.gen(function* () { + const after = yield* parseIsoInstant(afterIso, "after"); + if (task.scheduleKind === "once") { + const runAt = yield* parseIsoInstant(task.scheduleValue, "scheduleValue"); + const isNext = options.inclusive ? runAt.getTime() >= after.getTime() : runAt > after; + return isNext ? runAt.toISOString() : null; + } + + const rule = yield* makeRRule(task); + return rule.after(after, { inclusive: options.inclusive })?.toISOString() ?? null; + }).pipe( + Effect.mapError((cause) => + isScheduledTaskError(cause) + ? cause + : toScheduledTaskError("Failed to calculate the next scheduled run.", cause), + ), + ); +} + +export function isOverdueByMoreThan( + scheduledForIso: string, + nowIso: string, + milliseconds: number, +): Effect.Effect { + return Effect.gen(function* () { + const [nowMs, scheduledForMs] = yield* Effect.all([ + parseEpochMillis(nowIso, "now"), + parseEpochMillis(scheduledForIso, "scheduledFor"), + ]); + return nowMs - scheduledForMs > milliseconds; + }); +} diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts new file mode 100644 index 00000000000..4c72b7f66ee --- /dev/null +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts @@ -0,0 +1,562 @@ +import { + DEFAULT_PROVIDER_DRIVER_KIND, + DEFAULT_PROVIDER_INSTANCE_ID, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + getDefaultModelForProvider, + MessageId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationThreadShell, + ProjectId, + ScheduledTaskId, + type ScheduledTask, + ScheduledTaskRunId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectionTurnRepositoryLive } from "../persistence/Layers/ProjectionTurns.ts"; +import { + ScheduledTaskRepositoryLive, + ScheduledTaskRunRepositoryLive, +} from "../persistence/Layers/ScheduledTasks.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; +import { + ScheduledTaskRepository, + ScheduledTaskRunRepository, +} from "../persistence/Services/ScheduledTasks.ts"; +import { ScheduledTaskService, ScheduledTaskServiceLive } from "./ScheduledTaskService.ts"; + +const projectId = ProjectId.make("project-scheduled"); +const taskId = ScheduledTaskId.make("task-due"); +const scheduledFor = "1969-12-31T17:00:00.000Z"; +const now = "1969-12-31T00:00:00.000Z"; +const modelSelection = { + instanceId: DEFAULT_PROVIDER_INSTANCE_ID, + model: getDefaultModelForProvider(DEFAULT_PROVIDER_DRIVER_KIND), +}; +const explicitAutomationModelSelection = { + instanceId: DEFAULT_PROVIDER_INSTANCE_ID, + model: "scheduled-model", + options: [{ id: "reasoningEffort", value: "xhigh" }], +}; + +const projectShell: OrchestrationProjectShell = { + id: projectId, + title: "Scheduled Project", + workspaceRoot: "/tmp/scheduled-project", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, +}; + +const dueStandaloneTask: ScheduledTask = { + id: taskId, + name: "Daily check", + kind: "standalone", + projectId, + targetThreadId: null, + prompt: "Summarize the project status.", + scheduleKind: "once", + scheduleValue: scheduledFor, + timezone: "America/Los_Angeles", + status: "active", + modelSelection: null, + runtimeMode: null, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + overlapPolicy: "skip", + catchUp: true, + nextRunAt: scheduledFor, + lastRunAt: null, + createdAt: now, + updatedAt: now, +}; + +const missingProjectTask: ScheduledTask = { + ...dueStandaloneTask, + id: ScheduledTaskId.make("task-missing-project"), + projectId: ProjectId.make("project-missing"), +}; + +const unused = (name: string) => Effect.die(`${name} was not expected in this test`); + +function makeTestLayer( + commandsRef: Ref.Ref>, + options?: { + readonly failTurnStart?: boolean; + readonly threadShells?: ReadonlyMap; + }, +) { + const scheduledTaskPersistenceLayer = Layer.mergeAll( + ProjectionTurnRepositoryLive, + ScheduledTaskRepositoryLive, + ScheduledTaskRunRepositoryLive, + ); + const orchestrationLayer = Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch: (command) => + Ref.modify(commandsRef, (commands) => [ + { sequence: commands.length + 1 }, + [...commands, command], + ]).pipe( + Effect.flatMap((result) => + options?.failTurnStart === true && command.type === "thread.turn.start" + ? Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Injected scheduled turn dispatch failure.", + }), + ) + : Effect.succeed(result), + ), + ), + streamDomainEvents: Stream.empty, + }); + const projectionLayer = Layer.mock(ProjectionSnapshotQuery)({ + getCommandReadModel: () => unused("getCommandReadModel"), + getSnapshot: () => unused("getSnapshot"), + getShellSnapshot: () => unused("getShellSnapshot"), + getArchivedShellSnapshot: () => unused("getArchivedShellSnapshot"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: (requestedProjectId) => + Effect.succeed(requestedProjectId === projectId ? Option.some(projectShell) : Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadShellById: (threadId) => { + const thread = options?.threadShells?.get(String(threadId)); + return Effect.succeed(thread ? Option.some(thread) : Option.none()); + }, + getThreadDetailById: () => Effect.succeed(Option.none()), + }); + + return ScheduledTaskServiceLive.pipe( + Layer.provideMerge(scheduledTaskPersistenceLayer), + Layer.provide(orchestrationLayer), + Layer.provide(projectionLayer), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), + ); +} + +describe("ScheduledTaskService", () => { + it.effect("dispatches a due standalone task through orchestration and records the run", () => + Effect.gen(function* () { + const commandsRef = yield* Ref.make>([]); + const program = Effect.gen(function* () { + const taskRepository = yield* ScheduledTaskRepository; + const scheduledTasks = yield* ScheduledTaskService; + + yield* taskRepository.upsert(dueStandaloneTask); + const beforeRun = yield* scheduledTasks.list(); + assert.equal(beforeRun.tasks.length, 1); + assert.equal(beforeRun.tasks[0]?.nextRunAt, scheduledFor); + yield* scheduledTasks.runDueTasks(); + + const commands = yield* Ref.get(commandsRef); + assert.equal(commands.length, 2); + assert.equal(commands[0]?.type, "thread.create"); + assert.equal(commands[1]?.type, "thread.turn.start"); + if (commands[1]?.type !== "thread.turn.start") { + return yield* Effect.die("Expected thread.turn.start command."); + } + assert.equal(commands[1].message.text, dueStandaloneTask.prompt); + assert.equal(commands[1].titleSeed, dueStandaloneTask.name); + + const { runs } = yield* scheduledTasks.listRuns({ taskId }); + assert.equal(runs.length, 1); + assert.equal(runs[0]?.status, "running"); + assert.equal(runs[0]?.scheduledFor, scheduledFor); + assert.equal(runs[0]?.threadId, commands[1].threadId); + assert.equal(runs[0]?.messageId, commands[1].message.messageId); + assert.equal(runs[0]?.turnId, null); + + const { tasks } = yield* scheduledTasks.list(); + assert.equal(tasks[0]?.lastRunAt, scheduledFor); + assert.equal(tasks[0]?.nextRunAt, null); + }); + + yield* program.pipe(Effect.provide(makeTestLayer(commandsRef))); + }), + ); + + it.effect("uses explicit model selections for standalone scheduled runs", () => + Effect.gen(function* () { + const commandsRef = yield* Ref.make>([]); + const taskWithModel: ScheduledTask = { + ...dueStandaloneTask, + modelSelection: explicitAutomationModelSelection, + }; + const program = Effect.gen(function* () { + const taskRepository = yield* ScheduledTaskRepository; + const scheduledTasks = yield* ScheduledTaskService; + + yield* taskRepository.upsert(taskWithModel); + yield* scheduledTasks.runDueTasks(); + + const commands = yield* Ref.get(commandsRef); + assert.equal(commands.length, 2); + assert.equal(commands[0]?.type, "thread.create"); + assert.equal(commands[1]?.type, "thread.turn.start"); + if (commands[0]?.type !== "thread.create" || commands[1]?.type !== "thread.turn.start") { + return yield* Effect.die("Expected scheduled standalone dispatch."); + } + assert.deepEqual(commands[0].modelSelection, explicitAutomationModelSelection); + assert.deepEqual(commands[1].modelSelection, explicitAutomationModelSelection); + }); + + yield* program.pipe(Effect.provide(makeTestLayer(commandsRef))); + }), + ); + + it.effect("uses explicit model selections instead of the target thread model", () => + Effect.gen(function* () { + const commandsRef = yield* Ref.make>([]); + const targetThreadId = ThreadId.make("thread-scheduled-target"); + const taskWithThreadOverride: ScheduledTask = { + ...dueStandaloneTask, + kind: "thread", + targetThreadId, + modelSelection: explicitAutomationModelSelection, + }; + const threadShell: OrchestrationThreadShell = { + id: targetThreadId, + projectId, + title: "Scheduled Thread", + modelSelection, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; + const program = Effect.gen(function* () { + const taskRepository = yield* ScheduledTaskRepository; + const scheduledTasks = yield* ScheduledTaskService; + + yield* taskRepository.upsert(taskWithThreadOverride); + yield* scheduledTasks.runDueTasks(); + + const commands = yield* Ref.get(commandsRef); + assert.equal(commands.length, 1); + assert.equal(commands[0]?.type, "thread.turn.start"); + if (commands[0]?.type !== "thread.turn.start") { + return yield* Effect.die("Expected scheduled thread dispatch."); + } + assert.equal(commands[0].threadId, targetThreadId); + assert.deepEqual(commands[0].modelSelection, explicitAutomationModelSelection); + }); + + yield* program.pipe( + Effect.provide( + makeTestLayer(commandsRef, { + threadShells: new Map([[String(targetThreadId), threadShell]]), + }), + ), + ); + }), + ); + + it.effect("persists dispatch identifiers before starting the scheduled turn", () => + Effect.gen(function* () { + const commandsRef = yield* Ref.make>([]); + const program = Effect.gen(function* () { + const taskRepository = yield* ScheduledTaskRepository; + const scheduledTasks = yield* ScheduledTaskService; + + yield* taskRepository.upsert(dueStandaloneTask); + yield* scheduledTasks.runDueTasks(); + + const commands = yield* Ref.get(commandsRef); + assert.equal(commands.length, 2); + assert.equal(commands[0]?.type, "thread.create"); + assert.equal(commands[1]?.type, "thread.turn.start"); + + const { runs } = yield* scheduledTasks.listRuns({ taskId }); + assert.equal(runs.length, 1); + const run = runs[0]; + assert.equal(run?.status, "failure"); + assert.equal( + run?.threadId, + commands[0]?.type === "thread.create" ? commands[0].threadId : null, + ); + assert.equal( + run?.messageId, + commands[1]?.type === "thread.turn.start" ? commands[1].message.messageId : null, + ); + assert.notEqual(run?.startedAt, null); + assert.equal(run?.finishedAt, run?.startedAt); + }); + + yield* program.pipe(Effect.provide(makeTestLayer(commandsRef, { failTurnStart: true }))); + }), + ); + + it.effect("fails and advances a due run when the target project is stale", () => + Effect.gen(function* () { + const commandsRef = yield* Ref.make>([]); + const program = Effect.gen(function* () { + const taskRepository = yield* ScheduledTaskRepository; + const scheduledTasks = yield* ScheduledTaskService; + + yield* taskRepository.upsert(missingProjectTask); + yield* scheduledTasks.runDueTasks(); + + const commands = yield* Ref.get(commandsRef); + assert.equal(commands.length, 0); + + const { runs } = yield* scheduledTasks.listRuns({ taskId: missingProjectTask.id }); + assert.equal(runs.length, 1); + assert.equal(runs[0]?.status, "failure"); + assert.match(runs[0]?.error ?? "", /Project was not found/); + + const { tasks } = yield* scheduledTasks.list(); + assert.equal(tasks[0]?.lastRunAt, missingProjectTask.nextRunAt); + assert.equal(tasks[0]?.nextRunAt, null); + }); + + yield* program.pipe(Effect.provide(makeTestLayer(commandsRef))); + }), + ); + + it.effect("continues polling other due tasks after one task fails", () => + Effect.gen(function* () { + const commandsRef = yield* Ref.make>([]); + const invalidTask: ScheduledTask = { + ...dueStandaloneTask, + id: ScheduledTaskId.make("task-a-invalid-schedule"), + scheduleKind: "rrule", + scheduleValue: "not-json", + }; + const validTask: ScheduledTask = { + ...dueStandaloneTask, + id: ScheduledTaskId.make("task-b-valid-schedule"), + prompt: "Run after the invalid task.", + }; + const program = Effect.gen(function* () { + const taskRepository = yield* ScheduledTaskRepository; + const scheduledTasks = yield* ScheduledTaskService; + + yield* taskRepository.upsert(invalidTask); + yield* taskRepository.upsert(validTask); + yield* scheduledTasks.runDueTasks(); + + const commands = yield* Ref.get(commandsRef); + assert.equal(commands.length, 4); + assert.equal(commands[3]?.type, "thread.turn.start"); + if (commands[3]?.type !== "thread.turn.start") { + return yield* Effect.die("Expected valid task to dispatch a turn."); + } + assert.equal(commands[3].message.text, validTask.prompt); + + const { runs } = yield* scheduledTasks.listRuns({ taskId: validTask.id }); + assert.equal(runs.length, 1); + assert.equal(runs[0]?.status, "running"); + }); + + yield* program.pipe(Effect.provide(makeTestLayer(commandsRef))); + }), + ); + + it.effect("reconciles provider start failures before a turn id exists", () => + Effect.gen(function* () { + const commandsRef = yield* Ref.make>([]); + const threadId = ThreadId.make("thread-start-failed"); + const messageId = MessageId.make("message-start-failed"); + const failedAt = "1969-12-31T17:01:00.000Z"; + const threadShell: OrchestrationThreadShell = { + id: threadId, + projectId, + title: "Scheduled Thread", + modelSelection, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: now, + updatedAt: failedAt, + archivedAt: null, + session: { + threadId, + status: "ready", + providerName: "codex", + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: null, + lastError: "Provider session failed before turn start.", + updatedAt: failedAt, + }, + latestUserMessageAt: scheduledFor, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; + + const program = Effect.gen(function* () { + const taskRepository = yield* ScheduledTaskRepository; + const runRepository = yield* ScheduledTaskRunRepository; + const turnRepository = yield* ProjectionTurnRepository; + const scheduledTasks = yield* ScheduledTaskService; + + yield* taskRepository.upsert(dueStandaloneTask); + yield* runRepository.upsert({ + id: ScheduledTaskRunId.make("run-start-failed"), + taskId, + scheduledFor, + status: "running", + threadId, + messageId, + turnId: null, + startedAt: scheduledFor, + finishedAt: null, + error: null, + resultSummary: null, + createdAt: scheduledFor, + updatedAt: scheduledFor, + }); + yield* turnRepository.replacePendingTurnStart({ + threadId, + messageId, + sourceProposedPlanThreadId: null, + sourceProposedPlanId: null, + requestedAt: scheduledFor, + }); + + yield* scheduledTasks.reconcileOpenRuns(); + + const { runs } = yield* scheduledTasks.listRuns({ taskId }); + assert.equal(runs.length, 1); + assert.equal(runs[0]?.status, "failure"); + assert.equal(runs[0]?.turnId, null); + assert.equal(runs[0]?.finishedAt, failedAt); + assert.equal(runs[0]?.error, "Provider session failed before turn start."); + }); + + yield* program.pipe( + Effect.provide( + makeTestLayer(commandsRef, { + threadShells: new Map([[String(threadId), threadShell]]), + }), + ), + ); + }), + ); + + it.effect("reconciles a completed scheduled turn even after a newer turn becomes latest", () => + Effect.gen(function* () { + const commandsRef = yield* Ref.make>([]); + const threadId = ThreadId.make("thread-completed-run"); + const messageId = MessageId.make("message-scheduled-run"); + const turnId = TurnId.make("turn-scheduled-run"); + const newerTurnId = TurnId.make("turn-newer"); + const completedAt = "1969-12-31T17:05:00.000Z"; + const threadShell: OrchestrationThreadShell = { + id: threadId, + projectId, + title: "Scheduled Thread", + modelSelection, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + latestTurn: { + turnId: newerTurnId, + state: "running", + requestedAt: "1969-12-31T17:06:00.000Z", + startedAt: "1969-12-31T17:06:00.000Z", + completedAt: null, + assistantMessageId: null, + }, + createdAt: now, + updatedAt: completedAt, + archivedAt: null, + session: null, + latestUserMessageAt: scheduledFor, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; + + const program = Effect.gen(function* () { + const taskRepository = yield* ScheduledTaskRepository; + const runRepository = yield* ScheduledTaskRunRepository; + const turnRepository = yield* ProjectionTurnRepository; + const scheduledTasks = yield* ScheduledTaskService; + + yield* taskRepository.upsert(dueStandaloneTask); + yield* runRepository.upsert({ + id: ScheduledTaskRunId.make("run-completed"), + taskId, + scheduledFor, + status: "running", + threadId, + messageId, + turnId: null, + startedAt: scheduledFor, + finishedAt: null, + error: null, + resultSummary: null, + createdAt: scheduledFor, + updatedAt: scheduledFor, + }); + yield* turnRepository.upsertByTurnId({ + turnId, + threadId, + pendingMessageId: messageId, + sourceProposedPlanThreadId: null, + sourceProposedPlanId: null, + assistantMessageId: null, + state: "completed", + requestedAt: scheduledFor, + startedAt: scheduledFor, + completedAt, + checkpointTurnCount: null, + checkpointRef: null, + checkpointStatus: null, + checkpointFiles: [], + }); + + yield* scheduledTasks.reconcileOpenRuns(); + + const { runs } = yield* scheduledTasks.listRuns({ taskId }); + assert.equal(runs.length, 1); + assert.equal(runs[0]?.status, "success"); + assert.equal(runs[0]?.turnId, turnId); + assert.equal(runs[0]?.finishedAt, completedAt); + assert.equal(runs[0]?.resultSummary, "Turn completed."); + }); + + yield* program.pipe( + Effect.provide( + makeTestLayer(commandsRef, { + threadShells: new Map([[String(threadId), threadShell]]), + }), + ), + ); + }), + ); +}); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts new file mode 100644 index 00000000000..1cce5e00a2b --- /dev/null +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -0,0 +1,733 @@ +import { + CommandId, + DEFAULT_PROVIDER_DRIVER_KIND, + DEFAULT_PROVIDER_INSTANCE_ID, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + getDefaultModelForProvider, + MessageId, + type ModelSelection, + type OrchestrationProjectShell, + type OrchestrationThreadShell, + type ProjectId, + ScheduledTaskError, + type ScheduledTask, + type ScheduledTaskCreateInput, + ScheduledTaskId, + type ScheduledTaskIdInput, + type ScheduledTaskMutationResult, + type ScheduledTaskRun, + ScheduledTaskRunId, + type ScheduledTaskRunNowInput, + type ScheduledTaskRunNowResult, + type ScheduledTaskRunsListInput, + type ScheduledTaskRunsListResult, + type ScheduledTasksListResult, + type ScheduledTaskUpdateInput, + ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { + ScheduledTaskRepository, + ScheduledTaskRunRepository, +} from "../persistence/Services/ScheduledTasks.ts"; +import { + ProjectionTurnRepository, + type ProjectionTurn, +} from "../persistence/Services/ProjectionTurns.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { computeNextRunAt, isOverdueByMoreThan } from "./Schedule.ts"; + +const MISSED_RUN_GRACE_MS = 5 * 60 * 1_000; + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +function scheduledTaskError(message: string, cause?: unknown) { + return new ScheduledTaskError({ + message, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function fallbackModelSelection(): ModelSelection { + return { + instanceId: DEFAULT_PROVIDER_INSTANCE_ID, + model: getDefaultModelForProvider(DEFAULT_PROVIDER_DRIVER_KIND), + }; +} + +function isThreadBusy(thread: OrchestrationThreadShell): boolean { + return ( + thread.latestTurn?.state === "running" || + thread.session?.status === "starting" || + thread.session?.status === "running" + ); +} + +function pendingTurnStartFailed(thread: OrchestrationThreadShell): boolean { + const session = thread.session; + if (session === null) return false; + if ( + session.status === "error" || + session.status === "stopped" || + session.status === "interrupted" + ) { + return true; + } + return ( + session.lastError !== null && session.status !== "starting" && session.status !== "running" + ); +} + +function pendingTurnStartError(thread: OrchestrationThreadShell): string { + return thread.session?.lastError ?? "Scheduled turn did not start."; +} + +interface DispatchTarget { + readonly project: OrchestrationProjectShell; + readonly thread: OrchestrationThreadShell | null; +} + +interface DispatchThread { + readonly threadId: ThreadId; + readonly modelSelection: ModelSelection; + readonly runtimeMode: NonNullable; + readonly interactionMode: ScheduledTask["interactionMode"]; + readonly shouldCreateThread: boolean; +} + +interface ScheduledTaskServiceShape { + readonly list: () => Effect.Effect; + readonly create: ( + input: ScheduledTaskCreateInput, + ) => Effect.Effect; + readonly update: ( + input: ScheduledTaskUpdateInput, + ) => Effect.Effect; + readonly delete: ( + input: ScheduledTaskIdInput, + ) => Effect.Effect<{ readonly id: ScheduledTaskId }, ScheduledTaskError>; + readonly pause: ( + input: ScheduledTaskIdInput, + ) => Effect.Effect; + readonly resume: ( + input: ScheduledTaskIdInput, + ) => Effect.Effect; + readonly runNow: ( + input: ScheduledTaskRunNowInput, + ) => Effect.Effect; + readonly listRuns: ( + input: ScheduledTaskRunsListInput, + ) => Effect.Effect; + readonly runDueTasks: () => Effect.Effect; + readonly reconcileOpenRuns: () => Effect.Effect; +} + +export class ScheduledTaskService extends Context.Service< + ScheduledTaskService, + ScheduledTaskServiceShape +>()("t3/scheduledTasks/ScheduledTaskService") {} + +const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const taskRepository = yield* ScheduledTaskRepository; + const runRepository = yield* ScheduledTaskRunRepository; + const projectionTurnRepository = yield* ProjectionTurnRepository; + const orchestrationEngine = yield* OrchestrationEngineService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + + const randomUuid = crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => scheduledTaskError("Failed to generate identifier.", cause)), + ); + const commandId = (tag: string) => + randomUuid.pipe(Effect.map((uuid) => CommandId.make(`scheduled-task:${tag}:${uuid}`))); + const taskId = randomUuid.pipe(Effect.map(ScheduledTaskId.make)); + const runId = randomUuid.pipe(Effect.map(ScheduledTaskRunId.make)); + const threadId = randomUuid.pipe(Effect.map(ThreadId.make)); + const messageId = randomUuid.pipe(Effect.map(MessageId.make)); + + const mapRepositoryError = (operation: string) => (cause: unknown) => + scheduledTaskError(`Scheduled task ${operation} failed.`, cause); + + const getProject = (projectId: ProjectId) => + projectionSnapshotQuery.getProjectShellById(projectId).pipe( + Effect.mapError(mapRepositoryError("project lookup")), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(scheduledTaskError("Project was not found.")), + onSome: Effect.succeed, + }), + ), + ); + + const getExistingTask = (id: ScheduledTaskId) => + taskRepository.getById({ id }).pipe( + Effect.mapError(mapRepositoryError("load")), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(scheduledTaskError("Scheduled task was not found.")), + onSome: (task) => + task.status === "deleted" + ? Effect.fail(scheduledTaskError("Scheduled task was deleted.")) + : Effect.succeed(task), + }), + ), + ); + + const resolveTarget = (task: ScheduledTask): Effect.Effect => + Effect.gen(function* () { + const project = yield* getProject(task.projectId); + if (task.kind === "standalone") { + return { project, thread: null }; + } + if (task.targetThreadId === null) { + return yield* scheduledTaskError("Thread automations require a target thread."); + } + const thread = yield* projectionSnapshotQuery.getThreadShellById(task.targetThreadId).pipe( + Effect.mapError(mapRepositoryError("thread lookup")), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(scheduledTaskError("Target thread was not found.")), + onSome: Effect.succeed, + }), + ), + ); + if (thread.projectId !== task.projectId) { + return yield* scheduledTaskError("Target thread does not belong to the selected project."); + } + return { project, thread }; + }); + + const nextRunForStatus = (task: ScheduledTask, now: string) => + task.status === "active" + ? computeNextRunAt(task, now, { inclusive: true }) + : Effect.succeed(null); + + const ensureRun = (task: ScheduledTask, scheduledFor: string, createdAt: string) => + Effect.gen(function* () { + const existing = yield* runRepository + .getByOccurrence({ + taskId: task.id, + scheduledFor, + }) + .pipe(Effect.mapError(mapRepositoryError("run lookup"))); + if (Option.isSome(existing)) { + return existing.value; + } + const run: ScheduledTaskRun = { + id: yield* runId, + taskId: task.id, + scheduledFor, + status: "queued", + threadId: null, + messageId: null, + turnId: null, + startedAt: null, + finishedAt: null, + error: null, + resultSummary: null, + createdAt, + updatedAt: createdAt, + }; + const inserted = yield* runRepository + .insert(run) + .pipe(Effect.mapError(mapRepositoryError("run create"))); + if (!inserted) { + const claimed = yield* runRepository + .getByOccurrence({ + taskId: task.id, + scheduledFor, + }) + .pipe(Effect.mapError(mapRepositoryError("claimed run lookup"))); + return yield* Option.match(claimed, { + onNone: () => + Effect.fail(scheduledTaskError("Scheduled run occurrence could not be claimed.")), + onSome: Effect.succeed, + }); + } + return run; + }); + + const updateRun = (run: ScheduledTaskRun) => + runRepository.upsert(run).pipe(Effect.mapError(mapRepositoryError("run update"))); + + const getRunAfterLostClaim = (run: ScheduledTaskRun) => + runRepository.getById({ id: run.id }).pipe( + Effect.mapError(mapRepositoryError("claimed run load")), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(scheduledTaskError("Scheduled run claim disappeared.")), + onSome: (currentRun) => + currentRun.status === "queued" + ? Effect.fail(scheduledTaskError("Scheduled run could not be claimed.")) + : Effect.succeed(currentRun), + }), + ), + ); + + const claimRunForDispatch = ( + run: ScheduledTaskRun, + threadId: ThreadId, + userMessageId: MessageId, + now: string, + ) => + runRepository + .claimQueued({ + id: run.id, + threadId, + messageId: userMessageId, + startedAt: now, + updatedAt: now, + }) + .pipe( + Effect.mapError(mapRepositoryError("run claim")), + Effect.flatMap( + Option.match({ + onNone: () => getRunAfterLostClaim(run), + onSome: Effect.succeed, + }), + ), + ); + + const failRun = (run: ScheduledTaskRun, cause: unknown, now: string) => { + const failedRun: ScheduledTaskRun = { + ...run, + status: "failure", + finishedAt: now, + error: cause instanceof Error ? cause.message : "Failed to dispatch scheduled run.", + updatedAt: now, + }; + return updateRun(failedRun).pipe(Effect.as(failedRun)); + }; + + const advanceTask = (task: ScheduledTask, scheduledFor: string, now: string) => + computeNextRunAt(task, scheduledFor, { inclusive: false }).pipe( + Effect.flatMap((nextRunAt) => + taskRepository.upsert({ + ...task, + lastRunAt: scheduledFor, + nextRunAt, + updatedAt: now, + }), + ), + Effect.mapError(mapRepositoryError("advance")), + ); + + const skipMissedRun = (task: ScheduledTask, run: ScheduledTaskRun, now: string) => + computeNextRunAt(task, now, { inclusive: false }).pipe( + Effect.flatMap((nextRunAt) => + Effect.all([ + updateRun({ + ...run, + status: "skipped", + finishedAt: now, + error: "Scheduled run was missed while the app was not running.", + updatedAt: now, + }), + taskRepository.upsert({ + ...task, + lastRunAt: run.scheduledFor, + nextRunAt, + updatedAt: now, + }), + ]), + ), + Effect.asVoid, + Effect.mapError(mapRepositoryError("skip missed run")), + ); + + const prepareStandaloneThread = (task: ScheduledTask, target: DispatchTarget) => + Effect.gen(function* () { + const createdThreadId = yield* threadId; + const modelSelection = + task.modelSelection ?? target.project.defaultModelSelection ?? fallbackModelSelection(); + return { + threadId: createdThreadId, + modelSelection, + runtimeMode: task.runtimeMode ?? DEFAULT_RUNTIME_MODE, + interactionMode: task.interactionMode, + shouldCreateThread: true, + }; + }); + + const prepareDispatchThread = (task: ScheduledTask, target: DispatchTarget) => + Effect.gen(function* () { + if (target.thread === null) { + return yield* prepareStandaloneThread(task, target); + } + return { + threadId: target.thread.id, + modelSelection: task.modelSelection ?? target.thread.modelSelection, + runtimeMode: task.runtimeMode ?? target.thread.runtimeMode, + interactionMode: task.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, + shouldCreateThread: false, + }; + }); + + const dispatchPreparedRun = ( + task: ScheduledTask, + dispatchThread: DispatchThread, + userMessageId: MessageId, + now: string, + ) => + Effect.gen(function* () { + if (dispatchThread.shouldCreateThread) { + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: yield* commandId("thread-create"), + threadId: dispatchThread.threadId, + projectId: task.projectId, + title: task.name, + modelSelection: dispatchThread.modelSelection, + runtimeMode: dispatchThread.runtimeMode, + interactionMode: dispatchThread.interactionMode, + branch: null, + worktreePath: null, + createdAt: now, + }); + } + + yield* orchestrationEngine.dispatch({ + type: "thread.turn.start", + commandId: yield* commandId("turn-start"), + threadId: dispatchThread.threadId, + message: { + messageId: userMessageId, + role: "user", + text: task.prompt, + attachments: [], + }, + modelSelection: dispatchThread.modelSelection, + runtimeMode: dispatchThread.runtimeMode, + interactionMode: dispatchThread.interactionMode, + titleSeed: task.name, + createdAt: now, + }); + }); + + const dispatchRun = (task: ScheduledTask, run: ScheduledTaskRun, now: string) => + Effect.gen(function* () { + const target = yield* resolveTarget(task); + if (target.thread !== null && isThreadBusy(target.thread)) { + const skippedRun = { + ...run, + status: "skipped" as const, + finishedAt: now, + error: "Target thread is already running a turn.", + updatedAt: now, + }; + yield* updateRun(skippedRun); + return skippedRun; + } + + const dispatchThread = yield* prepareDispatchThread(task, target); + const userMessageId = yield* messageId; + const runningRun = yield* claimRunForDispatch( + run, + dispatchThread.threadId, + userMessageId, + now, + ); + if (runningRun.status !== "running" || runningRun.messageId !== userMessageId) { + return runningRun; + } + return yield* dispatchPreparedRun(task, dispatchThread, userMessageId, now).pipe( + Effect.matchEffect({ + onFailure: (cause) => failRun(runningRun, cause, now), + onSuccess: () => Effect.succeed(runningRun), + }), + ); + }).pipe(Effect.catch((cause) => failRun(run, cause, now))); + + const runTaskOccurrence = ( + task: ScheduledTask, + scheduledFor: string, + options: { readonly advanceSchedule: boolean }, + ) => + Effect.gen(function* () { + const startedAt = yield* nowIso; + const run = yield* ensureRun(task, scheduledFor, startedAt); + if (run.status !== "queued") { + return run; + } + const dispatchedRun = yield* dispatchRun(task, run, startedAt); + if (options.advanceSchedule) { + yield* advanceTask(task, scheduledFor, startedAt); + } + return dispatchedRun; + }); + + const runDueTask = (task: ScheduledTask, now: string) => + Effect.gen(function* () { + const scheduledFor = task.nextRunAt; + if (scheduledFor === null) { + return; + } + const run = yield* ensureRun(task, scheduledFor, now); + if (run.status !== "queued") { + yield* advanceTask(task, scheduledFor, now); + return; + } + if (!task.catchUp && (yield* isOverdueByMoreThan(scheduledFor, now, MISSED_RUN_GRACE_MS))) { + yield* skipMissedRun(task, run, now); + return; + } + const dispatchedRun = yield* dispatchRun(task, run, now); + if (dispatchedRun.status === "skipped") { + yield* advanceTask(task, scheduledFor, now); + return; + } + yield* advanceTask(task, scheduledFor, now); + }); + + const list: ScheduledTaskServiceShape["list"] = () => + taskRepository.listAll().pipe( + Effect.map((tasks) => ({ tasks })), + Effect.mapError(mapRepositoryError("list")), + ); + + const create: ScheduledTaskServiceShape["create"] = (input) => + Effect.gen(function* () { + const createdAt = yield* nowIso; + const task: ScheduledTask = { + id: yield* taskId, + name: input.name, + kind: input.kind, + projectId: input.projectId, + targetThreadId: input.kind === "thread" ? (input.targetThreadId ?? null) : null, + prompt: input.prompt, + scheduleKind: input.scheduleKind, + scheduleValue: input.scheduleValue, + timezone: input.timezone, + status: input.status ?? "active", + modelSelection: input.modelSelection ?? null, + runtimeMode: input.runtimeMode ?? null, + interactionMode: input.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, + overlapPolicy: input.overlapPolicy ?? "skip", + catchUp: input.catchUp ?? false, + nextRunAt: null, + lastRunAt: null, + createdAt, + updatedAt: createdAt, + }; + if (task.status === "deleted") { + return yield* scheduledTaskError("New scheduled tasks cannot start deleted."); + } + yield* resolveTarget(task); + const nextRunAt = yield* nextRunForStatus(task, createdAt); + const savedTask = { ...task, nextRunAt }; + yield* taskRepository.upsert(savedTask).pipe(Effect.mapError(mapRepositoryError("create"))); + return { task: savedTask }; + }); + + const update: ScheduledTaskServiceShape["update"] = ({ id, patch }) => + Effect.gen(function* () { + const existing = yield* getExistingTask(id); + const scheduleChanged = + patch.scheduleKind !== undefined || + patch.scheduleValue !== undefined || + patch.timezone !== undefined; + const statusChanged = patch.status !== undefined && patch.status !== existing.status; + const updatedAt = yield* nowIso; + const merged: ScheduledTask = { + ...existing, + ...(patch.name !== undefined ? { name: patch.name } : {}), + ...(patch.kind !== undefined ? { kind: patch.kind } : {}), + ...(patch.projectId !== undefined ? { projectId: patch.projectId } : {}), + ...(patch.prompt !== undefined ? { prompt: patch.prompt } : {}), + ...(patch.scheduleKind !== undefined ? { scheduleKind: patch.scheduleKind } : {}), + ...(patch.scheduleValue !== undefined ? { scheduleValue: patch.scheduleValue } : {}), + ...(patch.timezone !== undefined ? { timezone: patch.timezone } : {}), + ...(patch.status !== undefined ? { status: patch.status } : {}), + ...(patch.modelSelection !== undefined ? { modelSelection: patch.modelSelection } : {}), + ...(patch.runtimeMode !== undefined ? { runtimeMode: patch.runtimeMode } : {}), + ...(patch.interactionMode !== undefined ? { interactionMode: patch.interactionMode } : {}), + ...(patch.overlapPolicy !== undefined ? { overlapPolicy: patch.overlapPolicy } : {}), + ...(patch.catchUp !== undefined ? { catchUp: patch.catchUp } : {}), + targetThreadId: + (patch.kind ?? existing.kind) === "standalone" + ? null + : patch.targetThreadId !== undefined + ? patch.targetThreadId + : existing.targetThreadId, + updatedAt, + }; + if (merged.status === "deleted") { + return yield* scheduledTaskError("Use delete to remove a scheduled task."); + } + yield* resolveTarget(merged); + const nextRunAt = + merged.status !== "active" + ? null + : scheduleChanged || statusChanged || existing.nextRunAt === null + ? yield* computeNextRunAt(merged, updatedAt, { inclusive: true }) + : existing.nextRunAt; + const savedTask = { ...merged, nextRunAt }; + yield* taskRepository.upsert(savedTask).pipe(Effect.mapError(mapRepositoryError("update"))); + return { task: savedTask }; + }); + + const softDelete: ScheduledTaskServiceShape["delete"] = ({ id }) => + nowIso.pipe( + Effect.flatMap((updatedAt) => taskRepository.softDelete({ id, updatedAt })), + Effect.mapError(mapRepositoryError("delete")), + Effect.as({ id }), + ); + + const pause: ScheduledTaskServiceShape["pause"] = ({ id }) => + update({ id, patch: { status: "paused" } }); + + const resume: ScheduledTaskServiceShape["resume"] = ({ id }) => + update({ id, patch: { status: "active" } }); + + const runNow: ScheduledTaskServiceShape["runNow"] = ({ id }) => + Effect.gen(function* () { + const task = yield* getExistingTask(id); + const scheduledFor = yield* nowIso; + const run = yield* runTaskOccurrence(task, scheduledFor, { advanceSchedule: false }); + return { run }; + }); + + const listRuns: ScheduledTaskServiceShape["listRuns"] = ({ taskId }) => + runRepository.listByTaskId({ taskId }).pipe( + Effect.map((runs) => ({ runs })), + Effect.mapError(mapRepositoryError("run list")), + ); + + const findRunTurn = ( + run: ScheduledTaskRun, + ): Effect.Effect => + Effect.gen(function* () { + if (run.threadId === null) return null; + const turns = yield* projectionTurnRepository + .listByThreadId({ threadId: run.threadId }) + .pipe(Effect.mapError(mapRepositoryError("open run turn lookup"))); + return ( + turns.find((turn) => + run.turnId !== null + ? turn.turnId === run.turnId + : run.messageId !== null && turn.pendingMessageId === run.messageId, + ) ?? null + ); + }); + + const reconcileOpenRuns: ScheduledTaskServiceShape["reconcileOpenRuns"] = () => + Effect.gen(function* () { + const openRuns = yield* runRepository + .listOpen({}) + .pipe(Effect.mapError(mapRepositoryError("open run list"))); + yield* Effect.forEach( + openRuns, + (run) => + Effect.gen(function* () { + if (run.threadId === null) return; + const thread = yield* projectionSnapshotQuery + .getThreadShellById(run.threadId) + .pipe( + Effect.mapError(mapRepositoryError("open run thread lookup")), + Effect.map(Option.getOrNull), + ); + if (thread === null) { + yield* updateRun({ + ...run, + status: "failure", + finishedAt: yield* nowIso, + error: "Thread no longer exists.", + updatedAt: yield* nowIso, + }); + return; + } + const turn = yield* findRunTurn(run); + if (turn === null) return; + if (turn.turnId === null) { + if (!pendingTurnStartFailed(thread)) return; + const updatedAt = yield* nowIso; + yield* updateRun({ + ...run, + status: "failure", + startedAt: run.startedAt ?? turn.startedAt ?? turn.requestedAt, + finishedAt: thread.session?.updatedAt ?? updatedAt, + error: pendingTurnStartError(thread), + updatedAt, + }); + return; + } + const updatedAt = yield* nowIso; + if (turn.state === "pending" || turn.state === "running") { + yield* updateRun({ + ...run, + status: "running", + turnId: turn.turnId, + startedAt: run.startedAt ?? turn.startedAt ?? turn.requestedAt, + updatedAt, + }); + return; + } + const status = + turn.state === "completed" + ? "success" + : turn.state === "interrupted" + ? "canceled" + : "failure"; + yield* updateRun({ + ...run, + status, + turnId: turn.turnId, + startedAt: run.startedAt ?? turn.startedAt ?? turn.requestedAt, + finishedAt: turn.completedAt ?? updatedAt, + error: + turn.state === "error" + ? (thread.session?.lastError ?? "Scheduled turn ended with an error.") + : null, + resultSummary: turn.state === "completed" ? "Turn completed." : null, + updatedAt, + }); + }), + { concurrency: 4 }, + ); + }); + + const runDueTasks: ScheduledTaskServiceShape["runDueTasks"] = () => + Effect.gen(function* () { + yield* reconcileOpenRuns(); + const currentTime = yield* nowIso; + const tasks = yield* taskRepository + .listDue({ now: currentTime }) + .pipe(Effect.mapError(mapRepositoryError("due task list"))); + yield* Effect.forEach( + tasks, + (task) => + runDueTask(task, currentTime).pipe( + Effect.catch((cause) => + Effect.logWarning("scheduled task execution failed", { + taskId: task.id, + cause, + }), + ), + ), + { concurrency: 1 }, + ); + }); + + return { + list, + create, + update, + delete: softDelete, + pause, + resume, + runNow, + listRuns, + runDueTasks, + reconcileOpenRuns, + } satisfies ScheduledTaskServiceShape; +}); + +export const ScheduledTaskServiceLive = Layer.effect(ScheduledTaskService, make); diff --git a/apps/server/src/scheduledTasks/Scheduler.ts b/apps/server/src/scheduledTasks/Scheduler.ts new file mode 100644 index 00000000000..81d32cc79e8 --- /dev/null +++ b/apps/server/src/scheduledTasks/Scheduler.ts @@ -0,0 +1,29 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; + +import { ServerRuntimeStartup } from "../serverRuntimeStartup.ts"; +import { ScheduledTaskService } from "./ScheduledTaskService.ts"; + +const POLL_INTERVAL = "30 seconds"; + +export const ScheduledTaskSchedulerLive = Layer.effectDiscard( + Effect.gen(function* () { + const startup = yield* ServerRuntimeStartup; + const scheduledTasks = yield* ScheduledTaskService; + + const tick = startup.enqueueCommand( + scheduledTasks.runDueTasks().pipe( + Effect.catch((cause) => + Effect.logWarning("scheduled task poll failed", { + cause, + }), + ), + ), + ); + + yield* Effect.forkScoped( + tick.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)), Effect.ignoreCause({ log: true })), + ); + }), +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 929cebc856d..ba56a727882 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -101,6 +101,7 @@ import { import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; +import { ScheduledTaskService } from "./scheduledTasks/ScheduledTaskService.ts"; import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; @@ -713,12 +714,26 @@ const buildAppUnderTest = (options?: { ), ), Layer.provide( - Layer.mock(OrchestrationEngineService)({ - readEvents: () => Stream.empty, - dispatch: () => Effect.succeed({ sequence: 0 }), - streamDomainEvents: Stream.empty, - ...options?.layers?.orchestrationEngine, - }), + Layer.mergeAll( + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 0 }), + streamDomainEvents: Stream.empty, + ...options?.layers?.orchestrationEngine, + }), + Layer.mock(ScheduledTaskService)({ + list: () => Effect.succeed({ tasks: [] }), + create: () => Effect.die("ScheduledTaskService.create not stubbed in this test"), + update: () => Effect.die("ScheduledTaskService.update not stubbed in this test"), + delete: ({ id }) => Effect.succeed({ id }), + pause: () => Effect.die("ScheduledTaskService.pause not stubbed in this test"), + resume: () => Effect.die("ScheduledTaskService.resume not stubbed in this test"), + runNow: () => Effect.die("ScheduledTaskService.runNow not stubbed in this test"), + listRuns: () => Effect.succeed({ runs: [] }), + runDueTasks: () => Effect.void, + reconcileOpenRuns: () => Effect.void, + }), + ), ), Layer.provide( Layer.mock(ProjectionSnapshotQuery)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 125547cf6b2..6bede2c1c69 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -20,6 +20,10 @@ import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; +import { + ScheduledTaskRepositoryLive, + ScheduledTaskRunRepositoryLive, +} from "./persistence/Layers/ScheduledTasks.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; import { ProviderEventLoggersLive } from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; @@ -83,6 +87,8 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; +import { ScheduledTaskSchedulerLive } from "./scheduledTasks/Scheduler.ts"; +import { ScheduledTaskServiceLive } from "./scheduledTasks/ScheduledTaskService.ts"; import { clearPersistedServerRuntimeState, makePersistedServerRuntimeState, @@ -183,6 +189,11 @@ const ProviderLayerLive = ProviderServiceLive.pipe( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); +const ScheduledTaskLayerLive = ScheduledTaskServiceLive.pipe( + Layer.provideMerge(Layer.mergeAll(ScheduledTaskRepositoryLive, ScheduledTaskRunRepositoryLive)), + Layer.provideMerge(OrchestrationLayerLive), +); + const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProjectConfig.layer), ); @@ -249,6 +260,12 @@ const PreviewLayerLive = Layer.empty.pipe( Layer.provideMerge(PortScannerLayerLive), ); +const TerminalPreviewScheduledLayerLive = Layer.mergeAll( + TerminalLayerLive, + PreviewLayerLive, + ScheduledTaskLayerLive, +); + const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive)); const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( @@ -291,7 +308,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), - Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), + Layer.provideMerge(TerminalPreviewScheduledLayerLive), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(KeybindingsLive), Layer.provideMerge(ProviderRegistryLive), @@ -475,6 +492,7 @@ export const makeServerLayer = Layer.unwrap( runtimeStateLayer, tailscaleServeLayer, cloudDesiredLinkReconcileLayer, + ScheduledTaskSchedulerLive, ); return serverApplicationLayer.pipe( diff --git a/apps/server/src/types/rrule-es.d.ts b/apps/server/src/types/rrule-es.d.ts new file mode 100644 index 00000000000..437e80b9b75 --- /dev/null +++ b/apps/server/src/types/rrule-es.d.ts @@ -0,0 +1,62 @@ +declare module "rrule-es" { + export enum Frequency { + YEARLY = 0, + MONTHLY = 1, + WEEKLY = 2, + DAILY = 3, + HOURLY = 4, + MINUTELY = 5, + SECONDLY = 6, + } + + // Keep these aligned with rrule-es@1.0.0 target/types/types.d.ts and runtime output. + export enum Weekday { + MO = 1, + TU = 2, + WE = 3, + TH = 4, + FR = 5, + SA = 6, + SU = 7, + } + + export interface Params { + readonly tzid: string; + readonly dtStart: Date; + readonly freq?: Frequency; + readonly interval?: number; + readonly until?: Date | null; + readonly count?: number; + readonly wkst?: Weekday | number | null; + readonly byDay?: ReadonlyArray | null; + readonly byMonth?: ReadonlyArray | null; + readonly byMonthDay?: ReadonlyArray | null; + readonly byWeekNo?: ReadonlyArray | null; + readonly byHour?: ReadonlyArray | null; + readonly byMinute?: ReadonlyArray | null; + readonly bySecond?: ReadonlyArray | null; + readonly bySetPos?: ReadonlyArray | null; + readonly exDate?: ReadonlyArray; + readonly rDate?: ReadonlyArray; + } + + export interface Options { + readonly strict?: boolean; + } + + export interface MethodOptions { + readonly inclusive?: boolean; + } + + export class RRule { + constructor(params: Params, options?: Options); + static validate(params: Params): string[]; + static strict(params: Params, options?: Options): RRule; + before(dt: Date, options?: MethodOptions): Date | null; + after(dt: Date, options?: MethodOptions): Date | null; + between(start: Date, end: Date, options?: MethodOptions): Date[]; + list(options?: { readonly limit?: number }): Date[] & { readonly hasMore?: boolean }; + } + + export default RRule; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f89b1522ef7..0d0ef3308d2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -116,6 +116,7 @@ import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; +import { ScheduledTaskService } from "./scheduledTasks/ScheduledTaskService.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); @@ -191,6 +192,14 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], + [WS_METHODS.scheduledTasksList, AuthOrchestrationReadScope], + [WS_METHODS.scheduledTasksCreate, AuthOrchestrationOperateScope], + [WS_METHODS.scheduledTasksUpdate, AuthOrchestrationOperateScope], + [WS_METHODS.scheduledTasksDelete, AuthOrchestrationOperateScope], + [WS_METHODS.scheduledTasksPause, AuthOrchestrationOperateScope], + [WS_METHODS.scheduledTasksResume, AuthOrchestrationOperateScope], + [WS_METHODS.scheduledTasksRunNow, AuthOrchestrationOperateScope], + [WS_METHODS.scheduledTaskRunsList, AuthOrchestrationReadScope], [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], @@ -323,6 +332,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ), ); const sourceControlRepositories = yield* SourceControlRepositoryService; + const scheduledTasks = yield* ScheduledTaskService; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; @@ -1238,6 +1248,38 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { "rpc.aggregate": "server", }), + [WS_METHODS.scheduledTasksList]: (_input) => + observeRpcEffect(WS_METHODS.scheduledTasksList, scheduledTasks.list(), { + "rpc.aggregate": "scheduled-tasks", + }), + [WS_METHODS.scheduledTasksCreate]: (input) => + observeRpcEffect(WS_METHODS.scheduledTasksCreate, scheduledTasks.create(input), { + "rpc.aggregate": "scheduled-tasks", + }), + [WS_METHODS.scheduledTasksUpdate]: (input) => + observeRpcEffect(WS_METHODS.scheduledTasksUpdate, scheduledTasks.update(input), { + "rpc.aggregate": "scheduled-tasks", + }), + [WS_METHODS.scheduledTasksDelete]: (input) => + observeRpcEffect(WS_METHODS.scheduledTasksDelete, scheduledTasks.delete(input), { + "rpc.aggregate": "scheduled-tasks", + }), + [WS_METHODS.scheduledTasksPause]: (input) => + observeRpcEffect(WS_METHODS.scheduledTasksPause, scheduledTasks.pause(input), { + "rpc.aggregate": "scheduled-tasks", + }), + [WS_METHODS.scheduledTasksResume]: (input) => + observeRpcEffect(WS_METHODS.scheduledTasksResume, scheduledTasks.resume(input), { + "rpc.aggregate": "scheduled-tasks", + }), + [WS_METHODS.scheduledTasksRunNow]: (input) => + observeRpcEffect(WS_METHODS.scheduledTasksRunNow, scheduledTasks.runNow(input), { + "rpc.aggregate": "scheduled-tasks", + }), + [WS_METHODS.scheduledTaskRunsList]: (input) => + observeRpcEffect(WS_METHODS.scheduledTaskRunsList, scheduledTasks.listRuns(input), { + "rpc.aggregate": "scheduled-tasks", + }), [WS_METHODS.cloudGetRelayClientStatus]: (_input) => observeRpcEffect(WS_METHODS.cloudGetRelayClientStatus, relayClient.resolve, { "rpc.aggregate": "cloud", diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6298cb39dd5..40e4459343d 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -7327,6 +7327,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ...nextFixture.serverConfig.providers[0]!, driver: ProviderDriverKind.make("opencode"), instanceId: ProviderInstanceId.make("opencode"), + displayName: "OpenCode", models: [ { slug: "gpt-5.1-codex-max", @@ -7432,6 +7433,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ...provider, driver: ProviderDriverKind.make("opencode"), instanceId: ProviderInstanceId.make("opencode"), + displayName: "OpenCode", skills: [ { name: "agent-browser", diff --git a/apps/web/src/components/Sidebar.dblclick.browser.tsx b/apps/web/src/components/Sidebar.dblclick.browser.tsx index 71d744be194..2b5a530c660 100644 --- a/apps/web/src/components/Sidebar.dblclick.browser.tsx +++ b/apps/web/src/components/Sidebar.dblclick.browser.tsx @@ -108,6 +108,7 @@ function Harness() { isActive={false} jumpLabel={null} appSettingsConfirmThreadArchive={false} + automationCount={0} renamingThreadKey={renamingThreadKey} renamingTitle={renamingTitle} setRenamingTitle={setRenamingTitle} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0fc96124df3..343bf074604 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, + Clock3Icon, CloudIcon, FolderPlusIcon, Globe2Icon, @@ -234,6 +235,21 @@ const PROJECT_GROUPING_MODE_LABELS: Record = const SIDEBAR_ICON_ACTION_BUTTON_CLASS = "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; const CHAT_PROJECT_CREATE_WAIT_TIMEOUT_MS = 5_000; +const THREAD_AUTOMATION_REFRESH_INTERVAL_MS = 10_000; + +async function loadThreadAutomationCountById(): Promise | null> { + const api = readLocalApi(); + if (!api) return null; + const result = await api.server.scheduledTasks.list(); + const counts = new Map(); + for (const task of result.tasks) { + if (task.status === "deleted" || task.kind !== "thread" || task.targetThreadId === null) { + continue; + } + counts.set(task.targetThreadId, (counts.get(task.targetThreadId) ?? 0) + 1); + } + return counts; +} function projectMemberPhysicalKeys(project: SidebarProjectSnapshot): string[] { return project.memberProjects.map((member) => member.physicalProjectKey); @@ -335,6 +351,7 @@ interface SidebarThreadRowProps { isActive: boolean; jumpLabel: string | null; appSettingsConfirmThreadArchive: boolean; + automationCount: number; renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; @@ -372,6 +389,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr isActive, jumpLabel, appSettingsConfirmThreadArchive, + automationCount, renamingThreadKey, renamingTitle, setRenamingTitle, @@ -808,6 +826,27 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr {threadEnvironmentLabel} )} + {automationCount > 0 ? ( + + + } + > + + + + {automationCount === 1 + ? "Thread has a scheduled automation" + : `${automationCount} scheduled automations on this thread`} + + + ) : null} {jumpLabel ? ( ; appSettingsConfirmThreadArchive: boolean; + threadAutomationCountById: ReadonlyMap; renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; @@ -911,6 +951,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( activeRouteThreadKey, threadJumpLabelByKey, appSettingsConfirmThreadArchive, + threadAutomationCountById, renamingThreadKey, renamingTitle, setRenamingTitle, @@ -963,6 +1004,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( isActive={activeRouteThreadKey === threadKey} jumpLabel={threadJumpLabelByKey.get(threadKey) ?? null} appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive} + automationCount={threadAutomationCountById.get(thread.id) ?? 0} renamingThreadKey={renamingThreadKey} renamingTitle={renamingTitle} setRenamingTitle={setRenamingTitle} @@ -1034,6 +1076,7 @@ interface SidebarProjectItemProps { archiveThread: ReturnType["archiveThread"]; deleteThread: ReturnType["deleteThread"]; threadJumpLabelByKey: ReadonlyMap; + threadAutomationCountById: ReadonlyMap; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; @@ -1057,6 +1100,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec archiveThread, deleteThread, threadJumpLabelByKey, + threadAutomationCountById, attachThreadListAutoAnimateRef, expandThreadListForProject, collapseThreadListForProject, @@ -2298,6 +2342,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec projectCwd={project.cwd} activeRouteThreadKey={activeRouteThreadKey} threadJumpLabelByKey={threadJumpLabelByKey} + threadAutomationCountById={threadAutomationCountById} appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive} renamingThreadKey={renamingThreadKey} renamingTitle={renamingTitle} @@ -2764,6 +2809,7 @@ interface SidebarProjectsContentProps { newThreadShortcutLabel: string | null; commandPaletteShortcutLabel: string | null; threadJumpLabelByKey: ReadonlyMap; + threadAutomationCountById: ReadonlyMap; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; @@ -2807,6 +2853,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( newThreadShortcutLabel, commandPaletteShortcutLabel, threadJumpLabelByKey, + threadAutomationCountById, attachThreadListAutoAnimateRef, expandThreadListForProject, collapseThreadListForProject, @@ -2868,6 +2915,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( archiveThread={archiveThread} deleteThread={deleteThread} threadJumpLabelByKey={threadJumpLabelByKey} + threadAutomationCountById={threadAutomationCountById} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} @@ -3006,6 +3054,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( archiveThread={archiveThread} deleteThread={deleteThread} threadJumpLabelByKey={threadJumpLabelByKey} + threadAutomationCountById={threadAutomationCountById} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} @@ -3068,6 +3117,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( archiveThread={archiveThread} deleteThread={deleteThread} threadJumpLabelByKey={threadJumpLabelByKey} + threadAutomationCountById={threadAutomationCountById} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} @@ -3127,6 +3177,9 @@ export default function Sidebar() { const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); + const [threadAutomationCountById, setThreadAutomationCountById] = useState< + ReadonlyMap + >(() => new Map()); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const platform = navigator.platform; @@ -3135,6 +3188,46 @@ export default function Sidebar() { const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); + const refreshThreadAutomationCounts = useCallback(async () => { + const counts = await loadThreadAutomationCountById(); + if (counts) { + setThreadAutomationCountById(counts); + } + }, []); + + useEffect(() => { + let disposed = false; + const refresh = () => { + void loadThreadAutomationCountById() + .then((counts) => { + if (!disposed && counts) { + setThreadAutomationCountById(counts); + } + }) + .catch(() => undefined); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + refresh(); + } + }; + + refresh(); + const intervalId = window.setInterval(refresh, THREAD_AUTOMATION_REFRESH_INTERVAL_MS); + window.addEventListener("focus", refresh); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + disposed = true; + window.clearInterval(intervalId); + window.removeEventListener("focus", refresh); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, []); + + useEffect(() => { + void refreshThreadAutomationCounts().catch(() => undefined); + }, [refreshThreadAutomationCounts, sidebarThreads]); const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, @@ -3915,6 +4008,7 @@ export default function Sidebar() { newThreadShortcutLabel={newThreadShortcutLabel} commandPaletteShortcutLabel={commandPaletteShortcutLabel} threadJumpLabelByKey={visibleThreadJumpLabelByKey} + threadAutomationCountById={threadAutomationCountById} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} diff --git a/apps/web/src/components/settings/AutomationsSettings.tsx b/apps/web/src/components/settings/AutomationsSettings.tsx new file mode 100644 index 00000000000..09fc7505391 --- /dev/null +++ b/apps/web/src/components/settings/AutomationsSettings.tsx @@ -0,0 +1,1544 @@ +import { scopeProjectRef } from "@t3tools/client-runtime"; +import { + DEFAULT_PROVIDER_DRIVER_KIND, + DEFAULT_PROVIDER_INSTANCE_ID, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + getDefaultModelForProvider, + type ModelSelection, + type ProjectId, + type ProviderInstanceId, + type ProviderOptionSelection, + type ScheduledTask, + type ScheduledTaskRRuleConfig, + type ScheduledTaskRun, + type ScheduledTaskWeekday, + type ThreadId, +} from "@t3tools/contracts"; +import type { UnifiedSettings } from "@t3tools/contracts/settings"; +import { createModelSelection } from "@t3tools/shared/model"; +import { useRouter } from "@tanstack/react-router"; +import { + ChevronDownIcon, + ChevronRightIcon, + Clock3Icon, + FolderIcon, + InfoIcon, + MoreHorizontalIcon, + PauseCircleIcon, + PauseIcon, + PlayIcon, + PlusIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useShallow } from "zustand/react/shallow"; + +import { useComposerDraftStore } from "~/composerDraftStore"; +import { usePrimaryEnvironmentId } from "~/environments/primary"; +import { useSettings } from "~/hooks/useSettings"; +import { ensureLocalApi } from "~/localApi"; +import { cn, newDraftId, newThreadId } from "~/lib/utils"; +import { + getAppModelOptionsForInstance, + resolveAppModelSelectionForInstance, + type AppModelOption, +} from "~/modelSelection"; +import { + deriveProviderInstanceEntries, + sortProviderInstanceEntries, + type ProviderInstanceEntry, +} from "~/providerInstances"; +import { applyProvidersSkillPreferences } from "~/providerSkillPreferences"; +import { filterVisibleServerProviders } from "~/providerVisibility"; +import { useServerProviders } from "~/rpc/serverState"; +import { selectProjectsForEnvironment, selectThreadsForEnvironment, useStore } from "~/store"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { Dialog, DialogPanel, DialogPopup } from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Select, SelectContent, SelectItem, SelectTrigger } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { Textarea } from "../ui/textarea"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import { TraitsPicker } from "../chat/TraitsPicker"; +import { getComposerProviderState } from "../chat/composerProviderState"; +import { SettingsPageContainer } from "./settingsLayout"; + +type Cadence = "once" | ScheduledTaskRRuleConfig["frequency"]; + +const WEEKDAYS: ReadonlyArray<{ value: ScheduledTaskWeekday; label: string }> = [ + { value: "MO", label: "Mon" }, + { value: "TU", label: "Tue" }, + { value: "WE", label: "Wed" }, + { value: "TH", label: "Thu" }, + { value: "FR", label: "Fri" }, + { value: "SA", label: "Sat" }, + { value: "SU", label: "Sun" }, +]; + +const WEEKDAY_SET = "MO,TU,WE,TH,FR"; + +const KIND_LABELS: Record = { + standalone: "New thread", + thread: "Existing thread", +}; + +const CADENCE_LABELS: Record = { + daily: "Daily", + monthly: "Monthly", + once: "Once", + weekly: "Weekly", +}; + +const CREATE_VIA_CHAT_PROMPT = + "I want to set up an automation. Briefly explain how automations work in T3Code, then ask me a few questions to figure out what I'd like T3Code to do and when it should run."; + +interface AutomationFormState { + readonly name: string; + readonly kind: ScheduledTask["kind"]; + readonly projectId: string; + readonly targetThreadId: string; + readonly cadence: Cadence; + readonly startAt: string; + readonly interval: string; + readonly weekdays: ReadonlyArray; + readonly monthDay: string; + readonly timezone: string; + readonly prompt: string; + readonly catchUp: boolean; + readonly modelSelection: ModelSelection | null; +} + +function pad2(value: number): string { + return String(value).padStart(2, "0"); +} + +function toDatetimeLocalInputValue(date: Date): string { + return [ + date.getFullYear(), + "-", + pad2(date.getMonth() + 1), + "-", + pad2(date.getDate()), + "T", + pad2(date.getHours()), + ":", + pad2(date.getMinutes()), + ].join(""); +} + +function defaultStartAt(): string { + const date = new Date(); + date.setMinutes(0, 0, 0); + date.setHours(date.getHours() + 1); + return toDatetimeLocalInputValue(date); +} + +function initialFormState(timezone: string): AutomationFormState { + const now = new Date(); + return { + name: "", + kind: "standalone", + projectId: "", + targetThreadId: "", + cadence: "daily", + startAt: defaultStartAt(), + interval: "1", + weekdays: ["MO", "TU", "WE", "TH", "FR"], + monthDay: String(now.getDate()), + timezone, + prompt: "", + catchUp: false, + modelSelection: null, + }; +} + +function isAutomationKind(value: unknown): value is ScheduledTask["kind"] { + return value === "thread" || value === "standalone"; +} + +function isCadence(value: unknown): value is Cadence { + return value === "once" || value === "daily" || value === "weekly" || value === "monthly"; +} + +function createFallbackModelSelection(): ModelSelection { + return { + instanceId: DEFAULT_PROVIDER_INSTANCE_ID, + model: getDefaultModelForProvider(DEFAULT_PROVIDER_DRIVER_KIND), + }; +} + +function fallbackModelSelectionFromEntries( + entries: ReadonlyArray, +): ModelSelection { + const entry = entries.find((candidate) => candidate.enabled && candidate.isAvailable); + if (!entry) return createFallbackModelSelection(); + return { + instanceId: entry.instanceId, + model: + entry.models.find((model) => !model.isCustom)?.slug ?? + entry.models[0]?.slug ?? + getDefaultModelForProvider(entry.driverKind), + }; +} + +function resolveModelSelectionForEntries(input: { + readonly selection: ModelSelection | null | undefined; + readonly settings: UnifiedSettings; + readonly providers: ReadonlyArray[number]>; + readonly entries: ReadonlyArray; + readonly prompt: string; +}): ModelSelection { + const { selection, settings, providers, entries, prompt } = input; + const fallbackSelection = fallbackModelSelectionFromEntries(entries); + const selectedEntry = + entries.find( + (entry) => entry.instanceId === selection?.instanceId && entry.enabled && entry.isAvailable, + ) ?? + entries.find((entry) => entry.instanceId === fallbackSelection.instanceId) ?? + entries.find((entry) => entry.enabled && entry.isAvailable); + if (!selectedEntry) return fallbackSelection; + const model = + resolveAppModelSelectionForInstance( + selectedEntry.instanceId, + settings, + providers, + selection?.instanceId === selectedEntry.instanceId ? selection.model : null, + ) ?? + selectedEntry.models[0]?.slug ?? + getDefaultModelForProvider(selectedEntry.driverKind); + const currentOptions = + selection?.instanceId === selectedEntry.instanceId ? selection.options : undefined; + const providerState = getComposerProviderState({ + provider: selectedEntry.driverKind, + model, + models: selectedEntry.models, + prompt, + modelOptions: currentOptions, + }); + return createModelSelection( + selectedEntry.instanceId, + model, + providerState.modelOptionsForDispatch, + ); +} + +function preferredModelSelectionForTarget(input: { + readonly explicit: ModelSelection | null | undefined; + readonly threadModelSelection: ModelSelection | null | undefined; + readonly projectModelSelection: ModelSelection | null | undefined; + readonly settings: UnifiedSettings; + readonly providers: ReadonlyArray[number]>; + readonly entries: ReadonlyArray; + readonly prompt: string; +}): ModelSelection { + return resolveModelSelectionForEntries({ + selection: input.explicit ?? input.threadModelSelection ?? input.projectModelSelection ?? null, + settings: input.settings, + providers: input.providers, + entries: input.entries, + prompt: input.prompt, + }); +} + +function formatDateTime(iso: string | null): string { + if (!iso) return "Not scheduled"; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +function formatTime(iso: string | null): string { + if (!iso) return ""; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + return new Intl.DateTimeFormat(undefined, { timeStyle: "short" }).format(date); +} + +function parseDatetimeLocalInputValue(value: string): string | null { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function formatRelativeTime(iso: string | null): string { + if (!iso) return ""; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + const diffMs = Date.now() - date.getTime(); + const absMs = Math.abs(diffMs); + const suffix = diffMs >= 0 ? "" : " from now"; + const units: ReadonlyArray<[number, string]> = [ + [24 * 60 * 60 * 1_000, "d"], + [60 * 60 * 1_000, "h"], + [60 * 1_000, "m"], + ]; + for (const [unitMs, label] of units) { + if (absMs >= unitMs) { + return `${Math.max(1, Math.round(absMs / unitMs))}${label}${suffix}`; + } + } + return diffMs >= 0 ? "now" : "soon"; +} + +function parseRRuleConfig(task: ScheduledTask): ScheduledTaskRRuleConfig | null { + if (task.scheduleKind !== "rrule") return null; + try { + return JSON.parse(task.scheduleValue) as ScheduledTaskRRuleConfig; + } catch { + return null; + } +} + +function formatSchedule(task: ScheduledTask): string { + if (task.scheduleKind === "once") return `Once at ${formatDateTime(task.scheduleValue)}`; + const config = parseRRuleConfig(task); + if (!config) return "Custom schedule"; + const time = formatTime(config.dtStart); + const interval = config.interval === 1 ? "" : ` every ${config.interval}`; + if (config.frequency === "daily") return `Daily${time ? ` at ${time}` : ""}`; + if (config.frequency === "weekly") { + const days = config.byDay?.join(",") ?? ""; + const label = days === WEEKDAY_SET ? "Weekdays" : `Weekly${interval}`; + return `${label}${time ? ` at ${time}` : ""}`; + } + if (config.frequency === "monthly" && config.byMonthDay?.length) { + return `Monthly on day ${config.byMonthDay.join(", ")}${time ? ` at ${time}` : ""}`; + } + return `${config.frequency[0]?.toUpperCase()}${config.frequency.slice(1)}${interval}`; +} + +function statusVariant(status: ScheduledTask["status"]) { + switch (status) { + case "active": + return "success"; + case "paused": + return "warning"; + case "deleted": + return "outline"; + } +} + +function buildSchedule(form: AutomationFormState): { + readonly scheduleKind: ScheduledTask["scheduleKind"]; + readonly scheduleValue: string; +} { + const startAtDate = new Date(form.startAt); + if (Number.isNaN(startAtDate.getTime())) { + throw new Error("Choose a valid start time."); + } + const startAtIso = startAtDate.toISOString(); + if (form.cadence === "once") { + return { + scheduleKind: "once", + scheduleValue: startAtIso, + }; + } + const interval = Math.max(1, Number.parseInt(form.interval, 10) || 1); + const config: ScheduledTaskRRuleConfig = { + frequency: form.cadence, + interval, + dtStart: startAtIso, + ...(form.cadence === "weekly" ? { byDay: form.weekdays.length ? form.weekdays : ["MO"] } : {}), + ...(form.cadence === "monthly" + ? { byMonthDay: [Math.min(31, Math.max(1, Number.parseInt(form.monthDay, 10) || 1))] } + : {}), + }; + return { + scheduleKind: "rrule", + scheduleValue: JSON.stringify(config), + }; +} + +function AutomationField({ + label, + className, + children, +}: { + readonly label: string; + readonly className?: string; + readonly children: ReactNode; +}) { + return ( +
+ {label} + {children} +
+ ); +} + +function AutomationErrorBanner({ message }: { readonly message: string }) { + return ( +
+ {message} +
+ ); +} + +function IconButton({ + label, + disabled, + onClick, + variant = "ghost", + children, +}: { + readonly label: string; + readonly disabled?: boolean; + readonly onClick: () => void; + readonly variant?: "ghost" | "outline" | "destructive-outline"; + readonly children: ReactNode; +}) { + return ( + + { + event.stopPropagation(); + onClick(); + }} + size="icon-sm" + variant={variant} + > + {children} + + } + /> + {label} + + ); +} + +function AutomationModelControls({ + selection, + providerEntries, + modelOptionsByInstance, + settings, + providers, + prompt, + disabled, + className, + onPromptChange, + onSelectionChange, +}: { + readonly selection: ModelSelection | null | undefined; + readonly providerEntries: ReadonlyArray; + readonly modelOptionsByInstance: ReadonlyMap>; + readonly settings: UnifiedSettings; + readonly providers: ReadonlyArray[number]>; + readonly prompt: string; + readonly disabled?: boolean; + readonly className?: string; + readonly onPromptChange: (prompt: string) => void; + readonly onSelectionChange: (selection: ModelSelection) => void; +}) { + const entries = providerEntries.filter((entry) => entry.enabled && entry.isAvailable); + const currentSelection = resolveModelSelectionForEntries({ + selection, + settings, + providers, + entries, + prompt, + }); + const currentEntry = + entries.find((entry) => entry.instanceId === currentSelection.instanceId) ?? entries[0]; + + if (!currentEntry) { + return ( + + ); + } + + const updateSelection = ( + entry: ProviderInstanceEntry, + model: string, + options: ReadonlyArray | undefined, + ) => { + const providerState = getComposerProviderState({ + provider: entry.driverKind, + model, + models: entry.models, + prompt, + modelOptions: options, + }); + onSelectionChange( + createModelSelection(entry.instanceId, model, providerState.modelOptionsForDispatch), + ); + }; + + return ( +
+ { + const entry = entries.find((candidate) => candidate.instanceId === instanceId); + if (!entry) return; + const options = + instanceId === currentSelection.instanceId ? currentSelection.options : undefined; + updateSelection(entry, model, options); + }} + /> + { + updateSelection(currentEntry, currentSelection.model, nextOptions); + }} + onPromptChange={onPromptChange} + /> +
+ ); +} + +function AutomationRow({ + task, + projectName, + threadTitle, + selected, + busy, + onSelect, + onRun, + onPauseResume, + onDelete, +}: { + readonly task: ScheduledTask; + readonly projectName: string; + readonly threadTitle: string | null; + readonly selected: boolean; + readonly busy: boolean; + readonly onSelect: () => void; + readonly onRun: () => void; + readonly onPauseResume: () => void; + readonly onDelete: () => void; +}) { + const active = task.status === "active"; + return ( +
{ + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onSelect(); + }} + className={cn( + "group grid w-full min-w-0 grid-cols-[1rem_1fr_auto] items-center gap-4 rounded-xl px-3 py-3 text-left transition-colors", + "hover:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + selected && "bg-muted", + )} + > + + + + + {task.name} + + + {projectName} + {task.kind === "thread" && threadTitle ? ` - ${threadTitle}` : ""} + + + + + {formatSchedule(task)} + + + + + + {active ? : } + + + + + + +
+ ); +} + +function AutomationListSection({ + title, + tasks, + selectedTaskId, + projectById, + threadById, + mutatingTaskId, + onSelect, + onMutate, +}: { + readonly title: string; + readonly tasks: ReadonlyArray; + readonly selectedTaskId: string | null; + readonly projectById: Map; + readonly threadById: Map; + readonly mutatingTaskId: string | null; + readonly onSelect: (task: ScheduledTask) => void; + readonly onMutate: (task: ScheduledTask, action: "pause" | "resume" | "run" | "delete") => void; +}) { + if (tasks.length === 0) return null; + return ( +
+
+

{title}

+
+
+ {tasks.map((task) => { + const project = projectById.get(task.projectId); + const thread = task.targetThreadId ? threadById.get(task.targetThreadId) : null; + const busy = mutatingTaskId?.startsWith(`${task.id}:`) ?? false; + return ( + onSelect(task)} + onRun={() => onMutate(task, "run")} + onPauseResume={() => onMutate(task, task.status === "active" ? "pause" : "resume")} + onDelete={() => onMutate(task, "delete")} + /> + ); + })} +
+
+ ); +} + +function DetailRow({ + label, + value, + children, +}: { + readonly label: string; + readonly value?: ReactNode; + readonly children?: ReactNode; +}) { + return ( +
+ {label} +
{children ?? value}
+
+ ); +} + +function AutomationDetail({ + task, + runs, + projectName, + threadTitle, + preferredSelection, + providerEntries, + modelOptionsByInstance, + settings, + providers, + busy, + onBack, + onMutate, + onUpdateModelSelection, +}: { + readonly task: ScheduledTask; + readonly runs: ReadonlyArray; + readonly projectName: string; + readonly threadTitle: string | null; + readonly preferredSelection: ModelSelection; + readonly providerEntries: ReadonlyArray; + readonly modelOptionsByInstance: ReadonlyMap>; + readonly settings: UnifiedSettings; + readonly providers: ReadonlyArray[number]>; + readonly busy: boolean; + readonly onBack: () => void; + readonly onMutate: (task: ScheduledTask, action: "pause" | "resume" | "run" | "delete") => void; + readonly onUpdateModelSelection: (selection: ModelSelection) => void; +}) { + const active = task.status === "active"; + return ( +
+
+ +
+ onMutate(task, active ? "pause" : "resume")} + > + {active ? : } + + onMutate(task, "delete")}> + + + +
+
+ +
+
+
+

+ {task.name} +

+
+ {task.prompt} +
+
+
+ + +
+
+ ); +} + +function AutomationCreateDialog({ + open, + form, + projects, + projectThreads, + projectName, + threadTitle, + saving, + error, + effectiveModelSelection, + providerEntries, + modelOptionsByInstance, + settings, + providers, + onOpenChange, + onFormChange, + onSubmit, +}: { + readonly open: boolean; + readonly form: AutomationFormState; + readonly projects: ReadonlyArray<{ readonly id: ProjectId; readonly name: string }>; + readonly projectThreads: ReadonlyArray<{ readonly id: ThreadId; readonly title: string }>; + readonly projectName: string | undefined; + readonly threadTitle: string | undefined; + readonly saving: boolean; + readonly error: string | null; + readonly effectiveModelSelection: ModelSelection; + readonly providerEntries: ReadonlyArray; + readonly modelOptionsByInstance: ReadonlyMap>; + readonly settings: UnifiedSettings; + readonly providers: ReadonlyArray[number]>; + readonly onOpenChange: (open: boolean) => void; + readonly onFormChange: (updater: (current: AutomationFormState) => AutomationFormState) => void; + readonly onSubmit: () => void; +}) { + const scheduleTimeLabel = formatTime(parseDatetimeLocalInputValue(form.startAt)) || "time"; + + return ( + + + +
+ + + + + } + /> + Scheduled prompts run from this app. + + + +
+ +
+ { + const { value } = event.currentTarget; + onFormChange((current) => ({ ...current, name: value })); + }} + className="h-14 border-0 bg-transparent px-0 text-3xl shadow-none placeholder:text-muted-foreground/65 focus-visible:ring-0" + /> +