Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 163 additions & 11 deletions apps/desktop/src-tauri/src/commands/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,90 @@ fn normalize_path(input: &str) -> String {
trimmed.to_string()
}

fn path_distance(base: &str, candidate: &str) -> Option<usize> {
if base.is_empty() || candidate.is_empty() {
return None;
}

let base_path = Path::new(base);
let candidate_path = Path::new(candidate);
if candidate_path == base_path {
return Some(0);
}
if candidate_path.starts_with(base_path) || base_path.starts_with(candidate_path) {
let base_components = base_path.components().count();
let candidate_components = candidate_path.components().count();
return Some(base_components.abs_diff(candidate_components));
}

None
}

fn path_relation_rank(base: &str, candidate: &str) -> Option<(usize, usize)> {
if base.is_empty() || candidate.is_empty() {
return None;
}

let base_path = Path::new(base);
let candidate_path = Path::new(candidate);
if candidate_path == base_path {
return Some((0, 0));
}
if base_path.starts_with(candidate_path) {
return path_distance(base, candidate).map(|distance| (0, distance));
}
if candidate_path.starts_with(base_path) {
return path_distance(base, candidate).map(|distance| (1, distance));
}

None
}

fn select_entries_for_scope_root(
entries: Vec<JobEntry>,
scope_root: Option<&str>,
) -> Vec<JobEntry> {
let filter_root = scope_root.map(normalize_path).unwrap_or_default();
if filter_root.is_empty() {
return entries;
}

let exact: Vec<JobEntry> = entries
.iter()
.filter(|entry| {
entry
.job
.workdir
.as_deref()
.map(normalize_path)
.map(|wd| wd == filter_root)
.unwrap_or(false)
})
.cloned()
.collect();
if !exact.is_empty() {
return exact;
}

let mut related: Vec<(usize, JobEntry)> = entries
.into_iter()
.filter_map(|entry| {
let wd = normalize_path(entry.job.workdir.as_deref().unwrap_or(""));
path_relation_rank(&filter_root, &wd)
.map(|(kind, distance)| ((kind * 1000) + distance, entry))
})
.collect();
related.sort_by(|a, b| {
a.0.cmp(&b.0).then_with(|| {
a.1.job
.name
.to_lowercase()
.cmp(&b.1.job.name.to_lowercase())
})
});
related.into_iter().map(|(_, entry)| entry).collect()
}

fn load_job_file(path: &Path) -> Option<ScheduledJob> {
let raw = fs::read_to_string(path).ok()?;
serde_json::from_str(&raw).ok()
Expand Down Expand Up @@ -178,22 +262,90 @@ fn collect_jobs_for_scope_root(scope_root: Option<&str>) -> Result<Vec<JobEntry>
out.extend(collect_scoped_jobs(&scopes_dir));
out.extend(collect_legacy_jobs(&legacy_dir));

let filter_root = scope_root.map(|s| normalize_path(s)).unwrap_or_default();
if !filter_root.is_empty() {
out.retain(|entry| {
entry
.job
.workdir
.as_deref()
.map(|wd| normalize_path(wd) == filter_root)
.unwrap_or(false)
});
}
out = select_entries_for_scope_root(out, scope_root);

out.sort_by(|a, b| a.job.name.to_lowercase().cmp(&b.job.name.to_lowercase()));
Ok(out)
}

#[cfg(test)]
mod tests {
use super::{path_distance, path_relation_rank, select_entries_for_scope_root, JobEntry};
use crate::types::ScheduledJob;
use std::path::PathBuf;

fn job_entry(name: &str, workdir: Option<&str>) -> JobEntry {
JobEntry {
job: ScheduledJob {
slug: name.to_lowercase().replace(' ', "-"),
name: name.to_string(),
schedule: "0 9 * * *".to_string(),
prompt: None,
attach_url: None,
run: None,
source: None,
workdir: workdir.map(str::to_string),
created_at: "2026-04-07T00:00:00Z".to_string(),
updated_at: None,
last_run_at: None,
last_run_exit_code: None,
last_run_error: None,
last_run_source: None,
last_run_status: None,
scope_id: None,
timeout_seconds: None,
},
job_file: PathBuf::from(format!("/{name}.json")),
}
}

#[test]
fn path_distance_matches_exact_and_nested_paths() {
assert_eq!(path_distance("/repo", "/repo"), Some(0));
assert_eq!(path_distance("/repo", "/repo/app"), Some(1));
assert_eq!(path_distance("/repo/app", "/repo"), Some(1));
assert_eq!(path_distance("/repo", "/elsewhere"), None);
}

#[test]
fn path_relation_rank_prefers_ancestor_matches_before_descendants() {
assert_eq!(path_relation_rank("/repo/app", "/repo"), Some((0, 1)));
assert_eq!(
path_relation_rank("/repo/app", "/repo/app/nested"),
Some((1, 1))
);
}

#[test]
fn select_entries_prefers_exact_scope_matches() {
let entries = vec![
job_entry("Parent job", Some("/repo")),
job_entry("Exact job", Some("/repo/app")),
];

let selected = select_entries_for_scope_root(entries, Some("/repo/app"));
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].job.name, "Exact job");
}

#[test]
fn select_entries_falls_back_to_related_scope_matches() {
let entries = vec![
job_entry("Parent job", Some("/repo")),
job_entry("Child job", Some("/repo/app/nested")),
job_entry("Elsewhere", Some("/elsewhere")),
];

let selected = select_entries_for_scope_root(entries, Some("/repo/app"));
let names = selected
.iter()
.map(|entry| entry.job.name.as_str())
.collect::<Vec<_>>();

assert_eq!(names, vec!["Parent job", "Child job"]);
}
}

