Skip to content

Commit 6980ee9

Browse files
fix(server): scheduled-task moves reject a project that does not exist
update accepted any nextProjectId, so a task could be moved into a missing project, save successfully, and then fail every run at launch. Check the destination inside the update transaction. Co-Authored-By: Claude Fable 5.1 <[email protected]>
1 parent 0477c4a commit 6980ee9

2 files changed

Lines changed: 37 additions & 1 deletion

File tree

‎apps/server/src/scheduledTasks/ScheduledTaskService.test.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1028,6 +1028,17 @@ const v1ThreadId = ThreadId.make("thread:v1-only");
10281028
const deletedThreadId = ThreadId.make("thread:deleted");
10291029
const archivedThreadId = ThreadId.make("thread:archived");
10301030

1031+
// A move is only accepted into a project that exists.
1032+
const seedOtherProject = Effect.gen(function* () {
1033+
const sql = yield* SqlClient.SqlClient;
1034+
const now = "2026-09-14T00:00:00.000Z";
1035+
yield* sql`
1036+
INSERT INTO projection_projects
1037+
(project_id, title, workspace_root, scripts_json, created_at, updated_at)
1038+
VALUES (${otherProjectId}, 'other', '/tmp/other', '[]', ${now}, ${now})
1039+
`;
1040+
});
1041+
10311042
const seedProjectThreads = Effect.gen(function* () {
10321043
const sql = yield* SqlClient.SqlClient;
10331044
const now = "2026-09-14T00:00:00.000Z";
@@ -1088,6 +1099,16 @@ it.effect("update applies model selection and project moves patch-style", () =>
10881099
Effect.gen(function* () {
10891100
const tasks = yield* ScheduledTaskService;
10901101
const seeded = yield* seedTask;
1102+
const unknown = yield* tasks
1103+
.update({
1104+
id: updateTaskId,
1105+
projectId: updateProjectId,
1106+
nextProjectId: ProjectId.make("project:does-not-exist"),
1107+
})
1108+
.pipe(Effect.result);
1109+
if (Result.isSuccess(unknown)) assert.fail("expected the move to be rejected");
1110+
assert.equal(unknown.failure._tag, "ScheduledTaskError");
1111+
yield* seedOtherProject;
10911112
const moved = yield* tasks.update({
10921113
id: updateTaskId,
10931114
projectId: updateProjectId,
@@ -1119,14 +1140,15 @@ it.effect("update applies model selection and project moves patch-style", () =>
11191140
prompt: "new scope works",
11201141
});
11211142
assert.isTrue(Option.isSome(newScope));
1122-
}).pipe(Effect.provide(updateTestLayer)),
1143+
}).pipe(Effect.provide(updateTestLayerWithSql)),
11231144
);
11241145

11251146
it.effect("update keeps a project move and a thread binding consistent", () =>
11261147
Effect.gen(function* () {
11271148
const tasks = yield* ScheduledTaskService;
11281149
yield* seedTask;
11291150
yield* seedProjectThreads;
1151+
yield* seedOtherProject;
11301152
// Binding to a v2 thread that lives in the task's project works.
11311153
const bound = yield* tasks.update({
11321154
id: updateTaskId,

‎apps/server/src/scheduledTasks/ScheduledTaskService.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,20 @@ export const layer = Layer.effect(
927927
Effect.gen(function* () {
928928
const existing = yield* findTask(input.id);
929929
if (existing === null || existing.projectId !== input.projectId) return null;
930+
// A task in a project that does not exist saves fine and then
931+
// fails every run at launch, so reject the move up front.
932+
if (input.nextProjectId !== undefined && input.nextProjectId !== existing.projectId) {
933+
const projects = yield* sql<{ matched: number }>`
934+
SELECT COUNT(*) AS matched
935+
FROM projection_projects
936+
WHERE project_id = ${input.nextProjectId} AND deleted_at IS NULL
937+
`;
938+
if ((projects[0]?.matched ?? 0) === 0) {
939+
return yield* taskError("The destination project does not exist.", {
940+
taskId: input.id,
941+
});
942+
}
943+
}
930944
// The patch merges with whatever is already committed, so the
931945
// resulting (project, thread) pair must still be dispatchable.
932946
if (input.threadId !== undefined || input.nextProjectId !== undefined) {

0 commit comments

Comments
 (0)