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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/architecture/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/
Expand Down Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions docs/sequence-flows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
121 changes: 121 additions & 0 deletions docs/sequence-flows/schedules.md
Original file line number Diff line number Diff line change
@@ -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=<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.
134 changes: 134 additions & 0 deletions e2e/tests/schedules.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading