diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 992bccd78bc07..c1fabd7cc5aa1 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -13,7 +13,6 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro import type { EnvironmentId, ModelSelection, - OrchestrationV2ThreadLaunchWorkspaceStrategy, ProjectId, ScheduledTask, ScheduledTaskId, @@ -24,6 +23,7 @@ import type { ThreadId, } from "@t3tools/contracts"; import { DEFAULT_WEBHOOK_PROMPT } from "@t3tools/client-runtime/scheduled-task-webhook"; +import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; import { MAX_WEBHOOK_DELIVERY_AGE_MINUTES, MIN_SCHEDULED_TASK_INTERVAL_MS, @@ -54,6 +54,7 @@ import { type EnvironmentPresentation, } from "../../state/environments"; import { useProjects } from "../../state/entities"; +import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -67,7 +68,10 @@ import { matchesScheduledTaskScope, scheduleFromDraft, scheduledTaskDefaultModel, + scheduledTaskDefaultProjectId, + scheduledTaskProjectChoices, taskToDraft, + workspaceStrategyFromDraft, type DraftState, type ScheduleMode, type WorkspaceMode, @@ -106,6 +110,7 @@ import { const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0] as const; const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; const WEEKDAY_SHORT = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const; +const NO_PROJECT_VALUE = "no-project"; const WORKSPACE_MODE_LABELS: Record = { worktree: "Create a new worktree", @@ -786,14 +791,13 @@ function ScheduledTaskEditorDialog({ connected ? serverEnvironment.scheduledTasksLive({ environmentId, input: {} }) : null, ); const allProjects = useProjects(); - const projects = useMemo( - () => - allProjects.filter( - (project) => - project.environmentId === environmentId && - matchesScheduledTaskScope(scope, environmentId, project.id), - ), - [allProjects, environmentId, scope], + const scratchWorkspaceRoot = availableScratchWorkspaceRoot( + environment?.connection.phase, + environment?.serverConfig, + ); + const { projects, scratchProject, canSelectNoProject } = useMemo( + () => scheduledTaskProjectChoices(scope, environmentId, allProjects, scratchWorkspaceRoot), + [allProjects, environmentId, scope, scratchWorkspaceRoot], ); const settings = useEnvironmentSettings(environmentId); const providers = @@ -804,8 +808,14 @@ function ScheduledTaskEditorDialog({ const upsertTask = useAtomCommand(serverEnvironment.upsertScheduledTask, { label: "scheduled task upsert", }); + const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); const [draft, setDraft] = useState(() => - task ? taskToDraft(task) : { ...EMPTY_DRAFT, projectId: projects[0]?.id ?? "" }, + task + ? taskToDraft(task) + : { + ...EMPTY_DRAFT, + projectId: scheduledTaskDefaultProjectId({ projects, canSelectNoProject }), + }, ); const [saving, setSaving] = useState(false); const submissionPending = useRef(false); @@ -818,8 +828,11 @@ function ScheduledTaskEditorDialog({ const liveTask = tasksQuery.data ? (tasksQuery.data.tasks.find((entry) => entry.id === draft.editingId) ?? null) : task; - const selectedProjectId = draft.projectId || projects[0]?.id || ""; - const selectedProject = projects.find((project) => project.id === selectedProjectId); + const selectedProjectId = draft.projectId; + const noProject = selectedProjectId === null || selectedProjectId === scratchProject?.id; + const selectedProject = noProject + ? scratchProject + : projects.find((project) => project.id === selectedProjectId); // The task runs in selectedProject, so its model picker should reflect that // project's effective enablement, not just the machine's. const instanceEntries = useMemo( @@ -875,10 +888,13 @@ function ScheduledTaskEditorDialog({ if ( !draft.title.trim() || !draft.prompt.trim() || - !projects.some((project) => project.id === selectedProjectId) || + (noProject ? !canSelectNoProject : !selectedProject) || selection === null ) { - reportFailure("Scheduled task is incomplete", "Add a title, prompt, project, and model."); + reportFailure( + "Scheduled task is incomplete", + "Add a title, prompt, and model, and select a project or No project.", + ); return; } const schedule = scheduleFromDraft(draft); @@ -906,7 +922,11 @@ function ScheduledTaskEditorDialog({ reportFailure("Invalid interval", "Enter an interval of at least one minute."); return; } - if (draft.workspaceMode === "existing_worktree" && !draft.existingWorktreePath.trim()) { + if ( + !noProject && + draft.workspaceMode === "existing_worktree" && + !draft.existingWorktreePath.trim() + ) { reportFailure("Checkout path is required", "Enter the path of the checkout to run in."); return; } @@ -918,33 +938,37 @@ function ScheduledTaskEditorDialog({ draft.baseModelSelection.model === selection.model ? draft.baseModelSelection : selection; - const workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy = - draft.workspaceMode === "root" - ? { type: "root" } - : draft.workspaceMode === "existing_worktree" - ? { type: "existing_worktree", worktreePath: draft.existingWorktreePath.trim() } - : { - type: "worktree", - baseRef: draft.baseRef.trim() || "main", - startFromOrigin: draft.startFromOrigin, - }; + // Lock before resolving Scratch, which may create the folder on first use. + submissionPending.current = true; + setSaving(true); + let projectId = selectedProject?.id; + if (noProject) { + const result = await openScratch({ environmentId, input: {} }); + if (result._tag === "Failure") { + submissionPending.current = false; + setSaving(false); + if (!isAtomCommandInterrupted(result)) { + reportFailure("Could not save scheduled task", squashAtomCommandFailure(result)); + } + return; + } + projectId = result.value.id; + } const input: ScheduledTaskUpsertInput = { ...(draft.editingId ? { id: draft.editingId as ScheduledTaskId, requireExisting: true } : {}), title: draft.title.trim(), prompt: draft.prompt.trim(), enabled: draft.enabled, schedule, - projectId: selectedProjectId as ProjectId, + projectId: projectId as ProjectId, threadId: draft.threadId ? (draft.threadId as ThreadId) : null, - workspaceStrategy, + workspaceStrategy: workspaceStrategyFromDraft(draft, noProject), modelSelection, runtimeMode: draft.runtimeMode, interactionMode: draft.interactionMode, creationSource: "web", }; - // Lock before React renders, and keep successful creates locked until the form closes. - submissionPending.current = true; - setSaving(true); + // Keep successful creates locked until the form closes. const result = await upsertTask({ environmentId, input }); setSaving(false); if (result._tag === "Failure") { @@ -985,10 +1009,17 @@ function ScheduledTaskEditorDialog({ onValueChange={(id) => { const next = connectedEnvironments.find((entry) => entry.environmentId === id); if (!next) return; + const nextChoices = scheduledTaskProjectChoices( + scope, + next.environmentId, + allProjects, + availableScratchWorkspaceRoot(next.connection.phase, next.serverConfig), + ); setEnvironmentId(next.environmentId); setDraft((current) => ({ ...current, - projectId: "", + projectId: scheduledTaskDefaultProjectId(nextChoices, noProject), + threadId: "", modelKey: "", baseModelSelection: null, baseRef: "main", @@ -1045,17 +1076,27 @@ function ScheduledTaskEditorDialog({
- - - + {!noProject ? ( + + + + ) : null}
- {draft.workspaceMode === "worktree" ? ( + {!noProject && draft.workspaceMode === "worktree" ? ( ) : null} - {draft.workspaceMode === "existing_worktree" ? ( + {!noProject && draft.workspaceMode === "existing_worktree" ? ( { + const scratchWorkspaceRoot = "/home/t3/scratch"; + const scratch = { ...member("scratch", laptopId), workspaceRoot: scratchWorkspaceRoot }; + const remoteScratch = { ...scratch, environmentId: serverId }; + const allProjects = [scratch, first, second, third, remoteScratch]; + + it("offers No project before the environment's scratch folder has been created", () => { + const scope = resolveSettingsScope({}, groups, environments); + expect(scheduledTaskProjectChoices(scope, laptopId, [], scratchWorkspaceRoot)).toEqual({ + projects: [], + scratchProject: null, + canSelectNoProject: true, + }); + }); + + it("keeps scratch behind No project and resolves it only on the selected environment", () => { + const scope = resolveSettingsScope({}, groups, environments); + expect(scheduledTaskProjectChoices(scope, laptopId, allProjects, scratchWorkspaceRoot)).toEqual( + { + projects: [first, second], + scratchProject: scratch, + canSelectNoProject: true, + }, + ); + expect(scheduledTaskProjectChoices(scope, serverId, allProjects, scratchWorkspaceRoot)).toEqual( + { + projects: [third], + scratchProject: remoteScratch, + canSelectNoProject: true, + }, + ); + }); + + it.each([ + { project: "t3code" }, + { project: "t3code", checkout: second.physicalProjectKey }, + { project: "missing" }, + { machine: serverId }, + ])("does not offer No project outside the selected settings scope: %o", (search) => { + const scope = resolveSettingsScope(search, groups, environments); + const choices = scheduledTaskProjectChoices(scope, laptopId, allProjects, scratchWorkspaceRoot); + expect(choices.scratchProject).toBeNull(); + expect(choices.canSelectNoProject).toBe(false); + }); + + it("does not offer No project when the environment has no available scratch folder", () => { + const scope = resolveSettingsScope({}, groups, environments); + expect(scheduledTaskProjectChoices(scope, laptopId, [first], null)).toEqual({ + projects: [first], + scratchProject: null, + canSelectNoProject: false, + }); + }); +}); + +describe("scheduled task destination defaults", () => { + it("stores No project when the editor opens before ordinary projects load", () => { + const scope = resolveSettingsScope({}, groups, environments); + const choices = scheduledTaskProjectChoices(scope, laptopId, [], "/home/t3/scratch"); + expect(scheduledTaskDefaultProjectId(choices)).toBeNull(); + }); + + it("defaults to the first scoped project when one is available", () => { + const scope = resolveSettingsScope({ project: "t3code" }, groups, environments); + const choices = scheduledTaskProjectChoices(scope, serverId, [other, third], null); + expect(scheduledTaskDefaultProjectId(choices)).toBe(third.id); + }); + + it("preserves No project when switching to an environment that supports it", () => { + const scope = resolveSettingsScope({}, groups, environments); + const choices = scheduledTaskProjectChoices(scope, serverId, [third], "/remote/scratch"); + expect(scheduledTaskDefaultProjectId(choices, true)).toBeNull(); + }); + + it("switches to an available project when the destination does not support Scratch", () => { + const scope = resolveSettingsScope({}, groups, environments); + const choices = scheduledTaskProjectChoices(scope, serverId, [third], null); + expect(scheduledTaskDefaultProjectId(choices, true)).toBe(third.id); + }); + + it("leaves the destination unset when neither Scratch nor a project is available", () => { + const scope = resolveSettingsScope({}, groups, environments); + const choices = scheduledTaskProjectChoices(scope, serverId, [], null); + expect(scheduledTaskDefaultProjectId(choices, true)).toBe(""); + }); + + it("requires a project when the settings scope excludes Scratch", () => { + const scope = resolveSettingsScope({ project: "t3code" }, groups, environments); + const choices = scheduledTaskProjectChoices(scope, serverId, [third], "/remote/scratch"); + expect(scheduledTaskDefaultProjectId(choices, true)).toBe(third.id); + }); +}); + describe("scheduled task settings scope", () => { it.each<{ search: SettingsScopeSearch; expected: string[] }>([ { search: {}, expected: ["task-0", "task-1", "task-2", "task-3", "task-4"] }, @@ -169,6 +265,18 @@ describe("editing scheduled task branch settings", () => { }); }); +describe("scheduled task workspace selection", () => { + it.each([ + { type: "root" }, + { type: "worktree", baseRef: "release", startFromOrigin: true }, + { type: "existing_worktree", worktreePath: "/repos/checkout" }, + ])("uses a scratch folder after switching from %o to No project", (workspaceStrategy) => { + const draft = taskToDraft({ ...legacyTask, workspaceStrategy }); + expect(workspaceStrategyFromDraft(draft, true)).toEqual({ type: "root" }); + expect(workspaceStrategyFromDraft(draft, false)).toEqual(workspaceStrategy); + }); +}); + describe("webhook scheduled tasks", () => { const signature = { header: "x-signature", encoding: "base64", prefix: "" } as const; const webhookTask: ScheduledTask = { diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 6c738ce0eff06..a3e536a5e0b02 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -8,8 +8,10 @@ import { type RuntimeMode, type ProviderInteractionMode, type ServerSettings, + type OrchestrationV2ThreadLaunchWorkspaceStrategy, } from "@t3tools/contracts"; import { parseMaxDeliveryAge } from "@t3tools/client-runtime/scheduled-task-webhook"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { resolveProjectSettings, @@ -34,6 +36,36 @@ export function matchesScheduledTaskScope( return true; } +/** Keep the environment's scratch folder behind the "No project" choice, even before first use. */ +export function scheduledTaskProjectChoices< + T extends { + readonly environmentId: EnvironmentId; + readonly id: ProjectId; + readonly workspaceRoot: string; + }, +>( + scope: ResolvedSettingsScope, + environmentId: EnvironmentId, + allProjects: readonly T[], + scratchWorkspaceRoot: string | null, +) { + const scopedProjects = allProjects.filter( + (project) => + project.environmentId === environmentId && + matchesScheduledTaskScope(scope, environmentId, project.id), + ); + const scratchProject = + scopedProjects.find((project) => isScratchProject(project, scratchWorkspaceRoot)) ?? null; + return { + projects: scopedProjects.filter((project) => project !== scratchProject), + scratchProject, + canSelectNoProject: + scratchWorkspaceRoot !== null && + scope.environmentIds.includes(environmentId) && + (scope.kind === "all" || scope.kind === "environment" || scratchProject !== null), + }; +} + export function validateScheduledTasksSearch(raw: Record) { return { ...(typeof raw.environmentId === "string" && raw.environmentId.trim() @@ -45,6 +77,18 @@ export function validateScheduledTasksSearch(raw: Record) { }; } +/** Choose a destination when opening the editor or explicitly switching environments. */ +export function scheduledTaskDefaultProjectId( + choices: { + readonly projects: readonly { readonly id: ProjectId }[]; + readonly canSelectNoProject: boolean; + }, + preferNoProject = false, +) { + if (preferNoProject && choices.canSelectNoProject) return null; + return choices.projects[0]?.id ?? (choices.canSelectNoProject ? null : ""); +} + export type ScheduleMode = "fixed" | "interval" | "webhook"; export type WorkspaceMode = "root" | "worktree" | "existing_worktree"; @@ -57,7 +101,8 @@ export interface DraftState { readonly intervalMinutes: string; readonly timeOfDay: string; readonly weekdays: ReadonlySet; - readonly projectId: string; + /** Null selects "No project"; empty requires an explicit selection. */ + readonly projectId: string | null; readonly threadId: string; readonly workspaceMode: WorkspaceMode; readonly baseRef: string; @@ -170,6 +215,22 @@ export function taskToDraft(task: ScheduledTask): DraftState { }; } +/** Scratch tasks launch in their own folders, without git checkout settings. */ +export function workspaceStrategyFromDraft( + draft: DraftState, + noProject: boolean, +): OrchestrationV2ThreadLaunchWorkspaceStrategy { + if (noProject || draft.workspaceMode === "root") return { type: "root" }; + if (draft.workspaceMode === "existing_worktree") { + return { type: "existing_worktree", worktreePath: draft.existingWorktreePath.trim() }; + } + return { + type: "worktree", + baseRef: draft.baseRef.trim() || "main", + startFromOrigin: draft.startFromOrigin, + }; +} + /** Use configured defaults before the catalog's advertised default model. */ export function scheduledTaskDefaultModel( settings: ServerSettings,