diff --git a/README.md b/README.md index 1aa35d6e788..8c308ca76ab 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ Works with any CLI agent. Built for local worktree-based development. | **安定性・パフォーマンス改善** | LSP language services の安定性修正、拡張機能ホストのメモリリーク修正、ターミナル再表示遅延改善、認証切れ時の無限ループ防止、git status タイムアウト追加、ブラウザリダイレクトループ修正、ポップアウトウィンドウの認証修正、エラーの正規化と Sentry フィルタリング | [#88](https://github.com/MocA-Love/superset/pull/88) [#123](https://github.com/MocA-Love/superset/pull/123) [#121](https://github.com/MocA-Love/superset/pull/121) [#67](https://github.com/MocA-Love/superset/pull/67) [#66](https://github.com/MocA-Love/superset/pull/66) [#158](https://github.com/MocA-Love/superset/pull/158) [#146](https://github.com/MocA-Love/superset/pull/146) [#98](https://github.com/MocA-Love/superset/pull/98) | 2026-04-04〜14 | | **内部ブラウザの File System Access API 拒否回避** | 内部ブラウザで react-dropzone 系サイトを開くと `FileSystemFileHandle.getFile()` が NotAllowedError で落ちる問題を修正。`persist:superset` セッションに preload を追加し `DataTransferItem.getAsFileSystemHandle()` を null 返却に差し替えて legacy D&D パスへフォールバック | [#207](https://github.com/MocA-Love/superset/pull/207) | 2026-04-16 | | **PR コメント返信** | Review タブのコメント右上に Reply ボタンを追加。ダイアログから直接返信を投稿できる。レビュースレッドへの返信と通常 PR コメントの両方に対応 | [#206](https://github.com/MocA-Love/superset/pull/206) | 2026-04-16 | +| **TODO Agent スケジュール実行** | 毎日デプロイ / 毎時 lint のような定型 TODO を UI ビルダー (毎時/毎日/毎週/毎月/cron) で登録可能。アプリ起動中に時刻が来ると TODO セッションが自動作成され発火トーストを表示。前回未完了時は skip / queue 選択可 | [#211](https://github.com/MocA-Love/superset/pull/211) | 2026-04-16 | ## Fork のビルド方法 (macOS) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5f4c1393033..83eb9ba71cc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -159,6 +159,8 @@ "bindings": "^1.5.0", "bufferutil": "^4.1.0", "clsx": "^2.1.1", + "cron-parser": "^5.5.0", + "cronstrue": "^3.14.0", "culori": "^4.0.2", "date-fns": "^4.1.0", "default-shell": "^2.2.0", diff --git a/apps/desktop/plans/20260416-todo-schedule.md b/apps/desktop/plans/20260416-todo-schedule.md new file mode 100644 index 00000000000..8fbf7afc181 --- /dev/null +++ b/apps/desktop/plans/20260416-todo-schedule.md @@ -0,0 +1,186 @@ +# TODO Agent スケジュール実行 実装計画 + +既存の TODO 自律エージェントに **cron ライクな定期実行** 機能を追加する。 +ユーザーはスケジュールを登録しておくと、指定時刻にそのプロンプトで TODO セッションが自動作成・キュー投入される。 + +## 目的 + +- 「毎日 9:00 にデプロイ」「1時間ごとに lint 走らせる」のような + 定型的な AI タスクを手動トリガーなしで実行できる。 +- 既存の TODO 作成フロー・実行エンジン (supervisor) をそのまま再利用し、 + スケジュール層は薄く、単純にトリガー役に徹する。 +- フォーク限定機能。`apps/desktop` 内に閉じる。 + +## 前提 (ユーザー決定事項) + +1. 発火通知は **トースト** +2. cron 式の直接入力ではなく **UX 重視のビルダー UI** (プリセット + カスタム) +3. 前回実行中の発火時の挙動 (skip / queue) は **スケジュール毎にユーザーが選択** +4. UI は TodoManager **内に統合** (独立モーダルにはしない) + +## 非目的 (v1) + +- missed firing の補完 (閉じてた間の発火を後で実行): 初回は **skip + 通知のみ** +- タイムゾーン切替: ローカル TZ 固定 +- スケジュール間の依存関係 / 順序制御 +- スケジュール共有 (エクスポート/インポート) + +## アーキテクチャ + +``` +Renderer Main process +──────── ──────────── +TodoManager TodoScheduler (singleton) + └─ SchedulesSection ├─ tick (setInterval every 30s) + ├─ ScheduleList ├─ compute nextRunAt for each schedule + └─ ScheduleEditor │ and compare to now + └─ ScheduleFrequencyPicker ├─ on fire: + │ ├─ check overlap mode + │ ├─ call TodoSupervisor.createFromSchedule() + │ └─ emit `schedule.fired` event + └─ scheduleStore (SQLite) + +trpc todoAgent.schedule.* ─► scheduleStore CRUD +trpc todoAgent.schedule.onFire ─► observable + (for toast in renderer) +``` + +## DB schema (`packages/local-db/src/schema/todo-schedules.ts`) + +```ts +todo_schedules { + id: text pk + workspaceId: text (FK workspaces, cascade) + projectId: text (FK projects, set null) + name: text not null -- 表示名 + enabled: int bool not null dflt 1 + + -- スケジュール定義 (UI ビルダー経由で設定) + frequency: text enum("hourly","daily","weekly","monthly","custom") not null + minute: int -- 0-59 (hourly+) + hour: int -- 0-23 (daily+) + weekday: int -- 0-6, 0=Sun (weekly) + monthday: int -- 1-31 (monthly) + cronExpr: text -- frequency=custom のときのみ + + -- 発火時に作成する TODO の雛形 + title: text not null + description: text not null + goal: text + verifyCommand: text + maxIterations: int not null dflt 10 + maxWallClockSec: int not null dflt 1800 + customSystemPrompt:text + + overlapMode: text enum("skip","queue") not null dflt "skip" + + lastRunAt: int + lastRunSessionId: text + nextRunAt: int -- 予測値。tick で使う + createdAt: int + updatedAt: int +} + +index (workspaceId), (enabled, nextRunAt) +``` + +マイグレーション生成: +```sh +cd packages/local-db +bun run generate --name=add_todo_schedules +``` + +## スケジューラ (`apps/desktop/src/main/todo-agent/scheduler.ts`) + +- `setInterval(tick, 30_000)` でポーリング +- tick: 有効なスケジュールを DB から取得、`nextRunAt <= now` なものを発火 +- 発火: + 1. overlap チェック (skip なら、同 scheduleId の未完了セッションがあればスキップ) + 2. `TodoSupervisor.createFromSchedule(schedule)` で TODO セッションを作成 + 3. `session-store` に挿入 → 既存のキュー機構に乗る + 4. `lastRunAt = now`, `lastRunSessionId = ...`, `nextRunAt = computeNext(schedule, now)` を保存 + 5. `schedule.fired` イベントを emit → UI 側のトースト購読に届く +- `nextRunAt` 計算は frequency enum に応じた専用ヘルパ (custom のみ cron パース) +- cron パースは `cron-parser` (小さい・7日以上前のリリース確認必須) + +## UI (統合: TodoManager 内 Schedules セクション) + +配置: TodoManager の左サイドバーにタブ「Tasks / Schedules」を追加。 + +### ScheduleList +- 行: enable トグル / 名前 / 次回実行時刻 / 最終実行結果 / ... メニュー (edit / delete) +- 空状態: "+ New Schedule" ボタン + +### ScheduleEditor (ダイアログ) + +ビルダー UI: +1. **名前**: テキスト +2. **ワークスペース**: select (existing workspaces) +3. **プロンプト**: 既存の TodoComposer と同じ UI (description / goal / verify / preset / attachments) +4. **頻度ビルダー**: + - Hourly: `毎時 :MM 分` + - Daily: `毎日 HH:MM` + - Weekly: `毎週[曜日] HH:MM` (曜日チップ複数選択) + - Monthly: `毎月 DD 日 HH:MM` + - Custom: raw cron 式入力 + `cronstrue` でヒューマン表示 +5. **重複時の挙動**: radio `前回が走っていたらスキップ` / `キューに追加` +6. **有効/無効**: トグル + +次回実行予定をプレビュー表示 (`cronstrue` の locale=ja-JP). + +## トースト + +`electronTrpc.todoAgent.schedule.onFire.useSubscription` を TodoManager or +グローバルプロバイダで購読し、以下を表示: + +- 成功: `📅 {name} を実行しました` (→ セッション詳細への遷移リンク) +- skip: `⏭️ {name} の実行をスキップしました (前回が実行中)` + +## 実装ファイル一覧 (新規のみ) + +### Backend +- `packages/local-db/src/schema/todo-schedules.ts` +- `packages/local-db/drizzle/00XX_add_todo_schedules.sql` (自動生成) +- `packages/local-db/src/schema/index.ts` (追記) +- `apps/desktop/src/main/todo-agent/scheduler.ts` +- `apps/desktop/src/main/todo-agent/schedule-store.ts` +- `apps/desktop/src/main/todo-agent/trpc-router.ts` (nested `schedule` router 追記) +- `apps/desktop/src/main/todo-agent/supervisor.ts` (`createFromSchedule` 追加) + +### Frontend +- `apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/SchedulesSection.tsx` +- `apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/ScheduleList/ScheduleList.tsx` +- `apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/ScheduleEditor/ScheduleEditor.tsx` +- `apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/FrequencyBuilder/FrequencyBuilder.tsx` +- `apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/hooks/useScheduleFireToast/useScheduleFireToast.ts` +- `apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx` (タブ追加・1箇所変更) + +### 依存パッケージ追加 +- `cron-parser` (main side; for custom cron parsing + next-fire computation) +- `cronstrue` (renderer; human-readable cron) +両方とも 7日以上前のリリースが存在する安定 lib。 + +## テスト計画 + +- `scheduler.test.ts`: frequency → nextRunAt 計算, overlap 判定 +- `schedule-store.test.ts`: CRUD / inserted の shape +- `FrequencyBuilder` の簡易描画テスト (optional) + +## ロールアウト + +1. DB schema + migration +2. scheduler + store + tRPC +3. TodoManager UI 統合 +4. トースト +5. 型チェック + lint + 既存 todo セッションテストに干渉しないことを確認 +6. PR → セルフレビュー → マージ + +## リスクと対策 + +| リスク | 対策 | +|------|------| +| アプリ閉じてる間の発火が消える | v1 は諦める。UI に「アプリ起動中のみ」明記 | +| 破壊的コマンドの暴走 | `verifyCommand` は既存通り任意。ユーザー責任。初期はドキュメントで警告 | +| スケジュールの重複暴発 | overlapMode=skip デフォルト + DB index で pending 検出 | +| Claude API 料金の想定外消費 | maxIterations / maxWallClockSec は既存制約をそのまま使う | +| タイムゾーンずれ | ローカル TZ 固定。将来 tz 列追加で拡張可能 | diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index e34cba27cdc..66f17536458 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -406,6 +406,14 @@ app.on("before-quit", async (event) => { isQuitting = true; // FORK NOTE: cleanup window resources before exit to prevent port conflicts cleanupMainWindowResources(); + // Fork-local: stop the todo-agent scheduler before closing local-db so an + // in-flight tick can't insert a session into a closed SQLite handle. + try { + const { getTodoScheduler } = await import("./todo-agent/scheduler"); + getTodoScheduler().stop(); + } catch (error) { + console.warn("[main] todo-agent scheduler stop skipped", error); + } try { const mod = await loadVscodeShim(); await mod.shutdownExtensionHost(); @@ -540,6 +548,51 @@ if (!gotTheLock) { console.warn("[main] todo-agent attachment cleanup skipped", error); } + // Fork-local: prune terminal TODO sessions older than the + // user-configured retention (0 = off). Runs after the attachment + // sweep so deleted sessions' images also drop out of the + // attachment reference set on the next run. + try { + const { cleanupOldSessions } = await import("./todo-agent"); + cleanupOldSessions(); + } catch (error) { + console.warn("[main] todo-agent session cleanup skipped", error); + } + + // Fork-local: start the todo-agent schedule scheduler so cron-like + // recurring TODOs fire while the app is running. Scheduler is a + // noop until a user creates at least one schedule. + try { + const { getTodoScheduler } = await import("./todo-agent/scheduler"); + getTodoScheduler().start(); + } catch (error) { + console.warn("[main] todo-agent scheduler start skipped", error); + // Surface the failure via the existing schedule-fire event + // bus so ScheduleFireToasts shows a one-off toast. Without + // this the feature dies silently and the user keeps waiting + // for fires that will never come. + try { + const { getTodoScheduleStore } = await import( + "./todo-agent/schedule-store" + ); + getTodoScheduleStore().emitFire({ + scheduleId: "__scheduler_init__", + scheduleName: "スケジューラ", + kind: "failed", + sessionId: null, + message: + error instanceof Error + ? `起動に失敗しました: ${error.message}` + : "起動に失敗しました", + firedAt: Date.now(), + }); + } catch { + // If schedule-store itself failed to load there's + // nothing we can surface — console.warn above is our + // last resort. + } + } + // Must register on both default session and the app's custom partition const iconProtocolHandler = (request: Request) => { const url = new URL(request.url); diff --git a/apps/desktop/src/main/todo-agent/index.ts b/apps/desktop/src/main/todo-agent/index.ts index dd0f8986977..2c85c8935fb 100644 --- a/apps/desktop/src/main/todo-agent/index.ts +++ b/apps/desktop/src/main/todo-agent/index.ts @@ -1,5 +1,8 @@ export { cleanupOldAttachments } from "./attachments-cleanup"; +export { getTodoScheduleStore } from "./schedule-store"; +export { getTodoScheduler } from "./scheduler"; export { getTodoSessionStore } from "./session-store"; +export { cleanupOldSessions } from "./sessions-cleanup"; export { getTodoSupervisor } from "./supervisor"; export type { TodoAgentRouter } from "./trpc-router"; export { createTodoAgentRouter } from "./trpc-router"; diff --git a/apps/desktop/src/main/todo-agent/schedule-store.ts b/apps/desktop/src/main/todo-agent/schedule-store.ts new file mode 100644 index 00000000000..b4e905911df --- /dev/null +++ b/apps/desktop/src/main/todo-agent/schedule-store.ts @@ -0,0 +1,219 @@ +import { EventEmitter } from "node:events"; +import { + type InsertTodoSchedule, + type SelectTodoSchedule, + todoSchedules, +} from "@superset/local-db"; +import { and, desc, eq, isNotNull, lte } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; +import type { + TodoScheduleCreateInput, + TodoScheduleFireEvent, + TodoScheduleUpdateInput, +} from "./types"; + +/** + * Persistence layer for the TODO agent schedules table plus an event bus the + * scheduler uses to broadcast fire events into the tRPC subscription. + * + * Kept deliberately thin: the scheduler is responsible for cadence math, + * this module just does CRUD + emit. + */ +class TodoScheduleStore { + private readonly emitter = new EventEmitter(); + /** + * Cached init failure (kind="failed", scheduleId="__scheduler_init__"). + * The renderer subscribes after it mounts, which is well after the + * main-process bootstrap emits the failure. Replaying it on first + * subscription ensures the user still sees the toast. + */ + private pendingInitFailure: TodoScheduleFireEvent | null = null; + + emitFire(event: TodoScheduleFireEvent): void { + if (event.kind === "failed" && event.scheduleId === "__scheduler_init__") { + this.pendingInitFailure = event; + } + this.emitter.emit("fire", event); + } + + onFire(handler: (event: TodoScheduleFireEvent) => void): () => void { + this.emitter.on("fire", handler); + if (this.pendingInitFailure) { + const replayed = this.pendingInitFailure; + this.pendingInitFailure = null; + // Replay asynchronously so the subscriber is fully wired up + // before its handler runs, matching ordinary emit timing. + queueMicrotask(() => handler(replayed)); + } + return () => { + this.emitter.off("fire", handler); + }; + } + + insert( + input: TodoScheduleCreateInput & { nextRunAt: number | null }, + ): SelectTodoSchedule { + const row: InsertTodoSchedule = { + projectId: input.projectId, + workspaceId: input.workspaceId ?? null, + name: input.name, + enabled: input.enabled, + frequency: input.frequency, + minute: input.minute ?? null, + hour: input.hour ?? null, + weekday: input.weekday ?? null, + monthday: input.monthday ?? null, + cronExpr: input.cronExpr ?? null, + title: input.title, + description: input.description, + goal: input.goal ?? null, + verifyCommand: input.verifyCommand ?? null, + maxIterations: input.maxIterations, + maxWallClockSec: input.maxWallClockSec, + customSystemPrompt: input.customSystemPrompt ?? null, + overlapMode: input.overlapMode, + autoSyncBeforeFire: input.autoSyncBeforeFire, + nextRunAt: input.nextRunAt, + }; + + return localDb.insert(todoSchedules).values(row).returning().get(); + } + + update(input: TodoScheduleUpdateInput): SelectTodoSchedule | undefined { + const { id, ...rest } = input; + const patch: Partial & { updatedAt: number } = { + updatedAt: Date.now(), + }; + if (rest.name !== undefined) patch.name = rest.name; + if (rest.enabled !== undefined) patch.enabled = rest.enabled; + if (rest.frequency !== undefined) patch.frequency = rest.frequency; + if (rest.minute !== undefined) patch.minute = rest.minute ?? null; + if (rest.hour !== undefined) patch.hour = rest.hour ?? null; + if (rest.weekday !== undefined) patch.weekday = rest.weekday ?? null; + if (rest.monthday !== undefined) patch.monthday = rest.monthday ?? null; + if (rest.cronExpr !== undefined) patch.cronExpr = rest.cronExpr ?? null; + if (rest.title !== undefined) patch.title = rest.title; + if (rest.description !== undefined) patch.description = rest.description; + if (rest.goal !== undefined) patch.goal = rest.goal ?? null; + if (rest.verifyCommand !== undefined) + patch.verifyCommand = rest.verifyCommand ?? null; + if (rest.maxIterations !== undefined) + patch.maxIterations = rest.maxIterations; + if (rest.maxWallClockSec !== undefined) + patch.maxWallClockSec = rest.maxWallClockSec; + if (rest.customSystemPrompt !== undefined) + patch.customSystemPrompt = rest.customSystemPrompt ?? null; + if (rest.overlapMode !== undefined) patch.overlapMode = rest.overlapMode; + if (rest.autoSyncBeforeFire !== undefined) + patch.autoSyncBeforeFire = rest.autoSyncBeforeFire; + if (rest.workspaceId !== undefined) + patch.workspaceId = rest.workspaceId ?? null; + // projectId is intentionally not patched here — it is immutable + // once the schedule is created. + + return localDb + .update(todoSchedules) + .set(patch) + .where(eq(todoSchedules.id, id)) + .returning() + .get(); + } + + setNextRunAt(id: string, nextRunAt: number | null): void { + localDb + .update(todoSchedules) + .set({ nextRunAt, updatedAt: Date.now() }) + .where(eq(todoSchedules.id, id)) + .run(); + } + + recordRun({ + id, + sessionId, + firedAt, + nextRunAt, + }: { + id: string; + sessionId: string | null; + firedAt: number; + nextRunAt: number | null; + }): void { + localDb + .update(todoSchedules) + .set({ + lastRunAt: firedAt, + lastRunSessionId: sessionId, + nextRunAt, + updatedAt: Date.now(), + }) + .where(eq(todoSchedules.id, id)) + .run(); + } + + setEnabled(id: string, enabled: boolean): SelectTodoSchedule | undefined { + return localDb + .update(todoSchedules) + .set({ enabled, updatedAt: Date.now() }) + .where(eq(todoSchedules.id, id)) + .returning() + .get(); + } + + get(id: string): SelectTodoSchedule | undefined { + return localDb + .select() + .from(todoSchedules) + .where(eq(todoSchedules.id, id)) + .get(); + } + + delete(id: string): boolean { + const result = localDb + .delete(todoSchedules) + .where(eq(todoSchedules.id, id)) + .run(); + return result.changes > 0; + } + + listForProject(projectId: string): SelectTodoSchedule[] { + return localDb + .select() + .from(todoSchedules) + .where(eq(todoSchedules.projectId, projectId)) + .orderBy(desc(todoSchedules.createdAt)) + .all(); + } + + listAll(): SelectTodoSchedule[] { + return localDb + .select() + .from(todoSchedules) + .orderBy(desc(todoSchedules.createdAt)) + .all(); + } + + listDue(now: number): SelectTodoSchedule[] { + return localDb + .select() + .from(todoSchedules) + .where( + and( + eq(todoSchedules.enabled, true), + isNotNull(todoSchedules.nextRunAt), + lte(todoSchedules.nextRunAt, now), + ), + ) + .all(); + } +} + +let instance: TodoScheduleStore | null = null; + +export function getTodoScheduleStore(): TodoScheduleStore { + if (!instance) { + instance = new TodoScheduleStore(); + } + return instance; +} + +export type { TodoScheduleStore }; diff --git a/apps/desktop/src/main/todo-agent/schedule-sync.ts b/apps/desktop/src/main/todo-agent/schedule-sync.ts new file mode 100644 index 00000000000..6db1f61b977 --- /dev/null +++ b/apps/desktop/src/main/todo-agent/schedule-sync.ts @@ -0,0 +1,96 @@ +import { execGitWithShellPath } from "lib/trpc/routers/workspaces/utils/git-client"; + +export type ScheduleSyncResult = + | { kind: "ok"; checkedOut: string } + | { kind: "dirty"; message: string } + | { kind: "error"; message: string }; + +async function runGit( + args: string[], + cwd: string, + timeout = 60_000, +): Promise<{ stdout: string; stderr: string }> { + const result = await execGitWithShellPath(args, { cwd, timeout }); + return { stdout: result.stdout, stderr: result.stderr }; +} + +async function hasUncommittedChanges(cwd: string): Promise { + try { + const { stdout } = await runGit(["status", "--porcelain"], cwd, 15_000); + return stdout.trim().length > 0; + } catch { + // If status itself fails we can't be sure — treat as dirty to + // avoid destructive actions. + return true; + } +} + +async function resolveDefaultBranch(cwd: string): Promise { + try { + const { stdout } = await runGit( + ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], + cwd, + 10_000, + ); + const ref = stdout.trim(); + if (ref.startsWith("origin/")) { + return ref.slice("origin/".length); + } + } catch {} + // Fallbacks — conservative default. + return "main"; +} + +/** + * Opt-in "freshen the main repo before firing a schedule". Keeps the + * scope deliberately narrow: + * + * - `git fetch origin` + * - abort if the working tree has uncommitted changes (never stash — + * we refuse to touch the user's work) + * - `git checkout ` + * - `git pull --ff-only origin ` + * + * Called only when `todoSchedule.autoSyncBeforeFire` is true and the + * schedule is firing on the project main repo (no specific worktree). + */ +export async function autoSyncProjectMain( + cwd: string, +): Promise { + try { + await runGit(["fetch", "origin"], cwd, 120_000); + } catch (error) { + const message = + error instanceof Error ? error.message : "git fetch が失敗しました"; + return { kind: "error", message }; + } + + if (await hasUncommittedChanges(cwd)) { + return { + kind: "dirty", + message: "未コミット変更があるため main を更新できませんでした", + }; + } + + const defaultBranch = await resolveDefaultBranch(cwd); + + try { + await runGit(["checkout", defaultBranch], cwd, 60_000); + } catch (error) { + const message = + error instanceof Error + ? error.message + : `git checkout ${defaultBranch} が失敗しました`; + return { kind: "error", message }; + } + + try { + await runGit(["pull", "--ff-only", "origin", defaultBranch], cwd, 120_000); + } catch (error) { + const message = + error instanceof Error ? error.message : "git pull が失敗しました"; + return { kind: "error", message }; + } + + return { kind: "ok", checkedOut: defaultBranch }; +} diff --git a/apps/desktop/src/main/todo-agent/scheduler.ts b/apps/desktop/src/main/todo-agent/scheduler.ts new file mode 100644 index 00000000000..f8b9c2a40ef --- /dev/null +++ b/apps/desktop/src/main/todo-agent/scheduler.ts @@ -0,0 +1,392 @@ +import type { SelectTodoSchedule, SelectTodoSession } from "@superset/local-db"; +import { CronExpressionParser } from "cron-parser"; +import { getTodoScheduleStore } from "./schedule-store"; +import { autoSyncProjectMain } from "./schedule-sync"; +import { + ensureProjectBranchWorkspaceId, + getTodoSessionStore, + resolveWorktreePath, +} from "./session-store"; +import { getTodoSupervisor } from "./supervisor"; +import type { TodoScheduleFireEvent } from "./types"; + +const TICK_INTERVAL_MS = 30_000; + +/** + * Compute the next fire time (epoch ms) for a schedule, starting from + * `from`. For `custom` we delegate to cron-parser; for the builder-backed + * frequencies we compute it directly to avoid forcing the user through + * cron syntax. + */ +export function computeNextRunAt( + schedule: Pick< + SelectTodoSchedule, + "frequency" | "minute" | "hour" | "weekday" | "monthday" | "cronExpr" + >, + from: Date, +): number | null { + if (schedule.frequency === "custom") { + if (!schedule.cronExpr) return null; + try { + const interval = CronExpressionParser.parse(schedule.cronExpr, { + currentDate: from, + }); + return interval.next().getTime(); + } catch { + return null; + } + } + + const minute = schedule.minute ?? 0; + const hour = schedule.hour ?? 0; + const next = new Date(from); + // Snap seconds/ms to zero so fires land exactly on the minute boundary. + next.setSeconds(0, 0); + + switch (schedule.frequency) { + case "hourly": { + next.setMinutes(minute); + if (next.getTime() <= from.getTime()) { + next.setHours(next.getHours() + 1); + } + return next.getTime(); + } + case "daily": { + next.setHours(hour, minute, 0, 0); + if (next.getTime() <= from.getTime()) { + next.setDate(next.getDate() + 1); + } + return next.getTime(); + } + case "weekly": { + const targetWeekday = schedule.weekday ?? 0; + next.setHours(hour, minute, 0, 0); + const currentWeekday = next.getDay(); + let delta = targetWeekday - currentWeekday; + if (delta < 0) delta += 7; + if (delta === 0 && next.getTime() <= from.getTime()) { + delta = 7; + } + next.setDate(next.getDate() + delta); + return next.getTime(); + } + case "monthly": { + const targetMonthday = schedule.monthday ?? 1; + // Snap the target to the last valid day of each month so + // e.g. "every 31st" doesn't overflow Feb to Mar 3 — users + // who pick 31 expect "last day of every month" on short + // months. + const placeOnMonth = (base: Date) => { + const lastDay = new Date( + base.getFullYear(), + base.getMonth() + 1, + 0, + ).getDate(); + base.setDate(Math.min(targetMonthday, lastDay)); + base.setHours(hour, minute, 0, 0); + }; + next.setDate(1); + placeOnMonth(next); + if (next.getTime() <= from.getTime()) { + next.setDate(1); + next.setMonth(next.getMonth() + 1); + placeOnMonth(next); + } + return next.getTime(); + } + default: + return null; + } +} + +function isSessionActive(session: SelectTodoSession | undefined): boolean { + if (!session) return false; + return ( + session.status === "queued" || + session.status === "preparing" || + session.status === "running" || + session.status === "verifying" || + session.status === "paused" + ); +} + +class TodoScheduler { + private timer: ReturnType | null = null; + private inFlight = false; + private isStopped = false; + + start(): void { + if (this.timer) return; + this.isStopped = false; + // Re-seed nextRunAt for any schedule that lost its value (e.g. migration + // from schedules inserted before this field got populated). Safe to + // re-run on every boot because `lastRunAt` is respected. + this.rebuildNextRunTimes(); + this.timer = setInterval(() => { + void this.tick(); + }, TICK_INTERVAL_MS); + // Run an immediate tick so schedules already past-due when the app + // starts up fire on the first 30s window instead of waiting for it. + void this.tick(); + } + + stop(): void { + this.isStopped = true; + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + private rebuildNextRunTimes(): void { + const store = getTodoScheduleStore(); + const now = new Date(); + for (const schedule of store.listAll()) { + if (!schedule.enabled) continue; + if (schedule.nextRunAt !== null) continue; + const next = computeNextRunAt(schedule, now); + if (next !== null) { + store.setNextRunAt(schedule.id, next); + } + } + } + + /** + * Public hook used by the tRPC layer when a schedule is created or its + * cadence definition changes. Recomputes nextRunAt relative to `now`. + */ + refreshNextRunAt(scheduleId: string): void { + const store = getTodoScheduleStore(); + const schedule = store.get(scheduleId); + if (!schedule) return; + if (!schedule.enabled) { + store.setNextRunAt(scheduleId, null); + return; + } + const next = computeNextRunAt(schedule, new Date()); + store.setNextRunAt(scheduleId, next); + } + + private async tick(): Promise { + if (this.inFlight || this.isStopped) return; + this.inFlight = true; + try { + const store = getTodoScheduleStore(); + // Snapshot "due" using tick start time, but compute each + // schedule's firedAt from the actual moment fire() runs. + // Otherwise a slow fire leaves the next schedule in the loop + // advancing `nextRunAt` from a stale tick-start timestamp — + // for minute-level cron that can emit a "next run" already + // in the past and trigger duplicate fires on the next tick. + const due = store.listDue(Date.now()); + for (const schedule of due) { + // Abort mid-iteration if a shutdown came in while we were + // awaiting a previous fire. Prevents inserting a session + // row after closeLocalDb() has torn down SQLite. + if (this.isStopped) break; + await this.fire(schedule, Date.now()); + } + } catch (error) { + console.warn("[todo-scheduler] tick failed:", error); + } finally { + this.inFlight = false; + } + } + + private async fire( + schedule: SelectTodoSchedule, + firedAt: number, + ): Promise { + const store = getTodoScheduleStore(); + const sessionStore = getTodoSessionStore(); + const supervisor = getTodoSupervisor(); + + const nextRunAt = computeNextRunAt(schedule, new Date(firedAt)); + + // Overlap guard: if a previous session from this schedule is still + // active and the user asked us to skip, short-circuit without + // creating a new session. Still advance nextRunAt so we don't busy + // loop on the same tick. + // + // `overlapMode === "queue"` is intentionally handled by the existing + // TodoSupervisor queue: we always insert the new session and call + // supervisor.start(), which enqueues when another session is already + // running instead of spawning in parallel. No extra branch needed here. + if (schedule.overlapMode === "skip" && schedule.lastRunSessionId) { + const prev = sessionStore.get(schedule.lastRunSessionId); + if (isSessionActive(prev)) { + store.recordRun({ + id: schedule.id, + sessionId: schedule.lastRunSessionId, + firedAt, + nextRunAt, + }); + this.emit({ + scheduleId: schedule.id, + scheduleName: schedule.name, + kind: "skipped", + sessionId: null, + message: "前回の実行が終わっていないためスキップしました", + firedAt, + }); + return; + } + } + + // Resolve the workspace to attach the fired session to. If the + // schedule was saved project-only (workspaceId = null), fall back + // to the project's branch-type workspace, materializing one if the + // project doesn't already have it. That keeps `todo_sessions` + // workspaceId NOT NULL intact while letting the UI expose the + // "run on project main repo" mental model. + const fireWorkspaceId = + schedule.workspaceId ?? + ensureProjectBranchWorkspaceId(schedule.projectId); + if (!fireWorkspaceId) { + store.recordRun({ + id: schedule.id, + sessionId: null, + firedAt, + nextRunAt, + }); + this.emit({ + scheduleId: schedule.id, + scheduleName: schedule.name, + kind: "failed", + sessionId: null, + message: "プロジェクトのワークスペースを用意できませんでした", + firedAt, + }); + return; + } + + const worktreePath = resolveWorktreePath(fireWorkspaceId); + if (!worktreePath) { + store.recordRun({ + id: schedule.id, + sessionId: null, + firedAt, + nextRunAt, + }); + this.emit({ + scheduleId: schedule.id, + scheduleName: schedule.name, + kind: "failed", + sessionId: null, + message: "ワークスペースのパスが解決できませんでした", + firedAt, + }); + return; + } + + // Opt-in: freshen the project main repo before firing. Applies + // only when the schedule itself has no workspaceId (we refuse to + // yank HEAD on a worktree workspace — that would rewrite someone + // else's working branch). If the tree is dirty we deliberately + // skip the fire rather than stash the user's work. + if (schedule.autoSyncBeforeFire && schedule.workspaceId === null) { + const syncResult = await autoSyncProjectMain(worktreePath); + if (syncResult.kind !== "ok") { + store.recordRun({ + id: schedule.id, + sessionId: null, + firedAt, + nextRunAt, + }); + this.emit({ + scheduleId: schedule.id, + scheduleName: schedule.name, + kind: syncResult.kind === "dirty" ? "skipped" : "failed", + sessionId: null, + message: syncResult.message, + firedAt, + }); + return; + } + } + + try { + const sessionId = crypto.randomUUID(); + const artifactPath = supervisor.computeArtifactPath({ + sessionId, + workspaceId: fireWorkspaceId, + }); + const inserted = sessionStore.insertQueuedFromTemplate({ + id: sessionId, + projectId: schedule.projectId, + workspaceId: fireWorkspaceId, + title: schedule.title, + description: schedule.description, + goal: schedule.goal, + verifyCommand: schedule.verifyCommand, + maxIterations: schedule.maxIterations, + maxWallClockSec: schedule.maxWallClockSec, + customSystemPrompt: schedule.customSystemPrompt, + artifactPath, + }); + supervisor.prepareArtifacts(inserted); + void supervisor.start(inserted.id).catch((err) => { + const failureMessage = + err instanceof Error ? err.message : "Unknown error"; + console.warn( + `[todo-scheduler] supervisor.start failed for ${inserted.id}:`, + err, + ); + // The triggered toast has already fired, so publish a + // follow-up failed event. Otherwise the UI would claim the + // fire succeeded even though the supervisor never ran. + this.emit({ + scheduleId: schedule.id, + scheduleName: schedule.name, + kind: "failed", + sessionId: inserted.id, + message: `実行開始に失敗しました: ${failureMessage}`, + firedAt, + }); + }); + store.recordRun({ + id: schedule.id, + sessionId: inserted.id, + firedAt, + nextRunAt, + }); + this.emit({ + scheduleId: schedule.id, + scheduleName: schedule.name, + kind: "triggered", + sessionId: inserted.id, + message: null, + firedAt, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + store.recordRun({ + id: schedule.id, + sessionId: null, + firedAt, + nextRunAt, + }); + this.emit({ + scheduleId: schedule.id, + scheduleName: schedule.name, + kind: "failed", + sessionId: null, + message, + firedAt, + }); + } + } + + private emit(event: TodoScheduleFireEvent): void { + getTodoScheduleStore().emitFire(event); + } +} + +let instance: TodoScheduler | null = null; + +export function getTodoScheduler(): TodoScheduler { + if (!instance) { + instance = new TodoScheduler(); + } + return instance; +} diff --git a/apps/desktop/src/main/todo-agent/session-store.ts b/apps/desktop/src/main/todo-agent/session-store.ts index 29def23fd99..9ef3aa55166 100644 --- a/apps/desktop/src/main/todo-agent/session-store.ts +++ b/apps/desktop/src/main/todo-agent/session-store.ts @@ -9,7 +9,7 @@ import { workspaces, worktrees, } from "@superset/local-db"; -import { desc, eq, inArray, isNull } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, not } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; import type { TodoSessionListEntry, @@ -233,6 +233,57 @@ class TodoSessionStore { return inserted; } + /** + * Insert a fresh `queued` session from a user-authored template (TODO + * composer, schedule fire, or anywhere else that starts a new session + * from scratch). Centralizing this here keeps the full TodoSession row + * shape in one place — otherwise any new field on `todo_sessions` has + * to be remembered in every call site. + */ + insertQueuedFromTemplate(template: { + id: string; + projectId: string | null | undefined; + workspaceId: string; + title: string; + description: string; + goal?: string | null; + verifyCommand?: string | null; + maxIterations: number; + maxWallClockSec: number; + customSystemPrompt?: string | null; + artifactPath: string; + }): SelectTodoSession { + return this.insert({ + id: template.id, + projectId: template.projectId ?? null, + workspaceId: template.workspaceId, + title: template.title, + description: template.description, + goal: template.goal ?? null, + verifyCommand: template.verifyCommand ?? null, + maxIterations: template.maxIterations, + maxWallClockSec: template.maxWallClockSec, + status: "queued", + phase: "queued", + iteration: 0, + attachedPaneId: null, + attachedTabId: null, + claudeSessionId: null, + finalAssistantText: null, + totalCostUsd: null, + totalNumTurns: null, + pendingIntervention: null, + startHeadSha: null, + customSystemPrompt: template.customSystemPrompt ?? null, + verdictPassed: null, + verdictReason: null, + verdictFailingTest: null, + artifactPath: template.artifactPath, + startedAt: null, + completedAt: null, + }); + } + get(sessionId: string): SelectTodoSession | undefined { return localDb .select() @@ -355,3 +406,90 @@ export function resolveWorktreePath(workspaceId: string): string | undefined { .get(); return row?.worktreePath ?? row?.mainRepoPath ?? undefined; } + +/** + * Ensure a project has its `type="branch"` workspace (the row that maps + * to `mainRepoPath`). Creates one lazily if missing so schedules with + * no explicit workspaceId can attach their sessions to something real. + * Returns the workspace id, or undefined if the project itself is gone. + */ +export function ensureProjectBranchWorkspaceId( + projectId: string, +): string | undefined { + const existing = localDb + .select({ id: workspaces.id }) + .from(workspaces) + .where( + and( + eq(workspaces.projectId, projectId), + eq(workspaces.type, "branch"), + isNull(workspaces.deletingAt), + ), + ) + .get(); + if (existing) return existing.id; + + const project = localDb + .select({ + defaultBranch: projects.defaultBranch, + }) + .from(projects) + .where(eq(projects.id, projectId)) + .get(); + if (!project) return undefined; + + const branchName = project.defaultBranch ?? "main"; + const inserted = localDb + .insert(workspaces) + .values({ + projectId, + type: "branch", + branch: branchName, + name: branchName, + tabOrder: 0, + }) + .onConflictDoNothing() + .returning({ id: workspaces.id }) + .get(); + + if (inserted) { + // Mirror the standard workspace-create flow: bump every other + // workspace in the project by +1 so the new branch workspace + // lands uniquely at tabOrder 0 instead of colliding with an + // existing 0-ordered worktree (which would yield a + // non-deterministic sort in the sidebar). + const siblings = localDb + .select({ id: workspaces.id, tabOrder: workspaces.tabOrder }) + .from(workspaces) + .where( + and( + eq(workspaces.projectId, projectId), + not(eq(workspaces.id, inserted.id)), + isNull(workspaces.deletingAt), + ), + ) + .all(); + for (const sibling of siblings) { + localDb + .update(workspaces) + .set({ tabOrder: (sibling.tabOrder ?? 0) + 1 }) + .where(eq(workspaces.id, sibling.id)) + .run(); + } + return inserted.id; + } + + // Race: another path materialized it between our check and insert. + const raced = localDb + .select({ id: workspaces.id }) + .from(workspaces) + .where( + and( + eq(workspaces.projectId, projectId), + eq(workspaces.type, "branch"), + isNull(workspaces.deletingAt), + ), + ) + .get(); + return raced?.id; +} diff --git a/apps/desktop/src/main/todo-agent/sessions-cleanup.ts b/apps/desktop/src/main/todo-agent/sessions-cleanup.ts new file mode 100644 index 00000000000..552024b18ac --- /dev/null +++ b/apps/desktop/src/main/todo-agent/sessions-cleanup.ts @@ -0,0 +1,83 @@ +import { existsSync, rmSync } from "node:fs"; +import { todoSessions } from "@superset/local-db"; +import { and, inArray, sql } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; +import { getTodoSettings } from "./settings"; + +const TERMINAL_STATUSES = ["done", "failed", "aborted", "escalated"] as const; + +/** + * One-shot sweep of old terminal TODO sessions at app startup. + * + * Respects `todo-agent-settings.sessionRetentionDays`: + * - 0 (default) → no automatic deletion (legacy behavior) + * - N > 0 → delete sessions whose `completedAt` (or createdAt + * fallback for rows that finished without a timestamp) + * is older than N days AND whose status is terminal. + * + * Running / queued / paused / verifying / preparing sessions are NEVER + * touched — they're active user work. The session's artifact directory + * (`artifactPath`) is removed alongside the row. + */ +export function cleanupOldSessions(): void { + try { + const { sessionRetentionDays } = getTodoSettings(); + if (sessionRetentionDays <= 0) return; + + const cutoffMs = Date.now() - sessionRetentionDays * 24 * 60 * 60 * 1000; + + // The retention cutoff must be based on when the session finished, + // not when it started. A session created weeks ago but completed + // today is still "fresh" from the user's perspective. Fall back + // to createdAt only for the rare terminal row with no + // completedAt timestamp. + const candidates = localDb + .select({ + id: todoSessions.id, + artifactPath: todoSessions.artifactPath, + }) + .from(todoSessions) + .where( + and( + inArray(todoSessions.status, [...TERMINAL_STATUSES]), + sql`COALESCE(${todoSessions.completedAt}, ${todoSessions.createdAt}) < ${cutoffMs}`, + ), + ) + .all(); + + if (candidates.length === 0) return; + + // Delete rows in a single DB call so we don't thrash the journal + // if the retention window has hundreds of pending deletes. + localDb + .delete(todoSessions) + .where( + inArray( + todoSessions.id, + candidates.map((row) => row.id), + ), + ) + .run(); + + for (const row of candidates) { + if (!row.artifactPath) continue; + try { + if (existsSync(row.artifactPath)) { + rmSync(row.artifactPath, { recursive: true, force: true }); + } + } catch (error) { + console.warn( + "[todo-agent] failed to remove session artifact:", + row.artifactPath, + error, + ); + } + } + + console.log( + `[todo-agent] cleaned up ${candidates.length} session(s) older than ${sessionRetentionDays} days`, + ); + } catch (error) { + console.warn("[todo-agent] session cleanup failed:", error); + } +} diff --git a/apps/desktop/src/main/todo-agent/settings.ts b/apps/desktop/src/main/todo-agent/settings.ts index 67d737f5835..62dcafeabf8 100644 --- a/apps/desktop/src/main/todo-agent/settings.ts +++ b/apps/desktop/src/main/todo-agent/settings.ts @@ -15,6 +15,7 @@ const DEFAULT_SETTINGS: TodoSettings = { defaultMaxIterations: 10, defaultMaxWallClockMin: 30, maxConcurrentTasks: 1, + sessionRetentionDays: 0, }; let cached: TodoSettings | null = null; diff --git a/apps/desktop/src/main/todo-agent/trpc-router.ts b/apps/desktop/src/main/todo-agent/trpc-router.ts index c3c4b3c6fca..05e679a8929 100644 --- a/apps/desktop/src/main/todo-agent/trpc-router.ts +++ b/apps/desktop/src/main/todo-agent/trpc-router.ts @@ -16,17 +16,22 @@ import { getSessionGitSnapshot, type SessionDiffScope, } from "./git-status"; +import { getTodoScheduleStore } from "./schedule-store"; +import { computeNextRunAt, getTodoScheduler } from "./scheduler"; import { getTodoSessionStore, resolveWorktreePath } from "./session-store"; import { getTodoSettings, updateTodoSettings } from "./settings"; import { getTodoSupervisor } from "./supervisor"; import { TODO_ARTIFACT_SUBDIR, + type TodoScheduleFireEvent, type TodoSessionStateEvent, type TodoStreamUpdate, todoCreateInputSchema, todoEnhanceTextInputSchema, todoPresetCreateInputSchema, todoPresetUpdateInputSchema, + todoScheduleCreateInputSchema, + todoScheduleUpdateInputSchema, todoSendInputSchema, todoSettingsUpdateSchema, } from "./types"; @@ -98,34 +103,18 @@ export const createTodoAgentRouter = () => { workspaceId: input.workspaceId, }); - const session = store.insert({ + const session = store.insertQueuedFromTemplate({ id: sessionId, projectId: input.projectId ?? null, workspaceId: input.workspaceId, title: input.title, description: input.description, - goal: input.goal ?? null, - verifyCommand: input.verifyCommand ?? null, + goal: input.goal, + verifyCommand: input.verifyCommand, maxIterations: input.maxIterations, maxWallClockSec: input.maxWallClockSec, - status: "queued", - phase: "queued", - iteration: 0, - attachedPaneId: null, - attachedTabId: null, - claudeSessionId: null, - finalAssistantText: null, - totalCostUsd: null, - totalNumTurns: null, - pendingIntervention: null, - startHeadSha: null, - customSystemPrompt: input.customSystemPrompt ?? null, - verdictPassed: null, - verdictReason: null, - verdictFailingTest: null, + customSystemPrompt: input.customSystemPrompt, artifactPath, - startedAt: null, - completedAt: null, }); // Materialize the directory + goal.md. If this throws after @@ -662,6 +651,109 @@ export const createTodoAgentRouter = () => { .input(todoSettingsUpdateSchema) .mutation(({ input }) => updateTodoSettings(input)), }), + + schedule: router({ + list: publicProcedure + .input(z.object({ projectId: z.string().min(1) })) + .query(({ input }) => + getTodoScheduleStore().listForProject(input.projectId), + ), + listAll: publicProcedure.query(() => getTodoScheduleStore().listAll()), + create: publicProcedure + .input(todoScheduleCreateInputSchema) + .mutation(({ input }) => { + const nextRunAt = input.enabled + ? computeNextRunAt( + { + frequency: input.frequency, + minute: input.minute ?? null, + hour: input.hour ?? null, + weekday: input.weekday ?? null, + monthday: input.monthday ?? null, + cronExpr: input.cronExpr ?? null, + }, + new Date(), + ) + : null; + const row = getTodoScheduleStore().insert({ + ...input, + nextRunAt, + }); + return row; + }), + update: publicProcedure + .input(todoScheduleUpdateInputSchema) + .mutation(({ input }) => { + const row = getTodoScheduleStore().update(input); + if (row) { + getTodoScheduler().refreshNextRunAt(row.id); + } + return row ?? null; + }), + setEnabled: publicProcedure + .input( + z.object({ + id: z.string().min(1), + enabled: z.boolean(), + }), + ) + .mutation(({ input }) => { + const row = getTodoScheduleStore().setEnabled( + input.id, + input.enabled, + ); + if (row) { + getTodoScheduler().refreshNextRunAt(row.id); + } + return row ?? null; + }), + delete: publicProcedure + .input(z.object({ id: z.string().min(1) })) + .mutation(({ input }) => { + const ok = getTodoScheduleStore().delete(input.id); + return { ok }; + }), + previewNextRun: publicProcedure + .input( + z.object({ + frequency: z.enum([ + "hourly", + "daily", + "weekly", + "monthly", + "custom", + ]), + minute: z.number().int().min(0).max(59).nullish(), + hour: z.number().int().min(0).max(23).nullish(), + weekday: z.number().int().min(0).max(6).nullish(), + monthday: z.number().int().min(1).max(31).nullish(), + cronExpr: z.string().trim().max(200).nullish(), + }), + ) + .query(({ input }) => + computeNextRunAt( + { + frequency: input.frequency, + minute: input.minute ?? null, + hour: input.hour ?? null, + weekday: input.weekday ?? null, + monthday: input.monthday ?? null, + cronExpr: input.cronExpr ?? null, + }, + new Date(), + ), + ), + onFire: publicProcedure.subscription(() => + observable((emit) => { + const off = getTodoScheduleStore().onFire((event) => { + emit.next(event); + }); + return () => { + off(); + }; + }), + ), + }), }); }; diff --git a/apps/desktop/src/main/todo-agent/types.ts b/apps/desktop/src/main/todo-agent/types.ts index 616f9f0ab8b..b095f2377bf 100644 --- a/apps/desktop/src/main/todo-agent/types.ts +++ b/apps/desktop/src/main/todo-agent/types.ts @@ -1,4 +1,9 @@ -import type { SelectTodoSession } from "@superset/local-db"; +import type { + SelectTodoSchedule, + SelectTodoSession, + TodoScheduleFrequency, + TodoScheduleOverlapMode, +} from "@superset/local-db"; import { z } from "zod"; /** @@ -85,6 +90,9 @@ export const todoSettingsSchema = z.object({ defaultMaxIterations: z.number().int().min(1).max(100).default(10), defaultMaxWallClockMin: z.number().int().min(1).max(240).default(30), maxConcurrentTasks: z.number().int().min(1).max(10).default(1), + // 0 = 無制限 (手動削除のみ). 1-365 = その日数より古い終了済み + // セッションを起動時に自動削除する (queued / running / paused は対象外)。 + sessionRetentionDays: z.number().int().min(0).max(365).default(0), }); export type TodoSettings = z.infer; @@ -170,3 +178,120 @@ export interface TodoStreamUpdate { sessionId: string; events: TodoStreamEvent[]; } + +// ---- Schedules ---- + +export const todoScheduleFrequencySchema = z.enum([ + "hourly", + "daily", + "weekly", + "monthly", + "custom", +]); + +export const todoScheduleOverlapModeSchema = z.enum(["skip", "queue"]); + +export const todoScheduleCreateInputSchema = z + .object({ + projectId: z.string().min(1), + // Null/omitted means "run on the project's main repo path" (the + // non-worktree source tree). Set to a workspace id to bind the + // schedule to a specific worktree instead. + workspaceId: z.string().min(1).nullish(), + name: z.string().trim().min(1).max(120), + enabled: z.boolean().default(true), + frequency: todoScheduleFrequencySchema, + minute: z.number().int().min(0).max(59).nullish(), + hour: z.number().int().min(0).max(23).nullish(), + weekday: z.number().int().min(0).max(6).nullish(), + monthday: z.number().int().min(1).max(31).nullish(), + cronExpr: z.string().trim().min(1).max(200).nullish(), + title: z.string().trim().min(1).max(200), + description: z.string().trim().min(1).max(10_000), + goal: z.string().trim().max(10_000).nullish(), + verifyCommand: z.string().trim().max(10_000).nullish(), + maxIterations: z.number().int().min(1).max(100).default(10), + maxWallClockSec: z + .number() + .int() + .min(60) + .max(60 * 60 * 4) + .default(1800), + customSystemPrompt: z.string().trim().max(20_000).nullish(), + overlapMode: todoScheduleOverlapModeSchema.default("skip"), + autoSyncBeforeFire: z.boolean().default(false), + }) + .refine( + (v) => + v.frequency !== "custom" || + (typeof v.cronExpr === "string" && v.cronExpr.length > 0), + { + message: "cronExpr is required when frequency is 'custom'", + path: ["cronExpr"], + }, + ); + +export type TodoScheduleCreateInput = z.infer< + typeof todoScheduleCreateInputSchema +>; + +const todoScheduleBaseSchema = z.object({ + projectId: z.string().min(1), + workspaceId: z.string().min(1).nullish(), + name: z.string().trim().min(1).max(120), + enabled: z.boolean(), + frequency: todoScheduleFrequencySchema, + minute: z.number().int().min(0).max(59).nullish(), + hour: z.number().int().min(0).max(23).nullish(), + weekday: z.number().int().min(0).max(6).nullish(), + monthday: z.number().int().min(1).max(31).nullish(), + cronExpr: z.string().trim().min(1).max(200).nullish(), + title: z.string().trim().min(1).max(200), + description: z.string().trim().min(1).max(10_000), + goal: z.string().trim().max(10_000).nullish(), + verifyCommand: z.string().trim().max(10_000).nullish(), + maxIterations: z.number().int().min(1).max(100), + maxWallClockSec: z + .number() + .int() + .min(60) + .max(60 * 60 * 4), + customSystemPrompt: z.string().trim().max(20_000).nullish(), + overlapMode: todoScheduleOverlapModeSchema, + autoSyncBeforeFire: z.boolean(), +}); + +// projectId is intentionally omitted from the update surface: a schedule's +// project is immutable, otherwise `lastRunSessionId` could point at a +// session from a different project than the schedule currently belongs to. +// Users who want to move a schedule to another project should recreate it. +export const todoScheduleUpdateInputSchema = todoScheduleBaseSchema + .omit({ projectId: true }) + .partial() + .extend({ id: z.string().min(1) }); + +export type TodoScheduleUpdateInput = z.infer< + typeof todoScheduleUpdateInputSchema +>; + +/** + * Event emitted by the scheduler when a schedule fires. The renderer uses + * this to show a toast and, when `sessionId` is non-null, deep-link to the + * freshly-created session. + */ +export type TodoScheduleFireKind = "triggered" | "skipped" | "failed"; + +export interface TodoScheduleFireEvent { + scheduleId: string; + scheduleName: string; + kind: TodoScheduleFireKind; + sessionId: string | null; + message: string | null; + firedAt: number; +} + +export type { + SelectTodoSchedule, + TodoScheduleFrequency, + TodoScheduleOverlapMode, +}; diff --git a/apps/desktop/src/renderer/features/todo-agent/ScheduleFireToasts/ScheduleFireToasts.tsx b/apps/desktop/src/renderer/features/todo-agent/ScheduleFireToasts/ScheduleFireToasts.tsx new file mode 100644 index 00000000000..189232d3b9f --- /dev/null +++ b/apps/desktop/src/renderer/features/todo-agent/ScheduleFireToasts/ScheduleFireToasts.tsx @@ -0,0 +1,41 @@ +import { toast } from "@superset/ui/sonner"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +/** + * Subscribes to the scheduler's fire events in the main process and shows + * a toast for each one. Mounted once at the layout level so notifications + * surface regardless of whether the TodoManager dialog is open. + * + * Renders nothing. + */ +export function ScheduleFireToasts() { + const utils = electronTrpc.useUtils(); + + electronTrpc.todoAgent.schedule.onFire.useSubscription(undefined, { + onError: (err) => { + console.warn("[schedule-toasts] subscription error", err); + }, + onData: (event) => { + if (event.kind === "triggered") { + toast.success(`📅 ${event.scheduleName} を実行しました`, { + description: event.sessionId + ? "TODO Manager のタスクタブから進捗を確認できます" + : undefined, + }); + } else if (event.kind === "skipped") { + toast.info(`⏭️ ${event.scheduleName} をスキップしました`, { + description: event.message ?? undefined, + }); + } else if (event.kind === "failed") { + toast.error(`⚠️ ${event.scheduleName} の発火に失敗しました`, { + description: event.message ?? undefined, + }); + } + + void utils.todoAgent.schedule.listAll.invalidate(); + void utils.todoAgent.listAll.invalidate(); + }, + }); + + return null; +} diff --git a/apps/desktop/src/renderer/features/todo-agent/ScheduleFireToasts/index.ts b/apps/desktop/src/renderer/features/todo-agent/ScheduleFireToasts/index.ts new file mode 100644 index 00000000000..30835cdc560 --- /dev/null +++ b/apps/desktop/src/renderer/features/todo-agent/ScheduleFireToasts/index.ts @@ -0,0 +1 @@ +export { ScheduleFireToasts } from "./ScheduleFireToasts"; diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoManager/PresetsDialog/PresetsDialog.tsx b/apps/desktop/src/renderer/features/todo-agent/TodoManager/PresetsDialog/PresetsDialog.tsx index 07bfd21b46e..9259d97f2d5 100644 --- a/apps/desktop/src/renderer/features/todo-agent/TodoManager/PresetsDialog/PresetsDialog.tsx +++ b/apps/desktop/src/renderer/features/todo-agent/TodoManager/PresetsDialog/PresetsDialog.tsx @@ -100,12 +100,14 @@ function SettingsTab() { const [maxIter, setMaxIter] = useState(10); const [maxMin, setMaxMin] = useState(30); const [maxConcurrent, setMaxConcurrent] = useState(1); + const [retentionDays, setRetentionDays] = useState(0); useEffect(() => { if (settings) { setMaxIter(settings.defaultMaxIterations); setMaxMin(settings.defaultMaxWallClockMin); setMaxConcurrent(settings.maxConcurrentTasks); + setRetentionDays(settings.sessionRetentionDays); } }, [settings]); @@ -113,7 +115,8 @@ function SettingsTab() { settings != null && (maxIter !== settings.defaultMaxIterations || maxMin !== settings.defaultMaxWallClockMin || - maxConcurrent !== settings.maxConcurrentTasks); + maxConcurrent !== settings.maxConcurrentTasks || + retentionDays !== settings.sessionRetentionDays); const handleSave = useCallback(async () => { try { @@ -121,6 +124,7 @@ function SettingsTab() { defaultMaxIterations: maxIter, defaultMaxWallClockMin: maxMin, maxConcurrentTasks: maxConcurrent, + sessionRetentionDays: retentionDays, }); await utils.todoAgent.settings.get.invalidate(); toast.success("設定を保存しました"); @@ -129,7 +133,7 @@ function SettingsTab() { error instanceof Error ? error.message : "保存に失敗しました", ); } - }, [maxIter, maxMin, maxConcurrent, updateMut, utils]); + }, [maxIter, maxMin, maxConcurrent, retentionDays, updateMut, utils]); return (
@@ -179,6 +183,25 @@ function SettingsTab() { 同時に実行する TODO セッションの上限。超えた分はキューで待機。

+
+ + + setRetentionDays(Math.max(0, Number(e.target.value) || 0)) + } + className="w-32" + /> +

+ この日数より古い終了済みセッション (done / failed / aborted / + escalated) をアプリ起動時に自動削除する。0 + で無効(手動削除のみ)。実行中・キュー中のセッションは対象外。 +

+
+
+ +
+ {(schedules?.length ?? 0) === 0 && ( +

+ まだスケジュールはありません。「新規」ボタンから作成してください。 +
+ + スケジュールはアプリ起動中のみ発火します。 + +

+ )} + {(schedules ?? []).map((schedule) => ( + openEdit(schedule)} + /> + ))} +
+
+ + + + ); +} diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/FrequencyPicker/FrequencyPicker.tsx b/apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/FrequencyPicker/FrequencyPicker.tsx new file mode 100644 index 00000000000..a7cc3dca02b --- /dev/null +++ b/apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/FrequencyPicker/FrequencyPicker.tsx @@ -0,0 +1,227 @@ +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { cn } from "@superset/ui/utils"; + +export type Frequency = "hourly" | "daily" | "weekly" | "monthly" | "custom"; + +export interface FrequencyValue { + frequency: Frequency; + minute: number | null; + hour: number | null; + weekday: number | null; + monthday: number | null; + cronExpr: string | null; +} + +interface FrequencyPickerProps { + value: FrequencyValue; + onChange: (next: FrequencyValue) => void; + disabled?: boolean; +} + +const WEEKDAYS = [ + { value: 0, label: "日" }, + { value: 1, label: "月" }, + { value: 2, label: "火" }, + { value: 3, label: "水" }, + { value: 4, label: "木" }, + { value: 5, label: "金" }, + { value: 6, label: "土" }, +]; + +function clampInt(raw: string, min: number, max: number): number | null { + if (raw === "") return null; + const n = Number.parseInt(raw, 10); + if (Number.isNaN(n)) return null; + return Math.min(max, Math.max(min, n)); +} + +export function FrequencyPicker({ + value, + onChange, + disabled, +}: FrequencyPickerProps) { + const patch = (partial: Partial) => { + onChange({ ...value, ...partial }); + }; + + const setFrequency = (frequency: Frequency) => { + // Re-seed sensible defaults whenever the frequency changes so each + // field has a visible starting value instead of the previous + // frequency's empty slots. + const base: FrequencyValue = { + frequency, + minute: null, + hour: null, + weekday: null, + monthday: null, + cronExpr: null, + }; + switch (frequency) { + case "hourly": + base.minute = value.minute ?? 0; + break; + case "daily": + base.hour = value.hour ?? 9; + base.minute = value.minute ?? 0; + break; + case "weekly": + base.weekday = value.weekday ?? 1; + base.hour = value.hour ?? 9; + base.minute = value.minute ?? 0; + break; + case "monthly": + base.monthday = value.monthday ?? 1; + base.hour = value.hour ?? 9; + base.minute = value.minute ?? 0; + break; + case "custom": + base.cronExpr = value.cronExpr ?? "0 9 * * *"; + break; + } + onChange(base); + }; + + return ( +
+
+ + +
+ + {value.frequency === "hourly" && ( +
+ + + patch({ minute: clampInt(e.target.value, 0, 59) ?? 0 }) + } + disabled={disabled} + className="h-8 w-20 text-xs" + /> + +
+ )} + + {(value.frequency === "daily" || + value.frequency === "weekly" || + value.frequency === "monthly") && ( +
+ + + patch({ hour: clampInt(e.target.value, 0, 23) ?? 9 }) + } + disabled={disabled} + className="h-8 w-20 text-xs" + /> + + + patch({ minute: clampInt(e.target.value, 0, 59) ?? 0 }) + } + disabled={disabled} + className="h-8 w-20 text-xs" + /> + +
+ )} + + {value.frequency === "weekly" && ( +
+ +
+ {WEEKDAYS.map((w) => { + const selected = value.weekday === w.value; + return ( + + ); + })} +
+
+ )} + + {value.frequency === "monthly" && ( +
+ + + patch({ monthday: clampInt(e.target.value, 1, 31) ?? 1 }) + } + disabled={disabled} + className="h-8 w-20 text-xs" + /> + +
+ )} + + {value.frequency === "custom" && ( +
+ + patch({ cronExpr: e.target.value })} + placeholder="0 9 * * * (毎日 9:00)" + disabled={disabled} + className="h-8 text-xs font-mono" + /> +

+ 5 フィールド形式 (分 時 日 月 曜)。秒は使えません。 +

+
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/FrequencyPicker/index.ts b/apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/FrequencyPicker/index.ts new file mode 100644 index 00000000000..f1350097159 --- /dev/null +++ b/apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/FrequencyPicker/index.ts @@ -0,0 +1 @@ +export { FrequencyPicker, type FrequencyValue } from "./FrequencyPicker"; diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/ScheduleEditorDialog/ScheduleEditorDialog.tsx b/apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/ScheduleEditorDialog/ScheduleEditorDialog.tsx new file mode 100644 index 00000000000..fbd6fcb72dd --- /dev/null +++ b/apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/ScheduleEditorDialog/ScheduleEditorDialog.tsx @@ -0,0 +1,498 @@ +import type { SelectTodoSchedule } from "@superset/local-db"; +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { toast } from "@superset/ui/sonner"; +import { Textarea } from "@superset/ui/textarea"; +import { useEffect, useMemo, useState } from "react"; +import { LuLoaderCircle } from "react-icons/lu"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { describeSchedule } from "../../utils/describeSchedule"; +import { formatNextRun } from "../../utils/formatNextRun"; +import { FrequencyPicker, type FrequencyValue } from "../FrequencyPicker"; + +interface ScheduleEditorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + initial: SelectTodoSchedule | null; + onSaved?: () => void; +} + +const DEFAULT_FREQUENCY: FrequencyValue = { + frequency: "daily", + minute: 0, + hour: 9, + weekday: null, + monthday: null, + cronExpr: null, +}; + +// Sentinel for the "run on the project's main repo (no specific worktree)" +// option in the 実行対象 Select. Kept out of the persisted workspaceId +// space — translated to null when saving, and back to empty string when +// loading an initial row with workspaceId = null. +const MAIN_REPO_SENTINEL = "__main__"; + +export function ScheduleEditorDialog({ + open, + onOpenChange, + initial, + onSaved, +}: ScheduleEditorDialogProps) { + const { data: workspaces } = electronTrpc.workspaces.getAll.useQuery(); + const { data: projects } = electronTrpc.projects.getRecents.useQuery(); + + const [name, setName] = useState(""); + const [projectId, setProjectId] = useState(""); + // Empty string = "プロジェクト本体 (main repo)" — resolved to the + // project's branch workspace at fire time. Otherwise a specific + // worktree workspace id. + const [workspaceId, setWorkspaceId] = useState(""); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [goal, setGoal] = useState(""); + const [verifyCommand, setVerifyCommand] = useState(""); + const [customSystemPrompt, setCustomSystemPrompt] = useState(""); + const [maxIterations, setMaxIterations] = useState(10); + const [maxWallClockMin, setMaxWallClockMin] = useState(30); + const [overlapMode, setOverlapMode] = useState<"skip" | "queue">("skip"); + const [autoSyncBeforeFire, setAutoSyncBeforeFire] = useState(false); + const [enabled, setEnabled] = useState(true); + const [freq, setFreq] = useState(DEFAULT_FREQUENCY); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!open) return; + if (initial) { + setName(initial.name); + setProjectId(initial.projectId); + setWorkspaceId(initial.workspaceId ?? ""); + setTitle(initial.title); + setDescription(initial.description); + setGoal(initial.goal ?? ""); + setVerifyCommand(initial.verifyCommand ?? ""); + setCustomSystemPrompt(initial.customSystemPrompt ?? ""); + setMaxIterations(initial.maxIterations); + setMaxWallClockMin(Math.round(initial.maxWallClockSec / 60)); + setOverlapMode(initial.overlapMode); + setAutoSyncBeforeFire(initial.autoSyncBeforeFire); + setEnabled(initial.enabled); + setFreq({ + frequency: initial.frequency, + minute: initial.minute, + hour: initial.hour, + weekday: initial.weekday, + monthday: initial.monthday, + cronExpr: initial.cronExpr, + }); + } else { + setName(""); + setProjectId(""); + setWorkspaceId(""); + setTitle(""); + setDescription(""); + setGoal(""); + setVerifyCommand(""); + setCustomSystemPrompt(""); + setMaxIterations(10); + setMaxWallClockMin(30); + setOverlapMode("skip"); + setAutoSyncBeforeFire(false); + setEnabled(true); + setFreq(DEFAULT_FREQUENCY); + } + }, [open, initial]); + + // Pre-select first project when creating a brand-new schedule. + useEffect(() => { + if (!open || initial || projectId) return; + const first = (projects ?? [])[0]; + if (first) setProjectId(first.id); + }, [open, initial, projectId, projects]); + + // Whenever the chosen project changes, drop a stale workspaceId that + // no longer belongs to this project so we don't save a cross-project + // mismatch. + useEffect(() => { + if (!workspaceId) return; + const ws = (workspaces ?? []).find((w) => w.id === workspaceId); + if (ws && ws.projectId !== projectId) { + setWorkspaceId(""); + } + }, [projectId, workspaceId, workspaces]); + + const { data: nextRunPreview } = + electronTrpc.todoAgent.schedule.previewNextRun.useQuery({ + frequency: freq.frequency, + minute: freq.minute, + hour: freq.hour, + weekday: freq.weekday, + monthday: freq.monthday, + cronExpr: freq.cronExpr, + }); + + const createMut = electronTrpc.todoAgent.schedule.create.useMutation(); + const updateMut = electronTrpc.todoAgent.schedule.update.useMutation(); + + const cadenceLabel = useMemo(() => describeSchedule(freq), [freq]); + + const canSubmit = + name.trim().length > 0 && + projectId.length > 0 && + title.trim().length > 0 && + description.trim().length > 0 && + (freq.frequency !== "custom" || + (freq.cronExpr && freq.cronExpr.trim().length > 0)); + + const handleSubmit = async () => { + if (!canSubmit || submitting) return; + + setSubmitting(true); + try { + const payload = { + projectId, + workspaceId: workspaceId.length > 0 ? workspaceId : null, + name: name.trim(), + enabled, + frequency: freq.frequency, + minute: freq.minute, + hour: freq.hour, + weekday: freq.weekday, + monthday: freq.monthday, + cronExpr: freq.cronExpr, + title: title.trim(), + description: description.trim(), + goal: goal.trim().length > 0 ? goal.trim() : null, + verifyCommand: + verifyCommand.trim().length > 0 ? verifyCommand.trim() : null, + customSystemPrompt: + customSystemPrompt.trim().length > 0 + ? customSystemPrompt.trim() + : null, + maxIterations, + maxWallClockSec: maxWallClockMin * 60, + overlapMode, + autoSyncBeforeFire, + }; + + if (initial) { + // projectId is immutable server-side and the Select is + // disabled while editing; strip it here too so a stale + // local state can't ever silently request a project + // change that the backend would ignore. + const { projectId: _omitProjectId, ...updatePayload } = payload; + await updateMut.mutateAsync({ + id: initial.id, + ...updatePayload, + }); + toast.success("スケジュールを更新しました"); + } else { + await createMut.mutateAsync(payload); + toast.success("スケジュールを作成しました"); + } + + onSaved?.(); + onOpenChange(false); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + toast.error(`保存に失敗しました: ${message}`); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + {initial ? "スケジュールを編集" : "新しいスケジュール"} + + + アプリ起動中に指定時刻が来ると TODO セッションが自動作成されます。 + + + +
+
+
+ + setName(e.target.value)} + placeholder="例: 毎日デプロイ" + className="h-8 text-xs" + autoFocus + disabled={submitting} + /> +
+ +
+ + + {initial !== null && ( +

+ プロジェクトは編集できません。変更したい場合はスケジュールを作り直してください。 +

+ )} +
+ +
+ + +
+ + + +
+
+ 発火: + {cadenceLabel} +
+
+ 次回: + {formatNextRun(nextRunPreview ?? null)} +
+
+ +
+ + +
+ + {!workspaceId && ( +
+ setAutoSyncBeforeFire(e.target.checked)} + disabled={submitting} + className="size-4 mt-0.5" + /> + +
+ )} + +
+ setEnabled(e.target.checked)} + disabled={submitting} + className="size-4" + /> + +
+
+ +
+
+ + setTitle(e.target.value)} + placeholder="発火時に作られる TODO の見出し" + className="h-8 text-xs" + disabled={submitting} + /> +
+ +
+ +