diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index efb8fe6..d24d253 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -67,7 +67,9 @@ pi-web/ │ │ ├── annotations.go # Per-session review annotations: list/create/delete + SSE snapshot (SQLite) │ │ ├── projects.go # Project visibility prefs: list/toggle/register + index filtering (SQLite) │ │ ├── sound.go # /api/sounds + /sounds/ asset serving -│ │ ├── push.go # PushManager: VAPID, subscribe/unsubscribe, NotifyDone +│ │ ├── push.go # PushManager: VAPID, subscribe/unsubscribe, NotifyDone, NotifyScheduleDone +│ │ ├── scheduler.go # Cron tick loop + fireSchedule runner (creates a session, sends instructions) +│ │ ├── schedules_api.go # /api/schedules + /api/schedule(/run|/runs) handlers │ │ ├── update.go # /api/version, check-update, update, restart handlers │ │ ├── events.go # SSE endpoint (/events) │ │ ├── sse_format.go # SSE event framing helper @@ -81,6 +83,8 @@ pi-web/ │ │ ├── title.go # ReadTitleInputs: extract auto-title source text from a session │ │ ├── cache.go # Modtime-aware session cache │ │ └── lookup.go # Resolve session by ID +│ ├── schedules/ +│ │ └── schedule.go # Schedule/Run structs, SQLite store, cron next-fire (robfig/cron) │ ├── share/ │ │ └── share.go # GitHub Gist creation logic │ └── workers/ @@ -281,6 +285,10 @@ type piRPCWorker struct { | `/api/push/vapid` | GET | `handleVapid` | VAPID public key (when push enabled) | | `/api/push/subscribe` | POST | `handleSubscribe` | Register a web-push subscription | | `/api/push/unsubscribe` | POST | `handleUnsubscribe` | Remove a web-push subscription | +| `/api/schedules` | GET/POST | `handleApiSchedules` | List schedules (with `nextRunAt`) / create (SQLite) | +| `/api/schedule` | GET/POST/PUT/DELETE | `handleApiSchedule` | Read/update/delete one schedule (`?id=`) | +| `/api/schedule/run` | POST | `handleApiScheduleRun` | Fire a schedule now (`?id=`); returns created `sessionId` | +| `/api/schedule/runs` | GET | `handleApiScheduleRuns` | Run log for a schedule (`?id=`) | | `/api/version` | GET | `handleVersion` | Current/latest version (when updater set) | | `/api/check-update` | POST | `handleCheckUpdate` | Force a version check | | `/api/update` | POST | `handleUpdate` | Install the latest pi-web | diff --git a/docs/sequence-flows/README.md b/docs/sequence-flows/README.md index 58d9852..7ed87a2 100644 --- a/docs/sequence-flows/README.md +++ b/docs/sequence-flows/README.md @@ -13,3 +13,4 @@ This directory documents the key runtime sequences in pi-web. | [mention-autocomplete.md](./mention-autocomplete.md) | `@`-triggered file/folder path autocomplete in the chat composer | | [btw.md](./btw.md) | Throwaway "btw" floating scratch-chats attached to a session page | | [share.md](./share.md) | Exporting a session to a private GitHub Gist | +| [schedules.md](./schedules.md) | Cron/preset schedules that auto-create pi sessions and push on completion | diff --git a/docs/sequence-flows/schedules.md b/docs/sequence-flows/schedules.md new file mode 100644 index 0000000..af8eca7 --- /dev/null +++ b/docs/sequence-flows/schedules.md @@ -0,0 +1,121 @@ +# Sequence Flow: Schedules + +Schedules run pi automatically on a cadence (or on demand). When a schedule +fires it creates a **fresh pi session**, sends the schedule's instructions as the +first message, and lets pi run autonomously. Each firing is recorded so the +created sessions can be tagged, filtered, and surfaced in a run log — and so a +schedule-specific push notification can be sent when the run finishes. + +Scheduling state lives in SQLite (`pi-web.sqlite`), not in pi's session files. +The `internal/schedules` package owns the store and the cron math; the firing +loop and the session-creating runner live in `internal/server` because they need +the chat workers and SSE broadcast. + +## Data model (SQLite) + +`schedules` — one row per definition: + +| column | notes | +|--------|-------| +| `id` | UUID | +| `name`, `instructions` | required | +| `model_provider`, `model_id`, `thinking_level` | optional → pi defaults | +| `project_path` | optional → user home dir | +| `cron_expr` | empty = manual (Run-now only) | +| `timezone` | IANA name; empty = server local | +| `enabled` | bool | +| `last_run_at` | last fire time | + +`schedule_runs` — one row per firing; also the **session → schedule mapping**: + +| column | notes | +|--------|-------| +| `schedule_id` | FK | +| `session_id` | created session UUID (filled after resolve) | +| `session_file` | created `.jsonl` filename | +| `fired_at`, `status`, `error` | `running` \| `error` | + +## Firing sequence + +``` +┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ ┌──────────┐ +│ scheduler│ │ schedules │ │ sessions │ │ workers │ │ push │ +│ (loop) │ │ (store) │ │ (create file)│ │ (manager) │ │ (manager)│ +└────┬─────┘ └──────┬───────┘ └──────┬───────┘ └─────┬──────┘ └────┬─────┘ + │ │ │ │ │ + │ every 30s: evaluateSchedules() │ │ │ + │── List() ─────▶│ │ │ │ + │◀── schedules ──│ │ │ │ + │ │ │ │ │ + │ for each enabled cron schedule: │ │ │ + │ next = NextFire(cron, tz, now) │ │ │ + │ (first sight only arms it — │ │ │ + │ missed past runs are skipped) │ │ │ + │ │ │ │ │ + │ when now >= next → fireSchedule() │ │ │ + │── RecordRun(running) ────────────▶│ │ │ + │── SetLastRun ────────────────────▶│ │ │ + │── CreateSessionFileWithSettings ───────────────────▶│ │ + │ (project dir or home; model/thinking as implicit entries) │ + │◀── filename ──────────────────────────────────────│ │ + │── ResolveByID ────────────────────────────────────▶│ │ + │◀── session UUID + path ───────────│ │ │ + │── AttachSession(runID, uuid) ────▶│ │ │ + │ │ │ │ │ + │── EnsureWorker(uuid, path) ───────────────────────▶│ │ + │── Send(uuid, path, {instructions}) ───────────────▶│─── pi runs ──▶│ + │ │ │ │ │ + │ (file watcher sees the new .jsonl → broadcasts `new-session`) │ + │ │ │ │ │ + │ [run completes; worker → idle] │ │ │ + │ recomputeAndBroadcastStatus: running → idle │ │ + │ scheduleNameForSession(uuid)? ──▶│ │ │ + │◀── name, true ────────────────────│ │ │ + │── NotifyScheduleDone(name, uuid) ─────────────────────────────────▶│ + │ │ │ │ web push ──▶ browser +``` + +## Manual / Run-now + +A schedule with an empty `cron_expr` never fires on the timer. Any schedule can +be fired immediately via `POST /api/schedule/run?id=`, which calls the same +`fireSchedule` path and returns the created `sessionId` so the UI can navigate to +it. + +## Missed runs + +Schedules only fire while pi-web is running. On startup (and on first sight of +any schedule) the loop computes the next fire time from *now*, so occurrences +that elapsed while the process was down are **skipped** rather than replayed. + +## HTTP endpoints + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/api/schedules` | list (with computed `nextRunAt`) | +| POST | `/api/schedules` | create | +| GET | `/api/schedule?id=` | read one | +| POST/PUT | `/api/schedule?id=` | update | +| DELETE | `/api/schedule?id=` | delete (and its runs) | +| POST | `/api/schedule/run?id=` | Run-now | +| GET | `/api/schedule/runs?id=` | run log | + +The `/schedules` page itself is the SPA shell (served by the catch-all index +route); the Svelte router renders `SchedulesPage.svelte`. + +## Push notifications + +Scheduled runs reuse the web-push subsystem ([share.md](./share.md) covers VAPID +setup). On the running→idle transition, `recomputeAndBroadcastStatus` checks +whether the session was schedule-created; if so it sends `NotifyScheduleDone` +(payload `type: "schedule-done"`) instead of the generic `session-done`. The +service worker (`internal/ui/embedded/assets/sw.js`) shows `schedule-done` +notifications **even when the app is foregrounded**, since a scheduled run is a +background event the user may not be watching. + +## Frontend frequency presets + +The editor offers presets (hourly, daily, weekdays, weekly) plus a raw custom +cron field and a manual option. Presets compile to a standard 5-field cron +expression client-side (`web/src/index/schedules.js` `buildCron`), and +`parseCron` recovers the preset + fields when editing an existing schedule. diff --git a/e2e/tests/schedules.spec.ts b/e2e/tests/schedules.spec.ts new file mode 100644 index 0000000..7260a9b --- /dev/null +++ b/e2e/tests/schedules.spec.ts @@ -0,0 +1,134 @@ +import { test, expect } from "../lib/test"; +import { realWorkingDir } from "../lib/sessions"; + +// Schedules auto-create pi sessions on a cadence (or via Run-now). These specs +// drive the /schedules UI against the stub `pi` worker (e2e/lib/stub-pi/pi): +// creating a schedule, firing it on demand, reading the run log, and deleting. +// +// Schedule state lives in the shared server SQLite DB, and all 7 Playwright +// projects hit one server, so every schedule is created with a unique name and +// located by that name to stay isolated across parallel runs. + +function uniqueName(testInfo: import("@playwright/test").TestInfo): string { + const proj = testInfo.project.name.replace(/[^a-z0-9]+/gi, "-"); + return `e2e sched ${proj} w${testInfo.workerIndex} ${Date.now()}`; +} + +// The "new schedule" entry point is responsive: a header button on desktop, a +// floating + on mobile (where the header button is hidden). Click whichever is +// visible so the spec runs across every project's viewport. +async function openCreateEditor(page: import("@playwright/test").Page) { + const header = page.locator('[data-testid="schedule-new"]'); + if (await header.isVisible()) { + await header.click(); + } else { + await page.locator('[data-testid="schedule-new-fab"]').click(); + } +} + +test.describe("schedules (stubbed pi)", () => { + test("nav button opens the schedules page", async ({ page }) => { + await page.goto("/"); + await page.locator("[data-schedules-btn]").click(); + await expect(page).toHaveURL(/\/schedules$/); + // A create entry point is present (header button on desktop, floating + on + // mobile). + const header = page.locator('[data-testid="schedule-new"]'); + const fab = page.locator('[data-testid="schedule-new-fab"]'); + expect((await header.isVisible()) || (await fab.isVisible())).toBe(true); + + // Guard against the styling regression where the page rendered unstyled: + // the schedules stylesheet must be inlined into the SPA shell. A plain div + // has max-width "none"; .schedules-page sets it to 880px. + const maxWidth = await page + .locator(".schedules-page") + .evaluate((el) => getComputedStyle(el).maxWidth); + expect(maxWidth).toBe("880px"); + }); + + test("create, run now, view run log, and delete a schedule", async ({ + page, + }, testInfo) => { + const name = uniqueName(testInfo); + // A real cwd so the created session has chat available (not "view only"). + const project = realWorkingDir(); + + await page.goto("/schedules"); + + // Create a manual schedule. + await openCreateEditor(page); + await page.locator('[data-testid="schedule-name"]').fill(name); + await page + .locator('[data-testid="schedule-instructions"]') + .fill("Run the scheduled job"); + await page.locator('[data-testid="schedule-project"]').fill(project); + await page + .locator('[data-testid="schedule-frequency"]') + .selectOption("manual"); + await page.locator('[data-testid="schedule-save"]').click(); + + const card = page + .locator('[data-testid="schedule-card"]') + .filter({ hasText: name }); + await expect(card).toBeVisible(); + + // Run-now fires immediately and navigates to the freshly created session. + await card.locator('[data-testid="schedule-run"]').click(); + await expect(page).toHaveURL(/\/session\?id=/, { timeout: 15000 }); + + // Back to the list, open the run log: a run is recorded and links to its + // session. + await page.goto("/schedules"); + const card2 = page + .locator('[data-testid="schedule-card"]') + .filter({ hasText: name }); + await card2.locator('[data-testid="schedule-runs"]').click(); + await expect(card2.locator('[data-testid="run-row"]')).toHaveCount(1); + await expect(card2.locator('[data-testid="run-open"]')).toBeVisible(); + + // The run-log link navigates back into the created session. + await card2.locator('[data-testid="run-open"]').click(); + await expect(page).toHaveURL(/\/session\?id=/, { timeout: 15000 }); + + // Delete it (confirm() is auto-accepted). + await page.goto("/schedules"); + page.on("dialog", (dialog) => dialog.accept()); + const card3 = page + .locator('[data-testid="schedule-card"]') + .filter({ hasText: name }); + await card3.locator('[data-testid="schedule-delete"]').click(); + await expect( + page.locator('[data-testid="schedule-card"]').filter({ hasText: name }), + ).toHaveCount(0); + }); + + test("preset frequency shows a next-run time", async ({ page }, testInfo) => { + const name = uniqueName(testInfo) + " daily"; + + await page.goto("/schedules"); + await openCreateEditor(page); + await page.locator('[data-testid="schedule-name"]').fill(name); + await page + .locator('[data-testid="schedule-instructions"]') + .fill("Daily digest"); + await page.locator('[data-testid="schedule-frequency"]').selectOption("daily"); + await page.locator('[data-testid="schedule-save"]').click(); + + const card = page + .locator('[data-testid="schedule-card"]') + .filter({ hasText: name }); + await expect(card).toBeVisible(); + // A cron schedule reports a computed next run in its meta line (default + // locale is English in e2e). + await expect(card).toContainText("Next"); + + // Cleanup so the shared list doesn't accumulate across runs. + await page.goto("/schedules"); + page.on("dialog", (dialog) => dialog.accept()); + await page + .locator('[data-testid="schedule-card"]') + .filter({ hasText: name }) + .locator('[data-testid="schedule-delete"]') + .click(); + }); +}); diff --git a/go.mod b/go.mod index 53ef97e..49ee770 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect diff --git a/go.sum b/go.sum index 8254750..bc8c17f 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/internal/schedules/schedule.go b/internal/schedules/schedule.go new file mode 100644 index 0000000..5b0c2b0 --- /dev/null +++ b/internal/schedules/schedule.go @@ -0,0 +1,315 @@ +// Package schedules persists user-defined automation schedules and the runs +// they trigger. A schedule fires by creating a fresh pi session and sending its +// instructions as the first message; the per-fire mapping in schedule_runs both +// drives the run log and tags which sessions were schedule-created (so the UI +// can filter them and route schedule-specific push notifications). +// +// The store and cron math live here (pure, testable); the ticker loop and the +// session-creating runner live in package server, which owns the chat workers +// and SSE broadcast. +package schedules + +import ( + "database/sql" + "errors" + "strings" + "time" + + "github.com/robfig/cron/v3" +) + +// ErrManual is returned by NextFire for a schedule with no cron expression: +// it only ever fires via an explicit Run-now, never on a timer. +var ErrManual = errors.New("schedule is manual") + +// SchedulesTableDDL and RunsTableDDL are registered alongside the other schema +// in server.initDB so all table creation stays on one path. +const SchedulesTableDDL = `CREATE TABLE IF NOT EXISTS schedules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + instructions TEXT NOT NULL, + model_provider TEXT NOT NULL DEFAULT '', + model_id TEXT NOT NULL DEFAULT '', + thinking_level TEXT NOT NULL DEFAULT '', + project_path TEXT NOT NULL DEFAULT '', + cron_expr TEXT NOT NULL DEFAULT '', + timezone TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + last_run_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +)` + +const RunsTableDDL = `CREATE TABLE IF NOT EXISTS schedule_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + schedule_id TEXT NOT NULL, + session_id TEXT NOT NULL DEFAULT '', + session_file TEXT NOT NULL DEFAULT '', + fired_at DATETIME NOT NULL, + status TEXT NOT NULL, + error TEXT NOT NULL DEFAULT '' +)` + +const RunsScheduleIndexDDL = `CREATE INDEX IF NOT EXISTS idx_schedule_runs_schedule ON schedule_runs(schedule_id, fired_at DESC)` +const RunsSessionIndexDDL = `CREATE INDEX IF NOT EXISTS idx_schedule_runs_session ON schedule_runs(session_id)` + +// Run statuses recorded in schedule_runs.status. +const ( + RunStatusRunning = "running" + RunStatusError = "error" +) + +// Schedule is one automation definition. Empty CronExpr means manual-only. +// Empty model/thinking/project fields fall back to pi defaults / home dir. +type Schedule struct { + ID string `json:"id"` + Name string `json:"name"` + Instructions string `json:"instructions"` + ModelProvider string `json:"modelProvider"` + ModelID string `json:"modelId"` + ThinkingLevel string `json:"thinkingLevel"` + ProjectPath string `json:"projectPath"` + CronExpr string `json:"cronExpr"` + Timezone string `json:"timezone"` + Enabled bool `json:"enabled"` + LastRunAt string `json:"lastRunAt,omitempty"` + NextRunAt string `json:"nextRunAt,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// Run is one firing of a schedule, mapping it to the session it created. +type Run struct { + ID int64 `json:"id"` + ScheduleID string `json:"scheduleId"` + SessionID string `json:"sessionId,omitempty"` + SessionFile string `json:"sessionFile,omitempty"` + FiredAt string `json:"firedAt"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +// IsManual reports whether the schedule never fires on a timer. +func (s Schedule) IsManual() bool { return strings.TrimSpace(s.CronExpr) == "" } + +// ValidateCron parses a standard 5-field cron expression, returning an error if +// it is malformed. An empty expression is valid (manual schedule). +func ValidateCron(expr string) error { + if strings.TrimSpace(expr) == "" { + return nil + } + _, err := cron.ParseStandard(expr) + return err +} + +// LoadLocation resolves an IANA timezone name, defaulting to server-local time +// when empty. +func LoadLocation(tz string) (*time.Location, error) { + if strings.TrimSpace(tz) == "" { + return time.Local, nil + } + return time.LoadLocation(tz) +} + +// NextFire returns the next time the schedule should fire strictly after the +// given instant, evaluated in the schedule's timezone. Returns ErrManual for an +// empty cron expression. +func NextFire(cronExpr, tz string, after time.Time) (time.Time, error) { + if strings.TrimSpace(cronExpr) == "" { + return time.Time{}, ErrManual + } + loc, err := LoadLocation(tz) + if err != nil { + return time.Time{}, err + } + sched, err := cron.ParseStandard(cronExpr) + if err != nil { + return time.Time{}, err + } + return sched.Next(after.In(loc)), nil +} + +// Store is a thin SQLite-backed repository for schedules and their runs. +type Store struct { + db *sql.DB + Now func() time.Time +} + +// NewStore wraps a database handle. The schema must already be created (see the +// *DDL constants, registered in server.initDB). +func NewStore(db *sql.DB) *Store { + return &Store{db: db, Now: time.Now} +} + +func (st *Store) now() time.Time { + if st.Now != nil { + return st.Now() + } + return time.Now() +} + +const scheduleColumns = `id, name, instructions, model_provider, model_id, thinking_level, + project_path, cron_expr, timezone, enabled, last_run_at, created_at, updated_at` + +func scanSchedule(scan func(dest ...any) error) (Schedule, error) { + var s Schedule + var enabled int + var lastRun sql.NullString + err := scan(&s.ID, &s.Name, &s.Instructions, &s.ModelProvider, &s.ModelID, + &s.ThinkingLevel, &s.ProjectPath, &s.CronExpr, &s.Timezone, &enabled, + &lastRun, &s.CreatedAt, &s.UpdatedAt) + if err != nil { + return Schedule{}, err + } + s.Enabled = enabled != 0 + if lastRun.Valid { + s.LastRunAt = lastRun.String + } + return s, nil +} + +// List returns all schedules, newest first. +func (st *Store) List() ([]Schedule, error) { + rows, err := st.db.Query(`SELECT ` + scheduleColumns + ` FROM schedules ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Schedule + for rows.Next() { + s, err := scanSchedule(rows.Scan) + if err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +// Get returns a single schedule by id. Returns sql.ErrNoRows if absent. +func (st *Store) Get(id string) (Schedule, error) { + row := st.db.QueryRow(`SELECT `+scheduleColumns+` FROM schedules WHERE id = ?`, id) + return scanSchedule(row.Scan) +} + +// Create inserts a new schedule, stamping created_at/updated_at. +func (st *Store) Create(s Schedule) (Schedule, error) { + now := st.now().UTC().Format(time.RFC3339) + s.CreatedAt = now + s.UpdatedAt = now + enabled := 0 + if s.Enabled { + enabled = 1 + } + _, err := st.db.Exec(`INSERT INTO schedules + (id, name, instructions, model_provider, model_id, thinking_level, + project_path, cron_expr, timezone, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + s.ID, s.Name, s.Instructions, s.ModelProvider, s.ModelID, s.ThinkingLevel, + s.ProjectPath, s.CronExpr, s.Timezone, enabled, s.CreatedAt, s.UpdatedAt) + if err != nil { + return Schedule{}, err + } + return s, nil +} + +// Update writes all editable fields and refreshes updated_at. +func (st *Store) Update(s Schedule) (Schedule, error) { + s.UpdatedAt = st.now().UTC().Format(time.RFC3339) + enabled := 0 + if s.Enabled { + enabled = 1 + } + _, err := st.db.Exec(`UPDATE schedules SET + name = ?, instructions = ?, model_provider = ?, model_id = ?, thinking_level = ?, + project_path = ?, cron_expr = ?, timezone = ?, enabled = ?, updated_at = ? + WHERE id = ?`, + s.Name, s.Instructions, s.ModelProvider, s.ModelID, s.ThinkingLevel, + s.ProjectPath, s.CronExpr, s.Timezone, enabled, s.UpdatedAt, s.ID) + if err != nil { + return Schedule{}, err + } + return st.Get(s.ID) +} + +// Delete removes a schedule and its run history. +func (st *Store) Delete(id string) error { + if _, err := st.db.Exec(`DELETE FROM schedule_runs WHERE schedule_id = ?`, id); err != nil { + return err + } + _, err := st.db.Exec(`DELETE FROM schedules WHERE id = ?`, id) + return err +} + +// SetLastRun records the most recent fire time for a schedule. +func (st *Store) SetLastRun(id string, t time.Time) error { + _, err := st.db.Exec(`UPDATE schedules SET last_run_at = ? WHERE id = ?`, + t.UTC().Format(time.RFC3339), id) + return err +} + +// RecordRun inserts a run row and returns its id. +func (st *Store) RecordRun(r Run) (int64, error) { + res, err := st.db.Exec(`INSERT INTO schedule_runs + (schedule_id, session_id, session_file, fired_at, status, error) + VALUES (?, ?, ?, ?, ?, ?)`, + r.ScheduleID, r.SessionID, r.SessionFile, r.FiredAt, r.Status, r.Error) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// AttachSession links a created session to a run after it is resolved. +func (st *Store) AttachSession(runID int64, sessionID, sessionFile string) error { + _, err := st.db.Exec(`UPDATE schedule_runs SET session_id = ?, session_file = ? WHERE id = ?`, + sessionID, sessionFile, runID) + return err +} + +// FailRun marks a run as errored with a message. +func (st *Store) FailRun(runID int64, msg string) error { + _, err := st.db.Exec(`UPDATE schedule_runs SET status = ?, error = ? WHERE id = ?`, + RunStatusError, msg, runID) + return err +} + +// ListRuns returns the run history for a schedule, newest first. +func (st *Store) ListRuns(scheduleID string, limit int) ([]Run, error) { + if limit <= 0 { + limit = 50 + } + rows, err := st.db.Query(`SELECT id, schedule_id, session_id, session_file, fired_at, status, error + FROM schedule_runs WHERE schedule_id = ? ORDER BY fired_at DESC LIMIT ?`, scheduleID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Run + for rows.Next() { + var r Run + if err := rows.Scan(&r.ID, &r.ScheduleID, &r.SessionID, &r.SessionFile, + &r.FiredAt, &r.Status, &r.Error); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// ScheduleNameForSession returns the schedule name that created the given +// session, if any. Used to route schedule-specific push notifications and tag +// sessions in the index. +func (st *Store) ScheduleNameForSession(sessionID string) (string, bool) { + if sessionID == "" { + return "", false + } + var name string + err := st.db.QueryRow(`SELECT s.name FROM schedules s + JOIN schedule_runs r ON r.schedule_id = s.id + WHERE r.session_id = ? LIMIT 1`, sessionID).Scan(&name) + if err != nil { + return "", false + } + return name, true +} diff --git a/internal/schedules/schedule_test.go b/internal/schedules/schedule_test.go new file mode 100644 index 0000000..8f6e336 --- /dev/null +++ b/internal/schedules/schedule_test.go @@ -0,0 +1,179 @@ +package schedules + +import ( + "database/sql" + "errors" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + for _, ddl := range []string{SchedulesTableDDL, RunsTableDDL, RunsScheduleIndexDDL, RunsSessionIndexDDL} { + if _, err := db.Exec(ddl); err != nil { + t.Fatalf("create schema: %v", err) + } + } + t.Cleanup(func() { db.Close() }) + return NewStore(db) +} + +func TestValidateCron(t *testing.T) { + tests := []struct { + expr string + wantErr bool + }{ + {"", false}, + {"0 9 * * *", false}, + {"0 9 * * 1-5", false}, + {"*/15 * * * *", false}, + {"bogus", true}, + {"0 9 * *", true}, + {"99 9 * * *", true}, + } + for _, tc := range tests { + err := ValidateCron(tc.expr) + if (err != nil) != tc.wantErr { + t.Errorf("ValidateCron(%q) err=%v, wantErr=%v", tc.expr, err, tc.wantErr) + } + } +} + +func TestNextFire(t *testing.T) { + base := time.Date(2026, 6, 15, 8, 0, 0, 0, time.UTC) + + t.Run("manual returns ErrManual", func(t *testing.T) { + _, err := NextFire("", "", base) + if !errors.Is(err, ErrManual) { + t.Fatalf("want ErrManual, got %v", err) + } + }) + + t.Run("daily in UTC", func(t *testing.T) { + next, err := NextFire("0 9 * * *", "UTC", base) + if err != nil { + t.Fatal(err) + } + want := time.Date(2026, 6, 15, 9, 0, 0, 0, time.UTC) + if !next.Equal(want) { + t.Errorf("next = %v, want %v", next, want) + } + }) + + t.Run("respects timezone", func(t *testing.T) { + // 09:00 in New York is 13:00 UTC (EDT in June). + next, err := NextFire("0 9 * * *", "America/New_York", base) + if err != nil { + t.Fatal(err) + } + if got := next.UTC().Hour(); got != 13 { + t.Errorf("next UTC hour = %d, want 13", got) + } + }) + + t.Run("invalid timezone", func(t *testing.T) { + if _, err := NextFire("0 9 * * *", "Mars/Phobos", base); err == nil { + t.Fatal("want error for bad timezone") + } + }) +} + +func TestStoreCRUD(t *testing.T) { + st := newTestStore(t) + fixed := time.Date(2026, 6, 15, 8, 0, 0, 0, time.UTC) + st.Now = func() time.Time { return fixed } + + created, err := st.Create(Schedule{ + ID: "abc", + Name: "Digest", + Instructions: "summarize", + CronExpr: "0 9 * * *", + Timezone: "UTC", + Enabled: true, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if created.CreatedAt == "" || created.UpdatedAt == "" { + t.Error("timestamps not stamped") + } + + got, err := st.Get("abc") + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Name != "Digest" || !got.Enabled { + t.Errorf("unexpected schedule: %+v", got) + } + + got.Name = "Renamed" + got.Enabled = false + if _, err := st.Update(got); err != nil { + t.Fatalf("update: %v", err) + } + reread, _ := st.Get("abc") + if reread.Name != "Renamed" || reread.Enabled { + t.Errorf("update not persisted: %+v", reread) + } + + list, err := st.List() + if err != nil || len(list) != 1 { + t.Fatalf("list = %v (err %v)", list, err) + } + + if err := st.Delete("abc"); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := st.Get("abc"); !errors.Is(err, sql.ErrNoRows) { + t.Errorf("want ErrNoRows after delete, got %v", err) + } +} + +func TestRunLifecycleAndSessionMapping(t *testing.T) { + st := newTestStore(t) + if _, err := st.Create(Schedule{ID: "s1", Name: "Nightly", Instructions: "x", Enabled: true}); err != nil { + t.Fatal(err) + } + + runID, err := st.RecordRun(Run{ + ScheduleID: "s1", + FiredAt: time.Now().UTC().Format(time.RFC3339), + Status: RunStatusRunning, + }) + if err != nil { + t.Fatalf("record run: %v", err) + } + if err := st.AttachSession(runID, "session-uuid", "file.jsonl"); err != nil { + t.Fatalf("attach: %v", err) + } + + name, ok := st.ScheduleNameForSession("session-uuid") + if !ok || name != "Nightly" { + t.Errorf("ScheduleNameForSession = %q, %v; want Nightly, true", name, ok) + } + if _, ok := st.ScheduleNameForSession("unknown"); ok { + t.Error("unknown session should not map to a schedule") + } + + runs, err := st.ListRuns("s1", 10) + if err != nil || len(runs) != 1 { + t.Fatalf("ListRuns = %v (err %v)", runs, err) + } + if runs[0].SessionID != "session-uuid" { + t.Errorf("run session = %q", runs[0].SessionID) + } + + if err := st.FailRun(runID, "boom"); err != nil { + t.Fatalf("fail run: %v", err) + } + runs, _ = st.ListRuns("s1", 10) + if runs[0].Status != RunStatusError || runs[0].Error != "boom" { + t.Errorf("fail not persisted: %+v", runs[0]) + } +} diff --git a/internal/server/push.go b/internal/server/push.go index f68f00a..8ecda2c 100644 --- a/internal/server/push.go +++ b/internal/server/push.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "sync" "time" @@ -176,9 +177,36 @@ func (m *PushManager) handleUnsubscribe(w http.ResponseWriter, r *http.Request) writeJSON(w, 0, map[string]any{"ok": true}) } -// NotifyDone sends a push to every registered subscription. Failed endpoints -// (gone / 410) are pruned. Best-effort: errors are logged to stderr. +// NotifyDone sends a "response ready" push for a finished session. func (m *PushManager) NotifyDone(sessionID string) { + m.notify(map[string]string{ + "type": "session-done", + "sessionId": sessionID, + "title": "pi session", + "body": "Response ready", + }) +} + +// NotifyScheduleDone sends a schedule-specific push when a scheduled run +// finishes. Unlike session-done, the service worker shows this even when the +// app is foregrounded, since a schedule firing is a background event the user +// may not be watching. +func (m *PushManager) NotifyScheduleDone(scheduleName, sessionID string) { + title := scheduleName + if strings.TrimSpace(title) == "" { + title = "Scheduled run" + } + m.notify(map[string]string{ + "type": "schedule-done", + "sessionId": sessionID, + "title": title, + "body": "Scheduled run finished", + }) +} + +// notify marshals payload and sends it to every registered subscription. Failed +// endpoints (gone / 410) are pruned. Best-effort: errors are logged to stderr. +func (m *PushManager) notify(payload map[string]string) { if m == nil { return } @@ -196,12 +224,7 @@ func (m *PushManager) NotifyDone(sessionID string) { subj := m.subject m.mu.Unlock() - payload, _ := json.Marshal(map[string]string{ - "type": "session-done", - "sessionId": sessionID, - "title": "pi session", - "body": "Response ready", - }) + payloadBytes, _ := json.Marshal(payload) var stale []string for _, s := range subs { @@ -209,7 +232,7 @@ func (m *PushManager) NotifyDone(sessionID string) { Endpoint: s.Endpoint, Keys: webpush.Keys{P256dh: s.Keys.P256dh, Auth: s.Keys.Auth}, } - resp, err := webpush.SendNotification(payload, ws, &webpush.Options{ + resp, err := webpush.SendNotification(payloadBytes, ws, &webpush.Options{ HTTPClient: m.client, Subscriber: subj, VAPIDPublicKey: pub, diff --git a/internal/server/scheduler.go b/internal/server/scheduler.go new file mode 100644 index 0000000..44ef208 --- /dev/null +++ b/internal/server/scheduler.go @@ -0,0 +1,176 @@ +package server + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" + + "pi-web/internal/chat" + "pi-web/internal/schedules" + "pi-web/internal/sessions" +) + +// scheduleTickInterval is how often the scheduler re-evaluates due schedules. +// Cron is minute-resolution, so a sub-minute tick keeps firing within a few +// seconds of the target time. +const scheduleTickInterval = 30 * time.Second + +// scheduleWorkerTimeout bounds the EnsureWorker step when a schedule fires. +const scheduleWorkerTimeout = 60 * time.Second + +// scheduleState tracks, per schedule, the next time it should fire and the +// cron/timezone signature it was computed from (so edits force a recompute). +type scheduleState struct { + next time.Time + sig string +} + +func scheduleSig(sc schedules.Schedule) string { + return sc.CronExpr + "|" + sc.Timezone +} + +// runScheduler ticks until stopped, firing any schedule whose next occurrence +// has arrived. Missed occurrences (while the process was down) are skipped: +// a schedule's first evaluation computes its next fire from now, never the past. +func (s *Server) runScheduler(stop <-chan struct{}, interval time.Duration) { + state := make(map[string]scheduleState) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + s.evaluateSchedules(state) + select { + case <-ticker.C: + case <-stop: + return + } + } +} + +func (s *Server) evaluateSchedules(state map[string]scheduleState) { + if s.schedules == nil { + return + } + list, err := s.schedules.List() + if err != nil { + fmt.Fprintf(os.Stderr, "scheduler: list schedules: %v\n", err) + return + } + now := s.now() + seen := make(map[string]bool, len(list)) + for _, sc := range list { + seen[sc.ID] = true + if !sc.Enabled || sc.IsManual() { + delete(state, sc.ID) + continue + } + sig := scheduleSig(sc) + st, ok := state[sc.ID] + if !ok || st.sig != sig { + next, err := schedules.NextFire(sc.CronExpr, sc.Timezone, now) + if err != nil { + fmt.Fprintf(os.Stderr, "scheduler: %q invalid cron %q: %v\n", sc.Name, sc.CronExpr, err) + delete(state, sc.ID) + continue + } + state[sc.ID] = scheduleState{next: next, sig: sig} + continue + } + if now.Before(st.next) { + continue + } + sc := sc + go func() { + if _, err := s.fireSchedule(sc); err != nil { + fmt.Fprintf(os.Stderr, "scheduler: fire %q: %v\n", sc.Name, err) + } + }() + next, err := schedules.NextFire(sc.CronExpr, sc.Timezone, now) + if err != nil { + delete(state, sc.ID) + continue + } + state[sc.ID] = scheduleState{next: next, sig: sig} + } + for id := range state { + if !seen[id] { + delete(state, id) + } + } +} + +// scheduleNameForSession reports whether a session was created by a schedule, +// returning the schedule's name. Used to route schedule-specific notifications. +func (s *Server) scheduleNameForSession(sessionID string) (string, bool) { + if s.schedules == nil { + return "", false + } + return s.schedules.ScheduleNameForSession(sessionID) +} + +// fireSchedule creates a fresh pi session for the schedule, records the run, and +// sends the instructions as the first message so pi runs autonomously. Returns +// the created session's UUID. Used by both the timer and the Run-now endpoint. +func (s *Server) fireSchedule(sc schedules.Schedule) (string, error) { + if s.schedules == nil { + return "", errors.New("schedules unavailable") + } + fired := s.now().UTC() + runID, err := s.schedules.RecordRun(schedules.Run{ + ScheduleID: sc.ID, + FiredAt: fired.Format(time.RFC3339), + Status: schedules.RunStatusRunning, + }) + if err != nil { + return "", fmt.Errorf("record run: %w", err) + } + _ = s.schedules.SetLastRun(sc.ID, fired) + + path := strings.TrimSpace(sc.ProjectPath) + if path == "" { + home, err := os.UserHomeDir() + if err != nil { + _ = s.schedules.FailRun(runID, err.Error()) + return "", err + } + path = home + } + + settings := sessions.InitialSettings{ + ModelProvider: sc.ModelProvider, + ModelID: sc.ModelID, + ThinkingLevel: sc.ThinkingLevel, + } + filename, err := sessions.CreateSessionFileWithSettings(s.sessionsDir, path, settings) + if err != nil { + _ = s.schedules.FailRun(runID, err.Error()) + return "", fmt.Errorf("create session: %w", err) + } + resolved, err := sessions.ResolveByID(s.sessionsDir, filename) + if err != nil { + _ = s.schedules.FailRun(runID, err.Error()) + return "", fmt.Errorf("resolve session: %w", err) + } + sessionID := resolved.Session.ID + if err := s.schedules.AttachSession(runID, sessionID, filename); err != nil { + fmt.Fprintf(os.Stderr, "scheduler: attach session: %v\n", err) + } + + if s.chatSender == nil { + _ = s.schedules.FailRun(runID, "chat unavailable") + return sessionID, errors.New("chat unavailable") + } + workerCtx, cancel := context.WithTimeout(context.Background(), scheduleWorkerTimeout) + defer cancel() + if err := s.chatSender.EnsureWorker(workerCtx, sessionID, resolved.Path); err != nil { + _ = s.schedules.FailRun(runID, err.Error()) + return sessionID, fmt.Errorf("ensure worker: %w", err) + } + if err := s.chatSender.Send(context.Background(), sessionID, resolved.Path, chat.Request{Message: sc.Instructions}); err != nil { + _ = s.schedules.FailRun(runID, err.Error()) + return sessionID, fmt.Errorf("send: %w", err) + } + return sessionID, nil +} diff --git a/internal/server/scheduler_test.go b/internal/server/scheduler_test.go new file mode 100644 index 0000000..d056718 --- /dev/null +++ b/internal/server/scheduler_test.go @@ -0,0 +1,204 @@ +package server + +import ( + "bytes" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "pi-web/internal/schedules" + + _ "modernc.org/sqlite" +) + +func newScheduleTestServer(t *testing.T) (*Server, *fakeSender) { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + db.SetMaxOpenConns(1) + for _, ddl := range []string{ + schedules.SchedulesTableDDL, + schedules.RunsTableDDL, + schedules.RunsScheduleIndexDDL, + schedules.RunsSessionIndexDDL, + } { + if _, err := db.Exec(ddl); err != nil { + t.Fatalf("schema: %v", err) + } + } + t.Cleanup(func() { db.Close() }) + + sender := &fakeSender{} + s := &Server{ + db: db, + schedules: schedules.NewStore(db), + sessionsDir: t.TempDir(), + chatSender: sender, + now: func() time.Time { return time.Date(2026, 6, 15, 8, 0, 0, 0, time.UTC) }, + } + return s, sender +} + +func TestFireScheduleCreatesSessionAndSends(t *testing.T) { + s, sender := newScheduleTestServer(t) + project := t.TempDir() + + sc, err := s.schedules.Create(schedules.Schedule{ + ID: "sched1", + Name: "Nightly digest", + Instructions: "Summarize the day", + ProjectPath: project, + Enabled: true, + }) + if err != nil { + t.Fatalf("create schedule: %v", err) + } + + sessionID, err := s.fireSchedule(sc) + if err != nil { + t.Fatalf("fireSchedule: %v", err) + } + if sessionID == "" { + t.Fatal("expected a session id") + } + + _, _, req := sender.sentInfo() + if req.Message != "Summarize the day" { + t.Errorf("Send message = %q, want instructions", req.Message) + } + if !sender.ensureWorkerCalled { + t.Error("EnsureWorker was not called") + } + + // The run is recorded and mapped to the created session. + name, ok := s.scheduleNameForSession(sessionID) + if !ok || name != "Nightly digest" { + t.Errorf("scheduleNameForSession = %q, %v", name, ok) + } + runs, err := s.schedules.ListRuns("sched1", 10) + if err != nil || len(runs) != 1 { + t.Fatalf("ListRuns = %v (err %v)", runs, err) + } + if runs[0].SessionID != sessionID { + t.Errorf("run session = %q, want %q", runs[0].SessionID, sessionID) + } +} + +func TestEvaluateSchedulesSkipsMissedRuns(t *testing.T) { + s, sender := newScheduleTestServer(t) + // A daily 09:00 schedule; "now" is 08:00. First evaluation must only arm the + // next fire, never fire immediately for a missed past occurrence. + if _, err := s.schedules.Create(schedules.Schedule{ + ID: "daily", + Name: "Daily", + Instructions: "go", + CronExpr: "0 9 * * *", + Timezone: "UTC", + Enabled: true, + }); err != nil { + t.Fatal(err) + } + + state := map[string]scheduleState{} + s.evaluateSchedules(state) + + if _, _, req := sender.sentInfo(); req.Message != "" { + t.Errorf("schedule fired on first evaluation; message=%q", req.Message) + } + st, ok := state["daily"] + if !ok { + t.Fatal("expected next-fire state to be armed") + } + want := time.Date(2026, 6, 15, 9, 0, 0, 0, time.UTC) + if !st.next.Equal(want) { + t.Errorf("next = %v, want %v", st.next, want) + } +} + +func TestEvaluateSchedulesIgnoresManualAndDisabled(t *testing.T) { + s, _ := newScheduleTestServer(t) + if _, err := s.schedules.Create(schedules.Schedule{ID: "m", Name: "Manual", Instructions: "x", Enabled: true}); err != nil { + t.Fatal(err) + } + if _, err := s.schedules.Create(schedules.Schedule{ID: "d", Name: "Disabled", Instructions: "x", CronExpr: "0 9 * * *", Enabled: false}); err != nil { + t.Fatal(err) + } + state := map[string]scheduleState{} + s.evaluateSchedules(state) + if len(state) != 0 { + t.Errorf("manual/disabled schedules should not be armed; state=%v", state) + } +} + +func TestSchedulesAPICreateListRun(t *testing.T) { + s, sender := newScheduleTestServer(t) + project := t.TempDir() + + body, _ := json.Marshal(map[string]any{ + "name": "API sched", + "instructions": "do it", + "projectPath": project, + "cronExpr": "0 9 * * *", + "timezone": "UTC", + "enabled": true, + }) + w := httptest.NewRecorder() + s.handleApiSchedules(w, httptest.NewRequest(http.MethodPost, "/api/schedules", bytes.NewReader(body))) + if w.Code != http.StatusCreated { + t.Fatalf("create status = %d, body %s", w.Code, w.Body.String()) + } + var created struct { + Schedule schedules.Schedule `json:"schedule"` + } + json.Unmarshal(w.Body.Bytes(), &created) + if created.Schedule.ID == "" { + t.Fatal("expected created schedule id") + } + if created.Schedule.NextRunAt == "" { + t.Error("expected nextRunAt to be computed") + } + + // List returns it. + lw := httptest.NewRecorder() + s.handleApiSchedules(lw, httptest.NewRequest(http.MethodGet, "/api/schedules", nil)) + var list struct { + Schedules []schedules.Schedule `json:"schedules"` + } + json.Unmarshal(lw.Body.Bytes(), &list) + if len(list.Schedules) != 1 { + t.Fatalf("list len = %d", len(list.Schedules)) + } + + // Run-now fires regardless of cadence. + rw := httptest.NewRecorder() + s.handleApiScheduleRun(rw, httptest.NewRequest(http.MethodPost, "/api/schedule/run?id="+created.Schedule.ID, nil)) + if rw.Code != http.StatusAccepted { + t.Fatalf("run status = %d, body %s", rw.Code, rw.Body.String()) + } + if _, _, req := sender.sentInfo(); req.Message != "do it" { + t.Errorf("run-now Send message = %q", req.Message) + } +} + +func TestSchedulesAPIValidation(t *testing.T) { + s, _ := newScheduleTestServer(t) + // Missing name. + body, _ := json.Marshal(map[string]any{"instructions": "x"}) + w := httptest.NewRecorder() + s.handleApiSchedules(w, httptest.NewRequest(http.MethodPost, "/api/schedules", bytes.NewReader(body))) + if w.Code != http.StatusBadRequest { + t.Errorf("missing name status = %d, want 400", w.Code) + } + // Bad cron. + body2, _ := json.Marshal(map[string]any{"name": "n", "instructions": "x", "cronExpr": "nope"}) + w2 := httptest.NewRecorder() + s.handleApiSchedules(w2, httptest.NewRequest(http.MethodPost, "/api/schedules", bytes.NewReader(body2))) + if w2.Code != http.StatusBadRequest { + t.Errorf("bad cron status = %d, want 400", w2.Code) + } +} diff --git a/internal/server/schedules_api.go b/internal/server/schedules_api.go new file mode 100644 index 0000000..fdd2e0e --- /dev/null +++ b/internal/server/schedules_api.go @@ -0,0 +1,223 @@ +package server + +import ( + "database/sql" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + + "pi-web/internal/schedules" +) + +// scheduleInput is the editable payload for create/update. Server-managed fields +// (id, timestamps, lastRun) are ignored on the way in. +type scheduleInput struct { + Name string `json:"name"` + Instructions string `json:"instructions"` + ModelProvider string `json:"modelProvider"` + ModelID string `json:"modelId"` + ThinkingLevel string `json:"thinkingLevel"` + ProjectPath string `json:"projectPath"` + CronExpr string `json:"cronExpr"` + Timezone string `json:"timezone"` + Enabled *bool `json:"enabled"` +} + +func (in scheduleInput) validate() (string, bool) { + if strings.TrimSpace(in.Name) == "" { + return "name is required", false + } + if strings.TrimSpace(in.Instructions) == "" { + return "instructions are required", false + } + if (in.ModelProvider == "") != (in.ModelID == "") { + return "model provider and id must be set together", false + } + if err := schedules.ValidateCron(in.CronExpr); err != nil { + return "invalid cron expression: " + err.Error(), false + } + if _, err := schedules.LoadLocation(in.Timezone); err != nil { + return "invalid timezone: " + err.Error(), false + } + return "", true +} + +func (in scheduleInput) apply(sc *schedules.Schedule) { + sc.Name = strings.TrimSpace(in.Name) + sc.Instructions = in.Instructions + sc.ModelProvider = in.ModelProvider + sc.ModelID = in.ModelID + sc.ThinkingLevel = in.ThinkingLevel + sc.ProjectPath = strings.TrimSpace(in.ProjectPath) + sc.CronExpr = strings.TrimSpace(in.CronExpr) + sc.Timezone = strings.TrimSpace(in.Timezone) + if in.Enabled != nil { + sc.Enabled = *in.Enabled + } +} + +// withNextRun annotates a schedule with its next computed fire time (not stored). +func (s *Server) withNextRun(sc schedules.Schedule) schedules.Schedule { + if !sc.Enabled || sc.IsManual() { + return sc + } + if next, err := schedules.NextFire(sc.CronExpr, sc.Timezone, s.now()); err == nil { + sc.NextRunAt = next.UTC().Format(time.RFC3339) + } + return sc +} + +// handleApiSchedules is the collection endpoint: GET lists, POST creates. +func (s *Server) handleApiSchedules(w http.ResponseWriter, r *http.Request) { + if s.schedules == nil { + writeJSONError(w, http.StatusInternalServerError, "schedules unavailable") + return + } + switch r.Method { + case http.MethodGet: + list, err := s.schedules.List() + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + out := make([]schedules.Schedule, 0, len(list)) + for _, sc := range list { + out = append(out, s.withNextRun(sc)) + } + writeJSON(w, 0, map[string]any{"schedules": out}) + case http.MethodPost: + var in scheduleInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid json body") + return + } + if msg, ok := in.validate(); !ok { + writeJSONError(w, http.StatusBadRequest, msg) + return + } + sc := schedules.Schedule{ID: uuid.NewString(), Enabled: true} + in.apply(&sc) + created, err := s.schedules.Create(sc) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"schedule": s.withNextRun(created)}) + default: + w.Header().Set("Allow", "GET, POST") + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +// handleApiSchedule is the item endpoint (?id=): GET reads, POST/PUT updates, +// DELETE removes. +func (s *Server) handleApiSchedule(w http.ResponseWriter, r *http.Request) { + if s.schedules == nil { + writeJSONError(w, http.StatusInternalServerError, "schedules unavailable") + return + } + id := strings.TrimSpace(r.URL.Query().Get("id")) + if id == "" { + writeJSONError(w, http.StatusBadRequest, "id is required") + return + } + existing, err := s.schedules.Get(id) + if err == sql.ErrNoRows { + writeJSONError(w, http.StatusNotFound, "schedule not found") + return + } + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + + switch r.Method { + case http.MethodGet: + writeJSON(w, 0, map[string]any{"schedule": s.withNextRun(existing)}) + case http.MethodPost, http.MethodPut: + var in scheduleInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid json body") + return + } + if msg, ok := in.validate(); !ok { + writeJSONError(w, http.StatusBadRequest, msg) + return + } + in.apply(&existing) + updated, err := s.schedules.Update(existing) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, 0, map[string]any{"schedule": s.withNextRun(updated)}) + case http.MethodDelete: + if err := s.schedules.Delete(id); err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, 0, map[string]any{"ok": true}) + default: + w.Header().Set("Allow", "GET, POST, PUT, DELETE") + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +// handleApiScheduleRun fires a schedule immediately (Run-now), independent of +// its cadence. Works for manual schedules too. +func (s *Server) handleApiScheduleRun(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if s.schedules == nil { + writeJSONError(w, http.StatusInternalServerError, "schedules unavailable") + return + } + id := strings.TrimSpace(r.URL.Query().Get("id")) + if id == "" { + writeJSONError(w, http.StatusBadRequest, "id is required") + return + } + sc, err := s.schedules.Get(id) + if err == sql.ErrNoRows { + writeJSONError(w, http.StatusNotFound, "schedule not found") + return + } + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + sessionID, err := s.fireSchedule(sc) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusAccepted, map[string]any{"ok": true, "sessionId": sessionID}) +} + +// handleApiScheduleRuns returns the run history for a schedule (?id=). +func (s *Server) handleApiScheduleRuns(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if s.schedules == nil { + writeJSONError(w, http.StatusInternalServerError, "schedules unavailable") + return + } + id := strings.TrimSpace(r.URL.Query().Get("id")) + if id == "" { + writeJSONError(w, http.StatusBadRequest, "id is required") + return + } + runs, err := s.schedules.ListRuns(id, 50) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, 0, map[string]any{"runs": runs}) +} diff --git a/internal/server/server.go b/internal/server/server.go index 6528dc3..d65c6e3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -20,6 +20,7 @@ import ( "pi-web/internal/auth" "pi-web/internal/render" "pi-web/internal/rpc" + "pi-web/internal/schedules" "pi-web/internal/sessions" "pi-web/internal/updater" @@ -80,6 +81,7 @@ type Server struct { stopOnce sync.Once wg sync.WaitGroup db *sql.DB + schedules *schedules.Store updater *updater.Checker runInstall func(ctx context.Context) error runRestart func() error @@ -150,6 +152,7 @@ func New(deps Deps) (*Server, error) { lastKnown: make(map[string]struct{}), stopCh: make(chan struct{}), db: db, + schedules: schedules.NewStore(db), updater: deps.Updater, runInstall: deps.RunInstall, runRestart: deps.RunRestart, @@ -164,6 +167,7 @@ func New(deps Deps) (*Server, error) { userOwned: make(map[string]bool), }, } + s.schedules.Now = now if pm, err := NewPushManager(agentDir); err != nil { fmt.Fprintf(os.Stderr, "push notifications unavailable: %v\n", err) } else { @@ -178,6 +182,11 @@ func New(deps Deps) (*Server, error) { defer s.wg.Done() s.runStatusSweeper(s.stopCh, time.Second) }() + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.runScheduler(s.stopCh, scheduleTickInterval) + }() return s, nil } @@ -215,6 +224,10 @@ func initDB(agentDir string) (*sql.DB, error) { {"btw_sessions table", btwSessionsSchema}, {"annotations table", annotationsSchema}, {"annotations index", annotationsIndex}, + {"schedules table", schedules.SchedulesTableDDL}, + {"schedule_runs table", schedules.RunsTableDDL}, + {"schedule_runs schedule index", schedules.RunsScheduleIndexDDL}, + {"schedule_runs session index", schedules.RunsSessionIndexDDL}, {"review_comments table", reviewCommentsSchema}, {"review_comments index", reviewCommentsIndex}, } @@ -278,6 +291,10 @@ func (s *Server) Register(mux *http.ServeMux) { mux.HandleFunc("/api/settings", s.getPostHandler(s.handleGetSettings, s.handleSaveSettings)) mux.HandleFunc("/api/btw", s.auth.Wrap(s.handleGetBtw)) mux.HandleFunc("/api/btw/new", s.auth.Wrap(s.handleNewBtw)) + mux.HandleFunc("/api/schedules", s.auth.Wrap(s.handleApiSchedules)) + mux.HandleFunc("/api/schedule", s.auth.Wrap(s.handleApiSchedule)) + mux.HandleFunc("/api/schedule/run", s.auth.Wrap(s.handleApiScheduleRun)) + mux.HandleFunc("/api/schedule/runs", s.auth.Wrap(s.handleApiScheduleRuns)) mux.HandleFunc("/metrics", s.auth.Wrap(s.handleMetricsPage)) mux.HandleFunc("/api/metrics", s.auth.Wrap(s.handleMetrics)) s.registerPprof(mux) diff --git a/internal/server/status.go b/internal/server/status.go index cb68fbb..8fa5b38 100644 --- a/internal/server/status.go +++ b/internal/server/status.go @@ -92,8 +92,13 @@ func (s *Server) recomputeAndBroadcastStatus(sessionID string) { // Transition running → idle: fire a push notification so subscribed // clients learn the response is ready even when the tab is closed - // or the device is locked. + // or the device is locked. Scheduled runs get a schedule-specific push + // (shown even in the foreground) instead of the generic one. if was && !now && s.push != nil { - go s.push.NotifyDone(sessionID) + if name, ok := s.scheduleNameForSession(sessionID); ok { + go s.push.NotifyScheduleDone(name, sessionID) + } else { + go s.push.NotifyDone(sessionID) + } } } diff --git a/internal/ui/embedded/assets/sw.js b/internal/ui/embedded/assets/sw.js index 6547ad0..d6a4cdd 100644 --- a/internal/ui/embedded/assets/sw.js +++ b/internal/ui/embedded/assets/sw.js @@ -37,26 +37,34 @@ self.addEventListener('activate', (event) => { // so the push notification is shown normally. self.addEventListener('push', (event) => { event.waitUntil((async () => { - const clientsList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); - const hasForegroundClient = clientsList.some((client) => { - // WindowClient.visibilityState is the ideal signal. Some browsers expose - // WindowClient.focused instead/also, so treat either as foreground. - return client.visibilityState === 'visible' || client.focused === true; - }); - if (hasForegroundClient) return; - let data = {}; try { data = event.data ? event.data.json() : {}; } catch (_) { data = { title: 'pi session', body: 'Response ready' }; } + + // Scheduled runs fire in the background regardless of whether the app is + // open, so they're always shown. The "response ready" cue for a session the + // user is actively watching is suppressed when a foreground client exists — + // the page handles that cue itself (including done.mp3). + const isSchedule = data.type === 'schedule-done'; + if (!isSchedule) { + const clientsList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + const hasForegroundClient = clientsList.some((client) => { + // WindowClient.visibilityState is the ideal signal. Some browsers expose + // WindowClient.focused instead/also, so treat either as foreground. + return client.visibilityState === 'visible' || client.focused === true; + }); + if (hasForegroundClient) return; + } + const title = data.title || 'pi session'; const options = { body: data.body || 'Response ready', icon: '/icon.svg', badge: '/icon.svg', - tag: 'pi-session-done', + tag: isSchedule ? `pi-schedule-${data.sessionId || ''}` : 'pi-session-done', renotify: true, data: { sessionId: data.sessionId || '' }, // Phones play their default notification sound when this fires. diff --git a/internal/ui/embedded/styles/index.css b/internal/ui/embedded/styles/index.css index da44f33..5c4e842 100644 --- a/internal/ui/embedded/styles/index.css +++ b/internal/ui/embedded/styles/index.css @@ -191,6 +191,38 @@ a { color: inherit; } [data-session-layout="projects"] [data-sessions-content]:not(.index-layout-ready) { visibility: hidden; } +.workspace-views { + display: inline-flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.schedules-nav-btn { + height: 28px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 12px; + border: 1px solid color-mix(in srgb, var(--dim) 76%, transparent); + background: color-mix(in srgb, var(--surface) 54%, transparent); + color: var(--muted); + border-radius: 6px; + cursor: pointer; + font: inherit; + font-size: 13px; + line-height: 1; + transition: color 0.12s, background 0.12s, border-color 0.12s; +} +.schedules-nav-btn:hover { + color: var(--text); + background: var(--surface-2); + border-color: var(--dim); +} +.schedules-nav-icon { + display: inline-flex; + align-items: center; + color: var(--accent); +} .stat-running { display: none; align-items: center; diff --git a/internal/ui/embedded/styles/schedules.css b/internal/ui/embedded/styles/schedules.css new file mode 100644 index 0000000..e1721f0 --- /dev/null +++ b/internal/ui/embedded/styles/schedules.css @@ -0,0 +1,478 @@ +/* Schedules page + editor. The live SPA shell inlines this via appStylesheets() + (spa_page.go); Svelte component ") + return template.HTML("") } // RenderAppShell renders the Svelte SPA host document. It deliberately reuses diff --git a/web/src/App.svelte b/web/src/App.svelte index 7f28dbb..f09671d 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -3,6 +3,7 @@ import SessionsPage from './routes/SessionsPage.svelte'; import SessionPage from './routes/SessionPage.svelte'; import SettingsPage from './routes/SettingsPage.svelte'; + import SchedulesPage from './routes/SchedulesPage.svelte'; import NotFoundPage from './routes/NotFoundPage.svelte'; import VersionController from './components/shared/VersionController.svelte'; @@ -71,6 +72,8 @@ {/key} {:else if path === '/settings'} +{:else if path === '/schedules'} + {:else} {/if} diff --git a/web/src/components/index/IndexHeader.svelte b/web/src/components/index/IndexHeader.svelte index 50c9951..b3ba683 100644 --- a/web/src/components/index/IndexHeader.svelte +++ b/web/src/components/index/IndexHeader.svelte @@ -1,5 +1,5 @@ @@ -51,18 +52,32 @@ > {t('index.active')} -
+
+
+ + +
- {t('schedules.navTitle')}
diff --git a/web/src/index/schedules.js b/web/src/index/schedules.js new file mode 100644 index 0000000..ff9199c --- /dev/null +++ b/web/src/index/schedules.js @@ -0,0 +1,139 @@ +import { getJSON, postJSON } from '../shared/api.js'; + +// Frequency presets the editor offers. 'custom' surfaces a raw cron field; +// 'manual' stores no cron (Run-now only). All others are sugar that compile to a +// standard 5-field cron expression via buildCron(). +export const FREQUENCIES = ['manual', 'hourly', 'daily', 'weekdays', 'weekly', 'custom']; + +function pad2(n) { + return String(n).padStart(2, '0'); +} + +// buildCron compiles a preset + time fields into a standard cron expression. +// minute 0-59, hour 0-23, weekday 0-6 (0=Sunday). Returns '' for manual/custom +// (custom carries its own raw expression). +export function buildCron({ frequency, minute = 0, hour = 9, weekday = 1 } = {}) { + const m = clampInt(minute, 0, 59, 0); + const h = clampInt(hour, 0, 23, 9); + const d = clampInt(weekday, 0, 6, 1); + switch (frequency) { + case 'hourly': + return `${m} * * * *`; + case 'daily': + return `${m} ${h} * * *`; + case 'weekdays': + return `${m} ${h} * * 1-5`; + case 'weekly': + return `${m} ${h} * * ${d}`; + default: + return ''; + } +} + +function clampInt(value, min, max, fallback) { + const n = Number.parseInt(value, 10); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, n)); +} + +// parseCron does the inverse of buildCron for editing: it recognizes the shapes +// buildCron emits and reports the matching preset + fields. Anything else is +// reported as 'custom' so the raw expression stays editable and lossless. +export function parseCron(expr) { + const trimmed = (expr || '').trim(); + if (!trimmed) return { frequency: 'manual', minute: 0, hour: 9, weekday: 1 }; + const parts = trimmed.split(/\s+/); + if (parts.length !== 5) return { frequency: 'custom', minute: 0, hour: 9, weekday: 1 }; + const [min, hr, dom, mon, dow] = parts; + const minute = numOrNull(min); + const hour = numOrNull(hr); + if (minute === null || dom !== '*' || mon !== '*') { + return { frequency: 'custom', minute: 0, hour: 9, weekday: 1 }; + } + if (hr === '*' && dow === '*') { + return { frequency: 'hourly', minute, hour: 9, weekday: 1 }; + } + if (hour === null) return { frequency: 'custom', minute: 0, hour: 9, weekday: 1 }; + if (dow === '*') return { frequency: 'daily', minute, hour, weekday: 1 }; + if (dow === '1-5') return { frequency: 'weekdays', minute, hour, weekday: 1 }; + const weekday = numOrNull(dow); + if (weekday !== null && weekday >= 0 && weekday <= 6) { + return { frequency: 'weekly', minute, hour, weekday }; + } + return { frequency: 'custom', minute: 0, hour: 9, weekday: 1 }; +} + +function numOrNull(s) { + if (!/^\d+$/.test(s)) return null; + return Number.parseInt(s, 10); +} + +// describeFrequency renders a short human label for a schedule's cadence. tr is +// the t() translator; passing it keeps this module free of an i18n import. +export function describeFrequency(schedule, tr) { + const t = tr || ((k) => k); + const expr = (schedule.cronExpr || '').trim(); + if (!expr) return t('schedules.freqManual'); + const { frequency, minute, hour, weekday } = parseCron(expr); + const time = `${pad2(hour)}:${pad2(minute)}`; + switch (frequency) { + case 'hourly': + return t('schedules.freqHourlyAt', { minute: pad2(minute) }); + case 'daily': + return t('schedules.freqDailyAt', { time }); + case 'weekdays': + return t('schedules.freqWeekdaysAt', { time }); + case 'weekly': + return t('schedules.freqWeeklyAt', { day: t('schedules.weekday' + weekday), time }); + default: + return expr; + } +} + +export function defaultFetchSchedules() { + return getJSON('/api/schedules'); +} +export function defaultFetchScheduleRuns(id) { + return getJSON('/api/schedule/runs?id=' + encodeURIComponent(id)); +} +export function defaultCreateSchedule(payload) { + return postJSON('/api/schedules', payload); +} +export function defaultUpdateSchedule(id, payload) { + return postJSON('/api/schedule?id=' + encodeURIComponent(id), payload); +} +export function defaultRunSchedule(id) { + return postJSON('/api/schedule/run?id=' + encodeURIComponent(id), {}); +} +export async function defaultDeleteSchedule(id, { fetchImpl = fetch } = {}) { + const response = await fetchImpl('/api/schedule?id=' + encodeURIComponent(id), { + method: 'DELETE', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + let message = `HTTP ${response.status}`; + try { + const payload = await response.json(); + if (payload && payload.error) message = payload.error; + } catch { + // ignore + } + throw new Error(message); + } + return response.json(); +} +export function defaultFetchModels() { + return getJSON('/api/models'); +} +export function defaultFetchRecent() { + return getJSON('/api/recent-locations'); +} + +// guessTimezone returns the browser's IANA timezone, falling back to ''. +export function guessTimezone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; + } catch { + return ''; + } +} diff --git a/web/src/index/schedules.test.js b/web/src/index/schedules.test.js new file mode 100644 index 0000000..97e2fee --- /dev/null +++ b/web/src/index/schedules.test.js @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { buildCron, parseCron, describeFrequency, FREQUENCIES } from './schedules.js'; + +describe('buildCron', () => { + it('returns empty for manual and custom', () => { + expect(buildCron({ frequency: 'manual' })).toBe(''); + expect(buildCron({ frequency: 'custom' })).toBe(''); + }); + it('builds hourly from minute only', () => { + expect(buildCron({ frequency: 'hourly', minute: 30 })).toBe('30 * * * *'); + }); + it('builds daily from time', () => { + expect(buildCron({ frequency: 'daily', minute: 5, hour: 9 })).toBe('5 9 * * *'); + }); + it('builds weekdays', () => { + expect(buildCron({ frequency: 'weekdays', minute: 0, hour: 8 })).toBe('0 8 * * 1-5'); + }); + it('builds weekly with day-of-week', () => { + expect(buildCron({ frequency: 'weekly', minute: 0, hour: 17, weekday: 5 })).toBe('0 17 * * 5'); + }); + it('clamps out-of-range values', () => { + expect(buildCron({ frequency: 'daily', minute: 99, hour: 40 })).toBe('59 23 * * *'); + }); +}); + +describe('parseCron', () => { + it('treats empty as manual', () => { + expect(parseCron('').frequency).toBe('manual'); + }); + it('round-trips presets built by buildCron', () => { + for (const f of ['hourly', 'daily', 'weekdays', 'weekly']) { + const expr = buildCron({ frequency: f, minute: 15, hour: 10, weekday: 3 }); + const parsed = parseCron(expr); + expect(parsed.frequency).toBe(f); + } + }); + it('recovers fields for weekly', () => { + const parsed = parseCron('15 10 * * 3'); + expect(parsed).toMatchObject({ frequency: 'weekly', minute: 15, hour: 10, weekday: 3 }); + }); + it('falls back to custom for unrecognized shapes', () => { + expect(parseCron('*/15 9 1 * *').frequency).toBe('custom'); + expect(parseCron('0 9 * *').frequency).toBe('custom'); + }); +}); + +describe('describeFrequency', () => { + const tr = (key, params) => `${key}:${JSON.stringify(params || {})}`; + it('describes manual', () => { + expect(describeFrequency({ cronExpr: '' }, tr)).toContain('freqManual'); + }); + it('describes daily with time', () => { + expect(describeFrequency({ cronExpr: '5 9 * * *' }, tr)).toContain('09:05'); + }); + it('returns the raw expression for custom', () => { + expect(describeFrequency({ cronExpr: '*/15 9 1 * *' }, tr)).toBe('*/15 9 1 * *'); + }); +}); + +describe('FREQUENCIES', () => { + it('lists all supported presets', () => { + expect(FREQUENCIES).toEqual(['manual', 'hourly', 'daily', 'weekdays', 'weekly', 'custom']); + }); +}); diff --git a/web/src/routes/SchedulesPage.svelte b/web/src/routes/SchedulesPage.svelte new file mode 100644 index 0000000..7a5e420 --- /dev/null +++ b/web/src/routes/SchedulesPage.svelte @@ -0,0 +1,699 @@ + + + + +
+
+ +

