Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useAtomValue } from "@effect/atom-react";
import {
AuthOrchestrationOperateScope,
resolveProjectProviderInstanceEnabled,
scheduledTaskLegacyUpsert,
} from "@t3tools/contracts";
import { readEnvironmentScope } from "../../state/session";
import type {
Expand Down Expand Up @@ -73,6 +74,7 @@ import { SettingsScreen } from "./components/SettingsScreen";
import { SettingsSection } from "./components/SettingsSection";
import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter";
import {
buildScheduledTaskUpdateInput,
editDraft,
scheduledTaskDefaultModel,
scheduleFromDraft,
Expand Down Expand Up @@ -597,6 +599,10 @@ function TaskForm({
label: "scheduled task upsert",
reportFailure: false,
});
const update = useAtomCommand(serverEnvironment.updateScheduledTask, {
label: "scheduled task update",
reportFailure: false,
});
const submissionPending = useRef(false);
const [timePickerOpen, setTimePickerOpen] = useState(false);
const taskMissing =
Expand Down Expand Up @@ -659,34 +665,61 @@ function TaskForm({
Dialog.alert("Project unavailable", "Choose a project in this environment.");
return;
}
const input: ScheduledTaskUpsertInput = {
...(draft.task ? { id: draft.task.id, requireExisting: true } : {}),
title: draft.title.trim(),
prompt: draft.prompt.trim(),
projectId: draft.projectId,
modelSelection: draft.modelSelection,
schedule,
enabled: draft.enabled,
threadId: draft.task?.threadId ?? null,
workspaceStrategy:
draft.workspace === "root"
? { type: "root" }
: draft.workspace === "existing_worktree"
? { type: "existing_worktree", worktreePath: draft.checkoutPath.trim() }
: {
type: "worktree",
baseRef: draft.baseRef.trim() || "main",
startFromOrigin: draft.startFromOrigin,
},
runtimeMode: draft.runtimeMode,
interactionMode: draft.task?.interactionMode ?? "default",
creationSource: draft.task?.creationSource ?? "mobile",
};
submissionPending.current = true;
setSaving(true);
const result = await upsert({ environmentId, input });
let result: AtomCommandResult<unknown, unknown> | null;
if (draft.task !== null) {
// Existing tasks save as a dirty-field patch through the atomic update
// path: a stale editor can never overwrite fields another client
// changed, and a delete racing the save is a typed not-found, not a
// resurrection. `taskMissing` already guarantees the task is in the
// live list.
const liveTask = tasks.data?.tasks.find((task) => task.id === draft.task?.id);
if (liveTask === undefined) {
// The live list has not returned (or the task vanished between render
// and save) — fail loudly instead of silently discarding the edit.
setSaving(false);
submissionPending.current = false;
Dialog.alert(
"Task unavailable",
"The task could not be loaded. Reopen it once the list has loaded and try again.",
);
return;
}
const patch = buildScheduledTaskUpdateInput(draft, liveTask);
result =
patch === null
? null
: config?.environment.capabilities.scheduledTaskUpdate === true
? await update({ environmentId, input: patch })
: await upsert({ environmentId, input: scheduledTaskLegacyUpsert(liveTask, patch) });
} else {
const input: ScheduledTaskUpsertInput = {
title: draft.title.trim(),
prompt: draft.prompt.trim(),
projectId: draft.projectId,
modelSelection: draft.modelSelection,
schedule,
enabled: draft.enabled,
threadId: null,
workspaceStrategy:
draft.workspace === "root"
? { type: "root" }
: draft.workspace === "existing_worktree"
? { type: "existing_worktree", worktreePath: draft.checkoutPath.trim() }
: {
type: "worktree",
baseRef: draft.baseRef.trim() || "main",
startFromOrigin: draft.startFromOrigin,
},
runtimeMode: draft.runtimeMode,
interactionMode: "default",
creationSource: "mobile",
};
result = await upsert({ environmentId, input });
}
setSaving(false);
if (result._tag === "Failure") {
if (result !== null && result._tag === "Failure") {
submissionPending.current = false;
failure("Could not save task", result);
return;
Expand Down
140 changes: 140 additions & 0 deletions apps/mobile/src/features/settings/scheduledTaskDraft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type ScheduledTask,
} from "@t3tools/contracts";
import {
buildScheduledTaskUpdateInput,
scheduledTaskDefaultModel,
createDraft,
editDraft,
Expand Down Expand Up @@ -352,3 +353,142 @@ describe("scheduled task model defaults", () => {
).toBeNull();
});
});

describe("buildScheduledTaskUpdateInput", () => {
const projectId = ProjectId.make("project:one");
const task: ScheduledTask = {
id: ScheduledTaskId.make("scheduled-task:mobile-fixture"),
title: "Original title",
prompt: "Original prompt",
enabled: true,
schedule: { type: "interval", everyMs: 15 * 60_000 },
projectId,
threadId: null,
workspaceStrategy: { type: "worktree", baseRef: "main", startFromOrigin: true },
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5.4",
},
runtimeMode: "full-access",
interactionMode: "default",
createdBy: "user",
creationSource: "mobile",
createdAt: "2026-09-01T00:00:00.000Z",
updatedAt: "2026-09-01T00:00:00.000Z",
nextRunAt: "2026-09-02T00:00:00.000Z",
lastRunAt: null,
lastRunStatus: "never",
lastRunError: null,
runCount: 0,
};

it("returns null when the draft matches the task it opened with", () => {
expect(buildScheduledTaskUpdateInput(editDraft(task), task)).toBeNull();
expect(buildScheduledTaskUpdateInput(createDraft(null, null), task)).toBeNull();
});

it("sends only the fields a stale editor changed, preserving concurrent edits", () => {
// Another client rewrote the prompt and paused the task while this
// editor was open; the mobile save carries only the rename the user
// actually made instead of restoring the stale prompt and enabled flag.
const draft = { ...editDraft(task), title: "Renamed" };
const live: ScheduledTask = {
...task,
prompt: "Rewritten elsewhere",
enabled: false,
};
expect(buildScheduledTaskUpdateInput(draft, live)).toEqual({
id: task.id,
projectId: task.projectId,
title: "Renamed",
});
});

it("leaves an unchanged webhook trigger out of the patch", () => {
const webhookTask: ScheduledTask = {
...task,
schedule: {
type: "webhook",
signature: { header: "x-hub-signature-256", encoding: "hex", prefix: "sha256=" },
maxDeliveryAgeMinutes: 30,
},
};
const draft = { ...editDraft(webhookTask), title: "Renamed" };
expect(buildScheduledTaskUpdateInput(draft, webhookTask)).toEqual({
id: webhookTask.id,
projectId: webhookTask.projectId,
title: "Renamed",
});
});

it("patches the editable runtime mode", () => {
const draft = { ...editDraft(task), runtimeMode: "auto" as const };
expect(buildScheduledTaskUpdateInput(draft, task)).toEqual({
id: task.id,
projectId: task.projectId,
runtimeMode: "auto",
});
});

it("detaches the live binding only when the patch itself re-homes the task", () => {
const other = ProjectId.make("project:other");
const bound: ScheduledTask = { ...task, threadId: "thread:bound" as ScheduledTask["threadId"] };
// A real move away from the binding's project must detach it.
const moving = { ...editDraft(bound), projectId: other };
expect(buildScheduledTaskUpdateInput(moving, bound)).toEqual({
id: bound.id,
projectId: bound.projectId,
nextProjectId: other,
threadId: null,
});
// A stale save landing where the task already lives keeps a concurrent
// rebind instead of erasing it.
const liveMoved: ScheduledTask = { ...bound, projectId: other };
expect(buildScheduledTaskUpdateInput(moving, liveMoved)).toEqual({
id: bound.id,
projectId: other,
nextProjectId: other,
});
});

it("emits the normalized schedule for a legacy sub-minute interval", () => {
const legacy: ScheduledTask = {
...task,
schedule: { type: "interval", everyMs: 30_000 },
};
// The draft is untouched — editDraft clamped the baseline to "1" — but
// the stored 30s interval must still be rewritten to the displayed 60s.
expect(buildScheduledTaskUpdateInput(editDraft(legacy), legacy)).toEqual({
id: legacy.id,
projectId: legacy.projectId,
schedule: { type: "interval", everyMs: 60_000 },
});
});

it("does not overwrite a concurrent fix to a legacy sub-minute interval", () => {
const legacy: ScheduledTask = {
...task,
schedule: { type: "interval", everyMs: 30_000 },
};
// Another client already normalized the schedule — an untouched save
// leaves their interval alone instead of forcing the editor's 60s.
const live: ScheduledTask = { ...legacy, schedule: { type: "interval", everyMs: 90_000 } };
expect(buildScheduledTaskUpdateInput(editDraft(legacy), live)).toBeNull();
});

it("sends only the workspace controls the user changed as a sparse patch", () => {
// Another client flipped startFromOrigin while this editor was open;
// the save sends just the edited key and the server merges it into the
// live strategy, so the concurrent toggle survives on both sides.
const draft = { ...editDraft(task), baseRef: "develop" };
const live: ScheduledTask = {
...task,
workspaceStrategy: { type: "worktree", baseRef: "main", startFromOrigin: false },
};
expect(buildScheduledTaskUpdateInput(draft, live)).toEqual({
id: task.id,
projectId: task.projectId,
workspaceStrategyPatch: { baseRef: "develop" },
});
});
});
Loading
Loading