From 7f09807938384615651c3e56415e6eecd8bf0688 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sun, 8 Nov 2026 18:58:53 +0100 Subject: [PATCH 1/4] (schedules): add the Schedules tab and move schedule files out of Agent Files Each project's schedule files are listed with their next run, last runs and state; Run now, the on/off toggle and Open live there, and re-enabling a catch-up schedule skips the minutes missed while it was off. --- .ai/contexts/ipc-bridge.md | 15 +- .ai/contexts/schedule-runner.md | 57 +++++- .ai/contexts/session-state.md | 2 +- CHANGELOG.md | 3 + archived-projects.js | 12 +- docs/README.md | 2 +- docs/automation.md | 23 ++- docs/memory-workfiles.md | 6 +- docs/product/BUILDING-BLOCKS.md | 2 +- docs/product/CONCEPT-MAP.md | 3 +- docs/product/GLOSSARY.md | 2 +- docs/product/UI-PATTERNS.md | 4 +- eslint.config.js | 6 + file-lists.js | 2 + main.js | 6 + preload.js | 1 + public/app.js | 38 ++++ public/index.html | 3 + public/memory-workfiles-view.js | 56 +----- public/schedules-view.js | 207 ++++++++++++++++++++ public/sidebar.js | 20 +- public/style.css | 70 ++----- src/main/schedules/schedule-runner.js | 32 ++- test/dom-archive-schedules-tab.test.js | 38 ++++ test/dom-folder-archive-undo.test.js | 9 +- test/dom-memory-workfiles-run-now.test.js | 71 ------- test/dom-project-archive-folder.test.js | 6 +- test/dom-row-action-errors.test.js | 6 +- test/dom-schedule-run-exit.test.js | 8 +- test/dom-schedules-tab.test.js | 216 +++++++++++++++++++++ test/file-lists-schedules.test.js | 35 ++++ test/keyboard-focus-css.test.js | 2 +- test/projects-worker-conflicts-719.test.js | 12 +- test/schedule-reenable-skip.test.js | 118 +++++++++++ test/search-stale-reply.test.js | 37 +++- test/theme-controls.test.js | 2 +- 36 files changed, 897 insertions(+), 235 deletions(-) create mode 100644 public/schedules-view.js create mode 100644 test/dom-archive-schedules-tab.test.js delete mode 100644 test/dom-memory-workfiles-run-now.test.js create mode 100644 test/dom-schedules-tab.test.js create mode 100644 test/file-lists-schedules.test.js create mode 100644 test/schedule-reenable-skip.test.js diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index 535694cab..e7dace4b4 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -4,10 +4,11 @@ This file is the **canonical inventory** of the IPC surface. When you add a new IPC, you change three places — main handler, preload bridge, renderer caller — and every IPC name should appear here. -## Schedules (#792, slice A1) +## Schedules (#792, slices A1 and A2) | IPC | Args | Returns | Preload | | --- | --- | --- | --- | +| `set-schedule-enabled` | `filePath, enabled` (boolean) | `{ ok }` or `{ ok: false, error }`; exact current listing path, linked-file and front-matter guards; re-enable skips missed catch-up before writing | `api.setScheduleEnabled(filePath, enabled)` | | `list-schedules` | none | `{ now, catchUpActive, projects: [{ projectPath, shortName, archived, sandboxed, known, schedules: [{ filePath, file, name, slug, cron, cronText, enabled, catchUp, cli, prompt, linked, problems, nextRunMs, catchUpDueMs, runs: [{ sessionId, created, messageCount, replied, running, result }] }] }] }` | `api.listSchedules()` | Main registers the handler and calls the Electron-free @@ -22,8 +23,8 @@ newest first, capped at 20 per schedule. `replied` is an assistant-record check child map. `result` is always `null` until the separate run-result slice lands. `run-schedule-now`, `read-memory`, `save-memory` and `schedule-run-exit` keep -their existing contracts. A1 exposes no schedule mutation channel or renderer -consumer. See [schedule-runner.md](schedule-runner.md), "Schedule listing +their existing contracts. A2 adds the Schedules renderer and the guarded +enable operation; creation and deletion are later slices. See [schedule-runner.md](schedule-runner.md), "Schedule listing backend", for field parsing, refusal rules, time-zone semantics and tests. ## Main-process error backstop (#896) @@ -335,6 +336,7 @@ uninitialised submodule refuses preview/deletion before force can hide its files | IPC | Returns | |---|---| | `get-memories` | `{global, projects}` | +| `set-schedule-enabled` | `listScheduleFiles(await scheduleProjects().list())` exact path equality, boolean argument, unlinked file and readable front matter; skip missed catch-up before enabling | current registry listing + disk-resolved link guard | | `read-memory` / `save-memory` | content / `{ok}` | | `get-work-files` | `{projects: WorkFilesProject[]}` — **dedupes by projectPath** since PR #15. Walks `/.work-files/` recursively, capped at 200 files per project. | | `read-work-file` / `delete-work-file` | content (with `.work-files/` path guard) / `{ok}` | @@ -1142,3 +1144,10 @@ raw prompts, names, CLI strings, paths, sockets, keys or liveVersion. Lists are capped at 128 with complete counts/truncation flags; oversized legitimate records compact before transport and remain below 64 KiB after main's timestamp. Invalid records or transport failures cannot break publication. + +Schedules reuse `run-schedule-now`, `read-memory`, `save-memory` and +`schedule-run-exit` unchanged. Delete/create IPCs are not part of A2. + +IPC path-guard inventory addition: `set-schedule-enabled` matches the supplied +path exactly against `listScheduleFiles(await scheduleProjects().list())` at +call time and refuses linked files and unread front matter before writing. diff --git a/.ai/contexts/schedule-runner.md b/.ai/contexts/schedule-runner.md index d4dec4f1d..ca3f32e40 100644 --- a/.ai/contexts/schedule-runner.md +++ b/.ai/contexts/schedule-runner.md @@ -81,7 +81,7 @@ No existing schedule event could carry asynchronous completion: `run-schedule-no is a request/reply that has already acknowledged spawn and is never invoked by cron. The new event stays in the existing schedule IPC module and is exposed as `onScheduleRunExit` in preload. `public/memory-workfiles-view.js` subscribes once -at script load, independently of whether Agent Files is open, and shows a +at script load, independently of whether the Schedules tab is open, and shows a persistent B8 error with the schedule name, code (or signal) and recovery hint. Manual notices use `ScheduleRun:` and say "Manual run of schedule". Cron notices use `ScheduleRun::`: each failure replaces the @@ -313,7 +313,7 @@ Scheduled runs from before v0.0.90 are listed one per row. They have no `schedul - **Sidebar run groups use runner provenance.** `createScheduleSession` seeds `scheduleSlug` alongside the CLI `slug` on its first user record, for regular, catch-up and run-now launches. Full and bounded header readers carry this marker through SQLite and the renderer payload; `public/sidebar.js` groups by `scheduleSlug`, names the header with it, and shows the header even for one visible run. A typed `Scheduled Task:` prompt never establishes provenance, and a changed or absent CLI slug does not change the schedule identity. Ordinary conversations each keep a separate row, even when CLI slugs match. Schedule DOM IDs encode the host alias, project path and raw schedule slug together without losing punctuation. Archived filtering stays upstream, and the grid lists individual sessions. - **Existing transcripts have no safe schedule backfill.** The runner previously wrote no provenance marker, including before it added the prompt prefix. Those runs remain separate rows even after re-indexing; neither a prompt prefix nor a matching schedule filename proves their origin. Schema reconciliation adds a nullable `scheduleSlug` column without clearing old rows. Previously indexed rows start with a null marker, and a later full read or changed-file header refresh copies a marker if their transcript actually carries one. Deleted schedule files do not remove marked historical groups; renaming a file without a stable frontmatter slug gives future runs a new identity. - **Legacy expansion migrates once without crossing schedule scopes.** An unscoped `slug-` key cannot identify its original project, host or raw punctuation. Inheritance is allowed only when all known projects (including those filtered out of the current render) identify exactly one matching host/project/raw-slug group. Ambiguous keys leave the groups collapsed. `migratedScheduleSlugs` records both visited scoped IDs and consumed legacy keys, so an explicit collapse survives and a schedule discovered later cannot reuse another scope's legacy expansion. Subsequent expand/collapse choices use independent scoped keys; already persisted scoped expansions are retained. -- **A schedule file may be a symlink.** `scanSchedules` lists names and reads through them, so a `schedule-*.md` linked in from a versioned dotfiles repo fires on cron like any other. The two places that *don't* go through that scanner had to be taught the same thing: the brain tab's listing (`scan-md-files.js` — accepted entries on `dirent.isFile()`, false for a symlink, so a linked-in schedule was invisible in the UI while still firing weekly) and the run-now guard (`run-schedule-now-target.js` — checked the `.claude/commands` shape against the resolved target instead of the listed path). If you add a third reader of these files, resolve the link rather than the dirent. +- **A schedule file may be a symlink.** `scanSchedules` lists names and reads through them, so a `schedule-*.md` linked in from a versioned dotfiles repo fires on cron like any other. The two places that *don't* go through that scanner had to be taught the same thing: Agent Files' former listing (`scan-md-files.js` — accepted entries on `dirent.isFile()`, false for a symlink, so a linked-in schedule was invisible in the UI while still firing weekly) and the run-now guard (`run-schedule-now-target.js` — checked the `.claude/commands` shape against the resolved target instead of the listed path). If you add a third reader of these files, resolve the link rather than the dirent. - **`run-schedule-now` resolves the file and the cwd separately, and they may differ.** `resolveRunNowTarget` takes the schedule's bytes from the link's target and roots the spawn at the project whose `.claude/commands` lists it; each is disk-resolved and allowlisted in its own right, neither is derived from the other. With `projA/.claude/commands/schedule-x.md -> projB/.claude/commands/schedule-x.md` and both projects known, the run executes projB's content with `cwd = projA`. That is the semantic, not an oversight — `test/run-schedule-now-target.test.js` pins it so it is not "corrected" in either direction by accident. - **Hand-rolled cron parser** in `cronFieldMatches` / `cronMatches`. Supports `*`, comma lists (`1,2,3`), ranges (`1-5`), steps (`*/5`). No support for `@daily`/`@hourly` aliases. No DST awareness — `new Date()` is local-time. - **No persistence across app close, unless opted in** — the scheduler runs in-process. If Switchboard isn't running at 9am, the 9am schedule doesn't fire, and a suspended machine loses the minutes it sleeps through. `catch-up: true` changes that for one schedule; without it the behaviour is the original one, because some tasks are useless late. @@ -346,8 +346,8 @@ only be detected through the CLI's own descriptors, with their existing limits. ## If you change this, also check - `public/dialogs.js` (`launchScheduleCreator`) — UI entry point for the schedule creator flow -- `public/memory-workfiles-view.js` brain tab — lists existing `schedule-*.md` files, surfaces the "run now" play button -- `scan-md-files.js` — what that brain tab list is actually built from (`get-memories` in `main.js`); it decides whether a schedule file is visible at all, and takes the memory allowlist so the list carries nothing the readers behind it would refuse to open +- `public/schedules-view.js` — lists schedules, runs them, toggles enabled and opens their file; `memory-workfiles-view.js` keeps the independent exit-notice subscription +- `file-lists.js` — excludes runner schedule names from project commands before `listed()`, removing them from Agent Files and its FTS input; other scopes are unchanged - `public/sidebar.js` — `.project-schedule-btn` clock icon wiring per project - `archived-projects.js` (`archivePlanForGroups`) and `test/schedule-disable.test.js` — if you change `parseFrontmatter`, the rewrite in `setScheduleEnabled` follows the same state machine - `schedule-ipc.js` `SCHEDULE_CREATOR_TEMPLATE` — if you change the schedule file format, update the template's instructions @@ -449,8 +449,8 @@ becomes a problem without creating a transcript or launching a process. `buildScheduleList` withholds next-run and catch-up timestamps when file or launch validation reports a problem. The existing shared-slug diagnostic is -the exception: it does not prevent the runner from launching (slice A2 will -revisit its presentation). Archived projects, disabled schedules and schedules +the exception: it does not prevent the runner from launching. A2 shows the +shared-slug diagnostic without calling the schedule unrunnable. Archived projects, disabled schedules and schedules without a prompt have no catch-up timestamp. A state-read error remains unknown, rather than an empty catch-up map; an unreadable transcript likewise keeps `replied: null` rather than claiming no reply. @@ -467,3 +467,48 @@ Numeric clock fields are formatted by value, so `007 09 * * *` says `daily at 9: Tests: `test/schedule-list.test.js` (real disposable schedule, state and transcript files, main handler extracted into a VM with filename attribution; counts PROJECTS_DIR scans), `test/schedule-next-run.test.js` (descriptions and DST). + + +## Schedules tab (#792, slice A2) + +`public/schedules-view.js` consumes `list-schedules` on open, after each action, +on projects-changed and on minute boundaries while visible. The clock tab follows +Agent Files; Work Files is preserved. Search is hidden. Rows show cron, local +next-run and relative time, disabled/archive/validation reasons, shared-slug +diagnostics, catch-up status and the newest 20 runs from the main-side list. +Run now retains `RunSchedule:` and the persistent error contract; +exit notices still subscribe once from memory-workfiles-view.js. Open checks a +nonempty read-memory response before opening memoryPanel. History resolves a +fresh indexed session and goes through openSession, preserving resume guards. + +`set-schedule-enabled` requires a boolean and exact equality with a path from +listScheduleFiles over the registry at call time. Linked/unread front matter is +refused. Before enabling a disabled catch-up schedule, skipMissedRunsOf claims +the prior-minute baseline, then setScheduleEnabled atomically rewrites the file. +This ordering also applies in isolated instances. Archive offers and Undo receive +the same stateDir via archiveDeps.reenable and reenableScheduleFiles; the writer +continues to perform file effects outside SQL. Skip failure prevents enabling. + +Archive choices keep their default and file lists, but display counts and a +Schedules tab reference. Undo uses a count; the re-enable offer adds Show in +Schedules for its project. Failure text continues to name failed schedules. + +## Run results + +Slice A2 keeps result null. Running uses the live map; otherwise the renderer +says No result recorded and gives the assistant/seed/started/unknown transcript +hint. It never infers Exit 0. Persistent outcomes, deletion, the creation form +and creator-template migration are later slices. + +History navigation maps the listed run IDs to local indexed project paths, so a +registry alias and its resolved transcript folder expand the same visible group. +Opening a run selects a local indexed row before openSession; remote copies of +the same session ID do not participate in this local Schedules surface. + +Open checks read-memory before changing viewers. On a valid read it uses +hideAllViewers before memoryPanel, so a previous Work Files or transcript viewer +does not cover the schedule editor. The existing save and watch contract stays. + +History navigation resolves physical run IDs through `rowSessionIds` in local +merged rows. It passes the full row to `openSession`, preserving the existing +continuation and live-elsewhere resume guards. diff --git a/.ai/contexts/session-state.md b/.ai/contexts/session-state.md index ab9c47f09..a38f7a5d1 100644 --- a/.ai/contexts/session-state.md +++ b/.ai/contexts/session-state.md @@ -266,7 +266,7 @@ recovery step. The headline is Archived, but not stopped; Unarchive clears only its member from that host's notice. A single-row Archive persistence failure after a successful Stop explicitly says the process stopped but the row is not archived. Folder partial success uses Archive folder partly done, refers to -schedule-*.md in Agent Files, and names Settings → Archived projects for restoring +schedules in the Schedules tab, and names Settings → Archived projects for restoring the folder. Folder descriptions include the project path once. Evidence: `test/dom-row-action-errors.test.js`, diff --git a/CHANGELOG.md b/CHANGELOG.md index b2dd31cbd..50f6b14f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased +### New +- A Schedules tab lists scheduled tasks by project with their schedule in plain words, next run, enabled state and recent runs; run, turn on or off, and open a schedule there. Schedule files no longer appear in Agent Files. (#792) + ### Fixed - A conversation's message count in the sidebar now grows as the conversation does, including remote conversations without recounting their entire transcript on each fetch. Conversations sharing copied history keep duplicate messages out of their counts. (#1017) diff --git a/archived-projects.js b/archived-projects.js index e5a0a0d8b..30f6b28c1 100644 --- a/archived-projects.js +++ b/archived-projects.js @@ -5,7 +5,7 @@ const path = require('path'); const { joinFolderKey, parseFolderKey } = require('./src/main/remote/remote-hosts'); const { encodeProjectPath } = require('./encode-project-path'); const { worktreeParentPath, isHiddenRepositoryWorktree } = require('./public/worktree-nesting'); -const { scheduleFileUnlinked, setScheduleEnabled, parseFrontmatter } = require('./src/main/schedules/schedule-runner'); +const { scheduleFileUnlinked, setScheduleEnabled, parseFrontmatter, skipMissedRunsOf } = require('./src/main/schedules/schedule-runner'); const SETTING_KEY = 'archivedProjects'; const OFFERS_KEY = 'scheduleReenableOffers'; @@ -144,7 +144,7 @@ function clearArchivedEntry(getSetting, setSetting, alias, projectPath) { * Turn back on the offered schedule files that still read `enabled: false`. * A vanished file, or one whose `enabled` was changed since, is left as it is. */ -function reenableScheduleFiles(filePaths, projectRoot) { +function reenableScheduleFiles(filePaths, projectRoot, { stateDir } = {}) { const enabled = []; const failed = []; for (const filePath of filePaths || []) { @@ -152,6 +152,14 @@ function reenableScheduleFiles(filePaths, projectRoot) { try { meta = parseFrontmatter(fs.readFileSync(filePath, 'utf8')).meta; } catch { continue; } if (meta.enabled !== 'false') continue; const name = meta.name || path.basename(filePath); + if (stateDir && scheduleFileUnlinked(projectRoot, filePath)) { + try { + skipMissedRunsOf({ filePath, name, catchUp: /^(["']?)true\1$/i.test(meta['catch-up'] || '') }, stateDir); + } catch (error) { + failed.push({ name, filePath, error: error.message }); + continue; + } + } const res = setScheduleEnabled(filePath, true, { projectRoot }); if (res.ok) enabled.push(name); else failed.push({ name, filePath, error: res.error }); diff --git a/docs/README.md b/docs/README.md index 566bdcca0..179e23c15 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,7 +21,7 @@ links are in the [README](../README.md#download). | [IDE emulation](ide-emulation.md) | Switchboard as Claude's IDE: file opens and diffs in a side panel | | [Changes view](changes-view.md) | A session's git status and diffs, with an editor for local sessions | | [Touched files](touched-files.md) | The files a session's file tools touched, including outside any repository | -| [Agent Files and Work Files](memory-workfiles.md) | The two file tabs: `CLAUDE.md` and memory files, schedules, `.work-files/` | +| [Agent Files and Work Files](memory-workfiles.md) | Agent Files for `CLAUDE.md` and memory, Work Files for `.work-files/`; schedules have their own tab | | [Stats](activity-stats.md) | Heatmap, totals, per-model tokens, rate limits | | [Session restore](session-restore.md) | Reopening the open sessions at the next launch | | [Remote hosts](remote-hosts.md) | Observing, attaching to and stopping sessions on SSH hosts | diff --git a/docs/automation.md b/docs/automation.md index 031afe0a8..53e946f81 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -180,8 +180,27 @@ session. ### Run now -The **Agent Files** tab lists the schedule files with a clock icon and a **Run -now** button, which starts a run at once. **Run now** ignores `cron` and +The **Schedules** tab (clock icon, after Agent Files) lists scheduled tasks by +project, including disabled tasks and archived projects. Each task shows its +schedule in plain words, local next-run time, enabled state and up to 20 recent +runs. **Run now** starts a run at once. **Turn on** and **Turn off** edit its +front matter; turning it back on skips runs missed while it was off, including +after Archive and Undo. **Open** edits the schedule file. Linked files can be +opened and run, but must be enabled or disabled at their source. + +The tab refreshes on open, after actions, on project changes and every minute +while visible. No search is offered here. Invalid files show their problems; +unknown projects cannot be run or opened. Archived projects stay suspended even +if their schedules are enabled. The Work Files tab remains available. + +A running task says **Running**. Other runs say **No result recorded** with a +transcript hint, including **replied; exit code unknown** when a reply exists. +Reply text does not prove a successful exit. Results are not yet stored. Use +**Show history in Sessions** for older runs; runs predating v0.0.90 have no +schedule marker and cannot be attributed. Creation still uses the project +header’s **Create scheduled task** command. + +**Run now** ignores `cron` and `enabled`, and does not wait for a run of the same schedule that is still going. The button shows a check mark (tooltip **Launched!**) only after the configured shell process starts. This acknowledges the shell launch, not that Claude has started diff --git a/docs/memory-workfiles.md b/docs/memory-workfiles.md index a5474051d..0b832344d 100644 --- a/docs/memory-workfiles.md +++ b/docs/memory-workfiles.md @@ -21,8 +21,10 @@ they live: No directory is scanned recursively. Archived and deleted projects are left out. -Files named `schedule-*.md` carry a clock icon and a **Run now** button — see -[Automation](automation.md#schedules). +Project command files named `schedule-*.md` appear in the **Schedules** tab +instead of Agent Files or its search results. Run, turn them on or off, and +open them there — see [Automation](automation.md#schedules). The filename +match is case-sensitive; other Markdown files remain here. The editor offers: diff --git a/docs/product/BUILDING-BLOCKS.md b/docs/product/BUILDING-BLOCKS.md index b2e26dc24..0da32c848 100644 --- a/docs/product/BUILDING-BLOCKS.md +++ b/docs/product/BUILDING-BLOCKS.md @@ -249,7 +249,7 @@ focus) with a form body. batches merge by member id, updating repeated members. Clearing a member's operation id removes its direct notice and its membership in every group, retaining other members and removing empty groups. Folder partial success says Archive folder partly done - and names Agent Files for schedule-*.md. Waiting for indexing and a handoff + and points to the Schedules tab for scheduled tasks. Waiting for indexing and a handoff in progress use informational status, without a failure headline; only automatic indexing retries enter the pending group, which indexing completion clears. Holds requiring a sidebar choice remain persistent errors. diff --git a/docs/product/CONCEPT-MAP.md b/docs/product/CONCEPT-MAP.md index 82a9c3edb..37fa2c30d 100644 --- a/docs/product/CONCEPT-MAP.md +++ b/docs/product/CONCEPT-MAP.md @@ -394,8 +394,7 @@ working-set restore…"). **States**: enabled / disabled (`enabled:` front matter); no per-run state of its own (`docs/automation.md:41-55`). -**Actions**: **Create scheduled task** (project header); **Run now** (Agent -Files tab, `docs/automation.md:173-179`); disable via Archive folder; +**Actions**: **Create scheduled task** (project header); **Run now**, enable/disable and Open (Schedules tab, `docs/automation.md`); disable via Archive folder; **Turn back on** notice. A run: row in a slug group; open → info-only dialog (§11); no Stop that works (m7, S for the "Failed" flash). diff --git a/docs/product/GLOSSARY.md b/docs/product/GLOSSARY.md index c172304d0..7d7dd99f2 100644 --- a/docs/product/GLOSSARY.md +++ b/docs/product/GLOSSARY.md @@ -546,7 +546,7 @@ concept. Each entry gives: `docs/automation.md:14-180`. - **Today**: "Create scheduled task" (`public/sidebar.js:903`), "schedule" in docs and the archive dialog ("Disable the N enabled schedules", - `public/sidebar.js:1184`), **Run now** in Agent Files. A run is a session + `public/sidebar.js:1184`), **Run now**, enable/disable and Open in the Schedules tab. A run is a session row in a slug group; it cannot be stopped or attached (m7). ### Trigger diff --git a/docs/product/UI-PATTERNS.md b/docs/product/UI-PATTERNS.md index 2dc1ed330..1d4bcf9bd 100644 --- a/docs/product/UI-PATTERNS.md +++ b/docs/product/UI-PATTERNS.md @@ -284,7 +284,7 @@ stopped` notice now uses this persistent error variant, with its wording unchanged and no console-only duplicate. Stop, Detach, Delete and Archive failures use B8 and clear only the successful -action's own notice. Run now reports success only after an `ok` reply; a refusal, +action's own notice. Run now in the Schedules tab reports success only after an `ok` reply; a refusal, rejected IPC or absent result restores the button and leaves a B8 error naming the schedule, reason and how to retry. Restore's pending indexing notice clears after its retry runs; an early return moves held members into persistent @@ -303,7 +303,7 @@ records the other feedback mechanisms from the measured baseline. | Mechanism | Where | Notes | |---|---|---| | B8 persistent action failure | Stop/Detach, Delete, Archive and restore (`app.js`, `sidebar.js`) | reason and recovery stay visible after pointer leave; successful actions clear their own member | -| B8 persistent Run now failure | `buildMemoryItem` (`memory-workfiles-view.js`) | restores the play button; no success check mark or Launched! on failure | +| B8 persistent Run now failure | `runScheduleFromTab` (`schedules-view.js`) | restores the play button; no success check mark or Launched! on failure | | Button flash success | `flashButtonText` (`viewer-toolbar.js:28-39`): `Saved!` (`file-panel.js:446, 2089`, `viewer-toolbar.js:266`), `✓` / `Copied!` (`viewer-toolbar.js:271, 276`) | on `.icon-btn` it always flashes **green**, whatever the text; today no `Failed` caller is an `.icon-btn` (verified from the caller list) | | `window.alert` | 12 sites: `sidebar.js:1158, 1225, 1227, 1230, 1305, 1349`; `viewer-panel.js:290, 293`; `file-panel.js:448, 451`; `activity-trace-panel.js:70, 133` | blocking, OS-styled | | Inline error, persistent | Agents (`agents-view.js:349`, `.agents-detail-error`); dialog error lines (Add project `dialogs.js:647-665`, New agent `dialogs.js:803-875`); worktree dialog `.dwt-error` (`sidebar.js:1985, 1999`) | stays until the next attempt | diff --git a/eslint.config.js b/eslint.config.js index e4f6c5688..b3c7c248c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,6 +24,12 @@ const ACTIVITY_CLASS_MESSAGE = 'Only public/session-activity-dom.js may write .c // public/sidebar.js (and equivalents). Kept readonly so reassignment // from another file gets flagged. const rendererCrossFileGlobals = { + setSchedulesVisible: 'readonly', + loadSchedules: 'readonly', + showSchedules: 'readonly', + openScheduleMemory: 'readonly', + openScheduleRun: 'readonly', + showScheduleSessions: 'readonly', validateSessionStopPlan: 'readonly', executeSessionStopTargets: 'readonly', confirmAndStopProjectFolder: 'readonly', diff --git a/file-lists.js b/file-lists.js index 289853d8a..8a4563c63 100644 --- a/file-lists.js +++ b/file-lists.js @@ -6,6 +6,7 @@ const path = require('node:path'); const { deriveProjectPath } = require('./derive-project-path'); const { isAllowedMemoryPath: _isAllowedMemoryPath } = require('./src/main/shared/ipc-path-validator'); const { scanMdFiles: scanMarkdown, acceptMdFile: acceptMarkdown } = require('./scan-md-files'); +const { isRunnerScheduleFileName } = require('./src/main/schedules/schedule-runner'); function getKnownProjectPaths(options) { const roots = new Set(options.activeProjectPaths || []); @@ -128,6 +129,7 @@ function listMemories(options, readContent) { const commandsDir = path.join(dotClaudeDir, 'commands'); const commandFiles = scanMdFiles(commandsDir, isAllowed); for (const f of commandFiles) { + if (isRunnerScheduleFileName(f.filename)) continue; if (!seenPaths.has(f.filePath)) { files.push({ ...listed(f), displayPath: shortName + '/.claude/commands/', source: 'project' }); seenPaths.add(f.filePath); diff --git a/main.js b/main.js index 9bb0fa021..fd93b77e5 100644 --- a/main.js +++ b/main.js @@ -989,6 +989,7 @@ async function projectArchivePlan(groups) { function archiveDeps() { return { + reenable: (files, projectRoot) => require('./archived-projects').reenableScheduleFiles(files, projectRoot, { stateDir: path.join(path.dirname(DB_PATH), 'schedule-state') }), isInitialScanComplete, seedProjects: () => initialScheduleProjects(() => listSettingKeys('project:')), scan: projectPath => scanSchedules(log, [projectPath]), @@ -1604,6 +1605,11 @@ const fileListsClient = { shutdown: () => Promise.all([...fileListClients.values()].map(client => client.shutdown())), }; +ipcMain.handle('set-schedule-enabled', (_event, filePath, enabled) => + require('./src/main/schedules/schedule-runner').setListedScheduleEnabled(filePath, enabled, { + scheduleProjects, stateDir: path.join(path.dirname(DB_PATH), 'schedule-state'), + })); + ipcMain.handle('list-schedules', () => { const knownProjectPaths = [...getKnownProjectPaths()]; return buildScheduleList({ diff --git a/preload.js b/preload.js index 4e71070b9..a9de2cc81 100644 --- a/preload.js +++ b/preload.js @@ -7,6 +7,7 @@ contextBridge.exposeInMainWorld('api', { getUsage: () => ipcRenderer.invoke('get-usage'), getMemories: () => ipcRenderer.invoke('get-memories'), listSchedules: () => ipcRenderer.invoke('list-schedules'), + setScheduleEnabled: (filePath, enabled) => ipcRenderer.invoke('set-schedule-enabled', filePath, enabled), readMemory: (filePath) => ipcRenderer.invoke('read-memory', filePath), saveMemory: (filePath, content, expected) => ipcRenderer.invoke('save-memory', filePath, content, expected), getWorkFiles: () => ipcRenderer.invoke('get-work-files'), diff --git a/public/app.js b/public/app.js index 5cc58313a..ea7725085 100644 --- a/public/app.js +++ b/public/app.js @@ -2712,6 +2712,8 @@ document.querySelectorAll('.sidebar-tab').forEach(tab => { if (tabName === activeTab) return; searchGeneration++; activeTab = tabName; + document.getElementById('schedules-content').style.display = tabName === 'schedules' ? '' : 'none'; + setSchedulesVisible(tabName === 'schedules'); document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tabName)); // Clear search on tab switch @@ -2976,6 +2978,7 @@ loadStartupProjects(); let projectsChangedTimer = null; let projectsChangedWhileAway = false; window.api.onProjectsChanged(() => { + if (activeTab === 'schedules') loadSchedules(); // Debounce to avoid rapid re-renders during bulk changes if (projectsChangedTimer) clearTimeout(projectsChangedTimer); if (activeTab !== 'sessions') { @@ -3190,3 +3193,38 @@ quotaGaugeEl.addEventListener('click', () => { // --- Initialize file panel (MCP bridge UI) --- if (typeof initFilePanel === 'function') initFilePanel(); + +async function showScheduleSessions(projectPath, slug, sessionIds = []) { + document.querySelector('.sidebar-tab[data-tab="sessions"]')?.click(); + await loadProjects(); + const ids = new Set(sessionIds); + const targets = cachedAllProjects.filter(p => !p.remoteAlias + && (p.projectPath === projectPath || p.sessions.some(s => rowSessionIds(s).some(id => ids.has(id))))); + const paths = new Set([...targets.map(p => p.projectPath), projectPath]); + const expanded = getExpandedSlugs(); + let scrolled = false; + for (const target of paths) { + const id = 'schedule-' + encodeURIComponent(JSON.stringify(['', target, slug])); + expanded.add(id); + const group = document.getElementById(id); + if (!group) continue; + group.classList.remove('collapsed'); + group.closest('.project-group')?.classList.remove('collapsed'); + if (!scrolled) group.scrollIntoView?.({ block: 'start' }); + scrolled = true; + } + sessionStorage.setItem('expandedSlugs', JSON.stringify([...expanded])); +} + +async function openScheduleRun(project, schedule, run) { + try { + await showScheduleSessions(project.projectPath, schedule.slug, [run.sessionId]); + const session = cachedAllProjects.filter(p => !p.remoteAlias).flatMap(p => p.sessions).find(s => rowSessionIds(s).includes(run.sessionId)); + if (!session) throw new Error('The recorded session is no longer in the index'); + await openSession(session); + } catch (error) { + showAppNotice({ id: 'OpenScheduleRun:' + run.sessionId, kind: 'error', message: 'Could not open the scheduled run: ' + (error?.message || error) + '. Retry from Sessions.' }); + } +} + +document.querySelector('.sidebar-tab[data-tab="schedules"]').innerHTML = ICONS.schedule(18); diff --git a/public/index.html b/public/index.html index 280134886..02cd6c70b 100644 --- a/public/index.html +++ b/public/index.html @@ -16,6 +16,7 @@ + @@ -45,6 +46,7 @@ + @@ -194,6 +196,7 @@ + diff --git a/public/memory-workfiles-view.js b/public/memory-workfiles-view.js index 100f18002..4936f61d6 100644 --- a/public/memory-workfiles-view.js +++ b/public/memory-workfiles-view.js @@ -137,13 +137,9 @@ function buildMemoryItem(file) { const row = document.createElement('div'); row.className = 'session-row'; - // Icon: schedule clock for schedule-*.md files, brain for everything else - const isSchedule = file.filename.startsWith('schedule-'); const icon = document.createElement('span'); - icon.className = isSchedule ? 'memory-schedule-icon' : 'memory-brain-icon'; - icon.innerHTML = isSchedule - ? ICONS.schedule(15) - : ''; + icon.className = 'memory-brain-icon'; + icon.innerHTML = ''; row.appendChild(icon); const info = document.createElement('div'); @@ -166,58 +162,19 @@ function buildMemoryItem(file) { info.appendChild(metaEl); row.appendChild(info); - // Play button for schedule files - if (isSchedule) { - const playBtn = document.createElement('button'); - playBtn.className = 'schedule-play-btn'; - playBtn.title = 'Run now'; - playBtn.innerHTML = ''; - const playIcon = ''; - const spinnerIcon = ''; - const checkIcon = ''; - playBtn.addEventListener('click', async (e) => { - e.stopPropagation(); - playBtn.classList.add('running'); - playBtn.innerHTML = spinnerIcon; - playBtn.title = 'Running...'; - const noticeId = `RunSchedule:${file.filePath}`; - try { - const result = await window.api.runScheduleNow(file.filePath); - if (!result?.ok) throw new Error(result?.error || 'The backend returned no result'); - clearAppNotice(noticeId); - playBtn.classList.add('done'); - playBtn.innerHTML = checkIcon; - playBtn.title = 'Launched!'; - setTimeout(() => { - playBtn.classList.remove('done'); - playBtn.innerHTML = playIcon; - playBtn.title = 'Run now'; - }, 2000); - } catch (error) { - playBtn.classList.remove('done'); - playBtn.innerHTML = playIcon; - playBtn.title = 'Run now'; - showAppNotice({ id: noticeId, kind: 'error', message: `Could not run "${file.filename}": ${error?.message || error || 'The backend is unavailable'}. Check the schedule file, then try Run now again.` }); - } finally { - playBtn.classList.remove('running'); - } - }); - row.appendChild(playBtn); - } - item.appendChild(row); item.addEventListener('click', () => openMemory(file)); return item; } -async function openMemory(file) { +async function openMemory(file, loadedContent) { // Mark active in sidebar memoryContent.querySelectorAll('.memory-item.active').forEach(el => el.classList.remove('active')); const target = memoryContent.querySelector(`.memory-item[data-filepath="${CSS.escape(file.filePath)}"]`); if (target) target.classList.add('active'); - const content = await window.api.readMemory(file.filePath); + const content = loadedContent === undefined ? await window.api.readMemory(file.filePath) : loadedContent; currentMemoryFilePath = file.filePath; currentMemoryContent = content; @@ -389,3 +346,8 @@ async function openWorkFile(file) { workFilesPanel.open(file.filename, file.filePath, content); } + +function openScheduleMemory(schedule, content) { + hideAllViewers(); + return openMemory({ filename: schedule.file, filePath: schedule.filePath }, content); +} diff --git a/public/schedules-view.js b/public/schedules-view.js new file mode 100644 index 000000000..3b4afb08d --- /dev/null +++ b/public/schedules-view.js @@ -0,0 +1,207 @@ +'use strict'; + +let schedulesVisible = false; +let schedulesTimer = null; +let schedulesRequest = 0; +let schedulesProject = null; + +function setSchedulesVisible(visible) { + schedulesVisible = visible; + clearTimeout(schedulesTimer); + schedulesTimer = null; + if (!visible) { schedulesRequest++; return; } + loadSchedules(); + scheduleSchedulesRefresh(); +} + +function scheduleSchedulesRefresh() { + if (!schedulesVisible) return; + schedulesTimer = setTimeout(() => { + if (!document.hidden) loadSchedules(); + scheduleSchedulesRefresh(); + }, 60000 - Date.now() % 60000); +} + +async function loadSchedules() { + const request = ++schedulesRequest; + try { + const data = await window.api.listSchedules(); + if (request !== schedulesRequest) return; + renderSchedules(data); + clearAppNotice('listSchedules'); + } catch (error) { + if (request !== schedulesRequest) return; + showAppNotice({ id: 'listSchedules', kind: 'error', message: `Schedules could not be loaded: ${error?.message || error}. Reopen the Schedules tab to retry.` }); + } +} + +function showSchedules(projectPath) { + schedulesProject = projectPath || null; + document.querySelector('.sidebar-tab[data-tab="schedules"]')?.click(); + loadSchedules(); +} + +function scheduleTime(ms, now) { + const date = new Date(ms); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + const minutes = Math.max(1, Math.ceil((ms - now) / 60000)); + const relative = date.toDateString() === tomorrow.toDateString() + ? `tomorrow ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}` + : `in ${minutes} min`; + return `${date.toLocaleString()} (${relative})`; +} + +function scheduleOutcome(run) { + if (run.running) return 'Running'; + const hint = run.replied === true ? 'replied; exit code unknown' + : run.replied === null ? 'Unknown' + : run.messageCount === 1 ? '(refused or not started, run by another instance, run before results were kept, or Switchboard closed during the run)' + : 'started; no reply recorded'; + return `No result recorded — ${hint}`; +} + +function scheduleText(parent, className, text) { + const el = document.createElement('div'); + el.className = className; + el.textContent = text; + parent.appendChild(el); + return el; +} + +function renderSchedules(data) { + const container = document.getElementById('schedules-content'); + if (!container) return; + container.replaceChildren(); + scheduleText(container, 'schedule-help', 'Runs before v0.0.90 carry no schedule marker and are not listed. Runs before results were kept, runs of another instance and runs alive when Switchboard closes have no result recorded.'); + let count = 0; + for (const project of data.projects) { + if (!project.schedules.length) continue; + count += project.schedules.length; + const group = document.createElement('section'); + group.className = 'schedule-project'; + group.dataset.projectPath = project.projectPath; + scheduleText(group, 'schedule-project-title', project.shortName); + scheduleText(group, 'schedule-path', project.projectPath); + for (const schedule of project.schedules) group.appendChild(buildScheduleItem(project, schedule, data)); + container.appendChild(group); + if (schedulesProject === project.projectPath) { + group.tabIndex = -1; + group.focus({ preventScroll: true }); + group.scrollIntoView?.({ block: 'start' }); + schedulesProject = null; + } + } + if (!count) scheduleText(container, 'plans-empty', 'No schedules found.'); +} + +function buildScheduleItem(project, schedule, data) { + const item = document.createElement('article'); + item.className = 'schedule-item'; + item.dataset.filepath = schedule.filePath; + scheduleText(item, 'schedule-name', schedule.name); + scheduleText(item, 'schedule-path', schedule.filePath); + scheduleText(item, 'schedule-cron', `${schedule.cronText} · ${schedule.cron}`); + const fatal = schedule.problems.filter(p => !p.startsWith('slug shared with ')); + const state = project.archived ? 'Suspended — project archived' + : !schedule.enabled ? 'Disabled' + : fatal.length ? `Never runs: ${fatal.join('; ')}` + : schedule.nextRunMs === null ? 'No run within a year' + : `Next run: ${scheduleTime(schedule.nextRunMs, data.now)}`; + scheduleText(item, 'schedule-state', state); + if (schedule.problems.length) scheduleText(item, 'schedule-problems', schedule.problems.join('; ')); + if (schedule.linked) scheduleText(item, 'schedule-problems', 'linked file: edit it at its source'); + if (schedule.catchUp) { + scheduleText(item, 'schedule-catch-up', !data.catchUpActive ? 'catch-up off in this instance' + : schedule.catchUpDueMs != null ? `Catch-up pending: due ${new Date(schedule.catchUpDueMs).toLocaleString()}` : 'Catch-up on'); + } + const actions = document.createElement('div'); + actions.className = 'schedule-actions'; + const unread = schedule.problems.some(p => p.startsWith('front matter not read')); + const action = (cls, label, disabled, reason, handler) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = cls; + btn.textContent = label; + btn.title = disabled ? reason : label; + btn.disabled = disabled; + btn.addEventListener('click', handler); + actions.appendChild(btn); + return btn; + }; + const play = action('schedule-run-btn', 'Run now', !project.known || unread, + !project.known ? 'not a known project' : 'front matter not read', () => runScheduleFromTab(schedule, play)); + action('schedule-toggle-btn', schedule.enabled ? 'Turn off' : 'Turn on', schedule.linked || unread, + schedule.linked ? 'linked file: edit it at its source' : 'front matter not read', async event => { + const btn = event.currentTarget; + btn.disabled = true; + const id = `SetSchedule:${schedule.filePath}`; + try { + const result = await window.api.setScheduleEnabled(schedule.filePath, !schedule.enabled); + if (!result?.ok) throw new Error(result?.error || 'The backend returned no result'); + clearAppNotice(id); + } catch (error) { + showAppNotice({ id, kind: 'error', message: `Could not change "${schedule.file}": ${error?.message || error}. Check the schedule file and retry in the Schedules tab.` }); + } finally { btn.disabled = false; await loadSchedules(); } + }); + action('schedule-open-btn', 'Open', !project.known, 'not a known project', async () => { + const id = `OpenSchedule:${schedule.filePath}`; + try { + const content = await window.api.readMemory(schedule.filePath); + if (typeof content !== 'string' || !content) throw new Error('The schedule could not be read or its path is not allowed'); + await openScheduleMemory(schedule, content); + clearAppNotice(id); + await loadSchedules(); + } catch (error) { + showAppNotice({ id, kind: 'error', message: `Could not open "${schedule.file}": ${error?.message || error}. Check the file and retry Open.` }); + } + }); + item.appendChild(actions); + const history = document.createElement('details'); + const summary = document.createElement('summary'); + const runs = schedule.runs.slice(0, 20); + summary.textContent = runs.length ? `Last run: ${new Date(runs[0].created).toLocaleString()} — ${scheduleOutcome(runs[0])}` : 'No recorded run'; + history.appendChild(summary); + for (const run of runs) { + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'schedule-history-run'; + row.textContent = `${new Date(run.created).toLocaleString()} — ${scheduleOutcome(run)}`; + row.addEventListener('click', () => openScheduleRun(project, schedule, run)); + history.appendChild(row); + } + const sessions = document.createElement('button'); + sessions.type = 'button'; + sessions.textContent = 'Show history in Sessions'; + sessions.addEventListener('click', () => showScheduleSessions(project.projectPath, schedule.slug, schedule.runs.map(run => run.sessionId))); + history.appendChild(sessions); + item.appendChild(history); + return item; +} + +async function runScheduleFromTab(schedule, button) { + const icon = button.innerHTML; + const id = `RunSchedule:${schedule.filePath}`; + button.disabled = true; + button.classList.add('running'); + button.title = 'Running...'; + button.textContent = 'Running...'; + try { + const result = await window.api.runScheduleNow(schedule.filePath); + if (!result?.ok) throw new Error(result?.error || 'The backend returned no result'); + clearAppNotice(id); + button.classList.add('done'); + button.textContent = '\u2713'; + button.title = 'Launched!'; + setTimeout(() => { button.classList.remove('done'); button.innerHTML = icon; button.title = 'Run now'; }, 2000); + } catch (error) { + button.classList.remove('done'); + button.innerHTML = icon; + button.title = 'Run now'; + showAppNotice({ id, kind: 'error', message: `Could not run "${schedule.file}": ${error?.message || error || 'The backend is unavailable'}. Check the schedule file, then try Run now again.` }); + } finally { + button.disabled = false; + button.classList.remove('running'); + if (schedulesVisible) await loadSchedules(); + } +} diff --git a/public/sidebar.js b/public/sidebar.js index 1fd816f9e..5aaac6356 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -1522,7 +1522,7 @@ function buildReenableNotice(project, fId) { el.id = 'sro-' + fId; const text = document.createElement('div'); text.className = 'schedule-reenable-text'; - text.textContent = `${n} schedule${n === 1 ? ' was' : 's were'} turned off when this folder was archived: ${offer.names.join(', ')}`; + text.textContent = `${n} schedule${n === 1 ? ' was' : 's were'} turned off when this folder was archived. See the Schedules tab.`; el.appendChild(text); if (Array.isArray(offer.failed) && offer.failed.length > 0) { const failed = document.createElement('div'); @@ -1540,7 +1540,12 @@ function buildReenableNotice(project, fId) { dismiss.type = 'button'; dismiss.className = 'schedule-reenable-dismiss'; dismiss.textContent = 'Dismiss'; - actions.append(on, dismiss); + const show = document.createElement('button'); + show.type = 'button'; + show.className = 'schedule-reenable-show'; + show.textContent = 'Show in Schedules'; + show.addEventListener('click', () => showSchedules(project.projectPath)); + actions.append(show, on, dismiss); el.appendChild(actions); return el; } @@ -1554,7 +1559,7 @@ function showFolderArchiveUndo(project, groups, archivedAt, rows, didStop, child message: [`Folder "${shortProjectPath(project.projectPath)}" archived.`, ...(didStop ? ['Stopped processes stay stopped.'] : []), ...(children.length ? [`Worktrees archived with it: ${children.map(child => child.projectPath).join(', ')}.`] : []), - ...(disabled.length ? [`Schedules turned off: ${disabled.join(', ')}. Undo turns them back on.`] : []), + ...(disabled.length ? [`${disabled.length} schedule${disabled.length === 1 ? '' : 's'} turned off; Undo turns them back on. See the Schedules tab.`] : []), ...staysEnabled].join(' '), actionLabel: 'Undo', onAction: async () => { @@ -1610,7 +1615,8 @@ async function archiveProjectFolder(project, button) { } const schedules = plan.schedules || []; const disableable = schedules.filter(s => s.disableable); - const staysEnabled = schedules.filter(s => !s.disableable).map(s => `${s.name} stays enabled: ${s.reason}`); + const enabledCount = schedules.filter(s => !s.disableable).length; + const staysEnabled = enabledCount ? [`${enabledCount} schedule${enabledCount === 1 ? '' : 's'} stay${enabledCount === 1 ? 's' : ''} enabled; see the Schedules tab`] : []; const message = ['The folder is hidden from the sidebar until it is added again or a new session starts in it.', ...staysEnabled]; if (children.length > 0) { @@ -1627,7 +1633,7 @@ async function archiveProjectFolder(project, button) { if (disableable.length > 0) { choices.push({ id: 'disableSchedules', - label: `Disable the ${disableable.length} enabled schedule${disableable.length === 1 ? '' : 's'}: ${disableable.map(s => s.name).join(', ')}`, + label: `Disable the ${disableable.length} enabled schedule${disableable.length === 1 ? '' : 's'} (listed in the Schedules tab)`, checked: true, rememberKey: 'archiveFolder.disableSchedules', }); @@ -1703,10 +1709,10 @@ async function archiveProjectFolder(project, button) { if (!res || res.ok === false || res.error) { const reason = res?.error === 'indexing' ? ARCHIVE_INDEXING_MESSAGE : 'The folder could not be archived: ' + (res?.error || 'The backend returned no result'); const disabled = res?.disabled?.length ? '. These schedules were turned off: ' + res.disabled.join(', ') : ''; - showSessionActionFailure('Archive folder', project, reason + disabled, 'Check schedule-*.md in the Agent Files tab, then retry Archive folder.'); + showSessionActionFailure('Archive folder', project, reason + disabled, 'Check the schedules in the Schedules tab, then retry Archive folder.'); } else if (res.failed?.length) { showSessionActionFailure('Archive folder', project, 'The folder is archived, but these schedules could not be disabled: ' + res.failed.map(f => f.name + ': ' + f.error).join('; '), - 'Review schedule-*.md in the Agent Files tab. Restore the folder from Settings → Archived projects if you need to retry Archive folder.', undefined, { headline: 'Archive folder partly done' }); + 'Review the schedules in the Schedules tab. Restore the folder from Settings → Archived projects if you need to retry Archive folder.', undefined, { headline: 'Archive folder partly done' }); } else { clearAppNotice(sessionActionNoticeId('Archive folder', project)); } diff --git a/public/style.css b/public/style.css index b614e7316..ec970a637 100644 --- a/public/style.css +++ b/public/style.css @@ -3602,66 +3602,8 @@ body { display: flex; flex-direction: column; } color: #8888a8; } -.memory-schedule-icon { - flex-shrink: 0; - display: flex; - align-items: center; - margin-top: 5px; - color: #e0a030; -} - -.schedule-play-btn { - flex-shrink: 0; - background: transparent; - border: 1px solid rgba(100,220,120,0.3); - color: #5ec46a; - width: 24px; - height: 24px; - border-radius: 5px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - padding: 0; - line-height: 0; - opacity: 0; - transition: all 0.15s; - margin-left: auto; - align-self: center; -} -.memory-item:hover .schedule-play-btn, -.schedule-play-btn:focus-visible { - opacity: 0.7; -} - -.schedule-play-btn:hover { - opacity: 1 !important; - background: rgba(100,220,120,0.1); - color: #5ec46a; -} - -.schedule-play-btn.running { - opacity: 1 !important; - color: #e0a030; - border-color: rgba(224,160,48,0.3); -} -.schedule-play-btn.running svg { - animation: spin-schedule 1s linear infinite; -} - -.schedule-play-btn.done { - opacity: 1 !important; - color: #5ec46a; - border-color: rgba(100,220,120,0.4); - background: rgba(100,220,120,0.1); -} - -@keyframes spin-schedule { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} .memory-file-count { font-size: 10px; @@ -5868,3 +5810,15 @@ body.window-frameless :is(.new-session-popover, .terminal-context-menu, .new-ses .mermaid-diagram > svg { display: block; max-width: none !important; } .mermaid-error { color: var(--text-muted); font-size: 12px; } .mermaid-render-scratch { position: fixed; left: -100000px; top: 0; visibility: hidden; pointer-events: none; } + +#schedules-content { overflow-y: auto; flex: 1; min-height: 0; padding: 12px; } +.schedule-project { margin-bottom: 20px; } +.schedule-project-title, .schedule-name { font-weight: 600; } +.schedule-item { padding: 12px 0; border-bottom: 1px solid var(--hairline); overflow-wrap: anywhere; } +.schedule-help, .schedule-path { color: var(--text-muted); font-size: 11px; } +.schedule-state, .schedule-problems, .schedule-catch-up { margin: 6px 0; } +.schedule-problems { color: #e0a030; } +.schedule-actions { display: flex; gap: 6px; margin: 8px 0; opacity: 0; } +.schedule-item:hover .schedule-actions, .schedule-actions:has(:focus-visible) { opacity: 1; } +.schedule-history-run { display: block; text-align: left; margin: 6px 0; width: 100%; } +.schedule-actions button:disabled { cursor: default; } diff --git a/src/main/schedules/schedule-runner.js b/src/main/schedules/schedule-runner.js index d0a04ab1f..8d47dabc2 100644 --- a/src/main/schedules/schedule-runner.js +++ b/src/main/schedules/schedule-runner.js @@ -560,15 +560,31 @@ function buildScheduleCommand(sessionId, schedule) { * see .ai/contexts/schedule-runner.md ("Archive") */ function skipMissedRuns(log, projects, stateDir) { + for (const schedule of scanSchedules(log, projects)) skipMissedRunsOf(schedule, stateDir); +} + +function skipMissedRunsOf(schedule, stateDir) { + if (!schedule.catchUp) return; const baseline = Math.floor(Date.now() / MINUTE_MS) * MINUTE_MS - MINUTE_MS; const state = readScheduleState(stateDir); - for (const schedule of scanSchedules(log, projects)) { - if (!schedule.catchUp) continue; - const key = scheduleStateKey(schedule); - const entry = state.get(key); - if (entry && entry.handled >= baseline) continue; - claimScheduleMinute(stateDir, key, baseline, { file: schedule.filePath, name: schedule.name }); - if (entry) pruneScheduleState(stateDir, entry); + const key = scheduleStateKey(schedule); + const entry = state.get(key); + if (entry && entry.handled >= baseline) return; + claimScheduleMinute(stateDir, key, baseline, { file: schedule.filePath, name: schedule.name }); + if (entry) pruneScheduleState(stateDir, entry); +} + +async function setListedScheduleEnabled(filePath, enabled, { scheduleProjects, stateDir, setEnabled = setScheduleEnabled }) { + if (typeof filePath !== 'string' || typeof enabled !== 'boolean') return { ok: false, error: 'invalid schedule or enabled value' }; + try { + const schedule = listScheduleFiles(await scheduleProjects().list()).find(s => s.filePath === filePath); + if (!schedule) return { ok: false, error: 'schedule is not listed' }; + if (schedule.linked) return { ok: false, error: 'linked file: edit it at its source' }; + if (schedule.problems.some(p => p.startsWith('front matter not read'))) return { ok: false, error: 'front matter not read' }; + if (enabled && !schedule.enabled) skipMissedRunsOf(schedule, stateDir); + return setEnabled(filePath, enabled, { projectRoot: schedule.projectPath }); + } catch (error) { + return { ok: false, error: error.message }; } } @@ -775,4 +791,4 @@ function setScheduleEnabled(filePath, enabled, { projectRoot, rewrite = rewriteE } } -module.exports = { listScheduleFiles, isRunnerScheduleFileName, nextCronMatch, describeCron, scheduleLaunchRefusal, readScheduleStateOnly, latestCronMatch, scheduleStateKey, scheduleFileUnlinked, setScheduleEnabled, parseFrontmatter, cronMatches, scanSchedules, startScheduler, skipMissedRuns, createScheduleSession, buildScheduleCommand, claimScheduleMinute, initialScheduleProjects, refusedScheduleBinds, scheduleBindRefusals, resolveScheduleSandbox, scheduleRegistry }; +module.exports = { setListedScheduleEnabled, skipMissedRunsOf, listScheduleFiles, isRunnerScheduleFileName, nextCronMatch, describeCron, scheduleLaunchRefusal, readScheduleStateOnly, latestCronMatch, scheduleStateKey, scheduleFileUnlinked, setScheduleEnabled, parseFrontmatter, cronMatches, scanSchedules, startScheduler, skipMissedRuns, createScheduleSession, buildScheduleCommand, claimScheduleMinute, initialScheduleProjects, refusedScheduleBinds, scheduleBindRefusals, resolveScheduleSandbox, scheduleRegistry }; diff --git a/test/dom-archive-schedules-tab.test.js b/test/dom-archive-schedules-tab.test.js new file mode 100644 index 000000000..fac9644f2 --- /dev/null +++ b/test/dom-archive-schedules-tab.test.js @@ -0,0 +1,38 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { setupSidebarDom, answerChoiceDialog } = require('./dom-setup'); + +test('archive choice, Undo and re-enable use counts and Schedules tab while failures keep names', async t => { + const h = setupSidebarDom(); + t.after(() => h.destroy()); + const project = { projectPath: '/p', folder: '-p', sessions: [], reenableOffer: { names: ['Secret nightly'], failed: [{ name: 'Failed nightly', error: 'EACCES' }] } }; + h.window.cachedAllProjects = [project]; + h.sidebar.renderProjects([project], true); + const notice = h.document.querySelector('.schedule-reenable-notice'); + assert.match(notice.textContent, /1 schedule was turned off/); + assert.doesNotMatch(notice.textContent, /Secret nightly/); + assert.match(notice.textContent, /Schedules/); + assert.match(notice.textContent, /Could not turn back on: Failed nightly: EACCES/); + let shown; + h.window.showSchedules = p => { shown = p; }; + notice.querySelector('.schedule-reenable-show').click(); + assert.equal(shown, '/p'); + h.window.api.getProjectArchivePlan = async () => ({ schedules: [ + { name: 'Secret nightly', filePath: '/p/.claude/commands/schedule-a.md', disableable: true }, + { name: 'Linked nightly', filePath: '/p/.claude/commands/schedule-b.md', disableable: false, reason: 'linked' }, + ] }); + h.window.api.archiveProject = async () => ({ ok: true, archivedAt: 'now', disabled: ['Secret nightly'], failed: [] }); + const pending = h.document.querySelector('.project-archive-btn').onclick({ stopPropagation() {} }); + const overlay = await answerChoiceDialog(h, { confirm: true }); + await pending; + const label = overlay.querySelector('[data-choice-id="disableSchedules"]').parentElement.textContent; + assert.match(label, /Disable the 1 enabled schedule.*listed in the Schedules tab/); + assert.doesNotMatch(overlay.textContent, /Secret nightly|Linked nightly/); + assert.match(overlay.textContent, /1 schedule stays enabled; see the Schedules tab/); + h.window.showFolderArchiveUndo(project, [{ projectPath: '/p', folderKey: '-p' }], 'now', [], false, [], ['Secret nightly'], []); + const undo = h.document.querySelector('#app-notices [role="status"]'); + assert.ok(undo, 'the Undo notice is displayed'); + assert.match(undo.textContent, /1 schedule turned off; Undo turns them back on. See the Schedules tab/); + assert.doesNotMatch(undo.textContent, /Secret nightly/); +}); diff --git a/test/dom-folder-archive-undo.test.js b/test/dom-folder-archive-undo.test.js index 597f78821..2ba6174ae 100644 --- a/test/dom-folder-archive-undo.test.js +++ b/test/dom-folder-archive-undo.test.js @@ -110,7 +110,7 @@ test('PR5b Q4 idle options are configuration without danger or Undo', async t => assert.deepEqual(ctx.calls, [['folder', [{ projectPath: '/p', folderKey: '-p' }], { disableSchedules: ['/x'] }]]); }); -test('PR5b Q5 Undo restores this click only and names disabled schedules and worktrees', async t => { +test('PR5b Q5 Undo restores this click only, counts disabled schedules and names worktrees', async t => { const child = { projectPath: '/p/.claude/worktrees/w', folder: '-p-w', sessions: [] }; const ctx = setup(t, [{ sessionId: 'S' }, { sessionId: 'old', archived: 1 }], [{ name: 'X', filePath: '/x', disableable: true }], [child]); ctx.window.activePtyIds.add('S'); @@ -120,7 +120,8 @@ test('PR5b Q5 Undo restores this click only and names disabled schedules and wor await pending; assert.ok(undo(ctx), 'confirmed Archive offers Undo'); assert.match(undo(ctx).parentElement.textContent, /stay stopped/); - assert.match(undo(ctx).parentElement.textContent, /X/); + assert.match(undo(ctx).parentElement.textContent, /1 schedule turned off; Undo turns them back on. See the Schedules tab/); + assert.doesNotMatch(undo(ctx).parentElement.textContent, /X/); assert.match(undo(ctx).parentElement.textContent, /\/p\/\.claude\/worktrees\/w/); undo(ctx).click(); await settle(); @@ -242,12 +243,12 @@ test('PR5b review stale folder Undo explains that nothing changed without retry assert.equal(ctx.calls.some(call => call[0] === 'archive' && call[2] === 0), false); }); -test('PR5b review R2 empty folder keeps the reason a schedule stays enabled in its notice', async t => { +test('PR5b review R2 empty folder points to Schedules with the count that stays enabled', async t => { const ctx = setup(t, [], [{ name: 'Y', disableable: false, reason: 'remote schedules are read-only' }]); await archive(ctx); assert.equal(ctx.document.querySelector('.modal-overlay'), null); assert.ok(undo(ctx)); - assert.match(undo(ctx).parentElement.textContent, /Y stays enabled: remote schedules are read-only/); + assert.match(undo(ctx).parentElement.textContent, /1 schedule stays enabled; see the Schedules tab/); }); for (const remembered of ['0', '1']) { diff --git a/test/dom-memory-workfiles-run-now.test.js b/test/dom-memory-workfiles-run-now.test.js deleted file mode 100644 index 969b24c91..000000000 --- a/test/dom-memory-workfiles-run-now.test.js +++ /dev/null @@ -1,71 +0,0 @@ -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const vm = require('node:vm'); -const { JSDOM } = require('jsdom'); - -function setup(t, run) { - const dom = new JSDOM('
', { runScripts: 'outside-only' }); - t.after(() => dom.window.close()); - const ctx = dom.getInternalVMContext(); - const calls = []; - dom.window.api = { runScheduleNow: async filePath => { calls.push(filePath); return run(); } }; - dom.window.formatDate = () => 'today'; - dom.window.ICONS = { schedule: () => '' }; - for (const name of ['app-notices.js', 'memory-workfiles-view.js']) { - const filename = path.join(__dirname, '../public', name); - vm.runInContext(fs.readFileSync(filename, 'utf8'), ctx, { filename }); - } - const item = dom.window.buildMemoryItem({ filename: 'schedule-nightly.md', filePath: '/p/schedule-nightly.md', displayPath: '/p', modified: Date.now() }); - dom.window.document.body.append(item); - const button = item.querySelector('.schedule-play-btn'); - const titles = []; - new dom.window.MutationObserver(records => { - titles.push(...records.map(record => record.oldValue), button.title); - }).observe(button, { attributes: true, attributeFilter: ['title'], attributeOldValue: true }); - return { dom, calls, button, titles }; -} - -for (const [label, run, reason] of [ - ['backend refusal', () => ({ ok: false, error: 'boom' }), 'boom'], - ['rejected IPC', () => { throw new Error('offline'); }, 'offline'], - ['missing result', () => undefined, 'no result'], -]) { - test(`Run now ${label} leaves a persistent error and restores its button`, async t => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const h = setup(t, run); - const icon = h.button.innerHTML; - h.button.click(); - await new Promise(resolve => setImmediate(resolve)); - const alert = h.dom.window.document.querySelector('[role="alert"]'); - assert.ok(alert, 'a failed launch must leave a visible notice'); - assert.ok(alert.textContent.includes(reason)); - assert.match(alert.textContent, /schedule-nightly\.md/); - assert.match(alert.textContent, /try Run now again/i); - assert.equal(h.button.title, 'Run now'); - assert.ok(!h.titles.includes('Launched!'), 'a failed launch never reports success, even briefly'); - assert.equal(h.button.innerHTML, icon); - assert.equal(h.button.classList.contains('running'), false); - assert.equal(h.button.classList.contains('done'), false); - assert.deepEqual(h.calls, ['/p/schedule-nightly.md']); - t.mock.timers.tick(20_000); - assert.ok(alert.isConnected); - }); -} - -test('Run now reports success only after ok and clears the error for that file', async t => { - let ok = false; - const h = setup(t, () => ({ ok, error: 'boom' })); - h.button.click(); - await new Promise(resolve => setImmediate(resolve)); - assert.ok(h.dom.window.document.querySelector('[role="alert"]')); - ok = true; - h.button.click(); - await new Promise(resolve => setImmediate(resolve)); - assert.equal(h.button.title, 'Launched!'); - assert.equal(h.button.classList.contains('done'), true); - assert.equal(h.dom.window.document.querySelector('[role="alert"]'), null); -}); diff --git a/test/dom-project-archive-folder.test.js b/test/dom-project-archive-folder.test.js index b56fa92e7..dbf6d5aa9 100644 --- a/test/dom-project-archive-folder.test.js +++ b/test/dom-project-archive-folder.test.js @@ -129,7 +129,7 @@ test('archive folder: no schedule box without a disableable schedule; a linked o const done = archiveButton(ctx, '/p').onclick(new ctx.window.MouseEvent('click')); await new Promise(r => setTimeout(r, 0)); assert.deepEqual(boxIds(ctx), ['archiveSessions']); - assert.match(ctx.document.querySelector('.modal-overlay').textContent, /b stays enabled: linked file or directory/); + assert.match(ctx.document.querySelector('.modal-overlay').textContent, /1 schedule stays enabled; see the Schedules tab/); await answerChoiceDialog(ctx, { confirm: false }); await done; } finally { ctx.destroy(); } @@ -434,7 +434,7 @@ test('re-enable notice: a project carrying an offer shows it, and Turn back on a const el = notice(ctx, '/p'); assert.ok(el, 'the notice must render under the header'); - assert.match(el.textContent, /2 schedules were turned off when this folder was archived: nightly, weekly/); + assert.match(el.textContent, /2 schedules were turned off when this folder was archived\. See the Schedules tab\./); await el.querySelector('.schedule-reenable-on').onclick(new ctx.window.MouseEvent('click')); assert.deepEqual(named(calls, 'reenableProjectSchedules').map(c => plain(c.args)), [['/p', '-p']]); @@ -458,7 +458,7 @@ test('re-enable notice: Dismiss deletes the offer and turns nothing on', async ( render(ctx, [project]); const el = notice(ctx, '/p'); assert.ok(el, 'the notice must render under the header'); - assert.match(el.textContent, /1 schedule was turned off when this folder was archived: nightly/); + assert.match(el.textContent, /1 schedule was turned off when this folder was archived\. See the Schedules tab\./); await el.querySelector('.schedule-reenable-dismiss').onclick(new ctx.window.MouseEvent('click')); assert.deepEqual(named(calls, 'dismissScheduleReenableOffer').map(c => plain(c.args)), [['/p', '-p']]); diff --git a/test/dom-row-action-errors.test.js b/test/dom-row-action-errors.test.js index a4f0cce8f..4e2138c3c 100644 --- a/test/dom-row-action-errors.test.js +++ b/test/dom-row-action-errors.test.js @@ -38,7 +38,7 @@ function setup(t, extra = {}) { assert.match(el.textContent, /Work|s1|\/project/); assert.match(el.textContent, /\/project/); assert.ok(el.querySelector('.app-notice-dismiss')); - assert.match(el.textContent, /retry (?:Stop|Detach|Delete|Unarchive|Archive(?: folder)?)|try again (?:once|when)|own terminal|open (?:it|the (?:session|conversation)) from the sidebar|Agent Files|Reindex all sessions/i); + assert.match(el.textContent, /retry (?:Stop|Detach|Delete|Unarchive|Archive(?: folder)?)|try again (?:once|when)|own terminal|open (?:it|the (?:session|conversation)) from the sidebar|Schedules tab|Reindex all sessions/i); assert.doesNotMatch(el.textContent, /schedules in Settings|reload the sidebar|eligible/i); assert.equal(h.document.activeElement, expectedFocus); el.dispatchEvent(new h.window.MouseEvent('mouseleave')); @@ -391,7 +391,7 @@ test('a failed Unarchive names that action and preserves its archived state', as assert.equal(h.session.archived, 1); }); -test('folder partial success names Agent Files and uses a partial headline with one path', async t => { +test('folder partial success names Schedules tab and uses a partial headline with one path', async t => { const h = setup(t); h.window.api.getProjectArchivePlan = async () => ({ schedules: [] }); h.window.api.archiveProject = async () => ({ ok: true, failed: [{ name: 'nightly', error: 'EACCES' }] }); @@ -400,7 +400,7 @@ test('folder partial success names Agent Files and uses a partial headline with await pending; const el = h.document.querySelector('[role="alert"]'); assert.match(el.textContent, /^Archive folder partly done/); - assert.match(el.textContent, /schedule-.*\.md.*Agent Files/); + assert.match(el.textContent, /Schedules tab/); assert.doesNotMatch(el.textContent, /schedules in Settings|Archive folder failed/); assert.equal(el.textContent.split('/project').length - 1, 1); }); diff --git a/test/dom-schedule-run-exit.test.js b/test/dom-schedule-run-exit.test.js index 11da3de2f..056ba5dbc 100644 --- a/test/dom-schedule-run-exit.test.js +++ b/test/dom-schedule-run-exit.test.js @@ -62,11 +62,13 @@ test('a fast failure survives the later successful launch acknowledgement', asyn }; h.window.formatDate = () => 'today'; h.window.ICONS = { schedule: () => '' }; - const item = h.window.buildMemoryItem({ filename: 'schedule-nightly.md', filePath: '/p/schedule-nightly.md' }); + const filename = path.join(__dirname, '../public/schedules-view.js'); + vm.runInContext(fs.readFileSync(filename, 'utf8'), h.context, { filename }); + const item = h.window.buildScheduleItem({ projectPath: '/p', known: true, archived: false }, { file: 'schedule-nightly.md', filePath: '/p/schedule-nightly.md', name: 'Nightly', slug: 'nightly', cron: '* * * * *', cronText: 'every min', problems: [], enabled: true, linked: false, catchUp: false, nextRunMs: Date.now() + 60000, runs: [] }, { now: Date.now(), catchUpActive: true }); h.document.body.append(item); - item.querySelector('.schedule-play-btn').click(); + item.querySelector('.schedule-run-btn').click(); await new Promise(resolve => setImmediate(resolve)); - assert.equal(item.querySelector('.schedule-play-btn').title, 'Launched!'); + assert.equal(item.querySelector('.schedule-run-btn').title, 'Launched!'); assert.match(h.document.querySelector('[role="alert"]')?.textContent || '', /Nightly.*127.*PATH/); }); diff --git a/test/dom-schedules-tab.test.js b/test/dom-schedules-tab.test.js new file mode 100644 index 000000000..d3b44aa40 --- /dev/null +++ b/test/dom-schedules-tab.test.js @@ -0,0 +1,216 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); +const { loadAppFunctions, extractFunction } = require('./app-source'); + +function setup(t, run = () => ({ ok: true })) { + const dom = new JSDOM('
', { runScripts: 'outside-only', url: 'http://localhost' }); + t.after(() => dom.window.close()); + const ctx = dom.getInternalVMContext(); + const calls = []; + const project = { projectPath: '/p', shortName: 'p', known: true, archived: false, schedules: [] }; + const schedule = { file: 'schedule-nightly.md', filePath: '/p/schedule-nightly.md', name: 'Nightly', slug: 'nightly', cron: '* * * * *', cronText: 'every min', enabled: true, linked: false, problems: [], nextRunMs: Date.now() + 60000, catchUp: false, runs: [] }; + project.schedules.push(schedule); + const data = { now: Date.now(), catchUpActive: true, projects: [project] }; + const w = dom.window; + w.api = { runScheduleNow: async filePath => { calls.push(filePath); return run(); }, listSchedules: async () => data, + setScheduleEnabled: async () => ({ ok: true }), readMemory: async () => '' }; + w.activeTab = 'schedules'; + w.formatDate = () => 'today'; + w.ICONS = { schedule: () => '' }; + const opened = []; + w.CSS = { escape: value => value }; + for (const name of ['memoryContent', 'memoryViewer', 'placeholder', 'terminalArea', 'statsViewer', 'settingsViewer', 'workFilesViewer', 'jsonlViewer']) w[name] = w.document.createElement('div'); + w.memoryPanel = { open: (...args) => opened.push(args) }; + w.openSession = session => calls.push(session); + w.loadProjects = async () => { w.cachedAllProjects = [{ projectPath: project.projectPath, sessions: schedule.runs }]; }; + const activityFilename = path.join(__dirname, '../public/session-activity-dom.js'); + const activitySource = fs.readFileSync(activityFilename, 'utf8'); + vm.runInContext(extractFunction(activitySource, 'rowSessionIds'), ctx, { filename: activityFilename, + lineOffset: activitySource.slice(0, activitySource.indexOf('function rowSessionIds')).split('\n').length - 1 }); + loadAppFunctions(ctx, { functions: ['getExpandedSlugs', 'showScheduleSessions', 'openScheduleRun'], installSpies: false }); + for (const name of ['app-notices.js', 'memory-workfiles-view.js', 'schedules-view.js']) { + const filename = path.join(__dirname, '../public', name); + assert.ok(fs.existsSync(filename), 'the Schedules renderer exists'); + vm.runInContext(fs.readFileSync(filename, 'utf8'), ctx, { filename }); + } + w.renderSchedules(data); + const button = w.document.querySelector('.schedule-run-btn'); + const titles = []; + new w.MutationObserver(records => titles.push(...records.map(record => record.oldValue), button.title)) + .observe(button, { attributes: true, attributeFilter: ['title'], attributeOldValue: true }); + return { dom, w, project, schedule, data, calls, button, titles, opened }; +} + +for (const [label, run, reason] of [ + ['backend refusal', () => ({ ok: false, error: 'boom' }), 'boom'], + ['rejected IPC', () => { throw new Error('offline'); }, 'offline'], + ['missing result', () => undefined, 'no result'], +]) { + test(`Run now ${label} leaves a persistent error and restores its button`, async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = setup(t, run); + const icon = h.button.innerHTML; + h.button.click(); + await new Promise(resolve => setImmediate(resolve)); + const alert = h.w.document.querySelector('[role="alert"]'); + assert.ok(alert, 'a failed launch must leave a visible notice'); + assert.ok(alert.textContent.includes(reason)); + assert.match(alert.textContent, /schedule-nightly\.md/); + assert.match(alert.textContent, /try Run now again/i); + assert.equal(alert.dataset.noticeId, 'RunSchedule:/p/schedule-nightly.md'); + assert.equal(h.button.title, 'Run now'); + assert.ok(!h.titles.includes('Launched!')); + assert.equal(h.button.innerHTML, icon); + assert.equal(h.button.classList.contains('running'), false); + assert.equal(h.button.classList.contains('done'), false); + assert.deepEqual(h.calls, ['/p/schedule-nightly.md']); + t.mock.timers.tick(20_000); + assert.ok(alert.isConnected); + }); +} + +test('Run now reports success only after ok and clears the error for that file', async t => { + let ok = false; + const h = setup(t, () => ({ ok, error: 'boom' })); + h.button.click(); + await new Promise(resolve => setImmediate(resolve)); + assert.ok(h.w.document.querySelector('[role="alert"]')); + ok = true; + h.button.click(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(h.button.title, 'Launched!'); + assert.equal(h.button.classList.contains('done'), true); + assert.equal(h.w.document.querySelector('[role="alert"]'), null); +}); + +test('schedule states, history and missing outcomes are shown without inventing success', async t => { + const h = setup(t); + const text = () => h.w.document.getElementById('schedules-content').textContent; + assert.match(text(), /No recorded run/); + assert.match(text(), /Next run:.*in 1 min/); + for (const [change, expected] of [ + [{ enabled: false }, /Disabled/], + [{ enabled: true, problems: ['no cron'] }, /Never runs: no cron/], + [{ problems: [], nextRunMs: null }, /No run within a year/], + ]) { Object.assign(h.schedule, change); h.w.renderSchedules(h.data); assert.match(text(), expected); } + h.project.archived = true; + h.w.renderSchedules(h.data); + assert.match(text(), /Suspended — project archived/); + h.project.archived = false; + h.schedule.problems = ['slug shared with schedule-other.md']; + h.schedule.runs = [ + { sessionId: 'reply', created: '2026-11-08T12:00Z', running: false, replied: true, messageCount: 2, result: null }, + { sessionId: 'seed', created: '2026-11-08T11:00Z', running: false, replied: false, messageCount: 1, result: null }, + { sessionId: 'start', created: '2026-11-08T10:00Z', running: false, replied: false, messageCount: 2, result: null }, + { sessionId: 'live', created: '2026-11-08T09:00Z', running: true, replied: false, result: null }, + ]; + h.w.renderSchedules(h.data); + assert.match(text(), /slug shared/); + assert.match(text(), /No result recorded/); + assert.match(text(), /replied; exit code unknown/); + assert.match(text(), /refused or not started/); + assert.match(text(), /started; no reply recorded/); + assert.match(text(), /Running/); + assert.doesNotMatch(text(), /Exit 0/); + h.w.document.querySelector('.schedule-history-run').click(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(h.calls[0].sessionId, 'reply'); +}); + +test('unknown projects and unread front matter disable their guarded actions', t => { + const h = setup(t); + h.project.known = false; + h.w.renderSchedules(h.data); + for (const cls of ['.schedule-run-btn', '.schedule-open-btn']) assert.equal(h.w.document.querySelector(cls).disabled, true, cls); + h.project.known = true; + h.schedule.problems = ['front matter not read: save with LF line endings']; + h.w.renderSchedules(h.data); + for (const cls of ['.schedule-run-btn', '.schedule-toggle-btn']) assert.equal(h.w.document.querySelector(cls).disabled, true, cls); + h.schedule.problems = []; + h.schedule.linked = true; + h.w.renderSchedules(h.data); + assert.equal(h.w.document.querySelector('.schedule-toggle-btn').disabled, true); + assert.equal(h.w.document.querySelector('.schedule-open-btn').disabled, false); +}); + +test('Open refuses an empty read without opening an editor; toggle refreshes the list', async t => { + const h = setup(t); + h.w.document.querySelector('.schedule-open-btn').click(); + await new Promise(resolve => setImmediate(resolve)); + assert.match(h.w.document.querySelector('[role="alert"]').textContent, /Could not open/); + const content = '---\ncron: * * * * *\n---\nPrompt'; + h.w.workFilesViewer.style.display = 'flex'; + h.w.jsonlViewer.style.display = 'flex'; + h.w.api.readMemory = async () => content; + h.w.document.querySelector('.schedule-open-btn').click(); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(h.opened, [['schedule-nightly.md', '/p/schedule-nightly.md', content]]); + assert.equal(h.w.memoryViewer.style.display, 'flex'); + assert.equal(h.w.workFilesViewer.style.display, 'none', 'Open gives the memory editor the main viewer'); + assert.equal(h.w.jsonlViewer.style.display, 'none'); + assert.equal(h.w.document.querySelector('[role="alert"]'), null); + let toggled; + h.w.api.setScheduleEnabled = async (...args) => { toggled = args; return { ok: true }; }; + h.w.document.querySelector('.schedule-toggle-btn').click(); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(toggled, ['/p/schedule-nightly.md', false]); +}); + +test('history follows its local run when registry and transcript project paths differ', async t => { + const h = setup(t); + h.project.projectPath = '/alias'; + h.schedule.filePath = '/alias/.claude/commands/schedule-nightly.md'; + const local = { sessionId: 'reply', projectPath: '/real', scheduleSlug: 'nightly' }; + h.schedule.runs = [{ sessionId: 'reply', created: '2026-11-08T12:00Z', replied: true, result: null }]; + h.w.loadProjects = async () => { + h.w.cachedAllProjects = [ + { projectPath: '/real', remoteAlias: 'vps', sessions: [{ ...local, remoteAlias: 'vps' }] }, + { projectPath: '/real', sessions: [local] }, + ]; + }; + h.w.renderSchedules(h.data); + const parent = h.w.document.createElement('section'); + parent.className = 'project-group collapsed'; + const group = h.w.document.createElement('div'); + group.className = 'slug-group collapsed'; + group.id = 'schedule-' + encodeURIComponent(JSON.stringify(['', '/real', 'nightly'])); + parent.appendChild(group); + h.w.document.body.appendChild(parent); + [...h.w.document.querySelectorAll('#schedules-content button')].find(b => b.textContent === 'Show history in Sessions').click(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(group.classList.contains('collapsed'), false, 'history expands the transcript project group, including a resolved registry alias'); + assert.equal(parent.classList.contains('collapsed'), false); + assert.ok(JSON.parse(h.w.sessionStorage.getItem('expandedSlugs')).includes(group.id)); + h.w.document.querySelector('.schedule-history-run').click(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(h.calls[0], local, 'local history never opens a remote copy of the same session id'); +}); + +test('history opens a continuation member through its merged local row', async t => { + const h = setup(t); + h.project.projectPath = '/alias'; + const merged = { sessionId: 'root', headSessionId: 'head', mergedMemberIds: ['head', 'other'], + projectPath: '/real', scheduleSlug: 'nightly' }; + h.schedule.runs = [{ sessionId: 'head', created: '2026-11-08T12:00Z', messageCount: 2, replied: true, running: false, result: null }]; + h.w.loadProjects = async () => { h.w.cachedAllProjects = [{ projectPath: '/real', sessions: [merged] }]; }; + h.w.renderSchedules(h.data); + const parent = h.w.document.createElement('section'); + parent.className = 'project-group collapsed'; + const group = h.w.document.createElement('div'); + group.className = 'slug-group collapsed'; + group.id = 'schedule-' + encodeURIComponent(JSON.stringify(['', '/real', 'nightly'])); + parent.appendChild(group); + h.w.document.body.appendChild(parent); + [...h.w.document.querySelectorAll('#schedules-content button')].find(b => b.textContent === 'Show history in Sessions').click(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(group.classList.contains('collapsed'), false, 'a physical run member expands its merged row project'); + h.w.document.querySelector('.schedule-history-run').click(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(h.calls[0], merged, 'openSession receives the full row and its merged membership for the existing resume guards'); + assert.equal(h.w.document.querySelector('[role="alert"]'), null); +}); diff --git a/test/file-lists-schedules.test.js b/test/file-lists-schedules.test.js new file mode 100644 index 000000000..f28d04168 --- /dev/null +++ b/test/file-lists-schedules.test.js @@ -0,0 +1,35 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { encodeProjectPath } = require('../encode-project-path'); + +test('Agent Files and its FTS files omit only runner schedules in project commands', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'file-schedules-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const oldHome = process.env.HOME; + const oldProfile = process.env.USERPROFILE; + process.env.HOME = root; + process.env.USERPROFILE = root; + t.after(() => { process.env.HOME = oldHome; process.env.USERPROFILE = oldProfile; }); + const { listMemories } = require('../file-lists'); + const project = path.join(root, 'project'); + const claudeDir = path.join(root, '.claude'); + const projectsDir = path.join(claudeDir, 'projects'); + const folder = path.join(projectsDir, encodeProjectPath(project)); + const commands = path.join(project, '.claude', 'commands'); + fs.mkdirSync(folder, { recursive: true }); + fs.mkdirSync(commands, { recursive: true }); + fs.writeFileSync(path.join(folder, 'seed.jsonl'), JSON.stringify({ type: 'user', cwd: project })); + for (const name of ['schedule-a.md', 'deploy.md', 'Schedule-B.md']) fs.writeFileSync(path.join(commands, name), name); + fs.writeFileSync(path.join(project, '.claude', 'schedule-root.md'), 'keep'); + fs.writeFileSync(path.join(claudeDir, 'schedule-global.md'), 'keep'); + const result = listMemories({ claudeDir, projectsDir, activeProjectPaths: [project] }, p => fs.readFileSync(p, 'utf8')); + for (const files of [result.files, result.view.projects.flatMap(p => p.files)]) { + assert.equal(files.some(f => f.filename === 'schedule-a.md'), false, 'runner schedules leave Agent Files and FTS'); + for (const name of ['deploy.md', 'Schedule-B.md', 'schedule-root.md']) assert.ok(files.some(f => f.filename === name), name); + } + assert.ok(result.view.global.files.some(f => f.filename === 'schedule-global.md')); +}); diff --git a/test/keyboard-focus-css.test.js b/test/keyboard-focus-css.test.js index d94848ffa..641117a7d 100644 --- a/test/keyboard-focus-css.test.js +++ b/test/keyboard-focus-css.test.js @@ -28,7 +28,7 @@ const containers = [ ['.session-item', '.session-actions'], ['.grid-card', '.grid-card-stop-btn'], ['.grid-card', '.grid-card-detach-btn'], - ['.memory-item', '.schedule-play-btn'], + ['.schedule-item', '.schedule-actions'], ['.project-header', '.remote-host-refresh-btn'], ['#file-panel-actions', '#file-panel-actions'], ]; diff --git a/test/projects-worker-conflicts-719.test.js b/test/projects-worker-conflicts-719.test.js index c887488bf..ea9ed9a0f 100644 --- a/test/projects-worker-conflicts-719.test.js +++ b/test/projects-worker-conflicts-719.test.js @@ -138,6 +138,11 @@ for (const away of [false, true]) test(`T5.5/T5.6 main conflict envelope survive vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../preload.js'), 'utf8'), { process, require: () => ({ contextBridge: { exposeInMainWorld: (_key, value) => { bridge = value; } }, ipcRenderer: transport }) }); const w = h.window, timers = []; + const schedulesContent = w.document.createElement('div'); + schedulesContent.id = 'schedules-content'; + w.document.body.append(schedulesContent); + const schedulesFilename = path.join(__dirname, '../public/schedules-view.js'); + vm.runInContext(fs.readFileSync(schedulesFilename, 'utf8'), h.context, { filename: schedulesFilename }); w.cachedProjects = [{ projectPath: 'accepted', sessions: [] }]; w.cachedAllProjects = w.cachedProjects; w.loadingStatus = w.document.createElement('div'); for (const name of ['reportActivityFocus', 'reportActivityTitles', 'refreshSidebar', 'renderDefaultStatus', 'maybeRetryRestoreWorkingSet']) w[name] = () => {}; @@ -147,9 +152,10 @@ for (const away of [false, true]) test(`T5.5/T5.6 main conflict envelope survive w.setTimeout = callback => { timers.push(callback); return timers.length; }; w.clearTimeout = () => {}; w.activeTab = away ? 'stats' : 'sessions'; loadAppFunctions(h.context, { declarations: ['searchGeneration'], functions: ['dedup', 'loadProjects', 'reloadProjects'] }); - const app = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8'); + const appFilename = path.join(__dirname, '../public/app.js'); + const app = fs.readFileSync(appFilename, 'utf8'); const eventStart = app.indexOf('let projectsChangedTimer = null;'); - vm.runInContext(app.slice(eventStart, app.indexOf('\n});', eventStart) + 4), h.context); + vm.runInContext(app.slice(eventStart, app.indexOf('\n});', eventStart) + 4), h.context, { filename: appFilename }); const initial = w.reloadProjects(); for (let i = 0; i < 3; i++) { await flush(); f.change(); f.reply(i, readonly); } assert.equal(await initial, false); assert.equal(w.cachedProjects[0].projectPath, 'accepted'); @@ -159,7 +165,7 @@ for (const away of [false, true]) test(`T5.5/T5.6 main conflict envelope survive for (const name of ['searchBar', 'workFilesContent', 'sessionFilters', 'placeholder']) w[name] = w.document.createElement('div'); w.searchInput = w.document.createElement('input'); w.hideAllViewers = () => {}; w.gridViewActive = false; const tabsStart = app.indexOf("document.querySelectorAll('.sidebar-tab').forEach(tab => {"); - vm.runInContext(app.slice(tabsStart, app.indexOf('\n// Grid view →', tabsStart)), h.context); + vm.runInContext(app.slice(tabsStart, app.indexOf('\n// Grid view →', tabsStart)), h.context, { filename: appFilename }); tab.click(); await flush(); assert.equal(w.activeTab, 'sessions'); } else { timers.shift()(); await flush(); } assert.equal(f.messages.length, 4, 'recovery dispatch requires no unrelated source event'); diff --git a/test/schedule-reenable-skip.test.js b/test/schedule-reenable-skip.test.js new file mode 100644 index 000000000..ee840fcc8 --- /dev/null +++ b/test/schedule-reenable-skip.test.js @@ -0,0 +1,118 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const runner = require('../src/main/schedules/schedule-runner'); +const { reenableScheduleFiles } = require('../archived-projects'); + +function fixture(t, catchUp = true) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'reenable-792-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const project = path.join(root, 'project'); + const commands = path.join(project, '.claude', 'commands'); + fs.mkdirSync(commands, { recursive: true }); + const file = path.join(commands, 'schedule-a.md'); + fs.writeFileSync(file, `---\ncron: 15 9 * * *\nenabled: false\ncatch-up: ${catchUp}\n---\nPrompt`); + const stateDir = path.join(root, 'state'); + if (catchUp) { + fs.mkdirSync(stateDir); + runner.claimScheduleMinute(stateDir, runner.scheduleStateKey({ filePath: file }), Math.floor(Date.now() / 60000) * 60000 - 180000, { file }); + } + const deps = { scheduleProjects: () => ({ list: async () => [project] }), stateDir }; + return { project, file, stateDir, deps }; +} + +test('set-schedule-enabled skips missed catch-up minutes and non-catch-up creates no state', async t => { + t.mock.timers.enable({ apis: ['Date'], now: new Date(2026, 10, 8, 9, 16, 30).getTime() }); + assert.equal(typeof runner.setListedScheduleEnabled, 'function', 'the listed-path enable operation exists'); + const h = fixture(t); + const baseline = Math.floor(Date.now() / 60000) * 60000 - 60000; + assert.equal((await runner.setListedScheduleEnabled(h.file, true, h.deps)).ok, true); + const state = runner.readScheduleStateOnly(h.stateDir).get(runner.scheduleStateKey({ filePath: h.file })); + assert.ok(state.handled >= baseline, 'missed minutes have been handled'); + assert.equal(runner.latestCronMatch('* * * * *', state.handled, baseline), null, 'a following catch-up check finds no missed minute'); + const isolation = process.env.SWITCHBOARD_DATA_DIR; + delete process.env.SWITCHBOARD_DATA_DIR; + const runs = []; + try { + const stop = runner.startScheduler({ info() {}, warn() {}, error() {} }, (...args) => runs.push(args), { stateDir: h.stateDir, projects: () => [h.project] }); + stop(); + assert.equal(runs.length, 0, 'the following scheduler catch-up check launches nothing'); + } finally { process.env.SWITCHBOARD_DATA_DIR = isolation; } + const plain = fixture(t, false); + assert.equal((await runner.setListedScheduleEnabled(plain.file, true, plain.deps)).ok, true); + assert.equal(fs.existsSync(plain.stateDir), false); + assert.equal((await runner.setListedScheduleEnabled(path.join(plain.project, 'outside.md'), true, plain.deps)).ok, false); + assert.equal((await runner.setListedScheduleEnabled(plain.file, 'true', plain.deps)).ok, false); +}); + +test('the catch-up baseline exists before the enable write, including isolated instances', async t => { + t.mock.timers.enable({ apis: ['Date'], now: new Date(2026, 10, 8, 9, 16, 30).getTime() }); + assert.equal(typeof runner.setListedScheduleEnabled, 'function', 'the listed-path enable operation exists'); + const h = fixture(t); + const result = await runner.setListedScheduleEnabled(h.file, true, { ...h.deps, + setEnabled(file, enabled, opts) { + const state = runner.readScheduleStateOnly(h.stateDir).get(runner.scheduleStateKey({ filePath: file })); + assert.ok(state, 'baseline precedes enabling'); + const baseline = Math.floor(Date.now() / 60000) * 60000 - 60000; + assert.equal(runner.latestCronMatch('* * * * *', state.handled, baseline), null); + const result = runner.setScheduleEnabled(file, enabled, opts); + const isolation = process.env.SWITCHBOARD_DATA_DIR; + delete process.env.SWITCHBOARD_DATA_DIR; + const runs = []; + try { + const stop = runner.startScheduler({ info() {}, warn() {}, error() {} }, (...args) => runs.push(args), { stateDir: h.stateDir, projects: () => [h.project] }); + stop(); + assert.equal(runs.length, 0, 'a catch-up check after the write cannot run a missed minute'); + } finally { process.env.SWITCHBOARD_DATA_DIR = isolation; } + return result; + }, + }); + assert.equal(result.ok, true); +}); + +test('offered and Undo catch-up schedules skip missed minutes before re-enabling', t => { + t.mock.timers.enable({ apis: ['Date'], now: new Date(2026, 10, 8, 9, 16, 30).getTime() }); + const h = fixture(t); + assert.deepEqual(reenableScheduleFiles([h.file], h.project, { stateDir: h.stateDir }), { enabled: ['schedule-a.md'], failed: [] }); + const handled = runner.readScheduleStateOnly(h.stateDir).get(runner.scheduleStateKey({ filePath: h.file })).handled; + assert.ok(handled >= Math.floor(Date.now() / 60000) * 60000 - 60000, 'offered schedules receive a baseline'); +}); + +test('the enable IPC wires listing guards and instance state through main and preload', async t => { + const vm = require('node:vm'); + const { createRequire } = require('node:module'); + const h = fixture(t); + const filename = path.join(__dirname, '..', 'main.js'); + const source = fs.readFileSync(filename, 'utf8'); + const start = source.indexOf("ipcMain.handle('set-schedule-enabled'"); + const end = source.indexOf("ipcMain.handle('list-schedules'", start); + assert.ok(start >= 0 && end > start, 'main registers the guarded enable operation'); + const handlers = new Map(); + vm.runInNewContext(source.slice(start, end), { + ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, + require: createRequire(filename), scheduleProjects: h.deps.scheduleProjects, + path, DB_PATH: path.join(path.dirname(h.stateDir), 'db'), + }, { filename, lineOffset: source.slice(0, start).split('\n').length - 1 }); + const result = await handlers.get('set-schedule-enabled')({}, h.file, true); + assert.equal(result.ok, true); + assert.ok(runner.readScheduleStateOnly(path.join(path.dirname(h.stateDir), 'schedule-state')) + .has(runner.scheduleStateKey({ filePath: h.file })), 'main passes this instance state directory'); + assert.equal((await handlers.get('set-schedule-enabled')({}, path.join(h.project, 'outside.md'), true)).ok, false); + let api; + const calls = []; + const preload = path.join(__dirname, '..', 'preload.js'); + vm.runInNewContext(fs.readFileSync(preload, 'utf8'), { + process: { platform: process.platform, argv: [] }, + require: name => { + assert.equal(name, 'electron'); + return { contextBridge: { exposeInMainWorld: (_name, value) => { api = value; } }, + ipcRenderer: { invoke: async (...args) => { calls.push(args); return { ok: true }; }, on() {}, send() {}, removeListener() {} } }; + }, + }, { filename: preload }); + assert.equal(typeof api.setScheduleEnabled, 'function'); + assert.equal((await api.setScheduleEnabled(h.file, false)).ok, true); + assert.deepEqual(calls, [['set-schedule-enabled', h.file, false]]); +}); diff --git a/test/search-stale-reply.test.js b/test/search-stale-reply.test.js index 068cfe87c..d0d039553 100644 --- a/test/search-stale-reply.test.js +++ b/test/search-stale-reply.test.js @@ -3,6 +3,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); +const fs = require('node:fs'); const vm = require('node:vm'); const { setupSidebarDom } = require('./dom-setup'); const { loadAppFunctions, readAppSource } = require('./app-source'); @@ -17,7 +18,11 @@ function setup(t, activeTab = 'sessions') { const ctx = setupSidebarDom(); t.after(() => ctx.destroy()); const { window, document } = ctx; + const schedulesContent = document.createElement('div'); + schedulesContent.id = 'schedules-content'; + document.body.append(schedulesContent); const requests = [], renders = [], frames = new Map(), timers = new Map(); + const delays = []; let nextId = 1; for (const name of ['searchInput', 'searchBar', 'workFilesContent', 'sessionFilters', 'terminalHeader', 'gridViewer', 'terminalArea', 'memoryViewer', 'settingsViewer', 'statsViewer']) { @@ -29,7 +34,7 @@ function setup(t, activeTab = 'sessions') { button.id = id; document.body.append(button); } - for (const name of ['sessions', 'memory', 'work-files', 'stats']) { + for (const name of ['sessions', 'memory', 'schedules', 'work-files', 'stats']) { const button = document.createElement('button'); button.className = 'sidebar-tab'; button.dataset.tab = name; @@ -46,7 +51,7 @@ function setup(t, activeTab = 'sessions') { renderMemories: ids => renders.push({ tab: 'memory', ids }), renderWorkFiles: ids => renders.push({ tab: 'work-files', ids }), loadMemories() {}, loadWorkFiles() {}, loadStats() {}, hideAllViewers() {}, fitAndScroll() {}, - setTimeout: cb => { const id = nextId++; timers.set(id, cb); return id; }, + setTimeout: (cb, ms) => { const id = nextId++; delays.push(ms); timers.set(id, cb); return id; }, clearTimeout: id => timers.delete(id), requestAnimationFrame: cb => { const id = nextId++; frames.set(id, cb); return id; }, cancelAnimationFrame: id => frames.delete(id), @@ -59,6 +64,8 @@ function setup(t, activeTab = 'sessions') { }; window.api.getSetting = async () => false; window.api.setSetting = async () => {}; + const schedulesFilename = path.join(__dirname, '..', 'public', 'schedules-view.js'); + vm.runInContext(fs.readFileSync(schedulesFilename, 'utf8'), ctx.context, { filename: schedulesFilename }); const src = readAppSource(); function section(start, end) { const from = src.indexOf(start); @@ -77,7 +84,7 @@ function setup(t, activeTab = 'sessions') { section("searchInput.addEventListener('input'", 'let rebuildInFlight ='); section("document.querySelectorAll('.sidebar-tab').forEach(tab =>", '// Grid view'); return { - ...ctx, ...fns, requests, renders, + ...ctx, ...fns, requests, renders, delays, start(query) { window.searchInput.value = query; return fns.runSearchQuery(); }, type(query) { window.searchInput.value = query; @@ -260,3 +267,27 @@ test('rebuild searches the current text and does not revive the query it interru assert.equal(ids(ctx), null); assert.equal(ctx.renders.length, count); }); + +test('Schedules tab drives the real listing lifecycle, hides search and leaves Work Files available', async t => { + const ctx = setup(t); + Object.defineProperty(ctx.document, 'hidden', { configurable: true, value: false }); + ctx.window.Date.now = () => 1500; + let listed = 0; + ctx.window.api.listSchedules = async () => { listed++; return { now: 1500, catchUpActive: false, projects: [] }; }; + ctx.switchTab('schedules'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(listed, 1, 'opening the tab lists schedules'); + assert.equal(ctx.window.searchBar.style.display, 'none'); + assert.equal(ctx.document.getElementById('schedules-content').style.display, ''); + assert.match(ctx.document.getElementById('schedules-content').textContent, /No schedules found/); + assert.equal(ctx.delays.at(-1), 58500, 'the refresh aligns with the next minute'); + ctx.flushTimers(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(listed, 2, 'a visible minute refresh lists again'); + ctx.switchTab('work-files'); + assert.equal(ctx.document.getElementById('schedules-content').style.display, 'none'); + assert.equal(ctx.window.workFilesContent.style.display, ''); + ctx.flushTimers(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(listed, 2, 'leaving the tab stops its refresh'); +}); diff --git a/test/theme-controls.test.js b/test/theme-controls.test.js index 2164e0c36..212ab1a5d 100644 --- a/test/theme-controls.test.js +++ b/test/theme-controls.test.js @@ -111,7 +111,7 @@ test('shared selects preserve the Settings look, themed menu and interaction sta test('every declared index button and every optional viewer toolbar button has a background rule', () => { const dom = new JSDOM(source('index.html'), { runScripts: 'outside-only' }); try { - assert.equal(assertThemedButtons(dom.window.document).length, 26); + assert.equal(assertThemedButtons(dom.window.document).length, 27); vm.runInContext(source('viewer-toolbar.js'), dom.getInternalVMContext()); const toolbar = dom.window.createViewerToolbar({ copyPath: true, copyContent: true, preview: true, wrap: true, gotoLine: true, From 493320ba4a7d3243a2424c7a8d6cee034604ac87 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sun, 8 Nov 2026 21:57:55 +0100 Subject: [PATCH 2/4] (schedules): keep the Schedules tab's row state across refreshes Run now keeps its launched state and cannot be clicked twice, focus and open history survive a refresh, reloads are coalesced, next runs read as minutes, today, tomorrow or a date, and re-enabling during a due minute that already passed skips it. --- .ai/contexts/ipc-bridge.md | 6 +- .ai/contexts/schedule-runner.md | 25 +++- archived-projects.js | 6 +- eslint.config.js | 1 + public/app.js | 2 +- public/schedules-view.js | 122 +++++++++++++++---- public/style.css | 3 - src/main/schedules/schedule-runner.js | 7 +- test/dom-schedules-tab.test.js | 163 +++++++++++++++++++++++++- test/schedule-reenable-skip.test.js | 83 ++++++++++++- 10 files changed, 373 insertions(+), 45 deletions(-) diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index e7dace4b4..2988a7820 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -336,7 +336,6 @@ uninitialised submodule refuses preview/deletion before force can hide its files | IPC | Returns | |---|---| | `get-memories` | `{global, projects}` | -| `set-schedule-enabled` | `listScheduleFiles(await scheduleProjects().list())` exact path equality, boolean argument, unlinked file and readable front matter; skip missed catch-up before enabling | current registry listing + disk-resolved link guard | | `read-memory` / `save-memory` | content / `{ok}` | | `get-work-files` | `{projects: WorkFilesProject[]}` — **dedupes by projectPath** since PR #15. Walks `/.work-files/` recursively, capped at 200 files per project. | | `read-work-file` / `delete-work-file` | content (with `.work-files/` path guard) / `{ok}` | @@ -513,6 +512,7 @@ Every handler that takes a renderer-supplied path or derives a spawn location fr | IPC | Guard | Kind | |---|---|---| +| `set-schedule-enabled` | `listScheduleFiles(await scheduleProjects().list())` exact path equality, boolean argument, unlinked file and readable front matter; skip missed catch-up before enabling | current registry listing + disk-resolved link guard | | `read-memory` / `save-memory` | `resolveAllowedMemoryPath` (read/write the returned path, not the caller's own re-resolved one) | disk-resolved allowlist | | `get-memories` (the list, and the FTS bodies it indexes) | `acceptMdFile` (`scan-md-files.js`) applies `isSensitivePath` itself and the `isAllowedMemoryPath` predicate `get-memories` passes in; `scanMdFiles` is the directory-wide form of it, and the project-root `CLAUDE.md`/`GEMINI.md`/`agents.md` go through it by name | disk-resolved denylist + allowlist — a listed file is read twice more downstream, once by `read-memory` behind `resolveAllowedMemoryPath` and once by the FTS indexer behind nothing at all, so the guard belongs on the list. The denylist is not the caller's to choose: containment in an allowed project root says nothing about a cloned repo's own `.ssh`/`.env` | | `open-main-log` | Main chooses electron-log's path; requires a string absolute path and `!isSensitivePath` before `shell.openPath`. | no renderer-supplied path; disk-resolved denylist | @@ -1147,7 +1147,3 @@ records or transport failures cannot break publication. Schedules reuse `run-schedule-now`, `read-memory`, `save-memory` and `schedule-run-exit` unchanged. Delete/create IPCs are not part of A2. - -IPC path-guard inventory addition: `set-schedule-enabled` matches the supplied -path exactly against `listScheduleFiles(await scheduleProjects().list())` at -call time and refuses linked files and unread front matter before writing. diff --git a/.ai/contexts/schedule-runner.md b/.ai/contexts/schedule-runner.md index ca3f32e40..cb0ded0f6 100644 --- a/.ai/contexts/schedule-runner.md +++ b/.ai/contexts/schedule-runner.md @@ -431,10 +431,10 @@ caught up again. **Failure.** If the directory cannot be read or a record cannot be written, the schedule falls back to plain cron matching on ticks and a warning is logged. -**Not covered.** A disabled schedule is dropped by `scanSchedules` before the -check, so re-enabling one catches up a run missed while disabled (within seven -days). `run-schedule-now` goes through `schedule-ipc.js` and does not touch the -record. +**Re-enable.** Disabled schedules are dropped by `scanSchedules`. Re-enabling +through the tab, an archive offer or Undo first skips missed minutes through +the shared helper described below. `run-schedule-now` goes through +`schedule-ipc.js` and does not touch the record. Tests: `test/schedule-catch-up.test.js` (fake `Date`/timers, `HOME` pointed at a temporary directory). @@ -472,7 +472,7 @@ PROJECTS_DIR scans), `test/schedule-next-run.test.js` (descriptions and DST). ## Schedules tab (#792, slice A2) `public/schedules-view.js` consumes `list-schedules` on open, after each action, -on projects-changed and on minute boundaries while visible. The clock tab follows +on projects-changed (coalesced over 900 ms) and on minute boundaries while visible. The clock tab follows Agent Files; Work Files is preserved. Search is hidden. Rows show cron, local next-run and relative time, disabled/archive/validation reasons, shared-slug diagnostics, catch-up status and the newest 20 runs from the main-side list. @@ -485,6 +485,12 @@ fresh indexed session and goes through openSession, preserving resume guards. listScheduleFiles over the registry at call time. Linked/unread front matter is refused. Before enabling a disabled catch-up schedule, skipMissedRunsOf claims the prior-minute baseline, then setScheduleEnabled atomically rewrites the file. +When re-enable happens after the start of a due minute, that current minute is +claimed too, so its tick missed while off cannot replay on the next check. At +the exact minute boundary the previous-minute baseline still lets the tick run. +Unarchive uses the same helper and therefore the same rule. Missing stateDir +refuses a catch-up re-enable with MissingScheduleStateDir. Archive parsing uses +the runner's shared scheduleFields helper. This ordering also applies in isolated instances. Archive offers and Undo receive the same stateDir via archiveDeps.reenable and reenableScheduleFiles; the writer continues to perform file effects outside SQL. Skip failure prevents enabling. @@ -500,6 +506,15 @@ says No result recorded and gives the assistant/seed/started/unknown transcript hint. It never infers Exit 0. Persistent outcomes, deletion, the creation form and creator-template migration are later slices. +Action state is keyed by filePath across renders: a pending launch stays disabled, +success shows the check mark and Launched! for two seconds, and a refusal restores +Run now with the persistent notice. A new launch invalidates the previous success +timer. Pending toggles likewise stay disabled. A render restores each row's open +history and focused control by filePath and control identity. An unmatched project +focus request is consumed by that render. Guard refusal reasons are visible row text. +Next-run wording uses minutes below an hour, then today's time, tomorrow's time, +a weekday within the following week, or a date for later runs. + History navigation maps the listed run IDs to local indexed project paths, so a registry alias and its resolved transcript folder expand the same visible group. Opening a run selects a local indexed row before openSession; remote copies of diff --git a/archived-projects.js b/archived-projects.js index 30f6b28c1..ec906df38 100644 --- a/archived-projects.js +++ b/archived-projects.js @@ -5,7 +5,7 @@ const path = require('path'); const { joinFolderKey, parseFolderKey } = require('./src/main/remote/remote-hosts'); const { encodeProjectPath } = require('./encode-project-path'); const { worktreeParentPath, isHiddenRepositoryWorktree } = require('./public/worktree-nesting'); -const { scheduleFileUnlinked, setScheduleEnabled, parseFrontmatter, skipMissedRunsOf } = require('./src/main/schedules/schedule-runner'); +const { scheduleFields, scheduleFileUnlinked, setScheduleEnabled, parseFrontmatter, skipMissedRunsOf } = require('./src/main/schedules/schedule-runner'); const SETTING_KEY = 'archivedProjects'; const OFFERS_KEY = 'scheduleReenableOffers'; @@ -152,9 +152,9 @@ function reenableScheduleFiles(filePaths, projectRoot, { stateDir } = {}) { try { meta = parseFrontmatter(fs.readFileSync(filePath, 'utf8')).meta; } catch { continue; } if (meta.enabled !== 'false') continue; const name = meta.name || path.basename(filePath); - if (stateDir && scheduleFileUnlinked(projectRoot, filePath)) { + if (scheduleFileUnlinked(projectRoot, filePath)) { try { - skipMissedRunsOf({ filePath, name, catchUp: /^(["']?)true\1$/i.test(meta['catch-up'] || '') }, stateDir); + skipMissedRunsOf({ ...scheduleFields(path.basename(filePath), meta), filePath, cron: meta.cron }, stateDir); } catch (error) { failed.push({ name, filePath, error: error.message }); continue; diff --git a/eslint.config.js b/eslint.config.js index b3c7c248c..3ca97fa88 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -26,6 +26,7 @@ const ACTIVITY_CLASS_MESSAGE = 'Only public/session-activity-dom.js may write .c const rendererCrossFileGlobals = { setSchedulesVisible: 'readonly', loadSchedules: 'readonly', + queueSchedulesReload: 'readonly', showSchedules: 'readonly', openScheduleMemory: 'readonly', openScheduleRun: 'readonly', diff --git a/public/app.js b/public/app.js index ea7725085..5ada23f5e 100644 --- a/public/app.js +++ b/public/app.js @@ -2978,7 +2978,7 @@ loadStartupProjects(); let projectsChangedTimer = null; let projectsChangedWhileAway = false; window.api.onProjectsChanged(() => { - if (activeTab === 'schedules') loadSchedules(); + if (activeTab === 'schedules') queueSchedulesReload(); // Debounce to avoid rapid re-renders during bulk changes if (projectsChangedTimer) clearTimeout(projectsChangedTimer); if (activeTab !== 'sessions') { diff --git a/public/schedules-view.js b/public/schedules-view.js index 3b4afb08d..932153c52 100644 --- a/public/schedules-view.js +++ b/public/schedules-view.js @@ -4,16 +4,29 @@ let schedulesVisible = false; let schedulesTimer = null; let schedulesRequest = 0; let schedulesProject = null; +let schedulesReloadTimer = null; +const schedulesActions = new Map(); function setSchedulesVisible(visible) { schedulesVisible = visible; clearTimeout(schedulesTimer); schedulesTimer = null; + clearTimeout(schedulesReloadTimer); + schedulesReloadTimer = null; if (!visible) { schedulesRequest++; return; } loadSchedules(); scheduleSchedulesRefresh(); } +function queueSchedulesReload() { + if (!schedulesVisible) return; + clearTimeout(schedulesReloadTimer); + schedulesReloadTimer = setTimeout(() => { + schedulesReloadTimer = null; + if (schedulesVisible) loadSchedules(); + }, 900); +} + function scheduleSchedulesRefresh() { if (!schedulesVisible) return; schedulesTimer = setTimeout(() => { @@ -23,6 +36,8 @@ function scheduleSchedulesRefresh() { } async function loadSchedules() { + clearTimeout(schedulesReloadTimer); + schedulesReloadTimer = null; const request = ++schedulesRequest; try { const data = await window.api.listSchedules(); @@ -46,9 +61,14 @@ function scheduleTime(ms, now) { const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1); const minutes = Math.max(1, Math.ceil((ms - now) / 60000)); - const relative = date.toDateString() === tomorrow.toDateString() - ? `tomorrow ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}` - : `in ${minutes} min`; + const clock = `${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`; + const week = new Date(now); + week.setDate(week.getDate() + 7); + const relative = ms - now < 3600000 ? `in ${minutes} min` + : date.toDateString() === new Date(now).toDateString() ? `today ${clock}` + : date.toDateString() === tomorrow.toDateString() ? `tomorrow ${clock}` + : ms <= week.getTime() ? `${date.toLocaleDateString('en', { weekday: 'long' })} ${clock}` + : `${date.toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' })} ${clock}`; return `${date.toLocaleString()} (${relative})`; } @@ -72,6 +92,14 @@ function scheduleText(parent, className, text) { function renderSchedules(data) { const container = document.getElementById('schedules-content'); if (!container) return; + const rows = [...container.querySelectorAll('.schedule-item')]; + const openHistory = new Map(rows.map(row => [row.dataset.filepath, row.querySelector('details').open])); + const focused = document.activeElement; + const focusedRow = focused?.closest('.schedule-item'); + const focusedPath = container.contains(focusedRow) ? focusedRow.dataset.filepath : null; + const focusedControl = focused?.dataset.scheduleControl; + const projectFocus = schedulesProject; + schedulesProject = null; container.replaceChildren(); scheduleText(container, 'schedule-help', 'Runs before v0.0.90 carry no schedule marker and are not listed. Runs before results were kept, runs of another instance and runs alive when Switchboard closes have no result recorded.'); let count = 0; @@ -83,16 +111,47 @@ function renderSchedules(data) { group.dataset.projectPath = project.projectPath; scheduleText(group, 'schedule-project-title', project.shortName); scheduleText(group, 'schedule-path', project.projectPath); - for (const schedule of project.schedules) group.appendChild(buildScheduleItem(project, schedule, data)); + for (const schedule of project.schedules) { + const item = buildScheduleItem(project, schedule, data); + item.querySelector('details').open = openHistory.get(schedule.filePath) || false; + group.appendChild(item); + } container.appendChild(group); - if (schedulesProject === project.projectPath) { + if (projectFocus === project.projectPath) { group.tabIndex = -1; group.focus({ preventScroll: true }); group.scrollIntoView?.({ block: 'start' }); - schedulesProject = null; } } if (!count) scheduleText(container, 'plans-empty', 'No schedules found.'); + if (!projectFocus && focusedPath && focusedControl) { + const row = [...container.querySelectorAll('.schedule-item')].find(item => item.dataset.filepath === focusedPath); + [...(row?.querySelectorAll('[data-schedule-control]') || [])] + .find(control => control.dataset.scheduleControl === focusedControl)?.focus({ preventScroll: true }); + } + const paths = new Set(data.projects.flatMap(project => project.schedules.map(schedule => schedule.filePath))); + for (const [filePath, state] of schedulesActions) { + if (!paths.has(filePath) && state.run !== 'running' && !state.toggling) schedulesActions.delete(filePath); + } +} + +function scheduleActionState(filePath) { + if (!schedulesActions.has(filePath)) schedulesActions.set(filePath, {}); + return schedulesActions.get(filePath); +} + +function updateScheduleRunButtons(filePath, original) { + const state = scheduleActionState(filePath); + const buttons = new Set([...document.querySelectorAll('.schedule-run-btn')] + .filter(button => button.closest('.schedule-item').dataset.filepath === filePath)); + if (original) buttons.add(original); + for (const button of buttons) { + button.disabled = button.dataset.unavailable === 'true' || state.run === 'running'; + button.classList.toggle('running', state.run === 'running'); + button.classList.toggle('done', state.run === 'done'); + button.textContent = state.run === 'running' ? 'Running...' : state.run === 'done' ? '\u2713' : 'Run now'; + button.title = state.run === 'running' ? 'Running...' : state.run === 'done' ? 'Launched!' : button.dataset.reason; + } } function buildScheduleItem(project, schedule, data) { @@ -118,10 +177,15 @@ function buildScheduleItem(project, schedule, data) { const actions = document.createElement('div'); actions.className = 'schedule-actions'; const unread = schedule.problems.some(p => p.startsWith('front matter not read')); + const actionState = scheduleActionState(schedule.filePath); + if (!project.known) scheduleText(item, 'schedule-problems', 'not a known project'); const action = (cls, label, disabled, reason, handler) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = cls; + btn.dataset.scheduleControl = cls; + btn.dataset.unavailable = String(disabled); + btn.dataset.reason = disabled ? reason : label; btn.textContent = label; btn.title = disabled ? reason : label; btn.disabled = disabled; @@ -131,9 +195,12 @@ function buildScheduleItem(project, schedule, data) { }; const play = action('schedule-run-btn', 'Run now', !project.known || unread, !project.known ? 'not a known project' : 'front matter not read', () => runScheduleFromTab(schedule, play)); - action('schedule-toggle-btn', schedule.enabled ? 'Turn off' : 'Turn on', schedule.linked || unread, + updateScheduleRunButtons(schedule.filePath, play); + const toggle = action('schedule-toggle-btn', schedule.enabled ? 'Turn off' : 'Turn on', schedule.linked || unread, schedule.linked ? 'linked file: edit it at its source' : 'front matter not read', async event => { const btn = event.currentTarget; + if (actionState.toggling) return; + actionState.toggling = true; btn.disabled = true; const id = `SetSchedule:${schedule.filePath}`; try { @@ -142,8 +209,16 @@ function buildScheduleItem(project, schedule, data) { clearAppNotice(id); } catch (error) { showAppNotice({ id, kind: 'error', message: `Could not change "${schedule.file}": ${error?.message || error}. Check the schedule file and retry in the Schedules tab.` }); - } finally { btn.disabled = false; await loadSchedules(); } + } finally { + actionState.toggling = false; + btn.disabled = false; + for (const current of document.querySelectorAll('.schedule-toggle-btn')) { + if (current.closest('.schedule-item').dataset.filepath === schedule.filePath) current.disabled = current.dataset.unavailable === 'true'; + } + await loadSchedules(); + } }); + toggle.disabled ||= !!actionState.toggling; action('schedule-open-btn', 'Open', !project.known, 'not a known project', async () => { const id = `OpenSchedule:${schedule.filePath}`; try { @@ -159,6 +234,7 @@ function buildScheduleItem(project, schedule, data) { item.appendChild(actions); const history = document.createElement('details'); const summary = document.createElement('summary'); + summary.dataset.scheduleControl = 'history-summary'; const runs = schedule.runs.slice(0, 20); summary.textContent = runs.length ? `Last run: ${new Date(runs[0].created).toLocaleString()} — ${scheduleOutcome(runs[0])}` : 'No recorded run'; history.appendChild(summary); @@ -166,12 +242,14 @@ function buildScheduleItem(project, schedule, data) { const row = document.createElement('button'); row.type = 'button'; row.className = 'schedule-history-run'; + row.dataset.scheduleControl = `history:${run.sessionId}`; row.textContent = `${new Date(run.created).toLocaleString()} — ${scheduleOutcome(run)}`; row.addEventListener('click', () => openScheduleRun(project, schedule, run)); history.appendChild(row); } const sessions = document.createElement('button'); sessions.type = 'button'; + sessions.dataset.scheduleControl = 'sessions'; sessions.textContent = 'Show history in Sessions'; sessions.addEventListener('click', () => showScheduleSessions(project.projectPath, schedule.slug, schedule.runs.map(run => run.sessionId))); history.appendChild(sessions); @@ -180,28 +258,28 @@ function buildScheduleItem(project, schedule, data) { } async function runScheduleFromTab(schedule, button) { - const icon = button.innerHTML; + const state = scheduleActionState(schedule.filePath); + if (state.run === 'running') return; + const token = {}; + state.token = token; + state.run = 'running'; const id = `RunSchedule:${schedule.filePath}`; - button.disabled = true; - button.classList.add('running'); - button.title = 'Running...'; - button.textContent = 'Running...'; + updateScheduleRunButtons(schedule.filePath, button); try { const result = await window.api.runScheduleNow(schedule.filePath); if (!result?.ok) throw new Error(result?.error || 'The backend returned no result'); clearAppNotice(id); - button.classList.add('done'); - button.textContent = '\u2713'; - button.title = 'Launched!'; - setTimeout(() => { button.classList.remove('done'); button.innerHTML = icon; button.title = 'Run now'; }, 2000); + state.run = 'done'; + setTimeout(() => { + if (state.token !== token) return; + state.run = null; + updateScheduleRunButtons(schedule.filePath, button); + }, 2000); } catch (error) { - button.classList.remove('done'); - button.innerHTML = icon; - button.title = 'Run now'; + state.run = null; showAppNotice({ id, kind: 'error', message: `Could not run "${schedule.file}": ${error?.message || error || 'The backend is unavailable'}. Check the schedule file, then try Run now again.` }); } finally { - button.disabled = false; - button.classList.remove('running'); + updateScheduleRunButtons(schedule.filePath, button); if (schedulesVisible) await loadSchedules(); } } diff --git a/public/style.css b/public/style.css index ec970a637..ed06da07d 100644 --- a/public/style.css +++ b/public/style.css @@ -3602,9 +3602,6 @@ body { display: flex; flex-direction: column; } color: #8888a8; } - - - .memory-file-count { font-size: 10px; color: #7a7a90; diff --git a/src/main/schedules/schedule-runner.js b/src/main/schedules/schedule-runner.js index 8d47dabc2..a59072906 100644 --- a/src/main/schedules/schedule-runner.js +++ b/src/main/schedules/schedule-runner.js @@ -565,7 +565,10 @@ function skipMissedRuns(log, projects, stateDir) { function skipMissedRunsOf(schedule, stateDir) { if (!schedule.catchUp) return; - const baseline = Math.floor(Date.now() / MINUTE_MS) * MINUTE_MS - MINUTE_MS; + if (!stateDir) throw new Error('MissingScheduleStateDir: cannot skip missed schedule runs'); + const now = Date.now(); + const minute = Math.floor(now / MINUTE_MS) * MINUTE_MS; + const baseline = now > minute && typeof schedule.cron === 'string' && cronMatches(schedule.cron, new Date(minute)) ? minute : minute - MINUTE_MS; const state = readScheduleState(stateDir); const key = scheduleStateKey(schedule); const entry = state.get(key); @@ -791,4 +794,4 @@ function setScheduleEnabled(filePath, enabled, { projectRoot, rewrite = rewriteE } } -module.exports = { setListedScheduleEnabled, skipMissedRunsOf, listScheduleFiles, isRunnerScheduleFileName, nextCronMatch, describeCron, scheduleLaunchRefusal, readScheduleStateOnly, latestCronMatch, scheduleStateKey, scheduleFileUnlinked, setScheduleEnabled, parseFrontmatter, cronMatches, scanSchedules, startScheduler, skipMissedRuns, createScheduleSession, buildScheduleCommand, claimScheduleMinute, initialScheduleProjects, refusedScheduleBinds, scheduleBindRefusals, resolveScheduleSandbox, scheduleRegistry }; +module.exports = { scheduleFields, setListedScheduleEnabled, skipMissedRunsOf, listScheduleFiles, isRunnerScheduleFileName, nextCronMatch, describeCron, scheduleLaunchRefusal, readScheduleStateOnly, latestCronMatch, scheduleStateKey, scheduleFileUnlinked, setScheduleEnabled, parseFrontmatter, cronMatches, scanSchedules, startScheduler, skipMissedRuns, createScheduleSession, buildScheduleCommand, claimScheduleMinute, initialScheduleProjects, refusedScheduleBinds, scheduleBindRefusals, resolveScheduleSandbox, scheduleRegistry }; diff --git a/test/dom-schedules-tab.test.js b/test/dom-schedules-tab.test.js index d3b44aa40..d3dc341fe 100644 --- a/test/dom-schedules-tab.test.js +++ b/test/dom-schedules-tab.test.js @@ -9,7 +9,7 @@ const { loadAppFunctions, extractFunction } = require('./app-source'); function setup(t, run = () => ({ ok: true })) { const dom = new JSDOM('
', { runScripts: 'outside-only', url: 'http://localhost' }); - t.after(() => dom.window.close()); + t.after(() => { dom.window.setSchedulesVisible(false); dom.window.close(); }); const ctx = dom.getInternalVMContext(); const calls = []; const project = { projectPath: '/p', shortName: 'p', known: true, archived: false, schedules: [] }; @@ -43,9 +43,167 @@ function setup(t, run = () => ({ ok: true })) { const titles = []; new w.MutationObserver(records => titles.push(...records.map(record => record.oldValue), button.title)) .observe(button, { attributes: true, attributeFilter: ['title'], attributeOldValue: true }); - return { dom, w, project, schedule, data, calls, button, titles, opened }; + w.setSchedulesVisible(true); + return { dom, w, project, schedule, data, calls, get button() { return w.document.querySelector('.schedule-run-btn'); }, titles, opened }; } +const settle = () => new Promise(resolve => setImmediate(resolve)); + +test('visible successful launch retains its acknowledgement after the action reload', async t => { + const h = setup(t); + h.w.setSchedulesVisible(true); + await settle(); + h.w.document.querySelector('.schedule-run-btn').click(); + await settle(); + assert.equal(h.w.document.querySelector('.schedule-run-btn').title, 'Launched!', 'visible success survives action reload'); +}); + +test('visible Run now stays in flight across refreshes and shows launch success until its timer expires', async t => { + let finish; + const h = setup(t, () => new Promise(resolve => { finish = resolve; })); + h.w.setSchedulesVisible(true); + await settle(); + const button = () => h.w.document.querySelector('.schedule-run-btn'); + button().click(); + await h.w.loadSchedules(); + assert.equal(button().disabled, true, 'visible in-flight launch cannot be clicked again'); + assert.equal(button().title, 'Running...'); + assert.equal(button().classList.contains('running'), true); + button().click(); + assert.equal(h.calls.length, 1); + finish({ ok: true }); + await settle(); + assert.equal(button().title, 'Launched!', 'visible row retains launch acknowledgement after action reload'); + assert.equal(button().textContent, '\u2713'); + assert.equal(button().disabled, false); + await h.w.loadSchedules(); + assert.equal(button().classList.contains('done'), true); + await new Promise(resolve => h.w.setTimeout(resolve, 2100)); + assert.equal(button().title, 'Run now'); + assert.equal(button().classList.contains('done'), false); +}); + +for (const [label, result] of [['refusal', { ok: false, error: 'refused' }], ['missing reply', undefined]]) { + test(`visible Run now ${label} restores the current row and leaves the notice`, async t => { + let finish; + const h = setup(t, () => new Promise(resolve => { finish = resolve; })); + h.w.setSchedulesVisible(true); + await settle(); + h.w.document.querySelector('.schedule-run-btn').click(); + await h.w.loadSchedules(); + assert.equal(h.w.document.querySelector('.schedule-run-btn').disabled, true, 'pending state survives refresh'); + finish(result); + await settle(); + const button = h.w.document.querySelector('.schedule-run-btn'); + assert.equal(button.disabled, false); + assert.equal(button.title, 'Run now'); + assert.equal(button.classList.contains('running'), false); + assert.equal(button.classList.contains('done'), false); + assert.equal(h.w.document.querySelector('[role="alert"]').dataset.noticeId, 'RunSchedule:/p/schedule-nightly.md'); + }); +} + +test('visible history stays open across a refresh', async t => { + const h = setup(t); + h.w.setSchedulesVisible(true); + await settle(); + h.w.document.querySelector('details').open = true; + await h.w.loadSchedules(); + assert.equal(h.w.document.querySelector('details').open, true, 'open history survives refresh'); +}); + +test('a visible pending toggle recovers its current button even if the following reload fails', async t => { + const h = setup(t); + await settle(); + let finish; + h.w.api.setScheduleEnabled = () => new Promise(resolve => { finish = resolve; }); + h.w.document.querySelector('.schedule-toggle-btn').click(); + await h.w.loadSchedules(); + assert.equal(h.w.document.querySelector('.schedule-toggle-btn').disabled, true, 'pending toggle survives refresh'); + h.w.api.listSchedules = async () => { throw new Error('offline'); }; + finish({ ok: true }); + await settle(); + assert.equal(h.w.document.querySelector('.schedule-toggle-btn').disabled, false, 'settled toggle restores the visible control despite reload failure'); +}); + +test('visible focused row controls keep focus across refreshes', async t => { + const h = setup(t); + h.schedule.runs = [{ sessionId: 'focused', created: '2026-11-08T12:00Z', replied: true, result: null }]; + h.w.setSchedulesVisible(true); + await settle(); + for (const selector of ['.schedule-run-btn', '.schedule-toggle-btn', '.schedule-open-btn', 'summary', '.schedule-history-run', 'details button:last-child']) { + h.w.document.querySelector('details').open = true; + h.w.document.querySelector(selector).focus(); + await h.w.loadSchedules(); + assert.equal(h.w.document.activeElement, h.w.document.querySelector(selector), `${selector} keeps focus`); + } +}); + +test('projects-changed bursts coalesce into one visible schedule reload', async t => { + const h = setup(t); + h.w.setSchedulesVisible(false); + const timers = new Map(); + let timerId = 0; + h.w.setTimeout = (callback, delay) => { const id = ++timerId; timers.set(id, { callback, delay }); return id; }; + h.w.clearTimeout = id => timers.delete(id); + h.w.setSchedulesVisible(true); + await settle(); + let calls = 0; + h.w.api.listSchedules = async () => { calls++; return h.data; }; + const filename = path.join(__dirname, '../public/app.js'); + const source = fs.readFileSync(filename, 'utf8'); + const start = source.indexOf('let projectsChangedTimer ='); + const end = source.indexOf('\n});', start) + '\n});'.length; + let changed; + h.w.api.onProjectsChanged = handler => { changed = handler; }; + vm.runInContext(source.slice(start, end), h.dom.getInternalVMContext(), { filename, + lineOffset: source.slice(0, start).split('\n').length - 1 }); + for (let i = 0; i < 8; i++) changed(); + await settle(); + assert.equal(calls, 0, 'burst waits for the debounce window'); + const pending = [...timers.values()].filter(timer => timer.delay === 900); + assert.equal(pending.length, 1, 'one pending debounce timer'); + pending[0].callback(); + await settle(); + assert.equal(calls, 1, 'one list-schedules request per burst'); +}); + +test('next-run wording uses minutes, today, tomorrow, weekday and date', t => { + const h = setup(t); + const now = new Date(2026, 10, 8, 8, 0).getTime(); + for (const [date, expected] of [ + [new Date(2026, 10, 8, 8, 12), /\(in 12 min\)$/], + [new Date(2026, 10, 8, 9, 0), /\(today 9:00\)$/], + [new Date(2026, 10, 8, 23, 0), /\(today 23:00\)$/], + [new Date(2026, 10, 9, 9, 0), /\(tomorrow 9:00\)$/], + [new Date(2026, 10, 13, 9, 0), /\(Friday 9:00\)$/], + [new Date(2026, 11, 1, 9, 0), /\(Dec 1, 2026 9:00\)$/], + ]) assert.match(h.w.scheduleTime(date.getTime(), now), expected); + h.schedule.cron = '0 9 * * 5'; + h.schedule.cronText = 'weekly on Friday at 9:00'; + h.schedule.nextRunMs = new Date(2026, 10, 13, 9, 0).getTime(); + h.data.now = now; + h.w.renderSchedules(h.data); + assert.match(h.w.document.querySelector('.schedule-state').textContent, /Friday 9:00/); +}); + +test('unknown project refusal is visible in row text', t => { + const h = setup(t); + h.project.known = false; + h.w.renderSchedules(h.data); + assert.match(h.w.document.querySelector('.schedule-item').textContent, /not a known project/); +}); + +test('an unmatched project focus request is cleared after rendering', async t => { + const h = setup(t); + h.w.showSchedules('/missing'); + h.w.renderSchedules(h.data); + h.project.projectPath = '/missing'; + h.w.renderSchedules(h.data); + assert.equal(h.w.document.activeElement, h.w.document.body, 'a stale unmatched request cannot steal focus later'); + await settle(); +}); + for (const [label, run, reason] of [ ['backend refusal', () => ({ ok: false, error: 'boom' }), 'boom'], ['rejected IPC', () => { throw new Error('offline'); }, 'offline'], @@ -130,6 +288,7 @@ test('unknown projects and unread front matter disable their guarded actions', t h.project.known = true; h.schedule.problems = ['front matter not read: save with LF line endings']; h.w.renderSchedules(h.data); + assert.match(h.w.document.querySelector('.schedule-item').textContent, /front matter not read: save with LF line endings/); for (const cls of ['.schedule-run-btn', '.schedule-toggle-btn']) assert.equal(h.w.document.querySelector(cls).disabled, true, cls); h.schedule.problems = []; h.schedule.linked = true; diff --git a/test/schedule-reenable-skip.test.js b/test/schedule-reenable-skip.test.js index ee840fcc8..348c069e5 100644 --- a/test/schedule-reenable-skip.test.js +++ b/test/schedule-reenable-skip.test.js @@ -40,7 +40,7 @@ test('set-schedule-enabled skips missed catch-up minutes and non-catch-up create const stop = runner.startScheduler({ info() {}, warn() {}, error() {} }, (...args) => runs.push(args), { stateDir: h.stateDir, projects: () => [h.project] }); stop(); assert.equal(runs.length, 0, 'the following scheduler catch-up check launches nothing'); - } finally { process.env.SWITCHBOARD_DATA_DIR = isolation; } + } finally { if (isolation === undefined) delete process.env.SWITCHBOARD_DATA_DIR; else process.env.SWITCHBOARD_DATA_DIR = isolation; } const plain = fixture(t, false); assert.equal((await runner.setListedScheduleEnabled(plain.file, true, plain.deps)).ok, true); assert.equal(fs.existsSync(plain.stateDir), false); @@ -66,13 +66,92 @@ test('the catch-up baseline exists before the enable write, including isolated i const stop = runner.startScheduler({ info() {}, warn() {}, error() {} }, (...args) => runs.push(args), { stateDir: h.stateDir, projects: () => [h.project] }); stop(); assert.equal(runs.length, 0, 'a catch-up check after the write cannot run a missed minute'); - } finally { process.env.SWITCHBOARD_DATA_DIR = isolation; } + } finally { if (isolation === undefined) delete process.env.SWITCHBOARD_DATA_DIR; else process.env.SWITCHBOARD_DATA_DIR = isolation; } return result; }, }); assert.equal(result.ok, true); }); +for (const operation of ['listed enable', 'archive offer', 'unarchive']) { + test(`${operation} during an already ticked due minute does not replay it at the next check`, async t => { + t.mock.timers.enable({ apis: ['Date'], now: new Date(2026, 10, 8, 9, 15, 0).getTime() }); + const isolation = process.env.SWITCHBOARD_DATA_DIR; + delete process.env.SWITCHBOARD_DATA_DIR; + t.after(() => { if (isolation === undefined) delete process.env.SWITCHBOARD_DATA_DIR; else process.env.SWITCHBOARD_DATA_DIR = isolation; }); + const h = fixture(t); + const runs = []; + const check = () => runner.startScheduler({ info() {}, warn() {}, error() {} }, (...args) => runs.push(args), + { stateDir: h.stateDir, projects: () => [h.project] })(); + check(); + t.mock.timers.setTime(new Date(2026, 10, 8, 9, 15, 30).getTime()); + if (operation === 'listed enable') assert.equal((await runner.setListedScheduleEnabled(h.file, true, h.deps)).ok, true); + else if (operation === 'archive offer') assert.equal(reenableScheduleFiles([h.file], h.project, { stateDir: h.stateDir }).failed.length, 0); + else { + runner.setScheduleEnabled(h.file, true, { projectRoot: h.project }); + runner.skipMissedRuns({ info() {}, warn() {}, error() {} }, [h.project], h.stateDir); + } + t.mock.timers.setTime(new Date(2026, 10, 8, 9, 16, 0).getTime()); + check(); + assert.equal(runs.length, 0, 'the 9:15 tick missed while off cannot replay at 9:16'); + }); +} + +test('archive catch-up re-enable refuses a missing state directory with a named error', t => { + const h = fixture(t); + const result = reenableScheduleFiles([h.file], h.project); + assert.equal(result.enabled.length, 0, 'catch-up file stays disabled without a skip record'); + assert.match(result.failed[0].error, /MissingScheduleStateDir/); + assert.equal(runner.parseFrontmatter(fs.readFileSync(h.file, 'utf8')).meta.enabled, 'false'); +}); + +test('archive catch-up parsing uses the shared schedule fields for quoted true values', t => { + const h = fixture(t); + for (const value of ['true', 'TRUE', '"true"', "'true'"]) { + fs.writeFileSync(h.file, `---\ncron: 15 9 * * *\nenabled: false\ncatch-up: ${value}\n---\nPrompt`); + const result = reenableScheduleFiles([h.file], h.project, { stateDir: h.stateDir }); + assert.equal(result.failed.length, 0); + assert.equal(runner.parseFrontmatter(fs.readFileSync(h.file, 'utf8')).meta.enabled, 'true'); + } +}); + +for (const operation of ['offer', 'Undo']) { + test(`main archiveDeps passes the instance state directory through the ${operation} re-enable`, async t => { + const vm = require('node:vm'); + const { createRequire } = require('node:module'); + const archive = require('../archived-projects'); + const h = fixture(t); + const entry = archive.archivedEntry(null, h.project); + const record = { archivedAt: 'T1', disabledSchedules: [h.file], disabledByArchive: [h.file] }; + const settings = { archivedProjects: { [entry]: record }, scheduleReenableOffers: { [entry]: record } }; + const stateDir = path.join(path.dirname(h.stateDir), 'schedule-state'); + const deps = { getSetting: key => settings[key], setSetting: (key, value) => { settings[key] = value; }, notify() {} }; + const filename = path.join(__dirname, '..', 'main.js'); + const source = fs.readFileSync(filename, 'utf8'); + const start = source.indexOf('function archiveDeps()'); + const end = source.indexOf("ipcMain.handle('dismiss-schedule-reenable-offer'", start); + const handlers = new Map(); + vm.runInNewContext(source.slice(start, end), { + require: createRequire(filename), path, DB_PATH: path.join(path.dirname(h.stateDir), 'db'), + isInitialScanComplete: () => true, initialScheduleProjects() {}, listSettingKeys() {}, + scanSchedules: runner.scanSchedules, log: { info() {}, warn() {} }, fs, + setScheduleEnabled: runner.setScheduleEnabled, mergePlaceholderSessions: value => value, + activeSessions: new Map(), notifyRendererProjectsChanged() {}, projectArchivePlan() {}, validArchiveGroups: archive.validArchiveGroups, + ipcMain: { handle: (name, handler) => handlers.set(name, handler) }, + writer: { + reenableOfferedSchedules: (project, key, wiring) => archive.reenableOfferedSchedules(project, key, { ...deps, ...wiring }), + undoArchiveProjectFolders: (groups, timestamp, wiring) => archive.undoArchiveProjectFolders(groups, timestamp, { ...deps, ...wiring }), + }, + }, { filename, lineOffset: source.slice(0, start).split('\n').length - 1 }); + const result = operation === 'offer' + ? await handlers.get('reenable-project-schedules')({}, h.project, null) + : await handlers.get('undo-archive-project')({}, [{ projectPath: h.project }], 'T1'); + assert.equal(result.failed.length, 0, 'main re-enable succeeds with stateDir'); + assert.equal(result.enabled.length, 1); + assert.ok(runner.readScheduleStateOnly(stateDir).has(runner.scheduleStateKey({ filePath: h.file })), 'main archiveDeps writes the instance skip record'); + }); +} + test('offered and Undo catch-up schedules skip missed minutes before re-enabling', t => { t.mock.timers.enable({ apis: ['Date'], now: new Date(2026, 10, 8, 9, 16, 30).getTime() }); const h = fixture(t); From a4ba3592336ed50081469056229a3846d4264e2d Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sun, 8 Nov 2026 22:57:54 +0100 Subject: [PATCH 3/4] (schedules): skip a due minute on re-enable only once its tick has run Re-enabling a catch-up schedule before the scheduler has handled the current minute keeps that run; focus waits on the row while Run now is in flight; the tab reloads only for changes that can affect schedules. --- .ai/contexts/ipc-bridge.md | 5 + .ai/contexts/schedule-runner.md | 36 ++++-- archived-projects.js | 4 +- main.js | 6 +- preload.js | 2 +- public/app.js | 4 +- public/schedules-view.js | 46 ++++++-- src/main/schedules/schedule-runner.js | 20 ++-- test/dom-schedules-tab.test.js | 155 +++++++++++++++++++++++++- test/schedule-reenable-skip.test.js | 119 ++++++++++++++++---- 10 files changed, 339 insertions(+), 58 deletions(-) diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index 2988a7820..8f8debfe6 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -474,6 +474,11 @@ An unknown remote target never falls back to local disk IO. `terminal-data`, `session-detected`, `process-exited`, `remote-resize-allowed`, `terminal-notification`, `cli-busy-state`, `session-forked`, `subagent-spawned`, `subagent-completed`, `subagent-watch-event`, `projects-changed`, `status-update`, `indexing-progress`, `file-changed`, `mcp-open-diff`, `mcp-open-file`, `mcp-close-all-diffs`, `mcp-close-tab`, `updater-event`, `main-process-error`, `show-whats-new`, `session-transcript-activity`, `bg-agents-changed`, `bg-handoff-notice`, `git-changes-file-changed`, `remote-activity`, `activity-trace-state`, `indexing-finished`, `task-snapshot`, `task-rekey`, `mcp-status`, `full-screen-changed`, `exit-background-progress`, `exit-attempt-invalidated`, `exit-flush`, `unsaved-check`, `unsaved-check-reason`, `unsaved-ping` +`onProjectsChanged(callback)` forwards the main-side `{ cause: string[] }` +payload. Schedules filters these causes before queuing a bounded debounce; +see [schedule-runner.md](schedule-runner.md), "Schedules tab". Other subscribers +may ignore the payload. + `remote-resize-allowed(sessionId)` is emitted once when a live remote attach promotes from shared to solo. `preload.onRemoteResizeAllowed` forwards the id to the renderer, which enables solo sizing and schedules a fitted resize. diff --git a/.ai/contexts/schedule-runner.md b/.ai/contexts/schedule-runner.md index cb0ded0f6..aeb288dd1 100644 --- a/.ai/contexts/schedule-runner.md +++ b/.ai/contexts/schedule-runner.md @@ -290,7 +290,8 @@ they do not launch Electron, Claude or a real schedule. `fs.realpathSync.native` and is case-insensitive. Already running commands continue. Unarchive clears the entry and passes over the catch-up record of the project's schedules (`skipMissedRuns` in `schedule-runner.js` claims the - previous minute), so a run that fell due while archived is not replayed and the + previous minute, or the current due minute if its tick already ran), so a run + that fell due while archived is not replayed and the next due minute runs normally. The reset passes the registry's own spellings of the project and its nested worktrees, selected with the same real-path, case-insensitive-on-Windows comparison as suspension, because the catch-up key @@ -471,8 +472,19 @@ PROJECTS_DIR scans), `test/schedule-next-run.test.js` (descriptions and DST). ## Schedules tab (#792, slice A2) -`public/schedules-view.js` consumes `list-schedules` on open, after each action, -on projects-changed (coalesced over 900 ms) and on minute boundaries while visible. The clock tab follows +`public/schedules-view.js` consumes `list-schedules` on open, after Open, after +Run now/toggle while visible, on relevant projects-changed events (900 ms debounce, 3000 ms maximum +wait) and on minute boundaries while visible. The event allowlist is `archive`, +`project-added`, `project-deleted`, `project-remap`, `worktree-deleted`, `watcher`, +`session-exit`, `session-attach` and `cli-status`: changes to the registry, +archive state, run history or child liveness. Scan progress (`scan`), activity, +metadata backfills/repair, remote updates and background-job events do not +reload this local tab; its minute refresh still picks up indexed scan results. +Preload forwards the `{ cause }` payload. Mixed-cause payloads refresh if any +cause is allowed. Hiding the tab cancels both +reload timers, invalidates pending requests and clears the requested project +focus. A pending toggle completed while hidden waits for reopening to reload. +The clock tab follows Agent Files; Work Files is preserved. Search is hidden. Rows show cron, local next-run and relative time, disabled/archive/validation reasons, shared-slug diagnostics, catch-up status and the newest 20 runs from the main-side list. @@ -485,9 +497,16 @@ fresh indexed session and goes through openSession, preserving resume guards. listScheduleFiles over the registry at call time. Linked/unread front matter is refused. Before enabling a disabled catch-up schedule, skipMissedRunsOf claims the prior-minute baseline, then setScheduleEnabled atomically rewrites the file. -When re-enable happens after the start of a due minute, that current minute is -claimed too, so its tick missed while off cannot replay on the next check. At -the exact minute boundary the previous-minute baseline still lets the tick run. +The scheduler owns an in-memory last tick minute, exposed by the returned stop +function's `getLastTickMinute()`. Only an actual cron tick records it, after +project selection resolves and before schedules are scanned; startup and resume +catch-up checks do not. Main passes its getter through the existing enable and +archive deps, and its value to unarchive's skip. The current due minute is +claimed only if it equals the last minute actually ticked. Before a fractional +or delayed tick (9:15:00.900 or 9:15:40), enabling in that same minute preserves +the due run; after that tick it is consumed. At 9:15:00.000, the ordering of the +tick and enable still decides: before the tick it may run, after the tick it +must not replay. A previous-minute tick never consumes the new minute. Unarchive uses the same helper and therefore the same rule. Missing stateDir refuses a catch-up re-enable with MissingScheduleStateDir. Archive parsing uses the runner's shared scheduleFields helper. @@ -510,7 +529,10 @@ Action state is keyed by filePath across renders: a pending launch stays disable success shows the check mark and Launched! for two seconds, and a refusal restores Run now with the persistent notice. A new launch invalidates the previous success timer. Pending toggles likewise stay disabled. A render restores each row's open -history and focused control by filePath and control identity. An unmatched project +history and focused control by filePath and control identity. If the control is +disabled, focus moves to its row (`tabIndex=-1`) and the control remains the +pending focus target across renders. Re-enabling restores it while focus stays +on that row; a newer focus choice takes precedence. An unmatched project focus request is consumed by that render. Guard refusal reasons are visible row text. Next-run wording uses minutes below an hour, then today's time, tomorrow's time, a weekday within the following week, or a date for later runs. diff --git a/archived-projects.js b/archived-projects.js index ec906df38..891ff1fec 100644 --- a/archived-projects.js +++ b/archived-projects.js @@ -144,7 +144,7 @@ function clearArchivedEntry(getSetting, setSetting, alias, projectPath) { * Turn back on the offered schedule files that still read `enabled: false`. * A vanished file, or one whose `enabled` was changed since, is left as it is. */ -function reenableScheduleFiles(filePaths, projectRoot, { stateDir } = {}) { +function reenableScheduleFiles(filePaths, projectRoot, { stateDir, getLastTickMinute = () => null } = {}) { const enabled = []; const failed = []; for (const filePath of filePaths || []) { @@ -154,7 +154,7 @@ function reenableScheduleFiles(filePaths, projectRoot, { stateDir } = {}) { const name = meta.name || path.basename(filePath); if (scheduleFileUnlinked(projectRoot, filePath)) { try { - skipMissedRunsOf({ ...scheduleFields(path.basename(filePath), meta), filePath, cron: meta.cron }, stateDir); + skipMissedRunsOf({ ...scheduleFields(path.basename(filePath), meta), filePath, cron: meta.cron }, stateDir, getLastTickMinute()); } catch (error) { failed.push({ name, filePath, error: error.message }); continue; diff --git a/main.js b/main.js index fd93b77e5..b49d10c2b 100644 --- a/main.js +++ b/main.js @@ -873,7 +873,7 @@ async function unarchivedScheduleProjects(projectPath) { function skipMissedScheduleRuns(projects) { try { - require('./src/main/schedules/schedule-runner').skipMissedRuns(log, projects, path.join(path.dirname(DB_PATH), 'schedule-state')); + require('./src/main/schedules/schedule-runner').skipMissedRuns(log, projects, path.join(path.dirname(DB_PATH), 'schedule-state'), stopScheduler?.getLastTickMinute()); } catch (err) { log.warn('[schedule] Cannot reset the catch-up record after unarchive:', err.message); } @@ -989,7 +989,7 @@ async function projectArchivePlan(groups) { function archiveDeps() { return { - reenable: (files, projectRoot) => require('./archived-projects').reenableScheduleFiles(files, projectRoot, { stateDir: path.join(path.dirname(DB_PATH), 'schedule-state') }), + reenable: (files, projectRoot) => require('./archived-projects').reenableScheduleFiles(files, projectRoot, { stateDir: path.join(path.dirname(DB_PATH), 'schedule-state'), getLastTickMinute: () => stopScheduler?.getLastTickMinute() }), isInitialScanComplete, seedProjects: () => initialScheduleProjects(() => listSettingKeys('project:')), scan: projectPath => scanSchedules(log, [projectPath]), @@ -1607,7 +1607,7 @@ const fileListsClient = { ipcMain.handle('set-schedule-enabled', (_event, filePath, enabled) => require('./src/main/schedules/schedule-runner').setListedScheduleEnabled(filePath, enabled, { - scheduleProjects, stateDir: path.join(path.dirname(DB_PATH), 'schedule-state'), + scheduleProjects, stateDir: path.join(path.dirname(DB_PATH), 'schedule-state'), getLastTickMinute: () => stopScheduler?.getLastTickMinute(), })); ipcMain.handle('list-schedules', () => { diff --git a/preload.js b/preload.js index a9de2cc81..a7b0df72a 100644 --- a/preload.js +++ b/preload.js @@ -180,7 +180,7 @@ contextBridge.exposeInMainWorld('api', { onSubagentWatchEvent: (cb) => ipcRenderer.on('subagent-watch-event', (_e, payload) => cb(payload)), onBgAgentsChanged: (cb) => ipcRenderer.on('bg-agents-changed', (_e, payload) => cb(payload)), onProjectsChanged: (callback) => { - ipcRenderer.on('projects-changed', () => callback()); + ipcRenderer.on('projects-changed', (_event, payload) => callback(payload)); }, onRemoteActivity: (callback) => { ipcRenderer.on('remote-activity', (_event, payload) => callback(payload)); diff --git a/public/app.js b/public/app.js index 5ada23f5e..e4febe52d 100644 --- a/public/app.js +++ b/public/app.js @@ -2977,8 +2977,8 @@ loadStartupProjects(); // Live-reload sidebar when filesystem changes are detected let projectsChangedTimer = null; let projectsChangedWhileAway = false; -window.api.onProjectsChanged(() => { - if (activeTab === 'schedules') queueSchedulesReload(); +window.api.onProjectsChanged(payload => { + if (activeTab === 'schedules' && payload?.cause?.some(cause => ['archive', 'project-added', 'project-deleted', 'project-remap', 'worktree-deleted', 'watcher', 'session-exit', 'session-attach', 'cli-status'].includes(cause))) queueSchedulesReload(); // Debounce to avoid rapid re-renders during bulk changes if (projectsChangedTimer) clearTimeout(projectsChangedTimer); if (activeTab !== 'sessions') { diff --git a/public/schedules-view.js b/public/schedules-view.js index 932153c52..3b7aa0612 100644 --- a/public/schedules-view.js +++ b/public/schedules-view.js @@ -5,6 +5,8 @@ let schedulesTimer = null; let schedulesRequest = 0; let schedulesProject = null; let schedulesReloadTimer = null; +let schedulesReloadMaxTimer = null; +let schedulesPendingFocus = null; const schedulesActions = new Map(); function setSchedulesVisible(visible) { @@ -13,7 +15,9 @@ function setSchedulesVisible(visible) { schedulesTimer = null; clearTimeout(schedulesReloadTimer); schedulesReloadTimer = null; - if (!visible) { schedulesRequest++; return; } + clearTimeout(schedulesReloadMaxTimer); + schedulesReloadMaxTimer = null; + if (!visible) { schedulesRequest++; schedulesProject = null; schedulesPendingFocus = null; return; } loadSchedules(); scheduleSchedulesRefresh(); } @@ -21,10 +25,11 @@ function setSchedulesVisible(visible) { function queueSchedulesReload() { if (!schedulesVisible) return; clearTimeout(schedulesReloadTimer); - schedulesReloadTimer = setTimeout(() => { - schedulesReloadTimer = null; + const reload = () => { if (schedulesVisible) loadSchedules(); - }, 900); + }; + schedulesReloadTimer = setTimeout(reload, 900); + if (schedulesReloadMaxTimer === null) schedulesReloadMaxTimer = setTimeout(reload, 3000); } function scheduleSchedulesRefresh() { @@ -38,6 +43,8 @@ function scheduleSchedulesRefresh() { async function loadSchedules() { clearTimeout(schedulesReloadTimer); schedulesReloadTimer = null; + clearTimeout(schedulesReloadMaxTimer); + schedulesReloadMaxTimer = null; const request = ++schedulesRequest; try { const data = await window.api.listSchedules(); @@ -97,7 +104,7 @@ function renderSchedules(data) { const focused = document.activeElement; const focusedRow = focused?.closest('.schedule-item'); const focusedPath = container.contains(focusedRow) ? focusedRow.dataset.filepath : null; - const focusedControl = focused?.dataset.scheduleControl; + const focusedControl = focused?.dataset.scheduleControl || (focusedPath === schedulesPendingFocus?.filePath ? schedulesPendingFocus.control : null); const projectFocus = schedulesProject; schedulesProject = null; container.replaceChildren(); @@ -125,16 +132,30 @@ function renderSchedules(data) { } if (!count) scheduleText(container, 'plans-empty', 'No schedules found.'); if (!projectFocus && focusedPath && focusedControl) { - const row = [...container.querySelectorAll('.schedule-item')].find(item => item.dataset.filepath === focusedPath); - [...(row?.querySelectorAll('[data-schedule-control]') || [])] - .find(control => control.dataset.scheduleControl === focusedControl)?.focus({ preventScroll: true }); - } + focusScheduleControl(focusedPath, focusedControl); + } else schedulesPendingFocus = null; const paths = new Set(data.projects.flatMap(project => project.schedules.map(schedule => schedule.filePath))); for (const [filePath, state] of schedulesActions) { if (!paths.has(filePath) && state.run !== 'running' && !state.toggling) schedulesActions.delete(filePath); } } +function focusScheduleControl(filePath, controlName) { + const row = [...document.querySelectorAll('#schedules-content .schedule-item')].find(item => item.dataset.filepath === filePath); + const control = [...(row?.querySelectorAll('[data-schedule-control]') || [])].find(el => el.dataset.scheduleControl === controlName); + schedulesPendingFocus = control?.disabled ? { filePath, control: controlName } : null; + if (control?.disabled) row.focus({ preventScroll: true }); + else control?.focus({ preventScroll: true }); +} + +function restorePendingScheduleFocus() { + if (!schedulesPendingFocus) return; + const { filePath, control } = schedulesPendingFocus; + const focused = document.activeElement; + if (focused?.matches('.schedule-item') && focused.dataset.filepath === filePath) focusScheduleControl(filePath, control); + else schedulesPendingFocus = null; +} + function scheduleActionState(filePath) { if (!schedulesActions.has(filePath)) schedulesActions.set(filePath, {}); return schedulesActions.get(filePath); @@ -146,17 +167,21 @@ function updateScheduleRunButtons(filePath, original) { .filter(button => button.closest('.schedule-item').dataset.filepath === filePath)); if (original) buttons.add(original); for (const button of buttons) { + const focused = document.activeElement === button; button.disabled = button.dataset.unavailable === 'true' || state.run === 'running'; button.classList.toggle('running', state.run === 'running'); button.classList.toggle('done', state.run === 'done'); button.textContent = state.run === 'running' ? 'Running...' : state.run === 'done' ? '\u2713' : 'Run now'; button.title = state.run === 'running' ? 'Running...' : state.run === 'done' ? 'Launched!' : button.dataset.reason; + if (focused && button.disabled) focusScheduleControl(filePath, button.dataset.scheduleControl); } + restorePendingScheduleFocus(); } function buildScheduleItem(project, schedule, data) { const item = document.createElement('article'); item.className = 'schedule-item'; + item.tabIndex = -1; item.dataset.filepath = schedule.filePath; scheduleText(item, 'schedule-name', schedule.name); scheduleText(item, 'schedule-path', schedule.filePath); @@ -215,7 +240,8 @@ function buildScheduleItem(project, schedule, data) { for (const current of document.querySelectorAll('.schedule-toggle-btn')) { if (current.closest('.schedule-item').dataset.filepath === schedule.filePath) current.disabled = current.dataset.unavailable === 'true'; } - await loadSchedules(); + restorePendingScheduleFocus(); + if (schedulesVisible) await loadSchedules(); } }); toggle.disabled ||= !!actionState.toggling; diff --git a/src/main/schedules/schedule-runner.js b/src/main/schedules/schedule-runner.js index a59072906..b84aa8c28 100644 --- a/src/main/schedules/schedule-runner.js +++ b/src/main/schedules/schedule-runner.js @@ -559,16 +559,16 @@ function buildScheduleCommand(sessionId, schedule) { * so a run that fell due while a project was archived is not replayed. * see .ai/contexts/schedule-runner.md ("Archive") */ -function skipMissedRuns(log, projects, stateDir) { - for (const schedule of scanSchedules(log, projects)) skipMissedRunsOf(schedule, stateDir); +function skipMissedRuns(log, projects, stateDir, lastTickMinute = null) { + for (const schedule of scanSchedules(log, projects)) skipMissedRunsOf(schedule, stateDir, lastTickMinute); } -function skipMissedRunsOf(schedule, stateDir) { +function skipMissedRunsOf(schedule, stateDir, lastTickMinute = null) { if (!schedule.catchUp) return; if (!stateDir) throw new Error('MissingScheduleStateDir: cannot skip missed schedule runs'); const now = Date.now(); const minute = Math.floor(now / MINUTE_MS) * MINUTE_MS; - const baseline = now > minute && typeof schedule.cron === 'string' && cronMatches(schedule.cron, new Date(minute)) ? minute : minute - MINUTE_MS; + const baseline = lastTickMinute === minute && typeof schedule.cron === 'string' && cronMatches(schedule.cron, new Date(minute)) ? minute : minute - MINUTE_MS; const state = readScheduleState(stateDir); const key = scheduleStateKey(schedule); const entry = state.get(key); @@ -577,14 +577,14 @@ function skipMissedRunsOf(schedule, stateDir) { if (entry) pruneScheduleState(stateDir, entry); } -async function setListedScheduleEnabled(filePath, enabled, { scheduleProjects, stateDir, setEnabled = setScheduleEnabled }) { +async function setListedScheduleEnabled(filePath, enabled, { scheduleProjects, stateDir, getLastTickMinute = () => null, setEnabled = setScheduleEnabled }) { if (typeof filePath !== 'string' || typeof enabled !== 'boolean') return { ok: false, error: 'invalid schedule or enabled value' }; try { const schedule = listScheduleFiles(await scheduleProjects().list()).find(s => s.filePath === filePath); if (!schedule) return { ok: false, error: 'schedule is not listed' }; if (schedule.linked) return { ok: false, error: 'linked file: edit it at its source' }; if (schedule.problems.some(p => p.startsWith('front matter not read'))) return { ok: false, error: 'front matter not read' }; - if (enabled && !schedule.enabled) skipMissedRunsOf(schedule, stateDir); + if (enabled && !schedule.enabled) skipMissedRunsOf(schedule, stateDir, getLastTickMinute()); return setEnabled(filePath, enabled, { projectRoot: schedule.projectPath }); } catch (error) { return { ok: false, error: error.message }; @@ -603,6 +603,7 @@ async function setListedScheduleEnabled(filePath, enabled, { scheduleProjects, s */ function startScheduler(log, runCommand, { resumeSource, stateDir, projects } = {}) { let running = true; + let lastTickMinute = null; const runningTasks = new Set(); // see .ai/contexts/schedule-runner.md ("Isolated instances") const catchUpOn = !process.env.SWITCHBOARD_DATA_DIR; @@ -674,6 +675,7 @@ function startScheduler(log, runCommand, { resumeSource, stateDir, projects } = function checkProjects(onTick, paths, now) { if (!running) return; + if (onTick) lastTickMinute = Math.floor(now.getTime() / MINUTE_MS) * MINUTE_MS; const schedules = scanSchedules(log, paths); let state = null; if (catchUpOn && schedules.some(s => s.catchUp)) { @@ -717,11 +719,13 @@ function startScheduler(log, runCommand, { resumeSource, stateDir, projects } = return checked; }, msUntilNextMinute); - return function stop() { + function stop() { running = false; clearTimeout(initialTimer); if (initialTimer._interval) clearInterval(initialTimer._interval); - }; + } + stop.getLastTickMinute = () => lastTickMinute; + return stop; } /** diff --git a/test/dom-schedules-tab.test.js b/test/dom-schedules-tab.test.js index d3dc341fe..aa2f71fb1 100644 --- a/test/dom-schedules-tab.test.js +++ b/test/dom-schedules-tab.test.js @@ -154,11 +154,10 @@ test('projects-changed bursts coalesce into one visible schedule reload', async const source = fs.readFileSync(filename, 'utf8'); const start = source.indexOf('let projectsChangedTimer ='); const end = source.indexOf('\n});', start) + '\n});'.length; - let changed; - h.w.api.onProjectsChanged = handler => { changed = handler; }; + const changed = connectProjectsChanged(h.w); vm.runInContext(source.slice(start, end), h.dom.getInternalVMContext(), { filename, lineOffset: source.slice(0, start).split('\n').length - 1 }); - for (let i = 0; i < 8; i++) changed(); + for (let i = 0; i < 8; i++) changed({ cause: ['archive'] }); await settle(); assert.equal(calls, 0, 'burst waits for the debounce window'); const pending = [...timers.values()].filter(timer => timer.delay === 900); @@ -168,6 +167,156 @@ test('projects-changed bursts coalesce into one visible schedule reload', async assert.equal(calls, 1, 'one list-schedules request per burst'); }); +for (const outcome of ['success', 'failure']) { + test(`pending Run now focus uses its row during refresh and returns after ${outcome}`, async t => { + let finish; + const h = setup(t, () => new Promise(resolve => { finish = resolve; })); + await settle(); + h.button.focus(); + h.button.click(); + await h.w.loadSchedules(); + assert.equal(h.w.document.activeElement, h.w.document.querySelector('.schedule-item'), 'disabled focused control falls back to its row'); + await h.w.loadSchedules(); + finish(outcome === 'success' ? { ok: true } : { ok: false, error: 'refused' }); + await settle(); + assert.equal(h.w.document.activeElement, h.button, 'settled launch restores the pending focus target'); + }); +} + +test('pending Run now focus yields to a different focused control', async t => { + let finish; + const h = setup(t, () => new Promise(resolve => { finish = resolve; })); + await settle(); + h.button.focus(); + h.button.click(); + await h.w.loadSchedules(); + assert.equal(h.w.document.activeElement, h.w.document.querySelector('.schedule-item'), 'pending launch focuses its row'); + h.w.document.querySelector('.schedule-open-btn').focus(); + finish({ ok: true }); + await settle(); + assert.equal(h.w.document.activeElement, h.w.document.querySelector('.schedule-open-btn'), 'completion does not steal a newer focus choice'); +}); + +function connectProjectsChanged(w) { + let bridge; + const listeners = new Map(); + const preload = path.join(__dirname, '../preload.js'); + vm.runInNewContext(fs.readFileSync(preload, 'utf8'), { + process: { platform: process.platform, argv: [] }, + require: name => { + assert.equal(name, 'electron'); + return { contextBridge: { exposeInMainWorld: (_name, api) => { bridge = api; } }, + ipcRenderer: { invoke() {}, on: (channel, handler) => listeners.set(channel, handler), send() {}, removeListener() {} } }; + }, + }, { filename: preload }); + w.api.onProjectsChanged = bridge.onProjectsChanged; + return payload => listeners.get('projects-changed')({}, payload); +} + +function projectsChangedHarness(t) { + const h = setup(t); + h.w.setSchedulesVisible(false); + let now = 0; + let id = 0; + const timers = new Map(); + h.w.Date.now = () => now; + h.w.setTimeout = (callback, delay) => { timers.set(++id, { callback, due: now + delay }); return id; }; + h.w.clearTimeout = key => timers.delete(key); + const advance = async ms => { + const end = now + ms; + for (;;) { + const next = [...timers].filter(([, timer]) => timer.due <= end).sort((a, b) => a[1].due - b[1].due)[0]; + if (!next) break; + now = next[1].due; + timers.delete(next[0]); + next[1].callback(); + await settle(); + } + now = end; + }; + const filename = path.join(__dirname, '../public/app.js'); + const source = fs.readFileSync(filename, 'utf8'); + const start = source.indexOf('let projectsChangedTimer ='); + const end = source.indexOf('\n});', start) + '\n});'.length; + const changed = connectProjectsChanged(h.w); + vm.runInContext(source.slice(start, end), h.dom.getInternalVMContext(), { filename, + lineOffset: source.slice(0, start).split('\n').length - 1 }); + h.w.setSchedulesVisible(true); + return { ...h, changed, advance }; +} + +test('scan and unrelated projects-changed causes do not rebuild schedules even at the main throttle cadence', async t => { + const h = projectsChangedHarness(t); + await settle(); + let calls = 0; + h.w.api.listSchedules = async () => { calls++; return h.data; }; + for (const cause of ['scan', 'activity', 'entrypoint-backfill', 'title-backfill', 'sdk-reveal', 'bridge-repair', 'remote', 'bg-jobs']) { + h.changed({ cause: [cause] }); + await h.advance(1500); + } + h.changed(); + h.changed({ cause: ['unknown-future-cause'] }); + await h.advance(4000); + assert.equal(calls, 0, 'scan and unrelated events never request list-schedules'); + for (const cause of ['archive', 'project-added', 'project-deleted', 'project-remap', 'worktree-deleted', 'watcher', 'session-exit', 'session-attach', 'cli-status']) { + const before = calls; + h.changed({ cause: ['scan', cause] }); + await h.advance(900); + assert.equal(calls, before + 1, `${cause} can refresh schedules in a mixed payload`); + } +}); + +test('continuous relevant projects-changed burst refreshes within the maximum wait', async t => { + const h = projectsChangedHarness(t); + await settle(); + let calls = 0; + h.w.api.listSchedules = async () => { calls++; return h.data; }; + for (let i = 0; i < 7; i++) { + h.changed({ cause: ['archive'] }); + await h.advance(600); + } + assert.ok(calls >= 1, 'a continuous burst refreshes within 3000 ms'); + assert.ok(calls <= 2, 'burst remains coalesced'); + await h.advance(900); + assert.equal(calls, 2, 'the trailing burst gets one final refresh'); + h.changed({ cause: ['archive'] }); + h.w.setSchedulesVisible(false); + await h.advance(4000); + assert.equal(calls, 2, 'hiding clears both debounce and maximum-wait timers'); +}); + +test('hiding schedules clears a pending project focus request before reopening', async t => { + const h = setup(t); + await settle(); + let finish; + h.w.api.listSchedules = () => new Promise(resolve => { finish = resolve; }); + h.w.showSchedules(h.project.projectPath); + h.w.setSchedulesVisible(false); + finish(h.data); + await settle(); + h.w.api.listSchedules = async () => h.data; + h.w.setSchedulesVisible(true); + await settle(); + assert.equal(h.w.document.activeElement, h.w.document.body, 'a hidden tab drops its project focus request'); +}); + +test('a toggle completed while schedules are hidden does not reload until reopening', async t => { + const h = setup(t); + await settle(); + let finish; + h.w.api.setScheduleEnabled = () => new Promise(resolve => { finish = resolve; }); + h.w.document.querySelector('.schedule-toggle-btn').click(); + h.w.setSchedulesVisible(false); + let calls = 0; + h.w.api.listSchedules = async () => { calls++; return h.data; }; + finish({ ok: true }); + await settle(); + assert.equal(calls, 0, 'a hidden toggle completion does not reload schedules'); + h.w.setSchedulesVisible(true); + await settle(); + assert.equal(calls, 1, 'reopening requests fresh schedule state'); +}); + test('next-run wording uses minutes, today, tomorrow, weekday and date', t => { const h = setup(t); const now = new Date(2026, 10, 8, 8, 0).getTime(); diff --git a/test/schedule-reenable-skip.test.js b/test/schedule-reenable-skip.test.js index 348c069e5..54a4b38b0 100644 --- a/test/schedule-reenable-skip.test.js +++ b/test/schedule-reenable-skip.test.js @@ -24,6 +24,51 @@ function fixture(t, catchUp = true) { return { project, file, stateDir, deps }; } +const at = (h, m, s = 0, ms = 0) => new Date(2026, 10, 8, h, m, s, ms).getTime(); +const quiet = { info() {}, warn() {}, error() {} }; + +function schedulerClock(t, h, runs, projects = () => [h.project]) { + let initial; + let interval; + t.mock.method(global, 'setTimeout', callback => { initial = callback; return {}; }); + t.mock.method(global, 'setInterval', callback => { interval = callback; return {}; }); + const stop = runner.startScheduler(quiet, (...args) => { runs.push(args); args[3](); }, { stateDir: h.stateDir, projects }); + t.after(stop); + return { stop, tick(time) { t.mock.timers.setTime(time); (interval || initial)(); } }; +} + +for (const [timeline, start, enable, tick] of [ + ['A before the due minute', [14, 0, 500], [14, 59], [15, 0, 500]], + ['B before the fractional tick', [14, 0, 900], [15, 0, 400], [15, 0, 900]], + ['B2 before a drifted tick', [14, 40], [15, 10], [15, 40]], + ['C next every-minute run', [14, 0, 500], [15, 30], [16, 0, 500]], + ['D exact minute boundary before tick', [14, 0, 300], [15, 0], [15, 0, 300]], +]) { + for (const operation of ['listed enable', 'archive offer', 'unarchive']) { + test(`${operation} ${timeline} preserves the unticked run`, async t => { + t.mock.timers.enable({ apis: ['Date'], now: at(9, ...start) }); + const isolation = process.env.SWITCHBOARD_DATA_DIR; + delete process.env.SWITCHBOARD_DATA_DIR; + t.after(() => { if (isolation === undefined) delete process.env.SWITCHBOARD_DATA_DIR; else process.env.SWITCHBOARD_DATA_DIR = isolation; }); + const h = fixture(t); + if (timeline.startsWith('C')) fs.writeFileSync(h.file, '---\ncron: * * * * *\nenabled: false\ncatch-up: true\n---\nPrompt'); + if (operation === 'unarchive') runner.setScheduleEnabled(h.file, true, { projectRoot: h.project }); + let archived = operation === 'unarchive'; + const runs = []; + const clock = schedulerClock(t, h, runs, () => archived ? [] : [h.project]); + const { stop } = clock; + const deps = { ...h.deps, getLastTickMinute: () => stop.getLastTickMinute?.() }; + if (timeline.startsWith('C')) clock.tick(at(9, 15, 0, 500)); + t.mock.timers.setTime(at(9, ...enable)); + if (operation === 'listed enable') assert.equal((await runner.setListedScheduleEnabled(h.file, true, deps)).ok, true); + else if (operation === 'archive offer') assert.equal(reenableScheduleFiles([h.file], h.project, deps).failed.length, 0); + else { runner.skipMissedRuns(quiet, [h.project], h.stateDir, deps.getLastTickMinute()); archived = false; } + clock.tick(at(9, ...tick)); + assert.equal(runs.length, 1, 'the due minute whose tick has not passed must run once'); + }); + } +} + test('set-schedule-enabled skips missed catch-up minutes and non-catch-up creates no state', async t => { t.mock.timers.enable({ apis: ['Date'], now: new Date(2026, 10, 8, 9, 16, 30).getTime() }); assert.equal(typeof runner.setListedScheduleEnabled, 'function', 'the listed-path enable operation exists'); @@ -73,28 +118,30 @@ test('the catch-up baseline exists before the enable write, including isolated i assert.equal(result.ok, true); }); -for (const operation of ['listed enable', 'archive offer', 'unarchive']) { - test(`${operation} during an already ticked due minute does not replay it at the next check`, async t => { - t.mock.timers.enable({ apis: ['Date'], now: new Date(2026, 10, 8, 9, 15, 0).getTime() }); - const isolation = process.env.SWITCHBOARD_DATA_DIR; - delete process.env.SWITCHBOARD_DATA_DIR; - t.after(() => { if (isolation === undefined) delete process.env.SWITCHBOARD_DATA_DIR; else process.env.SWITCHBOARD_DATA_DIR = isolation; }); - const h = fixture(t); - const runs = []; - const check = () => runner.startScheduler({ info() {}, warn() {}, error() {} }, (...args) => runs.push(args), - { stateDir: h.stateDir, projects: () => [h.project] })(); - check(); - t.mock.timers.setTime(new Date(2026, 10, 8, 9, 15, 30).getTime()); - if (operation === 'listed enable') assert.equal((await runner.setListedScheduleEnabled(h.file, true, h.deps)).ok, true); - else if (operation === 'archive offer') assert.equal(reenableScheduleFiles([h.file], h.project, { stateDir: h.stateDir }).failed.length, 0); - else { - runner.setScheduleEnabled(h.file, true, { projectRoot: h.project }); - runner.skipMissedRuns({ info() {}, warn() {}, error() {} }, [h.project], h.stateDir); - } - t.mock.timers.setTime(new Date(2026, 10, 8, 9, 16, 0).getTime()); - check(); - assert.equal(runs.length, 0, 'the 9:15 tick missed while off cannot replay at 9:16'); - }); +for (const [timing, delay] of [['during', 30000], ['at the boundary of', 0]]) { + for (const operation of ['listed enable', 'archive offer', 'unarchive']) { + test(`${operation} ${timing} an already ticked due minute does not replay it at the next check`, async t => { + t.mock.timers.enable({ apis: ['Date'], now: at(9, 14) }); + const isolation = process.env.SWITCHBOARD_DATA_DIR; + delete process.env.SWITCHBOARD_DATA_DIR; + t.after(() => { if (isolation === undefined) delete process.env.SWITCHBOARD_DATA_DIR; else process.env.SWITCHBOARD_DATA_DIR = isolation; }); + const h = fixture(t); + const runs = []; + const clock = schedulerClock(t, h, runs); + const { stop } = clock; + const deps = { ...h.deps, getLastTickMinute: () => stop.getLastTickMinute?.() }; + clock.tick(at(9, 15)); + t.mock.timers.setTime(at(9, 15) + delay); + if (operation === 'listed enable') assert.equal((await runner.setListedScheduleEnabled(h.file, true, deps)).ok, true); + else if (operation === 'archive offer') assert.equal(reenableScheduleFiles([h.file], h.project, deps).failed.length, 0); + else { + runner.setScheduleEnabled(h.file, true, { projectRoot: h.project }); + runner.skipMissedRuns(quiet, [h.project], h.stateDir, deps.getLastTickMinute()); + } + clock.tick(at(9, 16)); + assert.equal(runs.length, 0, 'the 9:15 tick missed while off cannot replay at 9:16'); + }); + } } test('archive catch-up re-enable refuses a missing state directory with a named error', t => { @@ -117,6 +164,7 @@ test('archive catch-up parsing uses the shared schedule fields for quoted true v for (const operation of ['offer', 'Undo']) { test(`main archiveDeps passes the instance state directory through the ${operation} re-enable`, async t => { + t.mock.timers.enable({ apis: ['Date'], now: at(9, 15, 30) }); const vm = require('node:vm'); const { createRequire } = require('node:module'); const archive = require('../archived-projects'); @@ -133,6 +181,7 @@ for (const operation of ['offer', 'Undo']) { const handlers = new Map(); vm.runInNewContext(source.slice(start, end), { require: createRequire(filename), path, DB_PATH: path.join(path.dirname(h.stateDir), 'db'), + stopScheduler: { getLastTickMinute: () => at(9, 15) }, isInitialScanComplete: () => true, initialScheduleProjects() {}, listSettingKeys() {}, scanSchedules: runner.scanSchedules, log: { info() {}, warn() {} }, fs, setScheduleEnabled: runner.setScheduleEnabled, mergePlaceholderSessions: value => value, @@ -149,6 +198,7 @@ for (const operation of ['offer', 'Undo']) { assert.equal(result.failed.length, 0, 'main re-enable succeeds with stateDir'); assert.equal(result.enabled.length, 1); assert.ok(runner.readScheduleStateOnly(stateDir).has(runner.scheduleStateKey({ filePath: h.file })), 'main archiveDeps writes the instance skip record'); + assert.equal(runner.readScheduleStateOnly(stateDir).get(runner.scheduleStateKey({ filePath: h.file })).handled, at(9, 15), 'archive wiring passes the scheduler tick minute'); }); } @@ -160,7 +210,29 @@ test('offered and Undo catch-up schedules skip missed minutes before re-enabling assert.ok(handled >= Math.floor(Date.now() / 60000) * 60000 - 60000, 'offered schedules receive a baseline'); }); +test('main unarchive skip passes the last minute actually ticked', t => { + t.mock.timers.enable({ apis: ['Date'], now: at(9, 15, 30) }); + const h = fixture(t); + runner.setScheduleEnabled(h.file, true, { projectRoot: h.project }); + const filename = path.join(__dirname, '..', 'main.js'); + const { extractFunction } = require('./app-source'); + const vm = require('node:vm'); + const { createRequire } = require('node:module'); + const source = fs.readFileSync(filename, 'utf8'); + const ctx = { + require: createRequire(filename), path, DB_PATH: path.join(path.dirname(h.stateDir), 'db'), + stopScheduler: { getLastTickMinute: () => at(9, 15) }, log: quiet, + }; + vm.runInNewContext(extractFunction(source, 'skipMissedScheduleRuns'), ctx, { filename, + lineOffset: source.slice(0, source.indexOf('function skipMissedScheduleRuns')).split('\n').length - 1 }); + ctx.skipMissedScheduleRuns([h.project]); + const stateDir = path.join(path.dirname(h.stateDir), 'schedule-state'); + assert.equal(runner.readScheduleStateOnly(stateDir).get(runner.scheduleStateKey({ filePath: h.file }))?.handled, + at(9, 15), 'unarchive wiring passes the scheduler tick minute'); +}); + test('the enable IPC wires listing guards and instance state through main and preload', async t => { + t.mock.timers.enable({ apis: ['Date'], now: at(9, 15, 30) }); const vm = require('node:vm'); const { createRequire } = require('node:module'); const h = fixture(t); @@ -173,12 +245,15 @@ test('the enable IPC wires listing guards and instance state through main and pr vm.runInNewContext(source.slice(start, end), { ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, require: createRequire(filename), scheduleProjects: h.deps.scheduleProjects, + stopScheduler: { getLastTickMinute: () => at(9, 15) }, path, DB_PATH: path.join(path.dirname(h.stateDir), 'db'), }, { filename, lineOffset: source.slice(0, start).split('\n').length - 1 }); const result = await handlers.get('set-schedule-enabled')({}, h.file, true); assert.equal(result.ok, true); assert.ok(runner.readScheduleStateOnly(path.join(path.dirname(h.stateDir), 'schedule-state')) .has(runner.scheduleStateKey({ filePath: h.file })), 'main passes this instance state directory'); + assert.equal(runner.readScheduleStateOnly(path.join(path.dirname(h.stateDir), 'schedule-state')) + .get(runner.scheduleStateKey({ filePath: h.file })).handled, at(9, 15), 'enable IPC passes the scheduler tick minute'); assert.equal((await handlers.get('set-schedule-enabled')({}, path.join(h.project, 'outside.md'), true)).ok, false); let api; const calls = []; From 7c6c77a02d453a6cfedb58beb76bdd3b612e68fe Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sun, 8 Nov 2026 23:58:00 +0100 Subject: [PATCH 4/4] (schedules): count the startup and resume checks as the minute's evaluation Re-enabling a catch-up schedule in the minute the app started or resumed no longer replays that minute; the tab ignores CLI status churn, refreshes when a scheduled run exits, and keeps focus on the on/off toggle. --- .ai/contexts/schedule-runner.md | 25 +++++++++++------ public/app.js | 2 +- public/memory-workfiles-view.js | 5 +++- public/schedules-view.js | 2 ++ src/main/schedules/schedule-runner.js | 2 +- test/dom-schedule-run-exit.test.js | 12 +++++++- test/dom-schedules-tab.test.js | 23 +++++++++++++-- test/schedule-reenable-skip.test.js | 40 +++++++++++++++++++++++++-- 8 files changed, 94 insertions(+), 17 deletions(-) diff --git a/.ai/contexts/schedule-runner.md b/.ai/contexts/schedule-runner.md index aeb288dd1..0031558c8 100644 --- a/.ai/contexts/schedule-runner.md +++ b/.ai/contexts/schedule-runner.md @@ -476,8 +476,9 @@ PROJECTS_DIR scans), `test/schedule-next-run.test.js` (descriptions and DST). Run now/toggle while visible, on relevant projects-changed events (900 ms debounce, 3000 ms maximum wait) and on minute boundaries while visible. The event allowlist is `archive`, `project-added`, `project-deleted`, `project-remap`, `worktree-deleted`, `watcher`, -`session-exit`, `session-attach` and `cli-status`: changes to the registry, -archive state, run history or child liveness. Scan progress (`scan`), activity, +`session-exit` and `session-attach`: changes to the registry, +archive state or run history. CLI status changes do not reload the tab; scheduled-run +liveness is read at list time and the minute refresh picks it up. Scan progress (`scan`), activity, metadata backfills/repair, remote updates and background-job events do not reload this local tab; its minute refresh still picks up indexed scan results. Preload forwards the `{ cause }` payload. Mixed-cause payloads refresh if any @@ -489,7 +490,9 @@ Agent Files; Work Files is preserved. Search is hidden. Rows show cron, local next-run and relative time, disabled/archive/validation reasons, shared-slug diagnostics, catch-up status and the newest 20 runs from the main-side list. Run now retains `RunSchedule:` and the persistent error contract; -exit notices still subscribe once from memory-workfiles-view.js. Open checks a +exit notices still subscribe once from memory-workfiles-view.js. Every scheduled-run +exit, including success, queues the same visible-tab reload before displaying any +failure notice. Open checks a nonempty read-memory response before opening memoryPanel. History resolves a fresh indexed session and goes through openSession, preserving resume guards. @@ -497,12 +500,15 @@ fresh indexed session and goes through openSession, preserving resume guards. listScheduleFiles over the registry at call time. Linked/unread front matter is refused. Before enabling a disabled catch-up schedule, skipMissedRunsOf claims the prior-minute baseline, then setScheduleEnabled atomically rewrites the file. -The scheduler owns an in-memory last tick minute, exposed by the returned stop -function's `getLastTickMinute()`. Only an actual cron tick records it, after -project selection resolves and before schedules are scanned; startup and resume -catch-up checks do not. Main passes its getter through the existing enable and +The scheduler owns an in-memory last evaluated minute, exposed by the returned stop +function's `getLastTickMinute()`. Every check records its minute, after +project selection resolves and before schedules are scanned: cron ticks, startup +and resume catch-up checks use the same scheduler-owned state. A minute is evaluated +once for re-enable purposes, even if the schedule was disabled during that check. +An already enabled schedule still receives its normal startup catch-up run. +Main passes its getter through the existing enable and archive deps, and its value to unarchive's skip. The current due minute is -claimed only if it equals the last minute actually ticked. Before a fractional +claimed only if it equals the last minute evaluated. Before a fractional or delayed tick (9:15:00.900 or 9:15:40), enabling in that same minute preserves the due run; after that tick it is consumed. At 9:15:00.000, the ordering of the tick and enable still decides: before the tick it may run, after the tick it @@ -532,7 +538,8 @@ timer. Pending toggles likewise stay disabled. A render restores each row's open history and focused control by filePath and control identity. If the control is disabled, focus moves to its row (`tabIndex=-1`) and the control remains the pending focus target across renders. Re-enabling restores it while focus stays -on that row; a newer focus choice takes precedence. An unmatched project +on that row; a newer focus choice takes precedence. The on/off toggle uses this +same focus hand-off as soon as its focused button becomes disabled. An unmatched project focus request is consumed by that render. Guard refusal reasons are visible row text. Next-run wording uses minutes below an hour, then today's time, tomorrow's time, a weekday within the following week, or a date for later runs. diff --git a/public/app.js b/public/app.js index e4febe52d..533cf3872 100644 --- a/public/app.js +++ b/public/app.js @@ -2978,7 +2978,7 @@ loadStartupProjects(); let projectsChangedTimer = null; let projectsChangedWhileAway = false; window.api.onProjectsChanged(payload => { - if (activeTab === 'schedules' && payload?.cause?.some(cause => ['archive', 'project-added', 'project-deleted', 'project-remap', 'worktree-deleted', 'watcher', 'session-exit', 'session-attach', 'cli-status'].includes(cause))) queueSchedulesReload(); + if (activeTab === 'schedules' && payload?.cause?.some(cause => ['archive', 'project-added', 'project-deleted', 'project-remap', 'worktree-deleted', 'watcher', 'session-exit', 'session-attach'].includes(cause))) queueSchedulesReload(); // Debounce to avoid rapid re-renders during bulk changes if (projectsChangedTimer) clearTimeout(projectsChangedTimer); if (activeTab !== 'sessions') { diff --git a/public/memory-workfiles-view.js b/public/memory-workfiles-view.js index 4936f61d6..74c77a199 100644 --- a/public/memory-workfiles-view.js +++ b/public/memory-workfiles-view.js @@ -25,7 +25,10 @@ function showScheduleRunExit({ name, projectPath, sessionId, manual = false, cod showAppNotice({ id, kind: 'error', message: `${subject} ${repeats}${result}: ${hint}` }); } -window.api.onScheduleRunExit?.(showScheduleRunExit); +window.api.onScheduleRunExit?.(payload => { + queueSchedulesReload(); + showScheduleRunExit(payload); +}); function hideAllViewers() { statsViewer.style.display = 'none'; diff --git a/public/schedules-view.js b/public/schedules-view.js index 3b7aa0612..1638c52f1 100644 --- a/public/schedules-view.js +++ b/public/schedules-view.js @@ -226,7 +226,9 @@ function buildScheduleItem(project, schedule, data) { const btn = event.currentTarget; if (actionState.toggling) return; actionState.toggling = true; + const focused = document.activeElement === btn; btn.disabled = true; + if (focused) focusScheduleControl(schedule.filePath, btn.dataset.scheduleControl); const id = `SetSchedule:${schedule.filePath}`; try { const result = await window.api.setScheduleEnabled(schedule.filePath, !schedule.enabled); diff --git a/src/main/schedules/schedule-runner.js b/src/main/schedules/schedule-runner.js index b84aa8c28..37fc3430e 100644 --- a/src/main/schedules/schedule-runner.js +++ b/src/main/schedules/schedule-runner.js @@ -675,7 +675,7 @@ function startScheduler(log, runCommand, { resumeSource, stateDir, projects } = function checkProjects(onTick, paths, now) { if (!running) return; - if (onTick) lastTickMinute = Math.floor(now.getTime() / MINUTE_MS) * MINUTE_MS; + lastTickMinute = Math.floor(now.getTime() / MINUTE_MS) * MINUTE_MS; const schedules = scanSchedules(log, paths); let state = null; if (catchUpOn && schedules.some(s => s.catchUp)) { diff --git a/test/dom-schedule-run-exit.test.js b/test/dom-schedule-run-exit.test.js index 056ba5dbc..d4316fe99 100644 --- a/test/dom-schedule-run-exit.test.js +++ b/test/dom-schedule-run-exit.test.js @@ -21,8 +21,18 @@ function setup(t) { }, }, { filename: preload }); const filename = path.join(__dirname, '../public/memory-workfiles-view.js'); + let reloads = 0; + h.window.queueSchedulesReload = () => { reloads++; }; vm.runInContext(fs.readFileSync(filename, 'utf8'), h.context, { filename }); - return { ...h, exit: payload => ipcRenderer.emit('schedule-run-exit', {}, payload) }; + return { ...h, get reloads() { return reloads; }, exit: payload => ipcRenderer.emit('schedule-run-exit', {}, payload) }; +} + +for (const code of [0, 42]) { + test(`schedule exit ${code} queues a schedules reload`, t => { + const h = setup(t); + h.exit({ name: 'Nightly', sessionId: 'refresh', code, failure: 'exit' }); + assert.equal(h.reloads, 1, 'every scheduled exit queues a tab refresh, including success'); + }); } for (const [failure, code, hint] of [ diff --git a/test/dom-schedules-tab.test.js b/test/dom-schedules-tab.test.js index aa2f71fb1..5aa3b0274 100644 --- a/test/dom-schedules-tab.test.js +++ b/test/dom-schedules-tab.test.js @@ -167,6 +167,25 @@ test('projects-changed bursts coalesce into one visible schedule reload', async assert.equal(calls, 1, 'one list-schedules request per burst'); }); +for (const outcome of ['success', 'failure']) { + test(`pending toggle focus uses its row immediately and returns after ${outcome}`, async t => { + const h = setup(t); + await settle(); + let finish; + h.w.api.setScheduleEnabled = () => new Promise(resolve => { finish = resolve; }); + const button = () => h.w.document.querySelector('.schedule-toggle-btn'); + button().focus(); + button().click(); + assert.equal(h.w.document.activeElement, h.w.document.querySelector('.schedule-item'), 'pending toggle hands focus to its row immediately'); + await h.w.loadSchedules(); + assert.equal(h.w.document.activeElement, h.w.document.querySelector('.schedule-item'), 'pending toggle retains row focus across refresh'); + finish(outcome === 'success' ? { ok: true } : { ok: false, error: 'refused' }); + await settle(); + assert.equal(button().disabled, false); + assert.equal(h.w.document.activeElement, button(), 'settled toggle restores focus to its enabled control'); + }); +} + for (const outcome of ['success', 'failure']) { test(`pending Run now focus uses its row during refresh and returns after ${outcome}`, async t => { let finish; @@ -250,7 +269,7 @@ test('scan and unrelated projects-changed causes do not rebuild schedules even a await settle(); let calls = 0; h.w.api.listSchedules = async () => { calls++; return h.data; }; - for (const cause of ['scan', 'activity', 'entrypoint-backfill', 'title-backfill', 'sdk-reveal', 'bridge-repair', 'remote', 'bg-jobs']) { + for (const cause of ['scan', 'activity', 'entrypoint-backfill', 'title-backfill', 'sdk-reveal', 'bridge-repair', 'remote', 'bg-jobs', 'cli-status']) { h.changed({ cause: [cause] }); await h.advance(1500); } @@ -258,7 +277,7 @@ test('scan and unrelated projects-changed causes do not rebuild schedules even a h.changed({ cause: ['unknown-future-cause'] }); await h.advance(4000); assert.equal(calls, 0, 'scan and unrelated events never request list-schedules'); - for (const cause of ['archive', 'project-added', 'project-deleted', 'project-remap', 'worktree-deleted', 'watcher', 'session-exit', 'session-attach', 'cli-status']) { + for (const cause of ['archive', 'project-added', 'project-deleted', 'project-remap', 'worktree-deleted', 'watcher', 'session-exit', 'session-attach']) { const before = calls; h.changed({ cause: ['scan', cause] }); await h.advance(900); diff --git a/test/schedule-reenable-skip.test.js b/test/schedule-reenable-skip.test.js index 54a4b38b0..f5a7b32ff 100644 --- a/test/schedule-reenable-skip.test.js +++ b/test/schedule-reenable-skip.test.js @@ -27,16 +27,52 @@ function fixture(t, catchUp = true) { const at = (h, m, s = 0, ms = 0) => new Date(2026, 10, 8, h, m, s, ms).getTime(); const quiet = { info() {}, warn() {}, error() {} }; -function schedulerClock(t, h, runs, projects = () => [h.project]) { +function schedulerClock(t, h, runs, projects = () => [h.project], resumeSource) { let initial; let interval; t.mock.method(global, 'setTimeout', callback => { initial = callback; return {}; }); t.mock.method(global, 'setInterval', callback => { interval = callback; return {}; }); - const stop = runner.startScheduler(quiet, (...args) => { runs.push(args); args[3](); }, { stateDir: h.stateDir, projects }); + const stop = runner.startScheduler(quiet, (...args) => { runs.push(args); args[3](); }, { stateDir: h.stateDir, projects, resumeSource }); t.after(stop); return { stop, tick(time) { t.mock.timers.setTime(time); (interval || initial)(); } }; } +for (const check of ['startup', 'resume']) { + test(`enable after the ${check} check consumed the due minute does not replay it`, async t => { + t.mock.timers.enable({ apis: ['Date'], now: at(9, check === 'startup' ? 15 : 10, 20) }); + const isolation = process.env.SWITCHBOARD_DATA_DIR; + delete process.env.SWITCHBOARD_DATA_DIR; + t.after(() => { if (isolation === undefined) delete process.env.SWITCHBOARD_DATA_DIR; else process.env.SWITCHBOARD_DATA_DIR = isolation; }); + const h = fixture(t); + const runs = []; + const resumeSource = new (require('node:events').EventEmitter)(); + const clock = schedulerClock(t, h, runs, () => [h.project], resumeSource); + if (check === 'resume') { + clock.tick(at(9, 11)); + t.mock.timers.setTime(at(9, 15, 20)); + resumeSource.emit('resume'); + } + t.mock.timers.setTime(at(9, 15, 30)); + assert.equal((await runner.setListedScheduleEnabled(h.file, true, { ...h.deps, getLastTickMinute: () => clock.stop.getLastTickMinute() })).ok, true); + clock.tick(at(9, 16)); + assert.equal(runs.length, 0, 'a due minute already evaluated while disabled cannot replay at 9:16'); + }); +} + +test('an already enabled schedule still runs once at startup in its due minute', t => { + t.mock.timers.enable({ apis: ['Date'], now: at(9, 15, 20) }); + const isolation = process.env.SWITCHBOARD_DATA_DIR; + delete process.env.SWITCHBOARD_DATA_DIR; + t.after(() => { if (isolation === undefined) delete process.env.SWITCHBOARD_DATA_DIR; else process.env.SWITCHBOARD_DATA_DIR = isolation; }); + const h = fixture(t); + runner.setScheduleEnabled(h.file, true, { projectRoot: h.project }); + const runs = []; + const clock = schedulerClock(t, h, runs); + assert.equal(runs.length, 1, 'startup retains the current due run for an enabled schedule'); + clock.tick(at(9, 16)); + assert.equal(runs.length, 1, 'the startup run is not repeated by the next tick'); +}); + for (const [timeline, start, enable, tick] of [ ['A before the due minute', [14, 0, 500], [14, 59], [15, 0, 500]], ['B before the fractional tick', [14, 0, 900], [15, 0, 400], [15, 0, 900]],