diff --git a/.plans/automations.md b/.plans/automations.md new file mode 100644 index 00000000..57105208 --- /dev/null +++ b/.plans/automations.md @@ -0,0 +1,284 @@ +# Helm Code Automations — Implementation Plan + +## Overview + +Build a scheduled task system that auto-starts turns on threads at configured +intervals/cron times, modeled after Codex Automations. The design follows Helm +Code's existing CQRS architecture: new commands, events, projector fields, and a +`ScheduleReactor` that maintains an in-memory timer heap. + +## Design Decisions + +**Thread-scoped, not project-scoped.** Schedules attach to threads (like +snooze), not projects. A schedule is "send this prompt to this thread on a +cadence." + +**Prompt-driven.** Each scheduled run sends a user message to the thread and +starts a turn — same as a manual `thread.turn.start`. The agent picks up context +from the existing thread history (same-thread model). + +**No new aggregate.** Schedules are optional fields on +`OrchestrationThread`/`OrchestrationThreadShell` (like `snoozedUntil`), not a +separate aggregate. This keeps the event count low and avoids a new subscription +stream. + +**Server-side timers, not client-side.** Unlike snooze (which clients poll), +schedules fire from the server's `ScheduleReactor`. This ensures they fire even +when no client is connected. + +--- + +## Phase 1: Contracts & Schema + +### 1a. New schemas in `packages/contracts/src/orchestration.ts` + +```typescript +// Schedule configuration — persisted on the thread +const ThreadSchedule = Schema.Struct({ + enabled: Schema.Boolean, + // ISO 8601 RRULE string (e.g., "FREQ=DAILY;BYHOUR=9;BYMINUTE=0") + // or null for one-shot interval + cron: Schema.NullOr(TrimmedNonEmptyString), + // Interval in milliseconds (for simple "every N minutes" schedules) + intervalMs: Schema.NullOr(Schema.Number), + // The prompt to send on each scheduled run + prompt: TrimmedNonEmptyString, + // When the next run should fire + nextRunAt: IsoDateTime, + // When the schedule was created + createdAt: IsoDateTime, + // Optional: model override for scheduled runs + modelSelection: Schema.optional(ModelSelection), +}); +``` + +### 1b. New commands (add to `DispatchableClientOrchestrationCommand`) + +- `thread.schedule.create` — Create/update a schedule on a thread +- `thread.schedule.cancel` — Remove a schedule from a thread + +### 1c. New event types (add to `OrchestrationEventType`) + +- `"thread.scheduled"` — Schedule was created/updated +- `"thread.unscheduled"` — Schedule was cancelled + +### 1d. Thread & Shell schema updates + +Add optional fields to `OrchestrationThread` and `OrchestrationThreadShell`: + +```typescript +schedule: Schema.optional(Schema.NullOr(ThreadSchedule)), +``` + +### Files to modify: + +- `packages/contracts/src/orchestration.ts` — schemas, unions, payloads + +--- + +## Phase 2: Server Backend + +### 2a. Decider (`apps/server/src/orchestration/decider.ts`) + +Add cases for the two new commands: + +- **`thread.schedule.create`**: Validate thread exists, not archived. Validate + schedule config (at least one of `cron` or `intervalMs`; `prompt` non-empty; + `nextRunAt` in the future). Emit `thread.scheduled` event. +- **`thread.schedule.cancel`**: Validate thread exists. Emit + `thread.unscheduled` event. Idempotent (cancel of no-schedule is a no-op). + +### 2b. Projector (`apps/server/src/orchestration/projector.ts`) + +Add cases for the two new events: + +- **`thread.scheduled`**: Set `thread.schedule = payload.schedule` on the + matching thread. +- **`thread.unscheduled`**: Set `thread.schedule = null`. + +### 2c. DB Migration (`apps/server/src/persistence/Migrations/043_ProjectionThreadsSchedule.ts`) + +```sql +ALTER TABLE projection_threads ADD COLUMN schedule TEXT; +``` + +Follows the exact pattern of migration 034 (snoozed). + +### 2d. ScheduleReactor (`apps/server/src/orchestration/Services/ScheduleReactor.ts`) + +A new reactor that: + +1. **On startup**: Reads all threads with non-null `schedule` from the + projection, loads them into an in-memory timer heap (sorted by `nextRunAt`). +2. **On `thread.scheduled` event**: Add/update the entry in the heap. +3. **On `thread.unscheduled` event**: Remove from the heap. +4. **On timer fire**: For the due thread: + - Generate a user message ID (UUID) + - Dispatch `thread.turn.start` command with the schedule's prompt as the + message text + - Compute the next `nextRunAt` based on `cron`/`intervalMs` and update via + `thread.schedule.create` (re-emission) + - If the thread has an active session, skip this fire and reschedule +5. **On shutdown**: Clear all timers. + +Implementation uses `Effect.sleep` + `Effect.fork` for timers (no external cron +library). Each scheduled thread gets its own fiber that sleeps until +`nextRunAt`, then fires and reschedules. + +### Files to create: + +- `apps/server/src/orchestration/Services/ScheduleReactor.ts` +- `apps/server/src/orchestration/Layers/ScheduleReactor.ts` +- `apps/server/src/persistence/Migrations/043_ProjectionThreadsSchedule.ts` + +### Files to modify: + +- `apps/server/src/orchestration/decider.ts` — new command cases +- `apps/server/src/orchestration/projector.ts` — new event cases +- `apps/server/src/persistence/Migrations.ts` — register migration 043 +- `apps/server/src/server.ts` — wire `ScheduleReactorLive` into the reactor + merge +- `apps/server/src/orchestration/Services/OrchestrationReactor.ts` — add + schedule reactor startup + +--- + +## Phase 3: Client Runtime + +### 3a. Command dispatchers (`packages/client-runtime/src/operations/commands.ts`) + +Add `scheduleThread()` and `cancelThreadSchedule()` functions that dispatch the +new commands over WebSocket. Follow the existing `snoozeThread()` / +`unsnoozeThread()` pattern. + +### 3b. Thread commands atom (`packages/client-runtime/src/state/threadCommands.ts`) + +Add `scheduleThread` and `cancelThreadSchedule` to the atom command set, with +capability gating. + +### 3c. Thread state (`packages/client-runtime/src/state/threads.ts`) + +The `schedule` field on `OrchestrationThreadShell` flows through automatically +via the shell subscription — no special handling needed since it's an optional +field on the existing shell type. + +### Files to modify: + +- `packages/client-runtime/src/operations/commands.ts` +- `packages/client-runtime/src/state/threadCommands.ts` + +--- + +## Phase 4: Web UI + +### 4a. Sidebar schedule indicator (`apps/web/src/components/Sidebar.tsx`) + +Add a small clock/schedule icon on thread rows that have an active schedule, +similar to how `backgroundLiveness` shows "working"/"monitoring". Show next run +time on hover. + +### 4b. Thread action menu (`apps/web/src/components/threadActionMenu.logic.ts`) + +Add "Schedule..." and "Cancel schedule" menu items alongside existing +settle/snooze/pin actions. + +### 4c. Schedule dialog (`apps/web/src/components/ScheduleDialog.tsx`) + +A modal dialog with: + +- Prompt textarea (the message to send on each run) +- Schedule type toggle: Interval (every N minutes/hours/days) or Cron (RRULE + input) +- Quick presets: Every hour, Daily at 9am, Weekly Monday 9am +- Model selection (optional override) +- "Next run" preview +- Save / Cancel buttons + +### 4d. Schedule management view + +For v1, the schedule is managed per-thread via the action menu and dialog. No +separate "Scheduled" inbox view (that can come in v2). + +### Files to create: + +- `apps/web/src/components/ScheduleDialog.tsx` + +### Files to modify: + +- `apps/web/src/components/Sidebar.tsx` — schedule indicator +- `apps/web/src/components/threadActionMenu.logic.ts` — menu items + +--- + +## Phase 5: Mobile & Desktop + +### Desktop + +The desktop app wraps the web app, so the web UI changes propagate +automatically. No separate work needed. + +### Mobile (React Native) + +Add the schedule action to the thread long-press menu. The schedule dialog needs +a React Native equivalent. Can be deferred to a follow-up if needed. + +--- + +## Migration & Backward Compatibility + +- New fields are `Schema.optional(...)` on both `OrchestrationThread` and + `OrchestrationThreadShell`, so older clients/servers decode safely (field + absent = no schedule). +- The DB migration uses `ALTER TABLE ... ADD COLUMN` with nullable `TEXT`, + following the established pattern. +- The `ScheduleReactor` only runs on the server — no client changes required for + firing. +- Event schema is additive (new event types in the `OrchestrationEventType` + literals union). + +--- + +## What's NOT in scope (v1) + +- Event-based triggers (GitHub PR, Gmail, Slack) — future +- Standalone (fresh thread) automations — future +- "Scheduled" inbox/management view — future +- Manual "Run now" button — future +- Skills integration — future +- Admin controls for disabling schedules — future + +--- + +## Summary of changes by layer + +| Layer | Files | Change | +| ---------------- | ------------------------------------------------------------------------- | ---------------------------------------------- | +| Contracts | `packages/contracts/src/orchestration.ts` | New schemas, commands, events, union additions | +| Server decider | `apps/server/src/orchestration/decider.ts` | 2 new command cases | +| Server projector | `apps/server/src/orchestration/projector.ts` | 2 new event cases | +| Server migration | `apps/server/src/persistence/Migrations/043_*.ts` | New migration | +| Server reactor | `apps/server/src/orchestration/Services/ScheduleReactor.ts` | New file: timer heap + fire logic | +| Server wiring | `apps/server/src/server.ts`, `OrchestrationReactor.ts` | Wire reactor | +| Client runtime | `packages/client-runtime/src/operations/commands.ts`, `threadCommands.ts` | 2 new command dispatchers | +| Web UI | `Sidebar.tsx`, `threadActionMenu.logic.ts`, `ScheduleDialog.tsx` | Schedule indicator, menu items, dialog | +| Mobile | Thread long-press menu | Deferred | + +## Codex Automations Reference + +For comparison, Codex Automations (official name: "Scheduled tasks") provide: + +- **Two types**: Standalone (fresh thread each run) and Thread (same thread, + context preserved) +- **Time-based scheduling**: RRULE format, minute intervals, + daily/weekly/monthly +- **Event triggers** (Aug 2026): Gmail, Slack, GitHub PR activity +- **Full agent capabilities**: write code, create PRs, run tests, use + plugins/MCP +- **Skills integration**: `$skill-name` syntax in prompts +- **Same-thread context**: Agent resumes the same conversation, accumulating + understanding across runs +- **GA since March 2026**, top-level feature April 2026 + +Our v1 covers the "thread-type, time-based" subset of this, which is the core +value. Event triggers, skills, and standalone automations are natural +follow-ups. diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 1dea9174..a454fc8f 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -63,6 +63,7 @@ import { type OrchestrationEngineShape, } from "../src/orchestration/Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; +import { ScheduleReactor } from "../src/orchestration/Services/ScheduleReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -375,6 +376,12 @@ export const makeOrchestrationIntegrationHarness = ( drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ScheduleReactor, { + start: () => Effect.void, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 558fe4d2..ec097002 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -152,6 +152,7 @@ export const make = Effect.gen(function* () { pullRequests: true, threadSettlement: true, threadSnooze: true, + threadScheduling: true, threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 300d1526..e2ec0bf0 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import { ScheduleReactor } from "../Services/ScheduleReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; @@ -64,6 +65,15 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ScheduleReactor, { + start: () => { + started.push("schedule-reactor"); + return Effect.void; + }, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, @@ -85,6 +95,7 @@ describe("OrchestrationReactor", () => { "provider-command-reactor", "checkpoint-reactor", "thread-deletion-reactor", + "schedule-reactor", "agent-awareness-relay", ]); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index fb7543e3..16777179 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import { ScheduleReactor } from "../Services/ScheduleReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { @@ -16,6 +17,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; + const scheduleReactor = yield* ScheduleReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { @@ -23,6 +25,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* providerCommandReactor.start(); yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); + yield* scheduleReactor.start(); yield* agentAwarenessRelay.start(); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 5bd8bb60..eb739b1e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -330,6 +330,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", titleRegeneration: null, + schedule: null, deletedAt: null, messages: [ { @@ -452,6 +453,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", titleRegeneration: null, + schedule: null, session: { threadId: ThreadId.make("thread-1"), status: "running", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 74660bd3..1d566f31 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -25,6 +25,7 @@ import { ModelSelection, ProjectId, ThreadId, + ThreadSchedule, ThreadTokenUsageSnapshot, } from "@helmcode/contracts"; import * as Arr from "effect/Array"; @@ -95,6 +96,7 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + schedule: Schema.NullOr(Schema.fromJsonString(ThreadSchedule)), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -471,6 +473,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + schedule AS "schedule", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -507,6 +510,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + schedule AS "schedule", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -545,6 +549,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + schedule AS "schedule", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -996,6 +1001,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + schedule AS "schedule", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1855,6 +1861,7 @@ pending_approval_requests AS ( pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + schedule: row.schedule, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -2062,6 +2069,7 @@ pending_approval_requests AS ( pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + schedule: row.schedule, deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -2198,6 +2206,7 @@ pending_approval_requests AS ( pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + schedule: row.schedule, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -2343,6 +2352,7 @@ pending_approval_requests AS ( pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + schedule: row.schedule, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -2622,6 +2632,7 @@ pending_approval_requests AS ( pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), + schedule: threadRow.value.schedule, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -2856,6 +2867,7 @@ pending_approval_requests AS ( pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), + schedule: threadRow.value.schedule, deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/Layers/ScheduleReactor.test.ts b/apps/server/src/orchestration/Layers/ScheduleReactor.test.ts new file mode 100644 index 00000000..cfded393 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ScheduleReactor.test.ts @@ -0,0 +1,339 @@ +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationReadModel, +} from "@helmcode/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { it } from "@effect/vitest"; +import { describe, expect } from "vite-plus/test"; + +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { ScheduleReactor } from "../Services/ScheduleReactor.ts"; +import { computeNextRunAt, ScheduleReactorLive } from "./ScheduleReactor.ts"; + +const now = "1970-01-01T00:00:00.000Z"; +const threadId = ThreadId.make("schedule-reactor-test-thread"); +const projectId = ProjectId.make("schedule-reactor-test-project"); + +const fakeCrypto: Crypto.Crypto = Crypto.make({ + randomBytes: (size) => globalThis.crypto.getRandomValues(new Uint8Array(size)), + digest: (algorithm, data) => + Effect.promise(() => globalThis.crypto.subtle.digest(algorithm as string, data)).pipe( + Effect.map((buffer) => new Uint8Array(buffer)), + ), +}); + +const stubSnapshotQuery = ( + getSnapshot: () => Effect.Effect, +): ProjectionSnapshotQuery["Service"] => ({ + getCommandReadModel: getSnapshot, + getSnapshot, + getShellSnapshot: () => + Effect.succeed({ snapshotSequence: 1, projects: [], threads: [], updatedAt: now }), + getArchivedShellSnapshot: () => + Effect.succeed({ snapshotSequence: 1, projects: [], threads: [], updatedAt: now }), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), + getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: (threadId) => + getSnapshot().pipe( + Effect.map((rm) => Option.fromNullishOr(rm.threads.find((thread) => thread.id === threadId))), + ), + getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + searchThreads: () => Effect.succeed({ matches: [] }), +}); + +type ThreadOverrides = { + schedule?: OrchestrationReadModel["threads"][number]["schedule"]; + session?: OrchestrationReadModel["threads"][number]["session"]; +}; + +const readModel = (overrides: ThreadOverrides): OrchestrationReadModel => ({ + snapshotSequence: 1, + updatedAt: now, + projects: [ + { + id: projectId, + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + ], + threads: [ + { + id: threadId, + projectId, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + latestTurn: null, + messages: [], + session: overrides.session ?? null, + activities: [], + proposedPlans: [], + checkpoints: [], + deletedAt: null, + schedule: overrides.schedule ?? null, + }, + ], +}); + +const runningSession: NonNullable = { + threadId, + status: "running", + providerName: null, + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, +}; + +const schedule = ( + overrides: Partial<{ + nextRunAt: string; + intervalMs: number | null; + cron: string | null; + enabled: boolean; + prompt: string; + }> = {}, +) => ({ + enabled: overrides.enabled ?? true, + cron: overrides.cron ?? null, + intervalMs: overrides.intervalMs ?? 60_000, + prompt: overrides.prompt ?? "Continue where you left off.", + nextRunAt: overrides.nextRunAt ?? "1970-01-01T00:00:10.000Z", + createdAt: now, +}); + +/** + * Boots the ScheduleReactor against mock engine + snapshot services under a + * TestClock, advances the virtual clock past `nextRunAt` so the timer fires + * deterministically, and returns the recorded commands. + */ +const runReactor = ( + overrides: ThreadOverrides, + adjust: Duration.Duration, +): Effect.Effect< + { + readonly commands: Array; + readonly turnStarts: Array<{ threadId: ThreadId; text: string }>; + readonly reschedules: Array; + }, + never, + never +> => + Effect.gen(function* () { + const commands = yield* Ref.make>([]); + const turnStarts = yield* Ref.make>([]); + const reschedules = yield* Ref.make>([]); + // TestClock.adjust only guarantees a due sleep's *immediate* continuation + // gets a turn to run — it does not wait for that continuation's own + // further async steps (dispatching commands here) to finish, so reading + // the Refs right after adjust races the fire. thread.schedule.create is + // always the last command fireSchedule dispatches (both the normal fire + // and the "turn already running" retry path end with it), so resolving + // this once it's seen is a reliable "fire has fully completed" signal — + // the same Deferred-based pattern VcsStatusBroadcaster.test.ts uses for + // the same class of TestClock race. + const fired = yield* Deferred.make(); + + const engine: OrchestrationEngineService["Service"] = { + readEvents: () => Stream.empty, + dispatch: (command: OrchestrationCommand) => + Ref.update(commands, (calls) => [...calls, command.type]).pipe( + Effect.flatMap(() => { + if (command.type === "thread.turn.start") { + return Ref.update(turnStarts, (calls) => [ + ...calls, + { threadId: command.threadId, text: command.message.text }, + ]); + } + if (command.type === "thread.schedule.create") { + return Ref.update(reschedules, (calls) => [ + ...calls, + command.schedule.nextRunAt, + ]).pipe(Effect.andThen(Deferred.succeed(fired, undefined))); + } + return Effect.void; + }), + Effect.as({ sequence: 1 } as const), + ), + streamDomainEvents: Stream.never, + latestSequence: Effect.succeed(0), + }; + + const snapshotQuery = stubSnapshotQuery(() => Effect.succeed(readModel(overrides))); + + const testLayer = ScheduleReactorLive.pipe( + Layer.provideMerge(TestClock.layer()), + Layer.provideMerge(Layer.succeed(OrchestrationEngineService, engine)), + Layer.provideMerge(Layer.succeed(ProjectionSnapshotQuery, snapshotQuery)), + Layer.provideMerge(Layer.succeed(Crypto.Crypto, fakeCrypto)), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const reactor = yield* Effect.service(ScheduleReactor); + yield* reactor.start(); + yield* TestClock.adjust(adjust); + yield* Deferred.await(fired); + }), + ).pipe(Effect.provide(testLayer)); + + return { + commands: yield* Ref.get(commands), + turnStarts: yield* Ref.get(turnStarts), + reschedules: yield* Ref.get(reschedules), + }; + }); + +describe("computeNextRunAt", () => { + it("adds the interval for interval-based schedules", () => { + const next = computeNextRunAt({ cron: null, intervalMs: 3_600_000 }, DateTime.makeUnsafe(now)); + expect(DateTime.formatIso(next)).toBe("1970-01-01T01:00:00.000Z"); + }); + + it("returns +1 hour for HOURLY cron with no BYMINUTE (defaults to :00)", () => { + const next = computeNextRunAt( + { cron: "FREQ=HOURLY", intervalMs: null }, + DateTime.makeUnsafe(now), + ); + expect(DateTime.formatIso(next)).toBe("1970-01-01T01:00:00.000Z"); + }); + + it("respects BYMINUTE for HOURLY cron, rolling to the next hour once passed", () => { + const next = computeNextRunAt( + { cron: "FREQ=HOURLY;BYMINUTE=30", intervalMs: null }, + DateTime.makeUnsafe("1970-01-01T00:10:00.000Z"), + ); + expect(DateTime.formatIso(next)).toBe("1970-01-01T00:30:00.000Z"); + + const wrapped = computeNextRunAt( + { cron: "FREQ=HOURLY;BYMINUTE=30", intervalMs: null }, + DateTime.makeUnsafe("1970-01-01T00:45:00.000Z"), + ); + expect(DateTime.formatIso(wrapped)).toBe("1970-01-01T01:30:00.000Z"); + }); + + it("computes the next DAILY run at the target time, advancing a day when passed", () => { + const midnight = DateTime.makeUnsafe(now); + const next = computeNextRunAt( + { cron: "FREQ=DAILY;BYHOUR=9;BYMINUTE=0", intervalMs: null }, + midnight, + ); + expect(DateTime.formatIso(next)).toBe("1970-01-01T09:00:00.000Z"); + + const midday = DateTime.makeUnsafe("1970-01-01T12:00:00.000Z"); + const nextDay = computeNextRunAt( + { cron: "FREQ=DAILY;BYHOUR=9;BYMINUTE=0", intervalMs: null }, + midday, + ); + expect(DateTime.formatIso(nextDay)).toBe("1970-01-02T09:00:00.000Z"); + }); + + it("rolls a WEEKLY schedule forward to the configured weekday, wrapping to next week", () => { + // 2026-09-04 is a Friday; the next Monday is 2 days ahead. + const friday = DateTime.makeUnsafe("2026-09-04T10:00:00.000Z"); + const next = computeNextRunAt( + { cron: "FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0", intervalMs: null }, + friday, + ); + expect(DateTime.formatIso(next)).toBe("2026-09-07T09:00:00.000Z"); + + // A Monday morning before the target time schedules the same Monday. + const mondayEarly = DateTime.makeUnsafe("2026-09-07T08:00:00.000Z"); + const sameDay = computeNextRunAt( + { cron: "FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0", intervalMs: null }, + mondayEarly, + ); + expect(DateTime.formatIso(sameDay)).toBe("2026-09-07T09:00:00.000Z"); + }); + + it("defaults a DAILY cron without a parseable target to +1 day", () => { + const next = computeNextRunAt( + { cron: "FREQ=DAILY", intervalMs: null }, + DateTime.makeUnsafe(now), + ); + expect(DateTime.formatIso(next)).toBe("1970-01-02T00:00:00.000Z"); + }); + + it("advances a MONTHLY cron by a calendar month once the target time has passed", () => { + const next = computeNextRunAt( + { cron: "FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", intervalMs: null }, + DateTime.makeUnsafe("1970-01-01T12:00:00.000Z"), + ); + expect(DateTime.formatIso(next)).toBe("1970-02-01T09:00:00.000Z"); + }); +}); + +describe("ScheduleReactor firing", () => { + it.effect( + "dispatches a turn start with the schedule prompt and reschedules when the timer fires", + () => + Effect.gen(function* () { + const { commands, turnStarts, reschedules } = yield* runReactor( + { schedule: schedule() }, + Duration.seconds(10), + ); + + // thread.turn.start must dispatch before the reschedule so a fired + // schedule cannot be "reset" ahead of the turn it was meant to start. + expect(commands).toEqual(["thread.turn.start", "thread.schedule.create"]); + expect(turnStarts).toEqual([{ threadId, text: "Continue where you left off." }]); + // Fired at the schedule's default nextRunAt (00:00:10) with + // intervalMs: 60_000, so the reschedule lands exactly 60s later. + expect(reschedules).toEqual(["1970-01-01T00:01:10.000Z"]); + }), + ); + + it.effect("skips firing while a turn is running and only reschedules", () => + Effect.gen(function* () { + const { commands, turnStarts, reschedules } = yield* runReactor( + { schedule: schedule(), session: runningSession }, + Duration.seconds(10), + ); + + expect(commands).toEqual(["thread.schedule.create"]); + expect(turnStarts).toEqual([]); + // Retried 5 minutes after the fire time (00:00:10), not the schedule's + // normal cadence. + expect(reschedules).toEqual(["1970-01-01T00:05:10.000Z"]); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ScheduleReactor.ts b/apps/server/src/orchestration/Layers/ScheduleReactor.ts new file mode 100644 index 00000000..27e6b27f --- /dev/null +++ b/apps/server/src/orchestration/Layers/ScheduleReactor.ts @@ -0,0 +1,348 @@ +import { + CommandId, + type OrchestrationCommand, + type OrchestrationThread, + MessageId, + type ThreadSchedule, + ThreadId, +} from "@helmcode/contracts"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as HashMap from "effect/HashMap"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import type * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import { forkParked } from "../../serverActivation.ts"; +import type { OrchestrationDispatchError } from "../Errors.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { ScheduleReactor, type ScheduleReactorShape } from "../Services/ScheduleReactor.ts"; + +const RRULE_FREQ = /FREQ=(DAILY|WEEKLY|MONTHLY|HOURLY)/; +const RRULE_HOUR = /BYHOUR=(\d+)/; +const RRULE_MINUTE = /BYMINUTE=(\d+)/; +const RRULE_WEEKDAY = /BYDAY=(MO|TU|WE|TH|FR|SA|SU)/; +const WEEKDAY_TO_JS_DAY: Record = { + MO: 1, + TU: 2, + WE: 3, + TH: 4, + FR: 5, + SA: 6, + SU: 0, +}; + +/** + * Compute the next run time for an interval- or cron-based schedule. + * Supports the basic RRULE subset (FREQ=DAILY|WEEKLY|MONTHLY|HOURLY with + * optional BYHOUR/BYMINUTE/BYDAY); anything else falls back to +1 hour. + */ +export const computeNextRunAt = ( + schedule: Pick, + now: DateTime.DateTime, +): DateTime.DateTime => { + if (schedule.intervalMs != null) { + return DateTime.add(now, { milliseconds: Math.max(1, schedule.intervalMs) }); + } + + const cron = schedule.cron; + if (cron == null) { + // Fallback: 1 hour from now. + return DateTime.add(now, { hours: 1 }); + } + + const upper = cron.toUpperCase(); + const freq = RRULE_FREQ.exec(upper)?.[1]; + const hour = RRULE_HOUR.exec(upper)?.[1]; + const minute = RRULE_MINUTE.exec(upper)?.[1]; + + if (freq === "HOURLY") { + // "Every hour at :BYMINUTE" (default :00), not a flat +1h from now. + const targetMinute = minute === undefined ? 0 : Number(minute); + let next = DateTime.makeUnsafe({ + year: DateTime.getPartUtc(now, "year"), + month: DateTime.getPartUtc(now, "month"), + day: DateTime.getPartUtc(now, "day"), + hour: DateTime.getPartUtc(now, "hour"), + minute: targetMinute, + second: 0, + millisecond: 0, + }); + if (DateTime.isLessThanOrEqualTo(next, now)) { + next = DateTime.add(next, { hours: 1 }); + } + return next; + } + + const targetHour = hour === undefined ? DateTime.getPartUtc(now, "hour") : Number(hour); + const targetMinute = minute === undefined ? DateTime.getPartUtc(now, "minute") : Number(minute); + + // Build the candidate "this period at target time" as UTC. + let next = DateTime.makeUnsafe({ + year: DateTime.getPartUtc(now, "year"), + month: DateTime.getPartUtc(now, "month"), + day: DateTime.getPartUtc(now, "day"), + hour: targetHour, + minute: targetMinute, + second: 0, + millisecond: 0, + }); + + if (freq === "WEEKLY") { + const weekday = RRULE_WEEKDAY.exec(upper)?.[1]; + const targetDay = weekday === undefined ? 1 : (WEEKDAY_TO_JS_DAY[weekday] ?? 1); + const currentDay = DateTime.getPartUtc(now, "weekDay"); + let daysAhead = targetDay - currentDay; + // Target earlier in the week than today: roll to next week. + if (daysAhead < 0) daysAhead += 7; + // Same weekday but the target time already passed: roll to next week. + if (daysAhead === 0 && DateTime.isLessThanOrEqualTo(next, now)) daysAhead = 7; + next = DateTime.add(next, { days: daysAhead }); + } else if (DateTime.isLessThanOrEqualTo(next, now)) { + // DAILY advances a day; MONTHLY advances a calendar month. + next = DateTime.add(next, freq === "MONTHLY" ? { months: 1 } : { days: 1 }); + } + + return next; +}; + +const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const randomUUID = crypto.randomUUIDv4; + const serverCommandId = (tag: string) => + randomUUID.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); + const orchestrationEngine = yield* OrchestrationEngineService; + const snapshotQuery = yield* ProjectionSnapshotQuery; + + // Map of threadId → active timer fiber. Kept in a Ref so schedule changes + // can interrupt and replace a fiber. Most of these are asleep until + // nextRunAt; `firingSemaphore` below (not this map) is what `drain` waits + // on, since interrupting/joining a sleeping timer isn't "idle work". + const timers = yield* Ref.make(HashMap.empty>()); + + // Acquired for the duration of a fire (dispatching the turn-start and + // rescheduling) so `drain` can wait for in-flight fires without joining + // the (typically many) timer fibers asleep until their next run. The + // permit count is just a generous ceiling on concurrent fires, not a + // concurrency limit — schedules never contend for it in practice. + const MAX_CONCURRENT_FIRINGS = 10_000; + const firingSemaphore = yield* Semaphore.make(MAX_CONCURRENT_FIRINGS); + + // Dispatch a schedule command (already built as an effect to surface UUID + // generation failures). Any error is logged and swallowed so the timer fiber + // never fails below. + const dispatchScheduleUpdate = ( + commandEffect: Effect.Effect< + OrchestrationCommand, + PlatformError.PlatformError | OrchestrationDispatchError, + never + >, + ): Effect.Effect => + commandEffect.pipe( + Effect.flatMap((command) => orchestrationEngine.dispatch(command)), + Effect.catch((error) => + Effect.logWarning("schedule reactor failed to dispatch command", { + cause: String(error), + }), + ), + Effect.asVoid, + ); + + const scheduleCreateCommand = ( + threadId: ThreadId, + schedule: ThreadSchedule, + ): Effect.Effect => + Effect.map(serverCommandId("schedule-update"), (commandId) => ({ + type: "thread.schedule.create" as const, + commandId, + threadId, + schedule, + })); + + // Dispatches the turn-start for a thread's schedule, then advances the + // schedule's nextRunAt so the timer chain continues. + const fireSchedule = Effect.fn("fireSchedule")(function* (thread: OrchestrationThread) { + const schedule = thread.schedule; + if (schedule == null || !schedule.enabled) { + return; + } + + // Skip firing while a turn is already running; retry in 5 minutes. + if (thread.session != null && thread.session.status === "running") { + const now = yield* DateTime.now; + const nextRunAt = DateTime.formatIso(DateTime.add(now, { minutes: 5 })); + yield* dispatchScheduleUpdate(scheduleCreateCommand(thread.id, { ...schedule, nextRunAt })); + return; + } + + const now = yield* DateTime.now; + const createdAt = DateTime.formatIso(now); + const messageId = yield* randomUUID.pipe( + Effect.map(MessageId.make), + Effect.catch((error) => + Effect.logWarning("schedule reactor failed to generate message id", { + threadId: thread.id, + cause: String(error), + }).pipe(Effect.as(MessageId.make(`schedule:${thread.id}:${DateTime.toEpochMillis(now)}`))), + ), + ); + yield* dispatchScheduleUpdate( + Effect.map(serverCommandId("schedule-turn-start"), (commandId) => ({ + type: "thread.turn.start" as const, + commandId, + threadId: thread.id, + message: { + messageId, + role: "user" as const, + text: schedule.prompt, + attachments: [], + }, + ...(schedule.modelSelection !== undefined + ? { modelSelection: schedule.modelSelection } + : {}), + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + createdAt, + })), + ); + + const nextRunAt = DateTime.formatIso(computeNextRunAt(schedule, yield* DateTime.now)); + yield* dispatchScheduleUpdate(scheduleCreateCommand(thread.id, { ...schedule, nextRunAt })); + }); + + // Interrupt any timer already scheduled for the thread (whether asleep or + // mid-fire — firing runs inline in the same fiber, see startTimerForSchedule) + // so a schedule update replaces rather than stacks. Removes the map entry + // too, but only if it still points at the fiber just interrupted — a + // concurrent replacement that already installed a newer fiber under the + // same key is left untouched. Without this removal, a cancelled (never + // re-scheduled) thread would leave a dead fiber reference in the map + // forever. + const interruptTimer = Effect.fn("interruptTimer")(function* (threadId: ThreadId) { + const map = yield* Ref.get(timers); + const entry = HashMap.get(map, threadId); + if (Option.isNone(entry)) return; + const fiber = entry.value; + yield* Fiber.interrupt(fiber); + yield* Ref.update(timers, (current) => + Option.match(HashMap.get(current, threadId), { + onNone: () => current, + onSome: (owner) => (owner === fiber ? HashMap.remove(current, threadId) : current), + }), + ); + }); + + // Reads the freshest state for just this thread and fires its schedule if + // due. A single-thread read (rather than the full projection snapshot, + // which hydrates every thread/message/activity in the system) since only + // one thread's schedule/session is needed to decide whether to fire. + const fireTimerForThread = Effect.fn("fireTimerForThread")(function* (threadId: ThreadId) { + const threadOption = yield* snapshotQuery.getThreadDetailById(threadId).pipe( + Effect.catch((error) => + Effect.logWarning("schedule reactor failed to read thread", { + threadId, + cause: String(error), + }).pipe(Effect.as(Option.none())), + ), + ); + if (Option.isNone(threadOption)) { + return; + } + const thread = threadOption.value; + yield* fireSchedule(thread); + }); + + const startTimerForSchedule = Effect.fn("startTimerForSchedule")(function* ( + threadId: ThreadId, + schedule: ThreadSchedule, + ) { + if (!schedule.enabled) { + return; + } + + yield* interruptTimer(threadId); + + const now = yield* DateTime.now; + const nextRunAt = Option.getOrElse(DateTime.make(schedule.nextRunAt), () => now); + const delayMs = Math.max(0, DateTime.toEpochMillis(nextRunAt) - DateTime.toEpochMillis(now)); + + const timer = Effect.sleep(`${delayMs} millis`).pipe( + // Runs inline (no extra fork) so interrupting this same fiber — e.g. a + // schedule cancelled or replaced mid-fire — cancels the fire too. + // Holding a permit for the duration is what lets `drain` (below) wait + // for in-flight fires without also waiting on fibers still asleep. + Effect.andThen(() => firingSemaphore.withPermits(1)(fireTimerForThread(threadId))), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + // Let interrupts (schedule replacement / scope close) propagate so the + // fiber ends cleanly instead of leaving a stray firing behind. + return Effect.failCause(cause); + } + return Effect.logWarning("schedule timer failed", { + threadId, + cause: Cause.pretty(cause), + }); + }), + ); + + const fiber = yield* Effect.forkScoped(timer); + yield* Ref.update(timers, (map) => HashMap.set(map, threadId, fiber)); + }); + + const start: ScheduleReactorShape["start"] = Effect.fn("start")(function* () { + // Subscribe to schedule lifecycle events before reading the bootstrap + // snapshot below: streamDomainEvents is a hot, events-from-now-only + // stream, so a schedule created in the gap between a snapshot read and + // the subscription would otherwise never get a timer. startTimerForSchedule + // already interrupts and replaces any existing timer for a thread, so a + // thread seen by both the subscription and the snapshot is harmless. + yield* forkParked( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + if (event.type === "thread.scheduled") { + return startTimerForSchedule(event.payload.threadId, event.payload.schedule); + } + if (event.type === "thread.unscheduled") { + return interruptTimer(event.payload.threadId); + } + return Effect.void; + }), + ); + + // Bootstrap timers for schedules that already exist, now that the + // subscription above is live. + const snapshot = yield* snapshotQuery.getSnapshot().pipe( + Effect.catch((error) => + Effect.logWarning("schedule reactor failed to read snapshot on start", { + cause: String(error), + }).pipe(Effect.as(null)), + ), + ); + if (snapshot != null) { + for (const thread of snapshot.threads) { + if (thread.schedule != null && thread.schedule.enabled) { + yield* startTimerForSchedule(thread.id, thread.schedule); + } + } + } + }); + + // Acquiring every permit blocks until no fire is in flight, without + // waiting on the (typically many) timer fibers asleep until their nextRunAt. + const drain: ScheduleReactorShape["drain"] = firingSemaphore + .withPermits(MAX_CONCURRENT_FIRINGS)(Effect.void) + .pipe(Effect.asVoid); + + return { + start, + drain, + } satisfies ScheduleReactorShape; +}); + +export const ScheduleReactorLive = Layer.effect(ScheduleReactor, make); diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 48eac92c..63d71ecb 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -13,6 +13,8 @@ import { ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema, ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, + ThreadScheduledPayload as ContractsThreadScheduledPayloadSchema, + ThreadUnscheduledPayload as ContractsThreadUnscheduledPayloadSchema, ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema, @@ -45,6 +47,8 @@ export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; +export const ThreadScheduledPayload = ContractsThreadScheduledPayloadSchema; +export const ThreadUnscheduledPayload = ContractsThreadUnscheduledPayloadSchema; export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; diff --git a/apps/server/src/orchestration/Services/ScheduleReactor.ts b/apps/server/src/orchestration/Services/ScheduleReactor.ts new file mode 100644 index 00000000..23f8ecb7 --- /dev/null +++ b/apps/server/src/orchestration/Services/ScheduleReactor.ts @@ -0,0 +1,39 @@ +/** + * ScheduleReactor - Scheduled task reactor service interface. + * + * Owns background fibers that fire scheduled turns on threads at configured + * intervals/cron times. Subscribes to domain events for schedule lifecycle + * and manages per-thread timer fibers. + * + * @module ScheduleReactor + */ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +/** + * ScheduleReactorShape - Service API for schedule reactor lifecycle. + */ +export interface ScheduleReactorShape { + /** + * Start reacting to thread.schedule.create / thread.unscheduled domain + * events and fire due schedules. + * + * The returned effect must be run in a scope so all timer fibers can be + * finalized on shutdown. + */ + readonly start: () => Effect.Effect; + + /** + * Resolves when the internal processing queue is empty and idle. + * Intended for test use to replace timing-sensitive sleeps. + */ + readonly drain: Effect.Effect; +} + +/** + * ScheduleReactor - Service tag for schedule reactor workers. + */ +export class ScheduleReactor extends Context.Service()( + "helmcode/orchestration/Services/ScheduleReactor", +) {} diff --git a/apps/server/src/orchestration/decider.scheduled.test.ts b/apps/server/src/orchestration/decider.scheduled.test.ts new file mode 100644 index 00000000..881d3b1f --- /dev/null +++ b/apps/server/src/orchestration/decider.scheduled.test.ts @@ -0,0 +1,184 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type ThreadSchedule, +} from "@helmcode/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +// The decider's clock is the Effect test clock, pinned to the epoch, so +// "future" run times are relative to 1970-01-01T00:00:00.000Z. +const FUTURE_RUN_AT = "1970-01-02T09:00:00.000Z"; +const PAST_RUN_AT = "1969-12-31T09:00:00.000Z"; + +function schedule(overrides: Partial = {}): ThreadSchedule { + return { + enabled: true, + cron: null, + intervalMs: 3_600_000, + prompt: "Continue where you left off.", + nextRunAt: FUTURE_RUN_AT, + createdAt: NOW, + ...overrides, + }; +} + +function makeReadModel(input: { + readonly schedule?: ThreadSchedule | null; +}): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + schedule: input.schedule ?? null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +const expectScheduleRejected = (input: ThreadSchedule) => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.schedule.create", + commandId: CommandId.make("cmd-schedule"), + threadId: ThreadId.make("thread-1"), + schedule: input, + }, + readModel: makeReadModel({}), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }); + +it.layer(NodeServices.layer)("scheduled thread decider", (it) => { + it.effect("creates a schedule with an interval cadence", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.schedule.create", + commandId: CommandId.make("cmd-schedule"), + threadId: ThreadId.make("thread-1"), + schedule: schedule(), + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.scheduled"); + }), + ); + + it.effect("creates a schedule with a supported cron cadence", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.schedule.create", + commandId: CommandId.make("cmd-schedule"), + threadId: ThreadId.make("thread-1"), + schedule: schedule({ cron: "FREQ=DAILY;BYHOUR=9;BYMINUTE=0", intervalMs: null }), + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.scheduled"); + }), + ); + + it.effect("rejects a schedule with neither cron nor intervalMs", () => + expectScheduleRejected(schedule({ cron: null, intervalMs: null })), + ); + + it.effect("rejects a schedule with both cron and intervalMs set", () => + expectScheduleRejected(schedule({ cron: "FREQ=DAILY", intervalMs: 3_600_000 })), + ); + + it.effect("rejects a non-positive intervalMs", () => + Effect.gen(function* () { + yield* expectScheduleRejected(schedule({ intervalMs: 0 })); + yield* expectScheduleRejected(schedule({ intervalMs: -1 })); + }), + ); + + it.effect("rejects a cron outside the supported RRULE subset", () => + Effect.gen(function* () { + yield* expectScheduleRejected(schedule({ cron: "FREQ=YEARLY", intervalMs: null })); + yield* expectScheduleRejected(schedule({ cron: "BYHOUR=9", intervalMs: null })); + yield* expectScheduleRejected(schedule({ cron: "FREQ=DAILY;COUNT=5", intervalMs: null })); + }), + ); + + it.effect("rejects a nextRunAt that is not in the future", () => + expectScheduleRejected(schedule({ nextRunAt: PAST_RUN_AT })), + ); + + it.effect("rejects an empty prompt", () => expectScheduleRejected(schedule({ prompt: " " }))); +}); + +it.layer(NodeServices.layer)("schedule cancellation decider", (it) => { + it.effect("cancels an existing schedule and bumps updatedAt", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.schedule.cancel", + commandId: CommandId.make("cmd-cancel"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ schedule: schedule() }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.unscheduled"); + if (events[0]?.type === "thread.unscheduled") { + expect(events[0].payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("cancelling an already-unscheduled thread is a no-op that preserves updatedAt", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.schedule.cancel", + commandId: CommandId.make("cmd-cancel-again"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ schedule: null }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.unscheduled"); + if (events[0]?.type === "thread.unscheduled") { + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index d77db296..6e322bb9 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -24,6 +24,20 @@ import { projectEvent } from "./projector.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +// The RRULE subset ScheduleReactor.computeNextRunAt actually understands +// (apps/server/src/orchestration/Layers/ScheduleReactor.ts): FREQ is +// required and must be one of the four listed frequencies; BYHOUR, BYMINUTE +// and BYDAY are optional. Anything outside this subset silently falls back +// to "+1 hour" there with no signal to the caller, so it is rejected here +// instead. Keep this in sync with ScheduleReactor's RRULE_* patterns. +const SUPPORTED_RRULE_PATTERN = + /^(?:FREQ=(?:DAILY|WEEKLY|MONTHLY|HOURLY)|BYHOUR=(?:[01]?\d|2[0-3])|BYMINUTE=(?:[0-5]?\d)|BYDAY=(?:MO|TU|WE|TH|FR|SA|SU))(?:;(?:FREQ=(?:DAILY|WEEKLY|MONTHLY|HOURLY)|BYHOUR=(?:[01]?\d|2[0-3])|BYMINUTE=(?:[0-5]?\d)|BYDAY=(?:MO|TU|WE|TH|FR|SA|SU)))*$/; + +function isSupportedRruleCron(cron: string): boolean { + const upper = cron.toUpperCase(); + return SUPPORTED_RRULE_PATTERN.test(upper) && /(?:^|;)FREQ=/.test(upper); +} + // Session adoption takes seconds; a user message still unadopted after this // window is a failed/stale start, not pending work. Mirrors the client's // QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts. @@ -646,6 +660,101 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.schedule.create": { + yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = yield* nowIso; + const { schedule } = command; + // Validate: cron and intervalMs are mutually exclusive cadences — exactly + // one must be set. ScheduleReactor.computeNextRunAt otherwise silently + // prefers intervalMs, leaving a supplied cron dead and unreported. + if (schedule.cron == null && schedule.intervalMs == null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} schedule must have at least one of cron or intervalMs`, + }); + } + if (schedule.cron != null && schedule.intervalMs != null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} schedule must not set both cron and intervalMs`, + }); + } + // Validate: intervalMs, when set, must be strictly positive. + if (schedule.intervalMs != null && !(schedule.intervalMs > 0)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} schedule intervalMs ${schedule.intervalMs} must be positive`, + }); + } + // Validate: cron, when set, must match the RRULE subset ScheduleReactor + // actually supports (FREQ=DAILY|WEEKLY|MONTHLY|HOURLY, optional + // BYHOUR/BYMINUTE/BYDAY) — anything else silently falls back to +1h + // there with no signal to the caller. + if (schedule.cron != null && !isSupportedRruleCron(schedule.cron)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} schedule cron "${schedule.cron}" is not a supported RRULE (expected FREQ=DAILY|WEEKLY|MONTHLY|HOURLY with optional BYHOUR/BYMINUTE/BYDAY)`, + }); + } + // Validate: prompt must be non-empty (schema guarantees this, but be safe). + if (schedule.prompt.trim().length === 0) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} schedule prompt must not be empty`, + }); + } + // Validate: nextRunAt must be in the future. + if (!(Date.parse(schedule.nextRunAt) > Date.parse(occurredAt))) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} schedule nextRunAt ${schedule.nextRunAt} is not in the future`, + }); + } + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.scheduled", + payload: { + threadId: command.threadId, + schedule, + updatedAt: occurredAt, + }, + }; + } + + case "thread.schedule.cancel": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = yield* nowIso; + // Idempotent: cancelling a schedule that doesn't exist is a no-op, so + // updatedAt is only bumped when a schedule is actually cleared. + const alreadyUnscheduled = thread.schedule == null; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unscheduled", + payload: { + threadId: command.threadId, + updatedAt: alreadyUnscheduled ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.pin": { const thread = yield* requireThreadNotArchived({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 1ee80756..eab4e5eb 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -28,6 +28,8 @@ import { ThreadSnoozedPayload, ThreadUnpinnedPayload, ThreadUnarchivedPayload, + ThreadUnscheduledPayload, + ThreadScheduledPayload, ThreadUnsettledPayload, ThreadUnsnoozedPayload, ThreadRevertedPayload, @@ -405,6 +407,28 @@ export function projectEvent( })), ); + case "thread.scheduled": + return decodeForEvent(ThreadScheduledPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + schedule: payload.schedule, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.unscheduled": + return decodeForEvent(ThreadUnscheduledPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + schedule: null, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.pinned": return decodeForEvent(ThreadPinnedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 76117906..7c8b489a 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -14,11 +14,12 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection } from "@helmcode/contracts"; +import { ModelSelection, ThreadSchedule } from "@helmcode/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + schedule: Schema.NullOr(Schema.fromJsonString(ThreadSchedule)), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -51,6 +52,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pin_order_key, title_regeneration_request_id, title_regeneration_started_at, + schedule, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -78,6 +80,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.pinOrderKey ?? null}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, + ${row.schedule != null ? JSON.stringify(row.schedule) : null}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -105,6 +108,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pin_order_key = excluded.pin_order_key, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, + schedule = excluded.schedule, latest_user_message_at = excluded.latest_user_message_at, pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, @@ -139,6 +143,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + schedule AS "schedule", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -175,6 +180,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + schedule AS "schedule", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 90bece92..c81f74d9 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -55,6 +55,7 @@ import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMo import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; import Migration0041 from "./Migrations/041_ProjectionTurnsStopReasonCost.ts"; import Migration0042 from "./Migrations/042_ProjectionTurnsTokenUsage.ts"; +import Migration0043 from "./Migrations/043_ProjectionThreadsSchedule.ts"; /** * Migration loader with all migrations defined inline. @@ -109,6 +110,7 @@ export const migrationEntries = [ [40, "ProjectionProjectFaviconPath", Migration0040], [41, "ProjectionTurnsStopReasonCost", Migration0041], [42, "ProjectionTurnsTokenUsage", Migration0042], + [43, "ProjectionThreadsSchedule", Migration0043], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/043_ProjectionThreadsSchedule.ts b/apps/server/src/persistence/Migrations/043_ProjectionThreadsSchedule.ts new file mode 100644 index 00000000..9acb43e1 --- /dev/null +++ b/apps/server/src/persistence/Migrations/043_ProjectionThreadsSchedule.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "schedule")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN schedule TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 9d743353..a177955e 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -15,6 +15,7 @@ import { ProviderInteractionMode, RuntimeMode, ThreadId, + ThreadSchedule, TurnId, } from "@helmcode/contracts"; import * as Option from "effect/Option"; @@ -45,6 +46,7 @@ export const ProjectionThread = Schema.Struct({ pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + schedule: Schema.optional(Schema.NullOr(ThreadSchedule)), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2f64a11e..e7fb5adb 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -60,6 +60,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; +import { ScheduleReactorLive } from "./orchestration/Layers/ScheduleReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; @@ -250,6 +251,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), + Layer.provideMerge(ScheduleReactorLive), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); diff --git a/apps/web/src/components/ScheduleDialog.logic.test.ts b/apps/web/src/components/ScheduleDialog.logic.test.ts new file mode 100644 index 00000000..ece5709e --- /dev/null +++ b/apps/web/src/components/ScheduleDialog.logic.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { computeNextRunAt, PRESETS } from "./ScheduleDialog.logic"; + +describe("PRESETS", () => { + it("exposes the three quick presets from the plan", () => { + expect(PRESETS.map((preset) => preset.label)).toEqual([ + "Every hour", + "Daily at 9am", + "Weekly Monday 9am", + ]); + }); + + it("each preset resolves to a schedule config that computeNextRunAt accepts", () => { + const now = new Date("2026-09-04T12:00:00.000Z"); // Friday + for (const preset of PRESETS) { + const nextRunAt = computeNextRunAt( + preset.mode, + preset.intervalMinutes ?? 60, + preset.cron ?? "", + now, + ); + expect(new Date(nextRunAt).getTime()).toBeGreaterThan(now.getTime()); + } + }); +}); + +describe("computeNextRunAt", () => { + it("interval mode adds N minutes from now", () => { + const now = new Date("2026-09-04T12:00:00.000Z"); + const nextRunAt = computeNextRunAt("interval", 60, "", now); + expect(nextRunAt).toBe("2026-09-04T13:00:00.000Z"); + }); + + it("interval mode floors to at least 1 minute for non-positive input", () => { + const now = new Date("2026-09-04T12:00:00.000Z"); + expect(computeNextRunAt("interval", 0, "", now)).toBe("2026-09-04T12:01:00.000Z"); + expect(computeNextRunAt("interval", -5, "", now)).toBe("2026-09-04T12:01:00.000Z"); + }); + + it("HOURLY cron with no BYMINUTE defaults to :00, rolling to the next hour", () => { + const now = new Date("2026-09-04T12:34:00.000Z"); + const nextRunAt = computeNextRunAt("cron", 60, "FREQ=HOURLY", now); + expect(nextRunAt).toBe("2026-09-04T13:00:00.000Z"); + }); + + it("HOURLY cron respects BYMINUTE, ignoring BYHOUR", () => { + const now = new Date("2026-09-04T12:10:00.000Z"); + const nextRunAt = computeNextRunAt("cron", 60, "FREQ=HOURLY;BYHOUR=9;BYMINUTE=30", now); + expect(nextRunAt).toBe("2026-09-04T12:30:00.000Z"); + + const wrapped = computeNextRunAt( + "cron", + 60, + "FREQ=HOURLY;BYHOUR=9;BYMINUTE=30", + new Date("2026-09-04T12:45:00.000Z"), + ); + expect(wrapped).toBe("2026-09-04T13:30:00.000Z"); + }); + + it("DAILY cron rolls to today's target UTC time when it hasn't passed yet", () => { + const now = new Date("2026-09-04T06:00:00.000Z"); + const nextRunAt = computeNextRunAt("cron", 60, "FREQ=DAILY;BYHOUR=9;BYMINUTE=0", now); + expect(nextRunAt).toBe("2026-09-04T09:00:00.000Z"); + }); + + it("DAILY cron rolls to tomorrow when today's target UTC time already passed", () => { + const now = new Date("2026-09-04T10:00:00.000Z"); + const nextRunAt = computeNextRunAt("cron", 60, "FREQ=DAILY;BYHOUR=9;BYMINUTE=0", now); + expect(nextRunAt).toBe("2026-09-05T09:00:00.000Z"); + }); + + it("WEEKLY cron with BYDAY rolls forward to the next occurrence of that weekday", () => { + // 2026-09-04 is a Friday. + const now = new Date("2026-09-04T12:00:00.000Z"); + const nextRunAt = computeNextRunAt("cron", 60, "FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0", now); + // Next Monday is 2026-09-07. + expect(nextRunAt).toBe("2026-09-07T09:00:00.000Z"); + }); + + it("WEEKLY cron on the target weekday rolls to next week once the time has passed", () => { + // 2026-09-07 is a Monday. + const now = new Date("2026-09-07T10:00:00.000Z"); + const nextRunAt = computeNextRunAt("cron", 60, "FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0", now); + expect(nextRunAt).toBe("2026-09-14T09:00:00.000Z"); + }); + + it("WEEKLY cron on the target weekday, before the time, fires today", () => { + // 2026-09-07 is a Monday. + const now = new Date("2026-09-07T06:00:00.000Z"); + const nextRunAt = computeNextRunAt("cron", 60, "FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0", now); + expect(nextRunAt).toBe("2026-09-07T09:00:00.000Z"); + }); + + it("WEEKLY cron without BYDAY defaults to Monday", () => { + const now = new Date("2026-09-04T12:00:00.000Z"); // Friday + const nextRunAt = computeNextRunAt("cron", 60, "FREQ=WEEKLY;BYHOUR=9;BYMINUTE=0", now); + expect(nextRunAt).toBe("2026-09-07T09:00:00.000Z"); + }); + + it("MONTHLY cron advances a calendar month once the target time has passed", () => { + const now = new Date("2026-09-04T10:00:00.000Z"); + const nextRunAt = computeNextRunAt("cron", 60, "FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", now); + expect(nextRunAt).toBe("2026-10-04T09:00:00.000Z"); + }); + + it("MONTHLY cron clamps a Jan 31 target to Feb 28 in a non-leap year", () => { + // 2026 is not a leap year. + const now = new Date("2026-01-31T10:00:00.000Z"); + const nextRunAt = computeNextRunAt("cron", 60, "FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", now); + expect(nextRunAt).toBe("2026-02-28T09:00:00.000Z"); + }); + + it("MONTHLY cron clamps a Jan 31 target to Feb 29 in a leap year", () => { + // 2028 is a leap year. + const now = new Date("2028-01-31T10:00:00.000Z"); + const nextRunAt = computeNextRunAt("cron", 60, "FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", now); + expect(nextRunAt).toBe("2028-02-29T09:00:00.000Z"); + }); + + it("with no recognizable FREQ, treats it like a daily run at the current UTC time", () => { + const now = new Date("2026-09-04T12:00:00.000Z"); + const nextRunAt = computeNextRunAt("cron", 60, "", now); + // No FREQ match means the DAILY/MONTHLY branch applies: the "target + // time" defaults to now's own UTC hour/minute, which counts as already + // passed, so it rolls to the same time tomorrow. + expect(nextRunAt).toBe("2026-09-05T12:00:00.000Z"); + }); + + it("parses cron case-insensitively", () => { + const now = new Date("2026-09-04T06:00:00.000Z"); + const nextRunAt = computeNextRunAt("cron", 60, "freq=daily;byhour=9;byminute=0", now); + expect(nextRunAt).toBe("2026-09-04T09:00:00.000Z"); + }); + + it("is unaffected by the local timezone (computes purely in UTC)", () => { + const now = new Date("2026-09-04T10:00:00.000Z"); + const nextRunAt = computeNextRunAt("cron", 60, "FREQ=DAILY;BYHOUR=9;BYMINUTE=0", now); + // Regardless of process.env.TZ, BYHOUR=9 means 09:00 UTC. + expect(nextRunAt.endsWith("T09:00:00.000Z")).toBe(true); + }); +}); diff --git a/apps/web/src/components/ScheduleDialog.logic.ts b/apps/web/src/components/ScheduleDialog.logic.ts new file mode 100644 index 00000000..dd7d93bd --- /dev/null +++ b/apps/web/src/components/ScheduleDialog.logic.ts @@ -0,0 +1,90 @@ +// Pure schedule-preview logic for ScheduleDialog, split out so it can be unit +// tested without the component's atom/settings dependencies. + +export type ScheduleMode = "interval" | "cron"; + +export interface SchedulePreset { + readonly label: string; + readonly mode: ScheduleMode; + readonly intervalMinutes?: number; + readonly cron?: string; +} + +export const PRESETS: ReadonlyArray = [ + { label: "Every hour", mode: "interval", intervalMinutes: 60 }, + { label: "Daily at 9am", mode: "cron", cron: "FREQ=DAILY;BYHOUR=9;BYMINUTE=0" }, + { label: "Weekly Monday 9am", mode: "cron", cron: "FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0" }, +]; + +const WEEKDAY_TO_JS_DAY: Record = { + MO: 1, + TU: 2, + WE: 3, + TH: 4, + FR: 5, + SA: 6, + SU: 0, +}; + +/** + * Preview the next run time for an interval- or cron-based schedule, from + * `now`. Mirrors the server-supported RRULE subset in the server's + * `ScheduleReactor.computeNextRunAt` (FREQ=DAILY|WEEKLY|MONTHLY|HOURLY with + * optional BYHOUR/BYMINUTE/BYDAY); the server recomputes authoritatively on + * each fire, this is only a client-side estimate for the dialog's preview. + */ +export function computeNextRunAt( + mode: ScheduleMode, + intervalMinutes: number, + cron: string, + now: Date = new Date(), +): string { + if (mode === "interval") { + const ms = Math.max(1, intervalMinutes) * 60_000; + return new Date(now.getTime() + ms).toISOString(); + } + const upper = cron.toUpperCase(); + const freq = /FREQ=(DAILY|WEEKLY|MONTHLY|HOURLY)/.exec(upper)?.[1]; + if (freq === "HOURLY") { + // "Every hour at :BYMINUTE" (default :00), not a flat +1h from now. + const targetMinute = Number(/BYMINUTE=(\d+)/.exec(upper)?.[1] ?? 0); + const next = new Date(now); + next.setUTCMinutes(targetMinute, 0, 0); + if (next.getTime() <= now.getTime()) { + next.setUTCHours(next.getUTCHours() + 1); + } + return next.toISOString(); + } + // Read/write in UTC (not local time) to match the server's + // ScheduleReactor.computeNextRunAt, which uses DateTime.getPartUtc. + const byHour = Number(/BYHOUR=(\d+)/.exec(upper)?.[1] ?? now.getUTCHours()); + const byMinute = Number(/BYMINUTE=(\d+)/.exec(upper)?.[1] ?? now.getUTCMinutes()); + const next = new Date(now); + next.setUTCMinutes(byMinute, 0, 0); + next.setUTCHours(byHour); + + if (freq === "WEEKLY") { + const weekday = /BYDAY=(MO|TU|WE|TH|FR|SA|SU)/.exec(upper)?.[1]; + const targetDay = weekday === undefined ? 1 : (WEEKDAY_TO_JS_DAY[weekday] ?? 1); + const currentDay = now.getUTCDay(); + let daysAhead = targetDay - currentDay; + if (daysAhead < 0) daysAhead += 7; + if (daysAhead === 0 && next.getTime() <= now.getTime()) daysAhead = 7; + next.setUTCDate(next.getUTCDate() + daysAhead); + } else if (next.getTime() <= now.getTime()) { + // DAILY advances a day; MONTHLY advances a calendar month, clamped to + // the target month's last day (mirrors DateTime.add's month-end + // clamping on the server — e.g. Jan 31 + 1 month lands on Feb 28/29, + // not rolling over into March like a plain setUTCMonth would). + if (freq === "MONTHLY") { + const day = next.getUTCDate(); + next.setUTCMonth(next.getUTCMonth() + 2, 0); + if (day < next.getUTCDate()) { + next.setUTCDate(day); + } + } else { + next.setUTCDate(next.getUTCDate() + 1); + } + } + return next.toISOString(); +} diff --git a/apps/web/src/components/ScheduleDialog.tsx b/apps/web/src/components/ScheduleDialog.tsx new file mode 100644 index 00000000..b8af63d9 --- /dev/null +++ b/apps/web/src/components/ScheduleDialog.tsx @@ -0,0 +1,267 @@ +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@helmcode/client-runtime/state/runtime"; +import type { ModelSelection, ScopedThreadRef, ThreadSchedule } from "@helmcode/contracts"; +import { createModelSelection } from "@helmcode/shared/model"; +import { useAtomValue } from "@effect/atom-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { ProviderModelPicker } from "./chat/ProviderModelPicker"; +import { computeNextRunAt, PRESETS, type SchedulePreset } from "./ScheduleDialog.logic"; +import { Button } from "./ui/button"; +import { Checkbox } from "./ui/checkbox"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { RadioGroup, RadioGroupItem } from "./ui/radio-group"; +import { Textarea } from "./ui/textarea"; +import { usePrimarySettings } from "../hooks/useSettings"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + resolveDefaultProviderModelSelection, + sortProviderInstanceEntries, +} from "../providerInstances"; +import { getCustomModelOptionsByInstance } from "../modelSelection"; +import { primaryServerProvidersAtom } from "../state/server"; + +interface ScheduleDialogProps { + threadRef: ScopedThreadRef; + open: boolean; + onClose: () => void; + onSave: ( + threadRef: ScopedThreadRef, + schedule: ThreadSchedule, + ) => Promise>; +} + +type ScheduleMode = "interval" | "cron"; + +const DEFAULT_PROMPT = "Continue where you left off."; + +export function ScheduleDialog({ threadRef, open, onClose, onSave }: ScheduleDialogProps) { + const [mode, setMode] = useState("interval"); + const [intervalMinutes, setIntervalMinutes] = useState(60); + const [cron, setCron] = useState("FREQ=DAILY;BYHOUR=9;BYMINUTE=0"); + const [prompt, setPrompt] = useState(DEFAULT_PROMPT); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [overrideModel, setOverrideModel] = useState(false); + const [modelSelection, setModelSelection] = useState(null); + + const settings = usePrimarySettings(); + const serverProviders = useAtomValue(primaryServerProvidersAtom); + const instanceEntries = useMemo( + () => + sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), + ), + [serverProviders, settings], + ); + const modelOptionsByInstance = useMemo( + () => getCustomModelOptionsByInstance(settings, serverProviders), + [serverProviders, settings], + ); + const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, modelSelection); + + useEffect(() => { + if (!open) return; + setMode("interval"); + setIntervalMinutes(60); + setCron("FREQ=DAILY;BYHOUR=9;BYMINUTE=0"); + setPrompt(DEFAULT_PROMPT); + setError(null); + setSaving(false); + setOverrideModel(false); + setModelSelection(null); + }, [open]); + + const nextRunAt = useMemo( + () => computeNextRunAt(mode, intervalMinutes, cron), + [mode, intervalMinutes, cron], + ); + + const applyPreset = useCallback((preset: SchedulePreset) => { + setMode(preset.mode); + if (preset.intervalMinutes !== undefined) setIntervalMinutes(preset.intervalMinutes); + if (preset.cron !== undefined) setCron(preset.cron); + }, []); + + const handleSave = useCallback(async () => { + const trimmedPrompt = prompt.trim(); + if (trimmedPrompt.length === 0) { + setError("Prompt cannot be empty."); + return; + } + const schedule: ThreadSchedule = { + enabled: true, + cron: mode === "cron" ? cron : null, + intervalMs: mode === "interval" ? Math.max(1, intervalMinutes) * 60_000 : null, + prompt: trimmedPrompt, + // Recomputed against the current time rather than reusing the + // memoized preview: the decider rejects a nextRunAt that isn't + // strictly in the future, and the preview can go stale if the dialog + // sits open past it (e.g. a short custom interval). + nextRunAt: computeNextRunAt(mode, intervalMinutes, cron), + createdAt: new Date().toISOString(), + ...(overrideModel && resolvedSelection + ? { + modelSelection: createModelSelection( + resolvedSelection.instanceId, + resolvedSelection.model, + ), + } + : {}), + }; + setSaving(true); + setError(null); + try { + const result = await onSave(threadRef, schedule); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + throw squashAtomCommandFailure(result); + } + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save schedule."); + } finally { + setSaving(false); + } + }, [ + cron, + intervalMinutes, + mode, + onClose, + onSave, + overrideModel, + prompt, + resolvedSelection, + threadRef, + ]); + + return ( + (nextOpen ? undefined : onClose())}> + + + Schedule this thread + + Automatically send a message and start a turn on this thread at a recurring interval. + + + +
+ +