+ + {t('schedules.title')} +

+ +
+ + {#if loadError} + + {/if} + + {#if loading} +

{t('schedules.loading')}

+ {:else if schedules.length === 0} +
+ +

{t('schedules.emptyTitle')}

+

{t('schedules.emptyHint')}

+ +
+ {:else} +
    + {#each schedules as schedule (schedule.id)} +
  • +
    +
    + {schedule.name} + + {schedule.enabled ? t('schedules.active') : t('schedules.paused')} + +
    +
    + + + {freqLabel(schedule)} + {#if schedule.cronExpr && schedule.timezone} + · {schedule.timezone} + {/if} + + {#if schedule.nextRunAt} + {t('schedules.next')}: {fmtTime(schedule.nextRunAt)} + {/if} + {#if schedule.lastRunAt} + {t('schedules.last')}: {fmtTime(schedule.lastRunAt)} + {/if} +
    + {#if schedule.projectPath} +
    {schedule.projectPath}
    + {/if} +
    + +
    + + + + + +
    + + {#if expandedId === schedule.id} +
    + {#if runsLoading} +

    {t('schedules.loading')}

    + {:else if runs.length === 0} +

    {t('schedules.noRuns')}

    + {:else} +
      + {#each runs as run (run.id)} +
    • + {fmtTime(run.firedAt)} + {#if run.status === 'error'} + {t('schedules.runError')}: {run.error} + {:else if run.sessionId} + + {:else} + {t('schedules.runStarted')} + {/if} +
    • + {/each} +
    + {/if} +
    + {/if} +
  • + {/each} +
+ {/if} +
+ + + + (editingId = '')} +> + + + + +
+ {t('schedules.fieldProject')} + (projectPickerOpen = true)} + onblur={() => (projectPickerOpen = false)} + /> + {#if projectPickerOpen && filteredRecent.length > 0} +
+
+ {#each filteredRecent as path (path)} + + {/each} +
+
+ {/if} +
+ +
+ {t('schedules.fieldModel')} +
+ + {#if form.modelId} + + {/if} +
+ {#if modelPickerOpen} +
+ +
+ {#if filteredProviders.length === 0} +
{t('schedules.modelNone')}
+ {:else} + {#each filteredProviders as group (group.provider)} +
{group.provider}
+ {#each group.models as model (model.id || model.modelId)} + + {/each} + {/each} + {/if} +
+
+ {/if} +
+ + + + + +
+ {#if form.frequency === 'hourly'} + + {/if} + + {#if showTime} + + {/if} + + {#if form.frequency === 'weekly'} + + {/if} +
+ + {#if form.frequency === 'custom'} + + {/if} + + {#if form.frequency !== 'manual'} + + {/if} + + + + {#if formError} + + {/if} + +
+ + +
+
diff --git a/web/src/routes/SessionsPage.svelte b/web/src/routes/SessionsPage.svelte index 72c6a15..bfad45c 100644 --- a/web/src/routes/SessionsPage.svelte +++ b/web/src/routes/SessionsPage.svelte @@ -296,6 +296,7 @@ onSearch={openPalette} onToggleMenu={toggleMenu} onLayoutChange={setLayout} + onSchedules={() => navigate('/schedules')} />