diff --git a/apps/desktop/src-tauri/src/commands/scheduler.rs b/apps/desktop/src-tauri/src/commands/scheduler.rs index ad01b5cc5..95600d71a 100644 --- a/apps/desktop/src-tauri/src/commands/scheduler.rs +++ b/apps/desktop/src-tauri/src/commands/scheduler.rs @@ -45,6 +45,90 @@ fn normalize_path(input: &str) -> String { trimmed.to_string() } +fn path_distance(base: &str, candidate: &str) -> Option { + 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, + scope_root: Option<&str>, +) -> Vec { + let filter_root = scope_root.map(normalize_path).unwrap_or_default(); + if filter_root.is_empty() { + return entries; + } + + let exact: Vec = 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 { let raw = fs::read_to_string(path).ok()?; serde_json::from_str(&raw).ok() @@ -178,22 +262,90 @@ fn collect_jobs_for_scope_root(scope_root: Option<&str>) -> Result 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::>(); + + 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 { diff --git a/apps/server/src/scheduler.test.ts b/apps/server/src/scheduler.test.ts new file mode 100644 index 000000000..47eb5c99e --- /dev/null +++ b/apps/server/src/scheduler.test.ts @@ -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"]); + }); +}); diff --git a/apps/server/src/scheduler.ts b/apps/server/src/scheduler.ts index 14720eb42..0cd7c9d14 100644 --- a/apps/server/src/scheduler.ts +++ b/apps/server/src/scheduler.ts @@ -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"; @@ -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 }>( + 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 { const job = await readJsonFile>(path); if (!job || typeof job !== "object") return null; @@ -233,14 +289,7 @@ export async function listScheduledJobs(workdir?: string): Promise 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; @@ -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) { diff --git a/pr/1391/automation-visibility-after.html b/pr/1391/automation-visibility-after.html new file mode 100644 index 000000000..c2f7c17fc --- /dev/null +++ b/pr/1391/automation-visibility-after.html @@ -0,0 +1,333 @@ + + + + + + Automation Visibility After + + + +
+
+
+ After +

Related parent and child workspace roots keep automations visible.

+

+ The same nested workspace now falls back to the nearest related automation workdir, so users can still manage real, + already-running jobs instead of seeing an empty state. +

+
+ +
+ +
+
+ Selected OpenWork workspace root +
/Users/jan/Programming/the-cloud-factory/_repos/openwork
+
+
+ Recovered automation workdir +
/Users/jan/Programming/the-cloud-factory
+
+
+ +
+
+
+
Automations
+ 3 visible jobs +
+
+
+
+
Daily docs improvement:
+ success +
+

Recurring docs maintenance automation stored at the parent workspace root.

+
+ daily at 7:42 PM + source: local scheduler + workdir: the-cloud-factory +
+
+ +
+
+
Daily socratic brief V2
+ success +
+

Weekday morning coaching automation now remains visible from the nested OpenWork repo.

+
+ weekdays at 7:04 AM + source: local scheduler + workdir: the-cloud-factory +
+
+ +
+
+
DAILY SOCRATRIC BRIEF
+ success +
+

The older weekday brief is still visible too, so duplicate or migrated jobs are easier to clean up.

+
+ weekdays at 7:00 AM + source: local scheduler + workdir: the-cloud-factory +
+
+
+
+ +
+
+
Why this is better
+ No more false empty state +
+
+
+ Users keep context +

The workspace can narrow from the repo root to a nested folder without losing visibility into existing automations.

+
+
+ Management actions still work +

The same related-workdir fallback is used in server and desktop resolution paths, so listing and delete lookups stay aligned.

+
+
+ The behavior is predictable +

Exact matches still win; the fallback only activates when the old behavior would have shown nothing.

+
+
Source data: the same 3 local scheduler jobs on this machine, rendered for the nested `_repos/openwork` workspace.
+
+
+
+
+ + diff --git a/pr/1391/automation-visibility-after.png b/pr/1391/automation-visibility-after.png new file mode 100644 index 000000000..073d59cb2 Binary files /dev/null and b/pr/1391/automation-visibility-after.png differ diff --git a/pr/1391/automation-visibility-before.html b/pr/1391/automation-visibility-before.html new file mode 100644 index 000000000..0c8641362 --- /dev/null +++ b/pr/1391/automation-visibility-before.html @@ -0,0 +1,255 @@ + + + + + + Automation Visibility Before + + + +
+
+
+ Before +

Nested OpenWork workspaces hide working automations.

+

+ This reproduction uses the real local scheduler jobs on this machine. The selected workspace is nested under the + job workdir, so the old exact-only filter returns an empty Automations view. +

+
+ +
+ +
+
+ Selected OpenWork workspace root +
/Users/jan/Programming/the-cloud-factory/_repos/openwork
+
+
+ Actual saved automation workdir +
/Users/jan/Programming/the-cloud-factory
+
+
+ +
+
+
+
Automations
+ 0 visible jobs +
+
+

No automations yet.

+

+ The automations are still scheduled and still running, but the UI hides them because the workspace root is nested + under the stored job root. +

+
+
+ +
+
+
Why it feels broken
+ User-visible bug +
+
+
+ The automations are real +

These three jobs exist locally and report lastRunStatus=success.

+
+
+ The workspace root moved deeper +

Opening a narrower workspace like _repos/openwork makes exact matching fail.

+
+
+ The view looks empty +

Users are nudged toward reinstalling or recreating automations even though nothing is wrong with scheduling.

+
+
Source data: `daily-docs-improvement`, `daily-socratic-brief-v2`, `daily-socratric-brief`.
+
+
+
+
+ + diff --git a/pr/1391/automation-visibility-before.png b/pr/1391/automation-visibility-before.png new file mode 100644 index 000000000..c3aafd3b7 Binary files /dev/null and b/pr/1391/automation-visibility-before.png differ