#[cfg(target_os = "macos")]
fn uninstall_job(slug: &str, scope_id: Option<&str>) -> Result<(), String> {
let Some(home) = home_dir() else {
Expand Down
36 changes: 36 additions & 0 deletions apps/server/src/scheduler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";

import type { ScheduledJob } from "./scheduler.js";
import { filterScheduledJobsForWorkdir } from "./scheduler.js";

function entry(name: string, workdir?: string) {
return {
job: {
slug: name.toLowerCase().replace(/\s+/g, "-"),
name,
schedule: "0 9 * * *",
workdir,
createdAt: "2026-04-07T00:00:00Z",
} satisfies ScheduledJob,
};
}

describe("filterScheduledJobsForWorkdir", () => {
test("prefers exact workdir matches when present", () => {
const filtered = filterScheduledJobsForWorkdir(
[entry("Parent job", "/repo"), entry("Exact job", "/repo/app")],
"/repo/app"
);

expect(filtered.map((item) => item.job.name)).toEqual(["Exact job"]);
});

test("falls back to related parent and child workdirs when exact matches are absent", () => {
const filtered = filterScheduledJobsForWorkdir(
[entry("Parent job", "/repo"), entry("Child job", "/repo/app/nested"), entry("Elsewhere", "/elsewhere")],
"/repo/app"
);

expect(filtered.map((item) => item.job.name)).toEqual(["Parent job", "Child job"]);
});
});
76 changes: 59 additions & 17 deletions apps/server/src/scheduler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readdir, rm } from "node:fs/promises";
import { spawnSync } from "node:child_process";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { isAbsolute, join, relative, resolve } from "node:path";

import { ApiError } from "./errors.js";
import { exists, readJsonFile } from "./utils.js";
Expand Down Expand Up @@ -86,6 +86,62 @@ function normalizePathForCompare(value: string): string {
return trimmed ? resolve(trimmed) : "";
}

function pathDistance(base: string, candidate: string): number | null {
if (!base || !candidate) return null;
if (base === candidate) return 0;

const baseToCandidate = relative(base, candidate);
if (baseToCandidate && !baseToCandidate.startsWith("..") && !isAbsolute(baseToCandidate)) {
return baseToCandidate.split(/[\\/]+/).filter(Boolean).length;
}

const candidateToBase = relative(candidate, base);
if (candidateToBase && !candidateToBase.startsWith("..") && !isAbsolute(candidateToBase)) {
return candidateToBase.split(/[\\/]+/).filter(Boolean).length;
}

return null;
}

function pathRelationRank(base: string, candidate: string): [kind: number, distance: number] | null {
if (!base || !candidate) return null;
if (base === candidate) return [0, 0];

const candidateToBase = relative(candidate, base);
if (candidateToBase && !candidateToBase.startsWith("..") && !isAbsolute(candidateToBase)) {
const distance = pathDistance(base, candidate);
return distance === null ? null : [0, distance];
}

const baseToCandidate = relative(base, candidate);
if (baseToCandidate && !baseToCandidate.startsWith("..") && !isAbsolute(baseToCandidate)) {
const distance = pathDistance(base, candidate);
return distance === null ? null : [1, distance];
}

return null;
}

export function filterScheduledJobsForWorkdir<T extends { job: Pick<ScheduledJob, "workdir" | "name"> }>(
entries: T[],
workdir?: string
): T[] {
const filterRoot = typeof workdir === "string" && workdir.trim() ? normalizePathForCompare(workdir) : "";
if (!filterRoot) return entries;

const exact = entries.filter((entry) => normalizePathForCompare(entry.job.workdir ?? "") === filterRoot);
if (exact.length > 0) return exact;

return entries
.map((entry) => {
const rank = pathRelationRank(filterRoot, normalizePathForCompare(entry.job.workdir ?? ""));
return rank === null ? null : { rank, entry };
})
.filter((value): value is { rank: [number, number]; entry: T } => value !== null)
.sort((a, b) => a.rank[0] - b.rank[0] || a.rank[1] - b.rank[1] || a.entry.job.name.localeCompare(b.entry.job.name))
.map(({ entry }) => entry);
}

async function loadJobFile(path: string): Promise<ScheduledJob | null> {
const job = await readJsonFile<Partial<ScheduledJob>>(path);
if (!job || typeof job !== "object") return null;
Expand Down Expand Up @@ -233,14 +289,7 @@ export async function listScheduledJobs(workdir?: string): Promise<ScheduledJob[
ensureSchedulerSupported();
const entries = await loadAllJobEntries();

const filterRoot = typeof workdir === "string" && workdir.trim() ? normalizePathForCompare(workdir) : null;
const jobs = entries
.map((entry) => entry.job)
.filter((job) => {
if (!filterRoot) return true;
if (!job.workdir) return false;
return normalizePathForCompare(job.workdir) === filterRoot;
});
const jobs = filterScheduledJobsForWorkdir(entries, workdir).map((entry) => entry.job);

jobs.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
return jobs;
Expand All @@ -261,14 +310,7 @@ export async function resolveScheduledJob(
}

const entries = await loadAllJobEntries();
const filterRoot = typeof workdir === "string" && workdir.trim() ? normalizePathForCompare(workdir) : null;
const filtered = filterRoot
? entries.filter((entry) => {
const wd = entry.job.workdir;
if (!wd) return false;
return normalizePathForCompare(wd) === filterRoot;
})
: entries;
const filtered = filterScheduledJobsForWorkdir(entries, workdir);

const found = findJobEntryByName(filtered, trimmed);
if (!found) {
Expand Down
Loading
Loading