diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index d63df012..c2981fed 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -86,6 +86,7 @@ import {
import { registerPluginDistributionRoutes } from "./plugin-distribution-routes";
import { registerSyncRoutes } from "./sync-routes";
import { registerMemoRoutes } from "./memo-routes";
+import { registerScheduledTaskRoutes } from "./scheduled-task-routes";
import { registerBackupRoutes } from "./backup-routes";
import { registerMcpRoutes } from "./mcp-routes";
import { callMcpTool as callMcpToolService } from "./mcp-tool-service";
@@ -311,6 +312,7 @@ registerSyncRoutes(app, {
});
registerTagRoutes(app);
registerPluginDistributionRoutes(app);
+registerScheduledTaskRoutes(app);
registerMemoShareRoutes(app);
registerTemplateRoutes(app, {
createMemoRecord: (...args) => createMemoRecord(...args),
diff --git a/apps/api/src/scheduled-task-routes.test.mjs b/apps/api/src/scheduled-task-routes.test.mjs
new file mode 100644
index 00000000..5cad8336
--- /dev/null
+++ b/apps/api/src/scheduled-task-routes.test.mjs
@@ -0,0 +1,265 @@
+import { describe, expect, test } from "bun:test";
+import { globSync, readFileSync } from "node:fs";
+import { Database } from "bun:sqlite";
+import { Hono } from "hono";
+import { registerScheduledTaskRoutes } from "./scheduled-task-routes.ts";
+
+const auth = {
+ kind: "user",
+ actorType: "user",
+ actorId: "usr_owner",
+ username: "owner",
+ displayName: "Owner",
+ scopes: [],
+ workspaceId: "ws_scheduled",
+ role: "owner",
+};
+
+class SqlitePreparedStatement {
+ constructor(db, sql, bindings = []) {
+ this.db = db;
+ this.sql = sql;
+ this.bindings = bindings;
+ }
+ bind(...bindings) { return new SqlitePreparedStatement(this.db, this.sql, bindings); }
+ async all() { return { results: this.db.query(this.sql).all(...this.bindings), success: true, meta: {} }; }
+ async first() { return this.db.query(this.sql).get(...this.bindings) ?? null; }
+ async run() {
+ this.db.query(this.sql).run(...this.bindings);
+ return { results: [], success: true, meta: {} };
+ }
+}
+
+class SqliteDatabase {
+ constructor(db) { this.db = db; }
+ prepare(sql) { return new SqlitePreparedStatement(this.db, sql); }
+ async batch(statements) {
+ return this.db.transaction(() => statements.map((statement) =>
+ this.db.query(statement.sql).run(...statement.bindings)))();
+ }
+}
+
+const createFixture = () => {
+ const sqlite = new Database(":memory:");
+ for (const migration of globSync("migrations/*.sql").sort()) {
+ sqlite.exec(readFileSync(migration, "utf8"));
+ }
+ sqlite.query("INSERT INTO workspaces (id, name, is_personal) VALUES (?, ?, 1)")
+ .run(auth.workspaceId, "Scheduled workspace");
+ const app = new Hono();
+ app.use("/api/v1/*", async (context, next) => {
+ context.set("auth", auth);
+ await next();
+ });
+ registerScheduledTaskRoutes(app);
+ return {
+ app,
+ environment: { storage: { db: new SqliteDatabase(sqlite), resources: {} } },
+ };
+};
+
+const deviceId = "desktop-device-1234567890";
+const otherDeviceId = "desktop-device-0987654321";
+
+describe("scheduled task routes", () => {
+ test("binds tasks to one device and atomically claims each occurrence", async () => {
+ const { app, environment } = createFixture();
+ const createdResponse = await app.request("/api/v1/scheduled-tasks", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ name: "Daily cleanup",
+ taskType: "plugin-command",
+ taskPayload: { pluginId: "org.edgeever.cleanup", commandId: "run" },
+ cronExpression: "0 9 * * *",
+ timezone: "Asia/Shanghai",
+ executorDeviceId: deviceId,
+ missedRunPolicy: "run-once",
+ }),
+ }, environment);
+ expect(createdResponse.status).toBe(201);
+ const task = (await createdResponse.json()).task;
+
+ const otherDeviceList = await app.request(
+ `/api/v1/scheduled-tasks?executorDeviceId=${encodeURIComponent(otherDeviceId)}`,
+ {},
+ environment,
+ );
+ expect((await otherDeviceList.json()).tasks).toEqual([]);
+
+ const wrongDeviceClaim = await app.request(`/api/v1/scheduled-tasks/${task.id}/runs/claim`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ scheduledFor: "2026-09-02T01:00:00.000Z", executorDeviceId: otherDeviceId }),
+ }, environment);
+ expect(wrongDeviceClaim.status).toBe(409);
+
+ const claimInput = {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ scheduledFor: "2026-09-02T01:00:00.000Z", executorDeviceId: deviceId }),
+ };
+ const claimedResponse = await app.request(`/api/v1/scheduled-tasks/${task.id}/runs/claim`, claimInput, environment);
+ expect(claimedResponse.status).toBe(201);
+ const run = (await claimedResponse.json()).run;
+ const duplicateResponse = await app.request(`/api/v1/scheduled-tasks/${task.id}/runs/claim`, claimInput, environment);
+ expect(duplicateResponse.status).toBe(409);
+
+ const finishedResponse = await app.request(`/api/v1/scheduled-tasks/${task.id}/runs/${run.id}/finish`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ executorDeviceId: deviceId, status: "succeeded" }),
+ }, environment);
+ expect(finishedResponse.status).toBe(200);
+ expect((await finishedResponse.json()).run).toMatchObject({ status: "succeeded", errorMessage: null });
+
+ const listedResponse = await app.request(
+ `/api/v1/scheduled-tasks?executorDeviceId=${encodeURIComponent(deviceId)}`,
+ {},
+ environment,
+ );
+ expect((await listedResponse.json()).tasks[0]).toMatchObject({
+ id: task.id,
+ executorDeviceId: deviceId,
+ lastRun: { id: run.id, status: "succeeded" },
+ });
+
+ const historyResponse = await app.request(
+ `/api/v1/scheduled-tasks/${task.id}/runs?offset=0&limit=1`,
+ {},
+ environment,
+ );
+ expect(historyResponse.status).toBe(200);
+ expect(await historyResponse.json()).toMatchObject({
+ runs: [{ id: run.id, status: "succeeded" }],
+ totalCount: 1,
+ nextOffset: null,
+ });
+
+ const staleRunId = "scheduled_run_stale";
+ await environment.storage.db.prepare(
+ `INSERT INTO scheduled_task_runs (
+ id, task_id, workspace_id, scheduled_for, executor_device_id,
+ status, started_at, finished_at
+ ) VALUES (?, ?, ?, ?, ?, 'succeeded', ?, ?)`,
+ ).bind(
+ staleRunId,
+ task.id,
+ auth.workspaceId,
+ "2026-07-01T01:00:00.000Z",
+ deviceId,
+ new Date(Date.now() - 31 * 24 * 60 * 60 * 1_000).toISOString(),
+ new Date(Date.now() - 31 * 24 * 60 * 60 * 1_000 + 1_000).toISOString(),
+ ).run();
+
+ const globalHistoryResponse = await app.request(
+ "/api/v1/scheduled-task-runs?offset=0&limit=1",
+ {},
+ environment,
+ );
+ expect(globalHistoryResponse.status).toBe(200);
+ expect(await globalHistoryResponse.json()).toMatchObject({
+ runs: [{
+ id: run.id,
+ taskId: task.id,
+ taskName: "Daily cleanup",
+ pluginId: "org.edgeever.cleanup",
+ ownerPluginId: null,
+ pluginScheduleKey: null,
+ status: "succeeded",
+ }],
+ totalCount: 1,
+ nextOffset: null,
+ });
+ expect(await environment.storage.db.prepare(
+ "SELECT id FROM scheduled_task_runs WHERE id = ?",
+ ).bind(staleRunId).first()).toBeNull();
+ });
+
+ test("does not expose task management to API-token actors", async () => {
+ const { app, environment } = createFixture();
+ const tokenApp = new Hono();
+ tokenApp.use("/api/v1/*", async (context, next) => {
+ context.set("auth", { ...auth, kind: "agent", actorType: "agent" });
+ await next();
+ });
+ registerScheduledTaskRoutes(tokenApp);
+ const response = await tokenApp.request("/api/v1/scheduled-tasks", {}, environment);
+ expect(response.status).toBe(403);
+ const historyResponse = await tokenApp.request("/api/v1/scheduled-task-runs", {}, environment);
+ expect(historyResponse.status).toBe(403);
+ });
+
+ test("rejects invalid cron expressions and timezones", async () => {
+ const { app, environment } = createFixture();
+ const response = await app.request("/api/v1/scheduled-tasks", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ name: "Invalid schedule",
+ taskType: "plugin-command",
+ taskPayload: { pluginId: "org.edgeever.cleanup", commandId: "run" },
+ cronExpression: "not a cron expression",
+ timezone: "Mars/Olympus_Mons",
+ executorDeviceId: deviceId,
+ missedRunPolicy: "run-once",
+ }),
+ }, environment);
+ expect(response.status).toBe(400);
+ });
+
+ test("upserts one plugin-owned schedule across multiple desktop devices", async () => {
+ const { app, environment } = createFixture();
+ const upsert = (executorDeviceId, input = {}) => app.request("/api/v1/scheduled-tasks/plugin-upsert", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ pluginId: "org.edgeever.cleanup",
+ scheduleKey: "daily-cleanup",
+ name: "Daily cleanup",
+ commandId: "run",
+ cronExpression: "0 9 * * *",
+ timezone: "Asia/Shanghai",
+ executorDeviceId,
+ missedRunPolicy: "run-once",
+ ...input,
+ }),
+ }, environment);
+
+ const first = (await (await upsert(deviceId)).json()).task;
+ expect(first).toMatchObject({
+ ownerPluginId: "org.edgeever.cleanup",
+ pluginScheduleKey: "daily-cleanup",
+ executorDeviceId: deviceId,
+ isEnabled: true,
+ });
+
+ const disabledResponse = await app.request(`/api/v1/scheduled-tasks/${first.id}`, {
+ method: "PATCH",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ isEnabled: false }),
+ }, environment);
+ expect(disabledResponse.status).toBe(200);
+
+ const second = (await (await upsert(otherDeviceId, { name: "Updated cleanup" })).json()).task;
+ expect(second).toMatchObject({
+ id: first.id,
+ name: "Updated cleanup",
+ executorDeviceId: deviceId,
+ isEnabled: false,
+ });
+
+ const repeated = (await (await upsert(otherDeviceId, { name: "Updated cleanup" })).json()).task;
+ expect(repeated.updatedAt).toBe(second.updatedAt);
+
+ const listed = await app.request("/api/v1/scheduled-tasks/plugin/org.edgeever.cleanup", {}, environment);
+ expect((await listed.json()).tasks).toHaveLength(1);
+
+ const removed = await app.request(
+ "/api/v1/scheduled-tasks/plugin/org.edgeever.cleanup/daily-cleanup",
+ { method: "DELETE" },
+ environment,
+ );
+ expect(removed.status).toBe(200);
+ });
+});
diff --git a/apps/api/src/scheduled-task-routes.ts b/apps/api/src/scheduled-task-routes.ts
new file mode 100644
index 00000000..8e38a38f
--- /dev/null
+++ b/apps/api/src/scheduled-task-routes.ts
@@ -0,0 +1,497 @@
+import {
+ ScheduledTaskClaimSchema,
+ ScheduledTaskCreateSchema,
+ ScheduledTaskFinishSchema,
+ PluginScheduleUpsertSchema,
+ ScheduledTaskUpdateSchema,
+ type ScheduledPluginCommandPayload,
+ type ScheduledTask,
+ type ScheduledTaskRun,
+} from "@edgeever/shared";
+import { zValidator } from "@hono/zod-validator";
+import { Cron } from "croner";
+import type { Hono } from "hono";
+import { audit } from "./audit";
+import type { AppEnv } from "./api-context";
+import { createId, isoNow } from "./entity-utils";
+import { badRequest, conflict, notFound } from "./http-errors";
+import { getAuditActor, getWorkspaceId, requireUser } from "./request-auth";
+import type { DatabaseAdapter } from "./storage-contract";
+
+type ScheduledTaskRow = {
+ id: string;
+ name: string;
+ task_type: "plugin-command";
+ task_payload_json: string;
+ owner_plugin_id: string | null;
+ plugin_schedule_key: string | null;
+ cron_expression: string;
+ timezone: string;
+ executor_device_id: string;
+ missed_run_policy: "run-once" | "skip";
+ is_enabled: number;
+ created_at: string;
+ updated_at: string;
+ run_id: string | null;
+ run_scheduled_for: string | null;
+ run_executor_device_id: string | null;
+ run_status: "running" | "succeeded" | "failed" | null;
+ run_error_message: string | null;
+ run_started_at: string | null;
+ run_finished_at: string | null;
+};
+
+type ScheduledTaskRunRow = {
+ id: string;
+ task_id: string;
+ scheduled_for: string;
+ executor_device_id: string;
+ status: "running" | "succeeded" | "failed";
+ error_message: string | null;
+ started_at: string;
+ finished_at: string | null;
+};
+
+type ScheduledTaskRunHistoryRow = ScheduledTaskRunRow & {
+ task_name: string;
+ task_payload_json: string;
+ owner_plugin_id: string | null;
+ plugin_schedule_key: string | null;
+};
+
+const SCHEDULED_TASK_RUN_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
+
+const pruneExpiredScheduledTaskRuns = async (
+ db: DatabaseAdapter,
+ workspaceId: string,
+ now = Date.now(),
+) => {
+ const cutoff = new Date(now - SCHEDULED_TASK_RUN_RETENTION_MS).toISOString();
+ await db.prepare(
+ "DELETE FROM scheduled_task_runs WHERE workspace_id = ? AND started_at < ?",
+ ).bind(workspaceId, cutoff).run();
+};
+
+const parseTaskPayload = (json: string): ScheduledPluginCommandPayload => {
+ try {
+ const value = JSON.parse(json) as Partial;
+ return {
+ pluginId: typeof value.pluginId === "string" ? value.pluginId : "",
+ commandId: typeof value.commandId === "string" ? value.commandId : "",
+ };
+ } catch {
+ return { pluginId: "", commandId: "" };
+ }
+};
+
+const validateSchedule = (cronExpression: string, timezone: string) => {
+ let validator: Cron | null = null;
+ try {
+ validator = new Cron(cronExpression, { timezone, paused: true });
+ return validator.nextRun() !== null;
+ } catch {
+ return false;
+ } finally {
+ validator?.stop();
+ }
+};
+
+export const mapScheduledTaskRunRow = (row: ScheduledTaskRunRow): ScheduledTaskRun => ({
+ id: row.id,
+ taskId: row.task_id,
+ scheduledFor: row.scheduled_for,
+ executorDeviceId: row.executor_device_id,
+ status: row.status,
+ errorMessage: row.error_message,
+ startedAt: row.started_at,
+ finishedAt: row.finished_at,
+});
+
+export const mapScheduledTaskRow = (row: ScheduledTaskRow): ScheduledTask => ({
+ id: row.id,
+ name: row.name,
+ taskType: row.task_type,
+ taskPayload: parseTaskPayload(row.task_payload_json),
+ ownerPluginId: row.owner_plugin_id,
+ pluginScheduleKey: row.plugin_schedule_key,
+ cronExpression: row.cron_expression,
+ timezone: row.timezone,
+ executorDeviceId: row.executor_device_id,
+ missedRunPolicy: row.missed_run_policy,
+ isEnabled: row.is_enabled === 1,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ lastRun: row.run_id && row.run_scheduled_for && row.run_executor_device_id && row.run_status && row.run_started_at
+ ? {
+ id: row.run_id,
+ taskId: row.id,
+ scheduledFor: row.run_scheduled_for,
+ executorDeviceId: row.run_executor_device_id,
+ status: row.run_status,
+ errorMessage: row.run_error_message,
+ startedAt: row.run_started_at,
+ finishedAt: row.run_finished_at,
+ }
+ : null,
+});
+
+const TASK_SELECT = `
+ SELECT t.id, t.name, t.task_type, t.task_payload_json, t.owner_plugin_id,
+ t.plugin_schedule_key, t.cron_expression,
+ t.timezone, t.executor_device_id, t.missed_run_policy, t.is_enabled,
+ t.created_at, t.updated_at,
+ r.id AS run_id, r.scheduled_for AS run_scheduled_for,
+ r.executor_device_id AS run_executor_device_id, r.status AS run_status,
+ r.error_message AS run_error_message, r.started_at AS run_started_at,
+ r.finished_at AS run_finished_at
+ FROM scheduled_tasks t
+ LEFT JOIN scheduled_task_runs r ON r.id = (
+ SELECT latest.id
+ FROM scheduled_task_runs latest
+ WHERE latest.task_id = t.id
+ ORDER BY latest.scheduled_for DESC
+ LIMIT 1
+ )`;
+
+const getScheduledTask = async (db: DatabaseAdapter, workspaceId: string, id: string) => {
+ const row = await db.prepare(`${TASK_SELECT} WHERE t.id = ? AND t.workspace_id = ?`)
+ .bind(id, workspaceId)
+ .first();
+ return row ? mapScheduledTaskRow(row) : null;
+};
+
+export const registerScheduledTaskRoutes = (app: Hono) => {
+ app.use("/api/v1/scheduled-tasks/*", async (c, next) => {
+ const response = requireUser(c);
+ if (response) return response;
+ await next();
+ });
+
+ app.use("/api/v1/scheduled-task-runs", async (c, next) => {
+ const response = requireUser(c);
+ if (response) return response;
+ await next();
+ });
+
+ app.get("/api/v1/scheduled-tasks", async (c) => {
+ const workspaceId = getWorkspaceId(c);
+ const executorDeviceId = c.req.query("executorDeviceId")?.trim();
+ const query = `${TASK_SELECT}
+ WHERE t.workspace_id = ?${executorDeviceId ? " AND t.executor_device_id = ?" : ""}
+ ORDER BY t.updated_at DESC, t.id ASC`;
+ const statement = c.env.storage.db.prepare(query);
+ const rows = executorDeviceId
+ ? await statement.bind(workspaceId, executorDeviceId).all()
+ : await statement.bind(workspaceId).all();
+ return c.json({ tasks: rows.results.map(mapScheduledTaskRow) });
+ });
+
+ app.get("/api/v1/scheduled-task-runs", async (c) => {
+ const workspaceId = getWorkspaceId(c);
+ await pruneExpiredScheduledTaskRuns(c.env.storage.db, workspaceId);
+ const requestedLimit = Number(c.req.query("limit") ?? 50);
+ const requestedOffset = Number(c.req.query("offset") ?? 0);
+ const limit = Number.isSafeInteger(requestedLimit) ? Math.min(Math.max(requestedLimit, 1), 100) : 50;
+ const offset = Number.isSafeInteger(requestedOffset) ? Math.max(requestedOffset, 0) : 0;
+ const [rows, countRow] = await Promise.all([
+ c.env.storage.db.prepare(
+ `SELECT r.id, r.task_id, r.scheduled_for, r.executor_device_id, r.status,
+ r.error_message, r.started_at, r.finished_at, t.name AS task_name,
+ t.task_payload_json, t.owner_plugin_id, t.plugin_schedule_key
+ FROM scheduled_task_runs r
+ JOIN scheduled_tasks t ON t.id = r.task_id AND t.workspace_id = r.workspace_id
+ WHERE r.workspace_id = ?
+ ORDER BY r.started_at DESC, r.id DESC
+ LIMIT ? OFFSET ?`,
+ ).bind(workspaceId, limit, offset).all(),
+ c.env.storage.db.prepare(
+ "SELECT COUNT(*) AS total_count FROM scheduled_task_runs WHERE workspace_id = ?",
+ ).bind(workspaceId).first<{ total_count: number }>(),
+ ]);
+ const totalCount = Number(countRow?.total_count ?? 0);
+ return c.json({
+ runs: rows.results.map((row) => ({
+ ...mapScheduledTaskRunRow(row),
+ taskName: row.task_name,
+ pluginId: parseTaskPayload(row.task_payload_json).pluginId,
+ ownerPluginId: row.owner_plugin_id,
+ pluginScheduleKey: row.plugin_schedule_key,
+ })),
+ totalCount,
+ nextOffset: offset + rows.results.length < totalCount ? offset + rows.results.length : null,
+ });
+ });
+
+ app.get("/api/v1/scheduled-tasks/plugin/:pluginId", async (c) => {
+ const workspaceId = getWorkspaceId(c);
+ const pluginId = c.req.param("pluginId");
+ const rows = await c.env.storage.db.prepare(`${TASK_SELECT}
+ WHERE t.workspace_id = ? AND t.owner_plugin_id = ?
+ ORDER BY t.plugin_schedule_key ASC`)
+ .bind(workspaceId, pluginId)
+ .all();
+ return c.json({ tasks: rows.results.map(mapScheduledTaskRow) });
+ });
+
+ app.post("/api/v1/scheduled-tasks/plugin-upsert", zValidator("json", PluginScheduleUpsertSchema), async (c) => {
+ const input = c.req.valid("json");
+ if (!validateSchedule(input.cronExpression, input.timezone)) {
+ return badRequest(c, "Cron expression or timezone is invalid.");
+ }
+ const workspaceId = getWorkspaceId(c);
+ const id = createId("scheduled_task");
+ const now = isoNow();
+ await c.env.storage.db.prepare(
+ `INSERT INTO scheduled_tasks (
+ id, workspace_id, name, task_type, task_payload_json, owner_plugin_id,
+ plugin_schedule_key, cron_expression, timezone, executor_device_id,
+ missed_run_policy, is_enabled, created_at, updated_at
+ ) VALUES (?, ?, ?, 'plugin-command', ?, ?, ?, ?, ?, ?, ?, COALESCE(?, 1), ?, ?)
+ ON CONFLICT(workspace_id, owner_plugin_id, plugin_schedule_key)
+ WHERE owner_plugin_id IS NOT NULL AND plugin_schedule_key IS NOT NULL
+ DO UPDATE SET name = excluded.name,
+ task_payload_json = excluded.task_payload_json,
+ cron_expression = excluded.cron_expression,
+ timezone = excluded.timezone,
+ missed_run_policy = excluded.missed_run_policy,
+ is_enabled = COALESCE(?, scheduled_tasks.is_enabled),
+ updated_at = CASE
+ WHEN scheduled_tasks.name <> excluded.name
+ OR scheduled_tasks.task_payload_json <> excluded.task_payload_json
+ OR scheduled_tasks.cron_expression <> excluded.cron_expression
+ OR scheduled_tasks.timezone <> excluded.timezone
+ OR scheduled_tasks.missed_run_policy <> excluded.missed_run_policy
+ OR (? IS NOT NULL AND scheduled_tasks.is_enabled <> ?)
+ THEN excluded.updated_at
+ ELSE scheduled_tasks.updated_at
+ END`,
+ ).bind(
+ id,
+ workspaceId,
+ input.name,
+ JSON.stringify({ pluginId: input.pluginId, commandId: input.commandId }),
+ input.pluginId,
+ input.scheduleKey,
+ input.cronExpression,
+ input.timezone,
+ input.executorDeviceId,
+ input.missedRunPolicy,
+ input.isEnabled === undefined ? null : input.isEnabled ? 1 : 0,
+ now,
+ now,
+ input.isEnabled === undefined ? null : input.isEnabled ? 1 : 0,
+ input.isEnabled === undefined ? null : input.isEnabled ? 1 : 0,
+ input.isEnabled === undefined ? null : input.isEnabled ? 1 : 0,
+ ).run();
+ const row = await c.env.storage.db.prepare(`${TASK_SELECT}
+ WHERE t.workspace_id = ? AND t.owner_plugin_id = ? AND t.plugin_schedule_key = ?`)
+ .bind(workspaceId, input.pluginId, input.scheduleKey)
+ .first();
+ if (!row) return notFound(c, "Plugin schedule not found after upsert");
+ const actor = getAuditActor(c);
+ await audit(c.env.storage.db, actor.actorType, actor.actorId, "scheduled_task.plugin_upsert", "scheduled_task", row.id, {
+ pluginId: input.pluginId,
+ scheduleKey: input.scheduleKey,
+ executorDeviceId: row.executor_device_id,
+ });
+ return c.json({ task: mapScheduledTaskRow(row) });
+ });
+
+ app.delete("/api/v1/scheduled-tasks/plugin/:pluginId/:scheduleKey", async (c) => {
+ const workspaceId = getWorkspaceId(c);
+ const pluginId = c.req.param("pluginId");
+ const scheduleKey = c.req.param("scheduleKey");
+ const row = await c.env.storage.db.prepare(`${TASK_SELECT}
+ WHERE t.workspace_id = ? AND t.owner_plugin_id = ? AND t.plugin_schedule_key = ?`)
+ .bind(workspaceId, pluginId, scheduleKey)
+ .first();
+ if (!row) return notFound(c, "Plugin schedule not found");
+ await c.env.storage.db.prepare(
+ "DELETE FROM scheduled_tasks WHERE id = ? AND workspace_id = ? AND owner_plugin_id = ?",
+ ).bind(row.id, workspaceId, pluginId).run();
+ const actor = getAuditActor(c);
+ await audit(c.env.storage.db, actor.actorType, actor.actorId, "scheduled_task.plugin_delete", "scheduled_task", row.id, {
+ pluginId,
+ scheduleKey,
+ });
+ return c.json({ ok: true });
+ });
+
+ app.post("/api/v1/scheduled-tasks", zValidator("json", ScheduledTaskCreateSchema), async (c) => {
+ const input = c.req.valid("json");
+ if (!validateSchedule(input.cronExpression, input.timezone)) {
+ return badRequest(c, "Cron expression or timezone is invalid.");
+ }
+ const workspaceId = getWorkspaceId(c);
+ const id = createId("scheduled_task");
+ const now = isoNow();
+ await c.env.storage.db.prepare(
+ `INSERT INTO scheduled_tasks (
+ id, workspace_id, name, task_type, task_payload_json, cron_expression,
+ timezone, executor_device_id, missed_run_policy, is_enabled, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ ).bind(
+ id,
+ workspaceId,
+ input.name,
+ input.taskType,
+ JSON.stringify(input.taskPayload),
+ input.cronExpression,
+ input.timezone,
+ input.executorDeviceId,
+ input.missedRunPolicy,
+ input.isEnabled ? 1 : 0,
+ now,
+ now,
+ ).run();
+ const actor = getAuditActor(c);
+ await audit(c.env.storage.db, actor.actorType, actor.actorId, "scheduled_task.create", "scheduled_task", id, {
+ taskType: input.taskType,
+ executorDeviceId: input.executorDeviceId,
+ });
+ return c.json({ task: await getScheduledTask(c.env.storage.db, workspaceId, id) }, 201);
+ });
+
+ app.patch("/api/v1/scheduled-tasks/:id", zValidator("json", ScheduledTaskUpdateSchema), async (c) => {
+ const id = c.req.param("id");
+ const input = c.req.valid("json");
+ const workspaceId = getWorkspaceId(c);
+ const current = await getScheduledTask(c.env.storage.db, workspaceId, id);
+ if (!current) return notFound(c, "Scheduled task not found");
+ if (!validateSchedule(input.cronExpression ?? current.cronExpression, input.timezone ?? current.timezone)) {
+ return badRequest(c, "Cron expression or timezone is invalid.");
+ }
+ const now = isoNow();
+ await c.env.storage.db.prepare(
+ `UPDATE scheduled_tasks
+ SET name = ?, task_payload_json = ?, cron_expression = ?, timezone = ?,
+ executor_device_id = ?, missed_run_policy = ?, is_enabled = ?, updated_at = ?
+ WHERE id = ? AND workspace_id = ?`,
+ ).bind(
+ input.name ?? current.name,
+ JSON.stringify(input.taskPayload ?? current.taskPayload),
+ input.cronExpression ?? current.cronExpression,
+ input.timezone ?? current.timezone,
+ input.executorDeviceId ?? current.executorDeviceId,
+ input.missedRunPolicy ?? current.missedRunPolicy,
+ (input.isEnabled ?? current.isEnabled) ? 1 : 0,
+ now,
+ id,
+ workspaceId,
+ ).run();
+ const actor = getAuditActor(c);
+ await audit(c.env.storage.db, actor.actorType, actor.actorId, "scheduled_task.update", "scheduled_task", id, {});
+ return c.json({ task: await getScheduledTask(c.env.storage.db, workspaceId, id) });
+ });
+
+ app.delete("/api/v1/scheduled-tasks/:id", async (c) => {
+ const id = c.req.param("id");
+ const workspaceId = getWorkspaceId(c);
+ const current = await getScheduledTask(c.env.storage.db, workspaceId, id);
+ if (!current) return notFound(c, "Scheduled task not found");
+ await c.env.storage.db.prepare("DELETE FROM scheduled_tasks WHERE id = ? AND workspace_id = ?")
+ .bind(id, workspaceId)
+ .run();
+ const actor = getAuditActor(c);
+ await audit(c.env.storage.db, actor.actorType, actor.actorId, "scheduled_task.delete", "scheduled_task", id, {});
+ return c.json({ ok: true });
+ });
+
+ app.get("/api/v1/scheduled-tasks/:id/runs", async (c) => {
+ const id = c.req.param("id");
+ const workspaceId = getWorkspaceId(c);
+ const task = await getScheduledTask(c.env.storage.db, workspaceId, id);
+ if (!task) return notFound(c, "Scheduled task not found");
+ await pruneExpiredScheduledTaskRuns(c.env.storage.db, workspaceId);
+ const requestedLimit = Number(c.req.query("limit") ?? 50);
+ const requestedOffset = Number(c.req.query("offset") ?? 0);
+ const limit = Number.isSafeInteger(requestedLimit) ? Math.min(Math.max(requestedLimit, 1), 100) : 50;
+ const offset = Number.isSafeInteger(requestedOffset) ? Math.max(requestedOffset, 0) : 0;
+ const [rows, countRow] = await Promise.all([
+ c.env.storage.db.prepare(
+ `SELECT id, task_id, scheduled_for, executor_device_id, status, error_message, started_at, finished_at
+ FROM scheduled_task_runs
+ WHERE task_id = ? AND workspace_id = ?
+ ORDER BY scheduled_for DESC, started_at DESC
+ LIMIT ? OFFSET ?`,
+ ).bind(id, workspaceId, limit, offset).all(),
+ c.env.storage.db.prepare(
+ "SELECT COUNT(*) AS total_count FROM scheduled_task_runs WHERE task_id = ? AND workspace_id = ?",
+ ).bind(id, workspaceId).first<{ total_count: number }>(),
+ ]);
+ const totalCount = Number(countRow?.total_count ?? 0);
+ return c.json({
+ runs: rows.results.map(mapScheduledTaskRunRow),
+ totalCount,
+ nextOffset: offset + rows.results.length < totalCount ? offset + rows.results.length : null,
+ });
+ });
+
+ app.post("/api/v1/scheduled-tasks/:id/runs/claim", zValidator("json", ScheduledTaskClaimSchema), async (c) => {
+ const id = c.req.param("id");
+ const input = c.req.valid("json");
+ const workspaceId = getWorkspaceId(c);
+ const task = await getScheduledTask(c.env.storage.db, workspaceId, id);
+ if (!task) return notFound(c, "Scheduled task not found");
+ if (!task.isEnabled || task.executorDeviceId !== input.executorDeviceId) {
+ return conflict(c, "scheduled_task_not_eligible", "This device is not eligible to run the scheduled task.");
+ }
+
+ await pruneExpiredScheduledTaskRuns(c.env.storage.db, workspaceId);
+
+ const runId = createId("scheduled_run");
+ const scheduledFor = new Date(input.scheduledFor).toISOString();
+ const startedAt = isoNow();
+ await c.env.storage.db.prepare(
+ `INSERT OR IGNORE INTO scheduled_task_runs (
+ id, task_id, workspace_id, scheduled_for, executor_device_id, status, started_at
+ ) VALUES (?, ?, ?, ?, ?, 'running', ?)`,
+ ).bind(runId, id, workspaceId, scheduledFor, input.executorDeviceId, startedAt).run();
+ const row = await c.env.storage.db.prepare(
+ `SELECT id, task_id, scheduled_for, executor_device_id, status, error_message, started_at, finished_at
+ FROM scheduled_task_runs
+ WHERE task_id = ? AND workspace_id = ? AND scheduled_for = ?`,
+ ).bind(id, workspaceId, scheduledFor).first();
+ if (!row || row.id !== runId) {
+ return conflict(c, "scheduled_task_already_claimed", "This scheduled occurrence was already claimed.");
+ }
+ return c.json({ run: mapScheduledTaskRunRow(row) }, 201);
+ });
+
+ app.post("/api/v1/scheduled-tasks/:taskId/runs/:runId/finish", zValidator("json", ScheduledTaskFinishSchema), async (c) => {
+ const taskId = c.req.param("taskId");
+ const runId = c.req.param("runId");
+ const input = c.req.valid("json");
+ const workspaceId = getWorkspaceId(c);
+ const current = await c.env.storage.db.prepare(
+ `SELECT id, task_id, scheduled_for, executor_device_id, status, error_message, started_at, finished_at
+ FROM scheduled_task_runs
+ WHERE id = ? AND task_id = ? AND workspace_id = ?`,
+ ).bind(runId, taskId, workspaceId).first();
+ if (!current) return notFound(c, "Scheduled task run not found");
+ if (current.executor_device_id !== input.executorDeviceId) {
+ return conflict(c, "scheduled_task_run_not_owned", "This device does not own the scheduled task run.");
+ }
+ if (current.status !== "running") {
+ return conflict(c, "scheduled_task_run_finished", "This scheduled task run is already finished.");
+ }
+ const finishedAt = isoNow();
+ await c.env.storage.db.prepare(
+ `UPDATE scheduled_task_runs
+ SET status = ?, error_message = ?, finished_at = ?
+ WHERE id = ? AND task_id = ? AND workspace_id = ? AND status = 'running'`,
+ ).bind(
+ input.status,
+ input.status === "failed" ? input.errorMessage ?? "Scheduled task failed" : null,
+ finishedAt,
+ runId,
+ taskId,
+ workspaceId,
+ ).run();
+ const row = await c.env.storage.db.prepare(
+ `SELECT id, task_id, scheduled_for, executor_device_id, status, error_message, started_at, finished_at
+ FROM scheduled_task_runs WHERE id = ? AND workspace_id = ?`,
+ ).bind(runId, workspaceId).first();
+ return c.json({ run: row ? mapScheduledTaskRunRow(row) : null });
+ });
+};
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 5c75813b..e3f74850 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -17,6 +17,7 @@
"electron-builder": "^26.0.12"
},
"dependencies": {
+ "croner": "^10.0.1",
"electron-updater": "^6.6.2"
}
}
diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs
index e7dae244..bf8e8c8f 100644
--- a/apps/desktop/src/main/index.mjs
+++ b/apps/desktop/src/main/index.mjs
@@ -33,6 +33,7 @@ import { LocalDataResetError, scheduleMacLocalDataReset } from "./local-data-res
import { buildDesktopDiagnosticIssueUrl, normalizeDesktopDiagnostic } from "./desktop-diagnostics.mjs";
import { createRendererStartupGuard } from "./renderer-startup-guard.mjs";
import { waitForChildProcessSpawn } from "./child-process-start.mjs";
+import { ScheduledTaskScheduler } from "./scheduled-task-scheduler.mjs";
import {
fetchTrustedWindowsUpdate,
verifyDownloadedWindowsUpdate,
@@ -109,6 +110,22 @@ let rendererStartupGuard = null;
let rendererUnresponsiveTimer = null;
let rendererUnresponsiveDialogOpen = false;
let recoveredAfterAbnormalExit = false;
+const pendingScheduledTaskRuns = [];
+const sendScheduledTaskRun = (task, scheduledFor) => {
+ const payload = { task, scheduledFor: scheduledFor.toISOString() };
+ if (!mainWindow || mainWindow.isDestroyed() || !rendererReady) {
+ pendingScheduledTaskRuns.push(payload);
+ return;
+ }
+ mainWindow.webContents.send("desktop:scheduled-task", payload);
+};
+const scheduledTaskScheduler = new ScheduledTaskScheduler({
+ onRun: async (task, scheduledFor) => sendScheduledTaskRun(task, scheduledFor),
+ onError: (error, task) => void writeDiagnostic("scheduled-task.scheduler-error", {
+ taskId: task?.id,
+ message: error instanceof Error ? error.message : String(error),
+ }),
+});
const updateCheckIntervalMs = 60 * 60 * 1_000;
const updateCheckFocusThrottleMs = 15 * 60 * 1_000;
const hasSingleInstanceLock = app.requestSingleInstanceLock();
@@ -475,6 +492,13 @@ const flushPendingDesktopCommands = () => {
}
};
+const flushPendingScheduledTaskRuns = () => {
+ if (!rendererReady || !mainWindow || mainWindow.isDestroyed()) return;
+ while (pendingScheduledTaskRuns.length > 0) {
+ mainWindow.webContents.send("desktop:scheduled-task", pendingScheduledTaskRuns.shift());
+ }
+};
+
const handleOpenTarget = (commandLine) => {
const target = commandLine.find((value) => value.startsWith("edgeever://"));
if (target) {
@@ -891,6 +915,7 @@ const configureAutoUpdater = () => {
}, updateCheckIntervalMs);
powerMonitor.on("resume", () => {
void checkForDesktopUpdate("resume", { force: true });
+ void scheduledTaskScheduler.runMissedOccurrences();
});
};
@@ -1185,6 +1210,7 @@ const startApplication = async () => {
ipcMain.handle("desktop:set-account-scope", async (_event, accountId) => {
const normalizedAccountId = typeof accountId === "string" && accountId.trim() ? accountId.trim() : null;
const nextScopeKey = accountScopeKey(configuredApiBaseUrl, normalizedAccountId);
+ scheduledTaskScheduler.clear();
if (sidecar && sidecarScopeKey === nextScopeKey) {
await sidecar.waitUntilReady();
return { ready: true, scope: nextScopeKey };
@@ -1200,6 +1226,7 @@ const startApplication = async () => {
rendererReady = true;
flushPendingDesktopCommands();
flushPendingMarkdownImport();
+ flushPendingScheduledTaskRuns();
});
ipcMain.on("desktop:renderer-bootstrap-ready", (event) => {
if (event.sender !== mainWindow?.webContents) return;
@@ -1219,9 +1246,16 @@ const startApplication = async () => {
return { stored: Boolean(desktopSessionToken) };
});
ipcMain.handle("desktop:clear-session-token", async () => {
+ scheduledTaskScheduler.clear();
await saveDesktopSessionToken("");
return { stored: false };
});
+ ipcMain.handle("desktop:sync-scheduled-tasks", async (event, tasks) => {
+ if (event.sender !== mainWindow?.webContents) throw new Error("Scheduled tasks must come from the main window");
+ if (!Array.isArray(tasks) || tasks.length > 1_000) throw new Error("Invalid scheduled task list");
+ await scheduledTaskScheduler.reconcile(tasks);
+ return { scheduled: scheduledTaskScheduler.entries.size };
+ });
ipcMain.handle("desktop:record-renderer-error", async (event, details) => {
if (event.sender !== mainWindow?.webContents) throw new Error("Renderer diagnostics must come from the main window");
await writeDiagnostic("renderer.react-error", normalizeDesktopDiagnostic(details));
@@ -1304,6 +1338,7 @@ const startApplication = async () => {
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("Desktop API URL must use HTTP(S)");
}
if (normalized === configuredApiBaseUrl) return configuredApiBaseUrl;
+ scheduledTaskScheduler.clear();
configuredApiBaseUrl = normalized;
await writeFile(instanceUrlPath(), configuredApiBaseUrl);
if (sidecar) {
@@ -1492,6 +1527,7 @@ app.on("before-quit", (event) => {
event.preventDefault();
shutdownCleanupStarted = true;
isQuitting = true;
+ scheduledTaskScheduler.clear();
rendererStartupGuard?.complete();
clearRendererUnresponsiveTimer();
if (sidecarRestartTimer) {
diff --git a/apps/desktop/src/main/scheduled-task-scheduler.mjs b/apps/desktop/src/main/scheduled-task-scheduler.mjs
new file mode 100644
index 00000000..2976af46
--- /dev/null
+++ b/apps/desktop/src/main/scheduled-task-scheduler.mjs
@@ -0,0 +1,101 @@
+import { Cron } from "croner";
+
+const taskFingerprint = (task) => [
+ task.id,
+ task.cronExpression,
+ task.timezone,
+ task.isEnabled ? "1" : "0",
+ task.updatedAt,
+].join("\0");
+
+export const scheduledOccurrenceBefore = (job, referenceDate = new Date()) =>
+ job.previousRuns(1, new Date(referenceDate.getTime() + 1_000))[0] ?? null;
+
+const isValidTask = (task) => Boolean(
+ task
+ && typeof task.id === "string"
+ && typeof task.cronExpression === "string"
+ && typeof task.timezone === "string"
+ && typeof task.createdAt === "string"
+ && (task.missedRunPolicy === "run-once" || task.missedRunPolicy === "skip"),
+);
+
+export class ScheduledTaskScheduler {
+ constructor({ onRun, onError = () => {}, now = () => new Date(), CronClass = Cron }) {
+ this.onRun = onRun;
+ this.onError = onError;
+ this.now = now;
+ this.CronClass = CronClass;
+ this.entries = new Map();
+ this.attemptedOccurrences = new Set();
+ }
+
+ async reconcile(tasks) {
+ const eligibleTasks = Array.isArray(tasks) ? tasks.filter((task) => isValidTask(task) && task.isEnabled) : [];
+ const eligibleIds = new Set(eligibleTasks.map((task) => task.id));
+ for (const [taskId, entry] of this.entries) {
+ if (eligibleIds.has(taskId)) continue;
+ entry.job.stop();
+ this.entries.delete(taskId);
+ }
+
+ for (const task of eligibleTasks) {
+ const fingerprint = taskFingerprint(task);
+ const current = this.entries.get(task.id);
+ if (!current || current.fingerprint !== fingerprint) {
+ current?.job.stop();
+ try {
+ const job = new this.CronClass(task.cronExpression, {
+ timezone: task.timezone,
+ protect: true,
+ catch: (error) => this.onError(error, task),
+ }, async (activeJob) => {
+ const scheduledFor = scheduledOccurrenceBefore(activeJob, this.now());
+ if (scheduledFor) await this.runOccurrence(task, scheduledFor);
+ });
+ this.entries.set(task.id, { fingerprint, job, task });
+ } catch (error) {
+ this.onError(error, task);
+ continue;
+ }
+ } else {
+ current.task = task;
+ }
+ }
+
+ await this.runMissedOccurrences();
+ }
+
+ async runMissedOccurrences() {
+ const referenceDate = this.now();
+ for (const { job, task } of this.entries.values()) {
+ if (task.missedRunPolicy !== "run-once") continue;
+ const scheduledFor = scheduledOccurrenceBefore(job, referenceDate);
+ const activeSince = Math.max(new Date(task.createdAt).getTime(), new Date(task.updatedAt).getTime());
+ if (!scheduledFor || scheduledFor.getTime() < activeSince) continue;
+ const lastScheduledAt = task.lastRun?.scheduledFor ? new Date(task.lastRun.scheduledFor).getTime() : 0;
+ if (lastScheduledAt >= scheduledFor.getTime()) continue;
+ await this.runOccurrence(task, scheduledFor);
+ }
+ }
+
+ async runOccurrence(task, scheduledFor) {
+ const occurrenceKey = `${task.id}\0${scheduledFor.toISOString()}`;
+ if (this.attemptedOccurrences.has(occurrenceKey)) return;
+ this.attemptedOccurrences.add(occurrenceKey);
+ if (this.attemptedOccurrences.size > 10_000) {
+ this.attemptedOccurrences.delete(this.attemptedOccurrences.values().next().value);
+ }
+ try {
+ await this.onRun(task, scheduledFor);
+ } catch (error) {
+ this.onError(error, task);
+ }
+ }
+
+ clear() {
+ for (const { job } of this.entries.values()) job.stop();
+ this.entries.clear();
+ this.attemptedOccurrences.clear();
+ }
+}
diff --git a/apps/desktop/src/main/scheduled-task-scheduler.test.mjs b/apps/desktop/src/main/scheduled-task-scheduler.test.mjs
new file mode 100644
index 00000000..233fcb22
--- /dev/null
+++ b/apps/desktop/src/main/scheduled-task-scheduler.test.mjs
@@ -0,0 +1,64 @@
+import { describe, expect, test } from "bun:test";
+import { ScheduledTaskScheduler, scheduledOccurrenceBefore } from "./scheduled-task-scheduler.mjs";
+
+const baseTask = {
+ id: "scheduled_task_1",
+ cronExpression: "0 * * * *",
+ timezone: "Asia/Shanghai",
+ isEnabled: true,
+ missedRunPolicy: "run-once",
+ createdAt: "2026-09-01T00:00:00.000Z",
+ updatedAt: "2026-09-01T00:00:00.000Z",
+ lastRun: null,
+};
+
+describe("scheduled desktop task scheduler", () => {
+ test("calculates a stable planned occurrence instead of using callback wall time", () => {
+ const previousRuns = (count, referenceDate) => {
+ expect(count).toBe(1);
+ expect(referenceDate.toISOString()).toBe("2026-09-02T04:27:43.000Z");
+ return [new Date("2026-09-02T04:00:00.000Z")];
+ };
+ expect(scheduledOccurrenceBefore({ previousRuns }, new Date("2026-09-02T04:27:42.000Z"))?.toISOString())
+ .toBe("2026-09-02T04:00:00.000Z");
+ });
+
+ test("coalesces missed occurrences into one run and never retries it in memory", async () => {
+ const runs = [];
+ const scheduler = new ScheduledTaskScheduler({
+ now: () => new Date("2026-09-02T04:27:42.000Z"),
+ onRun: async (_task, scheduledFor) => runs.push(scheduledFor.toISOString()),
+ });
+ await scheduler.reconcile([baseTask]);
+ await scheduler.runMissedOccurrences();
+ expect(runs).toEqual(["2026-09-02T04:00:00.000Z"]);
+ scheduler.clear();
+ });
+
+ test("does not replay missed occurrences when policy is skip", async () => {
+ const runs = [];
+ const scheduler = new ScheduledTaskScheduler({
+ now: () => new Date("2026-09-02T04:27:42.000Z"),
+ onRun: async (_task, scheduledFor) => runs.push(scheduledFor.toISOString()),
+ });
+ await scheduler.reconcile([{ ...baseTask, missedRunPolicy: "skip" }]);
+ expect(runs).toEqual([]);
+ scheduler.clear();
+ });
+
+ test("replaces changed schedules and stops removed tasks", async () => {
+ const stopped = [];
+ class FakeCron {
+ constructor(pattern) {
+ this.pattern = pattern;
+ }
+ previousRuns() { return []; }
+ stop() { stopped.push(this.pattern); }
+ }
+ const scheduler = new ScheduledTaskScheduler({ onRun: async () => {}, CronClass: FakeCron });
+ await scheduler.reconcile([baseTask]);
+ await scheduler.reconcile([{ ...baseTask, cronExpression: "30 * * * *", updatedAt: "2026-09-02T00:00:00.000Z" }]);
+ await scheduler.reconcile([]);
+ expect(stopped).toEqual(["0 * * * *", "30 * * * *"]);
+ });
+});
diff --git a/apps/desktop/src/preload/index.cjs b/apps/desktop/src/preload/index.cjs
index 333e6812..a8cdb373 100644
--- a/apps/desktop/src/preload/index.cjs
+++ b/apps/desktop/src/preload/index.cjs
@@ -43,6 +43,12 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({
ipcRenderer.on("desktop:command", listener);
return () => ipcRenderer.removeListener("desktop:command", listener);
},
+ syncScheduledTasks: (tasks) => ipcRenderer.invoke("desktop:sync-scheduled-tasks", tasks),
+ onScheduledTask: (callback) => {
+ const listener = (_event, payload) => callback(payload);
+ ipcRenderer.on("desktop:scheduled-task", listener);
+ return () => ipcRenderer.removeListener("desktop:scheduled-task", listener);
+ },
onImportMarkdown: (callback) => {
const listener = (_event, payload) => callback(payload);
ipcRenderer.on("desktop:import-markdown", listener);
diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx
index 37c3a552..e054dd26 100644
--- a/apps/web/src/app/App.tsx
+++ b/apps/web/src/app/App.tsx
@@ -227,6 +227,7 @@ export const App = () => {
} />
} />
} />
+ } />
} />
diff --git a/apps/web/src/components/AiPromptsPane.tsx b/apps/web/src/components/AiPromptsPane.tsx
index 4e3f684c..fe8658d7 100644
--- a/apps/web/src/components/AiPromptsPane.tsx
+++ b/apps/web/src/components/AiPromptsPane.tsx
@@ -40,6 +40,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { api, ApiRequestError } from "@/lib/api";
import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui";
import { formatDateTime } from "@/lib/utils";
+import { ExecutionCenterButton } from "@/components/execution/ExecutionCenterButton";
const emptyForm = () => ({
name: "",
@@ -49,7 +50,7 @@ const emptyForm = () => ({
resultMode: "both" as AiPromptResultMode,
});
-export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
+export const AiPromptsPane = ({ onClose, onOpenExecutionCenter }: { onClose: () => void; onOpenExecutionCenter: () => void }) => {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const [editing, setEditing] = useState(null);
@@ -168,7 +169,7 @@ export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
return (
-
+
@@ -188,6 +189,7 @@ export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
{t("aiPrompts.description")}
+
diff --git a/apps/web/src/components/AssetsPane.tsx b/apps/web/src/components/AssetsPane.tsx
index 343e81c2..afbbf920 100644
--- a/apps/web/src/components/AssetsPane.tsx
+++ b/apps/web/src/components/AssetsPane.tsx
@@ -31,6 +31,7 @@ import { PdfViewer } from "@/components/pdf/PdfViewer";
import { PdfThumbnail } from "@/components/pdf/PdfThumbnail";
import { AttachmentFileIcon } from "@/components/attachments/AttachmentFileIcon";
import { AppConfirmDialog } from "@/components/dialogs/ConfirmDialogs";
+import { ExecutionCenterButton } from "@/components/execution/ExecutionCenterButton";
export const formatBytes = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes <= 0) {
@@ -64,9 +65,10 @@ const DOCUMENT_MIME_TYPES = new Set([
interface AssetsPaneProps {
onClose: () => void;
repository: EdgeEverRepository;
+ onOpenExecutionCenter: () => void;
}
-export const AssetsPane = ({ onClose, repository }: AssetsPaneProps) => {
+export const AssetsPane = ({ onClose, repository, onOpenExecutionCenter }: AssetsPaneProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -220,6 +222,7 @@ export const AssetsPane = ({ onClose, repository }: AssetsPaneProps) => {
+
{/* Toolbar (Filters, Search, Layout mode) */}
diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx
index 13e6caa1..b220e67c 100644
--- a/apps/web/src/components/EditorPane.tsx
+++ b/apps/web/src/components/EditorPane.tsx
@@ -89,6 +89,7 @@ import {
} from "./editor/NoteLinkSuggestion";
import { WeChatIcon } from "./WeChatIcon";
import { ThemeToggle } from "./ThemeToggle";
+import { ExecutionCenterButton } from "./execution/ExecutionCenterButton";
import { useEditorTheme, useMarkdownTheme } from "./ThemeProvider";
import type { MarkdownSourceEditorRef } from "./editor/MarkdownSourceEditor";
@@ -464,6 +465,7 @@ type EditorPaneProps = {
onOpenAiPrompts?: () => void;
pluginHost: EdgeEverPluginHost;
pluginNavigationRequest?: { id: number; noteId: string; search: string } | null;
+ onOpenExecutionCenter: () => void;
};
type RichEditorPaneProps = EditorPaneProps & {
@@ -538,6 +540,7 @@ const RichEditorPane = ({
onOpenAiPrompts,
pluginHost,
pluginNavigationRequest,
+ onOpenExecutionCenter,
onRequestMobileNativeEdit,
}: RichEditorPaneProps) => {
const { t, i18n } = useTranslation();
@@ -3972,11 +3975,6 @@ const RichEditorPane = ({
-
-
-
+
{!effectiveReadOnly && (
diff --git a/apps/web/src/components/EvernoteImportGuidePane.tsx b/apps/web/src/components/EvernoteImportGuidePane.tsx
index 68a8ad4c..b91ffcb5 100644
--- a/apps/web/src/components/EvernoteImportGuidePane.tsx
+++ b/apps/web/src/components/EvernoteImportGuidePane.tsx
@@ -6,8 +6,9 @@ import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui";
import { marked } from "marked";
import migrationGuideMarkdown from "../../../../docs/evernote-migration-guide.md?raw";
import migrationGuideEnglishMarkdown from "../../../../docs/evernote-migration-guide.en-US.md?raw";
+import { ExecutionCenterButton } from "@/components/execution/ExecutionCenterButton";
-export const EvernoteImportGuidePane = ({ onClose }: { onClose: () => void }) => {
+export const EvernoteImportGuidePane = ({ onClose, onOpenExecutionCenter }: { onClose: () => void; onOpenExecutionCenter?: () => void }) => {
const { i18n, t } = useTranslation();
const markdown = i18n.resolvedLanguage === "en-US" ? migrationGuideEnglishMarkdown : migrationGuideMarkdown;
const htmlContent = useMemo(() => marked.parse(markdown) as string, [markdown]);
@@ -36,6 +37,7 @@ export const EvernoteImportGuidePane = ({ onClose }: { onClose: () => void }) =>
+ {onOpenExecutionCenter ? : null}
diff --git a/apps/web/src/components/PluginMarketplacePane.tsx b/apps/web/src/components/PluginMarketplacePane.tsx
index 2b11ba6f..f34c17c4 100644
--- a/apps/web/src/components/PluginMarketplacePane.tsx
+++ b/apps/web/src/components/PluginMarketplacePane.tsx
@@ -8,13 +8,16 @@ import { PluginManagerCard } from "@/components/settings/PluginManagerCard";
import type { EdgeEverPluginHost } from "@/lib/plugins/plugin-host";
import { contentEnterMotion } from "@/lib/motion";
import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui";
+import { ExecutionCenterButton } from "@/components/execution/ExecutionCenterButton";
export const PluginMarketplacePane = ({
host,
onClose,
+ onOpenExecutionCenter,
}: {
host: EdgeEverPluginHost;
onClose: () => void;
+ onOpenExecutionCenter: () => void;
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -45,7 +48,10 @@ export const PluginMarketplacePane = ({
{t("plugins.marketplace.title")}
-
+
+
+
+
diff --git a/apps/web/src/components/SettingsPane.tsx b/apps/web/src/components/SettingsPane.tsx
index 5f0c0fbf..e1179212 100644
--- a/apps/web/src/components/SettingsPane.tsx
+++ b/apps/web/src/components/SettingsPane.tsx
@@ -38,6 +38,7 @@ import { ThemeToggle } from "./ThemeToggle";
import type { AuthUser } from "@edgeever/shared";
import { contentEnterMotion } from "@/lib/motion";
import { useDeployedUpdateNotice } from "@/hooks/useDeployedUpdateNotice";
+import { ExecutionCenterButton } from "@/components/execution/ExecutionCenterButton";
interface SettingsPaneProps {
onClose: () => void;
@@ -56,6 +57,7 @@ interface SettingsPaneProps {
isOwner: boolean;
user: AuthUser | null;
refreshWorkspaceAfterImport: () => Promise;
+ onOpenExecutionCenter: () => void;
}
// Slate and brand color variables already switch values with the root theme.
@@ -95,6 +97,7 @@ export const SettingsPane = ({
isOwner,
user,
refreshWorkspaceAfterImport,
+ onOpenExecutionCenter,
}: SettingsPaneProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState("general");
@@ -283,6 +286,7 @@ export const SettingsPane = ({
+
diff --git a/apps/web/src/components/TagsPane.tsx b/apps/web/src/components/TagsPane.tsx
index 82a88d5a..a16438b3 100644
--- a/apps/web/src/components/TagsPane.tsx
+++ b/apps/web/src/components/TagsPane.tsx
@@ -9,15 +9,18 @@ import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui";
import type { TagSummary } from "@edgeever/shared";
import { AppConfirmDialog } from "./dialogs/ConfirmDialogs";
import type { EdgeEverRepository } from "@/lib/repository";
+import { ExecutionCenterButton } from "@/components/execution/ExecutionCenterButton";
export const TagsPane = ({
onClose,
onSelectTag,
repository,
+ onOpenExecutionCenter,
}: {
onClose: () => void;
onSelectTag: (tag: string) => void;
repository: EdgeEverRepository;
+ onOpenExecutionCenter: () => void;
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -49,7 +52,7 @@ export const TagsPane = ({
return (
-
+
+
diff --git a/apps/web/src/components/TemplatesPane.tsx b/apps/web/src/components/TemplatesPane.tsx
index adfa15a3..b182f9c9 100644
--- a/apps/web/src/components/TemplatesPane.tsx
+++ b/apps/web/src/components/TemplatesPane.tsx
@@ -24,6 +24,7 @@ import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui";
import { copyTextToClipboard } from "@/lib/clipboard";
import { ClipboardCopyNotice } from "@/components/ClipboardCopyNotice";
import type { MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
+import { ExecutionCenterButton } from "@/components/execution/ExecutionCenterButton";
const TemplateIconAction = ({
children,
@@ -63,6 +64,7 @@ export const TemplatesPane = ({
onUseSavedTemplate,
onDeleteSavedTemplate,
onUpdateSavedTemplate,
+ onOpenExecutionCenter,
}: {
canCreateMemo: boolean;
isCreating: boolean;
@@ -72,6 +74,7 @@ export const TemplatesPane = ({
onUseSavedTemplate: (template: SavedMemoTemplate) => void;
onDeleteSavedTemplate: (template: SavedMemoTemplate) => void;
onUpdateSavedTemplate: (templateId: string, payload: { name: string; description: string | null; title: string | null; contentMarkdown: string; tags: string[] }) => Promise;
+ onOpenExecutionCenter: () => void;
}) => {
const { t } = useTranslation();
const [editingTemplate, setEditingTemplate] = useState(null);
@@ -158,7 +161,7 @@ export const TemplatesPane = ({
{/* Header */}
-
+
@@ -176,6 +179,7 @@ export const TemplatesPane = ({
{t("templates.description")}
+
{/* Main Content Area */}
diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx
index 62cf6edb..e8d1182d 100644
--- a/apps/web/src/components/WorkspaceApp.tsx
+++ b/apps/web/src/components/WorkspaceApp.tsx
@@ -6,6 +6,7 @@ import {
useEffect,
useMemo,
useRef,
+ useSyncExternalStore,
type CSSProperties,
type ReactNode,
type MouseEvent,
@@ -29,7 +30,8 @@ import { MemoListPane, MemoSelectionActionBar } from "./MemoListPane";
import { QuickMemoSwitcher } from "./QuickMemoSwitcher";
import { AppConfirmDialog, MemoDeleteConfirmDialog, NotebookNameDialog } from "./dialogs/ConfirmDialogs";
import { PluginPanelDialog } from "./plugins/PluginPanelDialog";
-import { api } from "@/lib/api";
+import { api, getOrCreateClientDeviceId } from "@/lib/api";
+import { createPluginScheduleAdapter } from "@/lib/plugins/plugin-schedule-adapter";
import {
clearMobileEditorReturnPreview,
consumeStandaloneMobileEditorReturn,
@@ -155,6 +157,9 @@ const EvernoteImportGuidePane = lazy(() =>
const TagsPane = lazy(() => import("./TagsPane").then((module) => ({ default: module.TagsPane })));
const TemplatesPane = lazy(() => import("./TemplatesPane").then((module) => ({ default: module.TemplatesPane })));
const AiPromptsPane = lazy(() => import("./AiPromptsPane").then((module) => ({ default: module.AiPromptsPane })));
+const ExecutionCenterPane = lazy(() =>
+ import("./execution/ExecutionCenterPane").then((module) => ({ default: module.ExecutionCenterPane }))
+);
const PaneLoadingFallback = ({ label = "Loading" }: { label?: string }) => (
@@ -678,6 +683,7 @@ export const WorkspaceApp = ({
navigatePlugins: navigateWorkspacePlugins,
navigateTemplates: navigateWorkspaceTemplates,
navigateAiPrompts: navigateWorkspaceAiPrompts,
+ navigateExecutionCenter: navigateWorkspaceExecutionCenter,
} = useWorkspaceRoute();
const localDataScope = useMemo(
() => createLocalDataScope(window.location.origin, user?.id),
@@ -688,12 +694,13 @@ export const WorkspaceApp = ({
const isInitialPluginsRoute = route.isPlugins;
const isInitialTemplatesRoute = route.isTemplates;
const isInitialAiPromptsRoute = route.isAiPrompts;
+ const isInitialExecutionCenterRoute = route.isExecutionCenter;
const isInitialMobileEditorReturn = Boolean(route.mobileEditorReturnMemoId);
const isTrashRoute = route.isTrash;
const [rendererRecoveryMode, setRendererRecoveryMode] = useState(() =>
Boolean(window.edgeeverDesktop?.recoveredAfterAbnormalExit) || isRendererRecoveryRequired()
);
- const [activePane, setActivePane] = useState
(() => ((isInitialSettingsRoute || isInitialPluginsRoute || isInitialTemplatesRoute || isInitialAiPromptsRoute) && !isInitialMobileEditorReturn ? "editor" : "memos"));
+ const [activePane, setActivePane] = useState(() => ((isInitialSettingsRoute || isInitialPluginsRoute || isInitialTemplatesRoute || isInitialAiPromptsRoute || isInitialExecutionCenterRoute) && !isInitialMobileEditorReturn ? "editor" : "memos"));
const [memoView, setMemoView] = useState(() => (isTrashRoute ? "trash" : "notebook"));
const {
beginMemoSelection,
@@ -725,10 +732,19 @@ export const WorkspaceApp = ({
const [notebookDeleteConfirmation, setNotebookDeleteConfirmation] = useState(null);
const [appNoticeDialog, setAppNoticeDialog] = useState(null);
const [demoResetConfirmationOpen, setDemoResetConfirmationOpen] = useState(false);
+ const scheduledTaskDeviceId = useMemo(
+ () => window.edgeeverDesktop?.isAvailable ? getOrCreateClientDeviceId() : null,
+ [],
+ );
+ const pluginScheduleAdapter = useMemo(() => scheduledTaskDeviceId
+ ? createPluginScheduleAdapter(scheduledTaskDeviceId, () =>
+ queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] }))
+ : undefined, [queryClient, scheduledTaskDeviceId]);
const pluginHost = useMemo(() => new EdgeEverPluginHost({
repository,
scope: localDataScope,
onNotice: (message) => setAppNoticeDialog({ title: t("plugins.noticeTitle"), description: message }),
+ scheduleAdapter: pluginScheduleAdapter,
onWorkspaceChanged: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["memos"] }),
@@ -739,7 +755,98 @@ export const WorkspaceApp = ({
queryClient.invalidateQueries({ queryKey: ["resources"] }),
]);
},
- }), [localDataScope, queryClient, repository, t]);
+ }), [localDataScope, pluginScheduleAdapter, queryClient, repository, t]);
+ const [pluginHostReady, setPluginHostReady] = useState(false);
+ const pluginHostSnapshot = useSyncExternalStore(pluginHost.subscribe, pluginHost.getSnapshot, pluginHost.getSnapshot);
+ useEffect(() => {
+ let active = true;
+ setPluginHostReady(false);
+ void pluginHost.activateEnabled().then(() => {
+ if (active) setPluginHostReady(true);
+ }).catch(() => {
+ if (active) setPluginHostReady(false);
+ });
+ return () => {
+ active = false;
+ void pluginHost.dispose();
+ };
+ }, [pluginHost]);
+ const scheduledTasksQuery = useQuery({
+ queryKey: ["scheduled-tasks", scheduledTaskDeviceId],
+ queryFn: () => api.listScheduledTasks(scheduledTaskDeviceId ?? undefined),
+ enabled: Boolean(scheduledTaskDeviceId && pluginHostReady),
+ refetchInterval: 60_000,
+ });
+ const runningScheduledTaskIdsRef = useRef(new Set());
+ const runnableScheduledTasks = useMemo(() => {
+ const commandKeys = new Set(pluginHostSnapshot.commands.map((command) => `${command.pluginId}\0${command.id}`));
+ return (scheduledTasksQuery.data?.tasks ?? []).filter((task) =>
+ commandKeys.has(`${task.taskPayload.pluginId}\0${task.taskPayload.commandId}`));
+ }, [pluginHostSnapshot.commands, scheduledTasksQuery.data?.tasks]);
+
+ useEffect(() => {
+ if (!scheduledTaskDeviceId || !scheduledTasksQuery.data?.tasks) return;
+ const interrupted = scheduledTasksQuery.data.tasks.filter((task) =>
+ task.lastRun?.status === "running" && !runningScheduledTaskIdsRef.current.has(task.id));
+ if (interrupted.length === 0) return;
+ void Promise.all(interrupted.map((task) => api.finishScheduledTaskRun(task.id, task.lastRun!.id, {
+ executorDeviceId: scheduledTaskDeviceId,
+ status: "failed",
+ errorMessage: "The desktop app stopped before the scheduled task completed.",
+ }).catch(() => null))).then(() => {
+ void Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] }),
+ queryClient.invalidateQueries({ queryKey: ["scheduled-task-run-history"] }),
+ ]);
+ });
+ }, [queryClient, scheduledTaskDeviceId, scheduledTasksQuery.data?.tasks]);
+
+ useEffect(() => {
+ const bridge = window.edgeeverDesktop;
+ if (!bridge?.isAvailable || !scheduledTaskDeviceId || !pluginHostReady) return;
+ void bridge.syncScheduledTasks(runnableScheduledTasks).catch(() => {});
+ }, [pluginHostReady, runnableScheduledTasks, scheduledTaskDeviceId]);
+
+ useEffect(() => () => {
+ void window.edgeeverDesktop?.syncScheduledTasks([]).catch(() => {});
+ }, []);
+
+ useEffect(() => {
+ const bridge = window.edgeeverDesktop;
+ if (!bridge?.isAvailable || !scheduledTaskDeviceId || !pluginHostReady) return;
+ return bridge.onScheduledTask(async ({ task, scheduledFor }) => {
+ if (task.executorDeviceId !== scheduledTaskDeviceId || runningScheduledTaskIdsRef.current.has(task.id)) return;
+ runningScheduledTaskIdsRef.current.add(task.id);
+ let runId: string | null = null;
+ try {
+ const claimed = await api.claimScheduledTaskRun(task.id, {
+ scheduledFor,
+ executorDeviceId: scheduledTaskDeviceId,
+ });
+ runId = claimed.run.id;
+ if (task.taskType !== "plugin-command") throw new Error("Unsupported scheduled task type.");
+ await pluginHost.runCommand(task.taskPayload.pluginId, task.taskPayload.commandId);
+ await api.finishScheduledTaskRun(task.id, runId, {
+ executorDeviceId: scheduledTaskDeviceId,
+ status: "succeeded",
+ });
+ } catch (error) {
+ if (runId) {
+ await api.finishScheduledTaskRun(task.id, runId, {
+ executorDeviceId: scheduledTaskDeviceId,
+ status: "failed",
+ errorMessage: error instanceof Error ? error.message : String(error),
+ }).catch(() => {});
+ }
+ } finally {
+ runningScheduledTaskIdsRef.current.delete(task.id);
+ void Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] }),
+ queryClient.invalidateQueries({ queryKey: ["scheduled-task-run-history"] }),
+ ]);
+ }
+ });
+ }, [pluginHost, pluginHostReady, queryClient, scheduledTaskDeviceId]);
const [requestedPluginPanel, setRequestedPluginPanel] = useState<{
panel: RegisteredPluginPanel;
options?: PluginPanelOpenOptions;
@@ -747,13 +854,6 @@ export const WorkspaceApp = ({
const pluginNavigationRequestIdRef = useRef(0);
const [pluginNavigationRequest, setPluginNavigationRequest] = useState<{ id: number; noteId: string; search: string } | null>(null);
- useEffect(() => {
- void pluginHost.activateEnabled();
- return () => {
- void pluginHost.dispose();
- };
- }, [pluginHost]);
-
useEffect(() => pluginHost.setPanelAdapter({
openPanel(pluginId, panelId, options) {
const panel = pluginHost.getSnapshot().panels.find((candidate) =>
@@ -828,7 +928,7 @@ export const WorkspaceApp = ({
setShortcutSettings,
shortcutSettings,
} = useWorkspacePreferences();
- const [rightView, setRightView] = useState<"editor" | "settings" | "plugins" | "assets" | "tags" | "templates" | "ai-prompts" | "evernote-migration">(() =>
+ const [rightView, setRightView] = useState<"editor" | "settings" | "plugins" | "assets" | "tags" | "templates" | "ai-prompts" | "execution-center" | "evernote-migration">(() =>
isInitialSettingsRoute
? "settings"
: isInitialPluginsRoute
@@ -837,6 +937,8 @@ export const WorkspaceApp = ({
? "templates"
: isInitialAiPromptsRoute
? "ai-prompts"
+ : isInitialExecutionCenterRoute
+ ? "execution-center"
: "editor"
);
const [templatesOpen, setTemplatesOpen] = useState(false);
@@ -1197,6 +1299,14 @@ export const WorkspaceApp = ({
return;
}
+ if (route.isExecutionCenter) {
+ skipNextHomeRouteSyncRef.current = false;
+ setRightView("execution-center");
+ setMobileBottomNavActive("home");
+ setActivePane("editor");
+ return;
+ }
+
if (skipNextHomeRouteSyncRef.current) {
skipNextHomeRouteSyncRef.current = false;
return;
@@ -1205,7 +1315,7 @@ export const WorkspaceApp = ({
setMemoView(isTrashRoute ? "trash" : "notebook");
setRightView("editor");
setMobileBottomNavActive("home");
- }, [isTrashRoute, route.isSettings, route.isPlugins, route.isTemplates, route.isAiPrompts]);
+ }, [isTrashRoute, route.isSettings, route.isPlugins, route.isTemplates, route.isAiPrompts, route.isExecutionCenter]);
useEffect(() => {
if (window.edgeeverDesktop?.isAvailable) {
@@ -2411,6 +2521,23 @@ export const WorkspaceApp = ({
setActivePane("editor");
};
+ const handleOpenExecutionCenter = () => {
+ clearHiddenMobileSearch();
+ navigateWorkspaceExecutionCenter();
+ setRightView("execution-center");
+ setMobileBottomNavActive("home");
+ setActivePane("editor");
+ };
+
+ const handleCloseExecutionCenter = () => {
+ navigateWorkspaceHome();
+ setRightView("editor");
+ setMobileBottomNavActive("home");
+ if (!isDesktopViewport()) {
+ setActivePane("memos");
+ }
+ };
+
const handleCloseAssets = () => {
navigateWorkspaceHome();
setRightView("editor");
@@ -2575,6 +2702,11 @@ export const WorkspaceApp = ({
return true;
}
+ if (rightView === "execution-center") {
+ handleCloseExecutionCenter();
+ return true;
+ }
+
if (rightView === "tags") {
handleCloseAssets();
return true;
@@ -2611,6 +2743,7 @@ export const WorkspaceApp = ({
emptyTrashMutation.isPending,
handleCloseAssets,
handleCloseSettings,
+ handleCloseExecutionCenter,
handleCloseTemplates,
handleCancelMobileSearch,
memoDeleteConfirmation,
@@ -2883,6 +3016,8 @@ export const WorkspaceApp = ({
? t("templates.title")
: rightView === "ai-prompts"
? t("aiPrompts.title")
+ : rightView === "execution-center"
+ ? t("executionHistory.centerTitle")
: rightView === "evernote-migration"
? t("workspace.loading.migration")
: t("workspace.loading.editor");
@@ -3149,6 +3284,7 @@ export const WorkspaceApp = ({
{rightView === "settings" ? (
) : rightView === "plugins" ? (
-
+
) : rightView === "assets" ? (
-
+
) : rightView === "tags" ? (
-
+
) : rightView === "templates" ? (
{
await updateTemplateMutation.mutateAsync({ templateId, payload });
}}
+ onOpenExecutionCenter={handleOpenExecutionCenter}
/>
) : rightView === "ai-prompts" ? (
-
+
+ ) : rightView === "execution-center" ? (
+
) : rightView === "evernote-migration" ? (
- setRightView("settings")} />
+ setRightView("settings")} onOpenExecutionCenter={handleOpenExecutionCenter} />
) : rendererRecoveryMode ? (
) : (
@@ -3205,6 +3344,7 @@ export const WorkspaceApp = ({
}}
>
{
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+ {t("executionHistory.openCenter")}
+
+
+ );
+};
diff --git a/apps/web/src/components/execution/ExecutionCenterPane.tsx b/apps/web/src/components/execution/ExecutionCenterPane.tsx
new file mode 100644
index 00000000..1e731632
--- /dev/null
+++ b/apps/web/src/components/execution/ExecutionCenterPane.tsx
@@ -0,0 +1,138 @@
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { ChevronLeft, ChevronRight, Clock3, Loader2, ScrollText } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { Button } from "@/components/ui/button";
+import { api } from "@/lib/api";
+import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui";
+import { ExecutionHistoryItemCard, type ExecutionHistoryItem } from "./ExecutionHistoryItemCard";
+
+const PAGE_SIZE = 20;
+
+export const ExecutionCenterPane = ({
+ currentDeviceId,
+ onClose,
+}: {
+ currentDeviceId: string | null;
+ onClose(): void;
+}) => {
+ const { t } = useTranslation();
+ const [page, setPage] = useState(1);
+ const offset = (page - 1) * PAGE_SIZE;
+ const historyQuery = useQuery({
+ queryKey: ["scheduled-task-run-history", page],
+ queryFn: () => api.listScheduledTaskRunHistory(offset, PAGE_SIZE),
+ refetchInterval: 15_000,
+ });
+ const totalCount = historyQuery.data?.totalCount ?? 0;
+ const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
+ const items: ExecutionHistoryItem[] = (historyQuery.data?.runs ?? []).map((run) => ({
+ id: run.id,
+ title: run.taskName,
+ subtitle: t("executionHistory.pluginSchedule", {
+ plugin: run.ownerPluginId ?? run.pluginId,
+ key: run.pluginScheduleKey ?? run.taskId,
+ }),
+ status: run.status,
+ scheduledFor: run.scheduledFor,
+ startedAt: run.startedAt,
+ finishedAt: run.finishedAt,
+ executorId: run.executorDeviceId,
+ errorMessage: run.errorMessage,
+ }));
+ const rangeStart = totalCount === 0 ? 0 : offset + 1;
+ const rangeEnd = Math.min(offset + items.length, totalCount);
+
+ return (
+
+
+
+
+
+
+
+ {t("executionHistory.centerTitle")}
+
+
+ {t("executionHistory.centerDescription")}
+
+
+
+
+ {t("executionHistory.total", { count: totalCount })}
+
+
+
+
+
+ {historyQuery.isPending ? (
+
+
+
+ ) : historyQuery.isError ? (
+
+
{historyQuery.error instanceof Error ? historyQuery.error.message : t("executionHistory.loadFailed")}
+
+
+ ) : items.length === 0 ? (
+
+
+
{t("executionHistory.empty")}
+
+ ) : (
+
+ {items.map((item) => (
+
+ ))}
+
+ )}
+
+ {!historyQuery.isPending && !historyQuery.isError && totalCount > 0 ? (
+
+ ) : null}
+
+
+
+ );
+};
diff --git a/apps/web/src/components/execution/ExecutionHistoryDialog.tsx b/apps/web/src/components/execution/ExecutionHistoryDialog.tsx
new file mode 100644
index 00000000..37a80897
--- /dev/null
+++ b/apps/web/src/components/execution/ExecutionHistoryDialog.tsx
@@ -0,0 +1,101 @@
+import { useMemo } from "react";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { Clock3, Loader2 } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { ExecutionHistoryItemCard, type ExecutionHistoryItem } from "./ExecutionHistoryItemCard";
+
+export interface ExecutionHistoryPage {
+ items: ExecutionHistoryItem[];
+ totalCount: number;
+ nextOffset: number | null;
+}
+
+interface ExecutionHistoryDialogProps {
+ open: boolean;
+ onOpenChange(open: boolean): void;
+ title: string;
+ description: string;
+ queryKey: readonly unknown[];
+ loadPage(offset: number, limit: number): Promise;
+ currentExecutorId?: string | null;
+ refetchInterval?: number | false;
+}
+
+const PAGE_SIZE = 30;
+
+export const ExecutionHistoryDialog = ({
+ open,
+ onOpenChange,
+ title,
+ description,
+ queryKey,
+ loadPage,
+ currentExecutorId,
+ refetchInterval = false,
+}: ExecutionHistoryDialogProps) => {
+ const { t } = useTranslation();
+ const historyQuery = useInfiniteQuery({
+ queryKey,
+ queryFn: ({ pageParam }) => loadPage(pageParam, PAGE_SIZE),
+ initialPageParam: 0,
+ getNextPageParam: (page) => page.nextOffset ?? undefined,
+ enabled: open,
+ refetchInterval: open ? refetchInterval : false,
+ });
+ const items = useMemo(
+ () => historyQuery.data?.pages.flatMap((page) => page.items) ?? [],
+ [historyQuery.data?.pages],
+ );
+ const totalCount = historyQuery.data?.pages[0]?.totalCount ?? 0;
+
+ return (
+
+ );
+};
diff --git a/apps/web/src/components/execution/ExecutionHistoryItemCard.tsx b/apps/web/src/components/execution/ExecutionHistoryItemCard.tsx
new file mode 100644
index 00000000..534a3d5e
--- /dev/null
+++ b/apps/web/src/components/execution/ExecutionHistoryItemCard.tsx
@@ -0,0 +1,90 @@
+import { AlertCircle, CheckCircle2, Clock3, Loader2 } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+export type ExecutionHistoryStatus = "running" | "succeeded" | "failed";
+
+export interface ExecutionHistoryItem {
+ id: string;
+ title?: string;
+ subtitle?: string;
+ status: ExecutionHistoryStatus;
+ scheduledFor: string;
+ startedAt: string;
+ finishedAt: string | null;
+ executorId: string;
+ errorMessage: string | null;
+}
+
+const formatDuration = (startedAt: string, finishedAt: string | null, runningLabel: string) => {
+ if (!finishedAt) return runningLabel;
+ const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
+ if (!Number.isFinite(durationMs) || durationMs < 0) return "—";
+ if (durationMs < 1_000) return `${durationMs} ms`;
+ if (durationMs < 60_000) return `${(durationMs / 1_000).toFixed(durationMs < 10_000 ? 1 : 0)} s`;
+ const minutes = Math.floor(durationMs / 60_000);
+ const seconds = Math.floor((durationMs % 60_000) / 1_000);
+ return `${minutes}m ${seconds}s`;
+};
+
+const statusIcon = {
+ running: Loader2,
+ succeeded: CheckCircle2,
+ failed: AlertCircle,
+} satisfies Record;
+
+const statusClassName: Record = {
+ running: "bg-amber-50 text-amber-700",
+ succeeded: "bg-emerald-50 text-emerald-700",
+ failed: "bg-rose-50 text-rose-700",
+};
+
+export const ExecutionHistoryItemCard = ({
+ item,
+ currentExecutorId,
+}: {
+ item: ExecutionHistoryItem;
+ currentExecutorId?: string | null;
+}) => {
+ const { t } = useTranslation();
+ const StatusIcon = statusIcon[item.status];
+ const executorLabel = currentExecutorId && item.executorId === currentExecutorId
+ ? t("executionHistory.currentDevice")
+ : t("executionHistory.device", { id: item.executorId.slice(-8) });
+
+ return (
+
+
+
+ {item.title ?
{item.title}
: null}
+ {item.subtitle ?
{item.subtitle}
: null}
+
+
+ {t(`executionHistory.status.${item.status}`)}
+
+
+
{executorLabel}
+
+
+
+
- {t("executionHistory.scheduledFor")}
+ - {new Date(item.scheduledFor).toLocaleString()}
+
+
+
- {t("executionHistory.startedAt")}
+ - {new Date(item.startedAt).toLocaleString()}
+
+
+
- {t("executionHistory.duration")}
+ -
+ {formatDuration(item.startedAt, item.finishedAt, t("executionHistory.running"))}
+
+
+
+ {item.errorMessage ? (
+
+ {item.errorMessage}
+
+ ) : null}
+
+ );
+};
diff --git a/apps/web/src/components/execution/ScheduledTaskRunHistoryDialog.tsx b/apps/web/src/components/execution/ScheduledTaskRunHistoryDialog.tsx
new file mode 100644
index 00000000..acd1b775
--- /dev/null
+++ b/apps/web/src/components/execution/ScheduledTaskRunHistoryDialog.tsx
@@ -0,0 +1,51 @@
+import { useCallback } from "react";
+import { useTranslation } from "react-i18next";
+import type { ScheduledTask } from "@edgeever/shared";
+import { api } from "@/lib/api";
+import { ExecutionHistoryDialog, type ExecutionHistoryPage } from "./ExecutionHistoryDialog";
+
+interface ScheduledTaskRunHistoryDialogProps {
+ task: ScheduledTask | null;
+ currentDeviceId: string | null;
+ onOpenChange(open: boolean): void;
+}
+
+export const ScheduledTaskRunHistoryDialog = ({
+ task,
+ currentDeviceId,
+ onOpenChange,
+}: ScheduledTaskRunHistoryDialogProps) => {
+ const { t } = useTranslation();
+ const taskId = task?.id ?? null;
+ const loadPage = useCallback(async (offset: number, limit: number): Promise => {
+ if (!taskId) return { items: [], totalCount: 0, nextOffset: null };
+ const page = await api.listScheduledTaskRuns(taskId, offset, limit);
+ return {
+ items: page.runs.map((run) => ({
+ id: run.id,
+ status: run.status,
+ scheduledFor: run.scheduledFor,
+ startedAt: run.startedAt,
+ finishedAt: run.finishedAt,
+ executorId: run.executorDeviceId,
+ errorMessage: run.errorMessage,
+ })),
+ totalCount: page.totalCount,
+ nextOffset: page.nextOffset,
+ };
+ }, [taskId]);
+
+ return (
+
+ );
+};
diff --git a/apps/web/src/components/settings/PluginManagerCard.tsx b/apps/web/src/components/settings/PluginManagerCard.tsx
index b9938e21..a17200cc 100644
--- a/apps/web/src/components/settings/PluginManagerCard.tsx
+++ b/apps/web/src/components/settings/PluginManagerCard.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState, useSyncExternalStore } from "react";
-import { BadgeCheck, BookOpen, Download, ExternalLink, PanelRightOpen, Play, Puzzle, RefreshCw, Trash2 } from "lucide-react";
+import { BadgeCheck, BookOpen, CalendarClock, Download, ExternalLink, History, PanelRightOpen, Play, Puzzle, RefreshCw, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
@@ -13,9 +13,216 @@ import { GitHubMark } from "@/components/GitHubRepositoryLink";
import { checkPluginUpdates, type PluginUpdateInfo } from "@/lib/plugins/plugin-updates";
import { PluginUpdateDialog } from "@/components/plugins/PluginUpdateDialog";
import type { PluginManifest, PluginSettingValue } from "@edgeever/plugin-api";
+import type { ScheduledTask } from "@edgeever/shared";
+import { Cron } from "croner";
+import { api, getOrCreateClientDeviceId } from "@/lib/api";
+import { ScheduledTaskRunHistoryDialog } from "@/components/execution/ScheduledTaskRunHistoryDialog";
const permissionLabel = (permission: string) => permission.replace(":", " · ");
+const commandKey = (command: RegisteredPluginCommand) => JSON.stringify([command.pluginId, command.id]);
+
+const ScheduledPluginTasksSection = ({ commands }: { commands: RegisteredPluginCommand[] }) => {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const deviceId = window.edgeeverDesktop?.isAvailable ? getOrCreateClientDeviceId() : null;
+ const [selectedCommandKey, setSelectedCommandKey] = useState("");
+ const [name, setName] = useState("");
+ const [cronExpression, setCronExpression] = useState("0 9 * * *");
+ const [timezone, setTimezone] = useState(() => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC");
+ const [missedRunPolicy, setMissedRunPolicy] = useState<"run-once" | "skip">("run-once");
+ const [pending, setPending] = useState(false);
+ const [message, setMessage] = useState(null);
+ const [historyTask, setHistoryTask] = useState(null);
+ const tasksQuery = useQuery({
+ queryKey: ["scheduled-tasks", deviceId],
+ queryFn: () => api.listScheduledTasks(deviceId ?? undefined),
+ enabled: Boolean(deviceId),
+ refetchInterval: 60_000,
+ });
+
+ if (!deviceId) return null;
+
+ const selectedCommand = commands.find((command) => commandKey(command) === selectedCommandKey);
+ const refresh = async () => {
+ await queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
+ };
+ const updateTask = async (taskId: string, isEnabled: boolean) => {
+ setMessage(null);
+ try {
+ await api.updateScheduledTask(taskId, { isEnabled });
+ await refresh();
+ } catch (error) {
+ setMessage(error instanceof Error ? error.message : String(error));
+ }
+ };
+ const deleteTask = async (taskId: string) => {
+ setMessage(null);
+ try {
+ await api.deleteScheduledTask(taskId);
+ await refresh();
+ } catch (error) {
+ setMessage(error instanceof Error ? error.message : String(error));
+ }
+ };
+ const createTask = async () => {
+ if (!selectedCommand) return;
+ setPending(true);
+ setMessage(null);
+ let validator: Cron | null = null;
+ try {
+ validator = new Cron(cronExpression.trim(), { timezone: timezone.trim(), paused: true });
+ if (!validator.nextRun()) throw new Error(t("plugins.schedules.noNextRun"));
+ await api.createScheduledTask({
+ name: name.trim() || selectedCommand.title,
+ taskType: "plugin-command",
+ taskPayload: { pluginId: selectedCommand.pluginId, commandId: selectedCommand.id },
+ cronExpression: cronExpression.trim(),
+ timezone: timezone.trim(),
+ executorDeviceId: deviceId,
+ missedRunPolicy,
+ });
+ setName("");
+ setMessage(t("plugins.schedules.created"));
+ await refresh();
+ } catch (error) {
+ setMessage(error instanceof Error ? error.message : String(error));
+ } finally {
+ validator?.stop();
+ setPending(false);
+ }
+ };
+
+ const tasks = tasksQuery.data?.tasks ?? [];
+ return (
+
+
+
+
+
{t("plugins.schedules.title")}
+
{t("plugins.schedules.description")}
+
+
+
+
+ {message ? {message}
: null}
+
+ {tasks.length > 0 ? (
+
+ {tasks.map((task) => {
+ const command = commands.find((candidate) =>
+ candidate.pluginId === task.taskPayload.pluginId && candidate.id === task.taskPayload.commandId);
+ return (
+
+
+
{task.name}
+
+ {task.cronExpression} · {task.timezone} · {command?.title ?? `${task.taskPayload.pluginId}:${task.taskPayload.commandId}`}
+
+ {task.ownerPluginId && task.pluginScheduleKey ? (
+
+ {t("plugins.schedules.pluginManaged", {
+ plugin: task.ownerPluginId,
+ key: task.pluginScheduleKey,
+ })}
+
+ ) : null}
+ {task.lastRun ? (
+
+ {t(`plugins.schedules.status.${task.lastRun.status}`)} · {new Date(task.lastRun.startedAt).toLocaleString()}
+
+ ) : null}
+
+
void updateTask(task.id, isEnabled)}
+ />
+
+
+
+ );
+ })}
+
+ ) : (
+ {t("plugins.schedules.empty")}
+ )}
+ {
+ if (!open) setHistoryTask(null);
+ }}
+ />
+
+ );
+};
+
const PluginSettingsSection = ({ host, manifest }: { host: EdgeEverPluginHost; manifest: PluginManifest }) => {
const { t } = useTranslation();
const fields = manifest.settings?.fields ?? [];
@@ -424,6 +631,8 @@ export const PluginManagerCard = ({
{error ? {error}
: null}
+
+
{selectedPluginId ? (
selectedExtension ? (
{
isPlugins: false,
isTemplates: false,
isAiPrompts: false,
+ isExecutionCenter: false,
});
expect(resolveWorkspaceRoute("/", "?view=trash").isTrash).toBe(true);
expect(resolveWorkspaceRoute("/settings", "").isSettings).toBe(true);
@@ -16,6 +17,7 @@ describe("workspace route resolution", () => {
expect(resolveWorkspaceRoute("/plugins/org.edgeever.example", "").isPlugins).toBe(true);
expect(resolveWorkspaceRoute("/templates", "").isTemplates).toBe(true);
expect(resolveWorkspaceRoute("/ai-prompts", "").isAiPrompts).toBe(true);
+ expect(resolveWorkspaceRoute("/execution-center", "").isExecutionCenter).toBe(true);
});
test("only treats the canonical trash query as trash", () => {
diff --git a/apps/web/src/hooks/useWorkspaceRoute.ts b/apps/web/src/hooks/useWorkspaceRoute.ts
index 84504ecb..f62ffab4 100644
--- a/apps/web/src/hooks/useWorkspaceRoute.ts
+++ b/apps/web/src/hooks/useWorkspaceRoute.ts
@@ -6,6 +6,7 @@ export const WORKSPACE_SETTINGS_PATH = "/settings";
export const WORKSPACE_PLUGINS_PATH = "/plugins";
export const WORKSPACE_TEMPLATES_PATH = "/templates";
export const WORKSPACE_AI_PROMPTS_PATH = "/ai-prompts";
+export const WORKSPACE_EXECUTION_CENTER_PATH = "/execution-center";
export const WORKSPACE_TRASH_SEARCH = "?view=trash";
export type WorkspaceRouteState = {
@@ -15,6 +16,7 @@ export type WorkspaceRouteState = {
isPlugins: boolean;
isTemplates: boolean;
isAiPrompts: boolean;
+ isExecutionCenter: boolean;
isTrash: boolean;
mobileEditorReturnMemoId: string | null;
};
@@ -26,6 +28,7 @@ export const resolveWorkspaceRoute = (pathname: string, search: string): Workspa
isPlugins: pathname === WORKSPACE_PLUGINS_PATH || pathname.startsWith(`${WORKSPACE_PLUGINS_PATH}/`),
isTemplates: pathname === WORKSPACE_TEMPLATES_PATH,
isAiPrompts: pathname === WORKSPACE_AI_PROMPTS_PATH,
+ isExecutionCenter: pathname === WORKSPACE_EXECUTION_CENTER_PATH,
isTrash: pathname === "/" && search === WORKSPACE_TRASH_SEARCH,
mobileEditorReturnMemoId: new URLSearchParams(search).get(MOBILE_EDITOR_RETURN_PARAM),
});
@@ -62,6 +65,10 @@ export const useWorkspaceRoute = () => {
if (!route.isAiPrompts) navigate(WORKSPACE_AI_PROMPTS_PATH);
}, [navigate, route.isAiPrompts]);
+ const navigateExecutionCenter = useCallback(() => {
+ if (!route.isExecutionCenter) navigate(WORKSPACE_EXECUTION_CENTER_PATH);
+ }, [navigate, route.isExecutionCenter]);
+
return {
route,
navigateHome,
@@ -69,6 +76,7 @@ export const useWorkspaceRoute = () => {
navigatePlugins,
navigateTemplates,
navigateAiPrompts,
+ navigateExecutionCenter,
navigateTrash,
};
};
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 2bebb53a..fb8c7e05 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -143,7 +143,7 @@ export const saveDesktopApiBaseUrl = async (value: string) => {
const createWebDeviceId = () => `web-${createClientUuid()}`;
-const getOrCreateWebDeviceId = () => {
+export const getOrCreateClientDeviceId = () => {
try {
const existing = window.localStorage.getItem(WEB_DEVICE_ID_STORAGE_KEY);
if (existing) return existing;
@@ -258,7 +258,7 @@ export const api = {
},
login: async (payload: { username: string; password: string }) => {
- const session = await client.login({ ...payload, deviceId: getOrCreateWebDeviceId() });
+ const session = await client.login({ ...payload, deviceId: getOrCreateClientDeviceId() });
desktopSessionRejected = false;
return session;
},
diff --git a/apps/web/src/lib/plugins/plugin-host-schedules.fixture.mjs b/apps/web/src/lib/plugins/plugin-host-schedules.fixture.mjs
new file mode 100644
index 00000000..ee4a7368
--- /dev/null
+++ b/apps/web/src/lib/plugins/plugin-host-schedules.fixture.mjs
@@ -0,0 +1,11 @@
+export default {
+ async activate(context) {
+ context.commands.register({ id: "refresh", title: "Refresh", run() {} });
+ await context.schedules.upsert({
+ key: "hourly-refresh",
+ name: "Hourly refresh",
+ commandId: "refresh",
+ cronExpression: "0 * * * *",
+ });
+ },
+};
diff --git a/apps/web/src/lib/plugins/plugin-host.test.mjs b/apps/web/src/lib/plugins/plugin-host.test.mjs
index 4688e450..8aaf158a 100644
--- a/apps/web/src/lib/plugins/plugin-host.test.mjs
+++ b/apps/web/src/lib/plugins/plugin-host.test.mjs
@@ -53,6 +53,47 @@ beforeEach(() => {
});
describe("EdgeEverPluginHost", () => {
+ test("lets a permitted plugin idempotently own schedules for its registered commands", async () => {
+ const calls = [];
+ const scheduleAdapter = {
+ upsert: async (pluginId, input) => {
+ calls.push({ pluginId, input });
+ return {
+ ...input,
+ timezone: input.timezone ?? "UTC",
+ missedRunPolicy: input.missedRunPolicy ?? "run-once",
+ isEnabled: input.isEnabled ?? true,
+ runsOnThisDevice: true,
+ lastRun: null,
+ };
+ },
+ list: async () => [],
+ remove: async () => undefined,
+ };
+ const host = new EdgeEverPluginHost({ repository, scope: "test", scheduleAdapter });
+ host.installManifest({
+ type: "plugin",
+ id: "org.edgeever.schedule-test",
+ name: "Schedule Test",
+ version: "1.0.0",
+ apiVersion: "1",
+ entry: new URL("./plugin-host-schedules.fixture.mjs", import.meta.url).href,
+ permissions: ["ui:commands", "schedules"],
+ }, "https://example.com/schedule-plugin/manifest.json");
+
+ await host.setEnabled("org.edgeever.schedule-test", true);
+ expect(calls).toEqual([{
+ pluginId: "org.edgeever.schedule-test",
+ input: {
+ key: "hourly-refresh",
+ name: "Hourly refresh",
+ commandId: "refresh",
+ cronExpression: "0 * * * *",
+ },
+ }]);
+ await host.dispose();
+ });
+
test("applies a validated code-free theme", async () => {
const host = new EdgeEverPluginHost({ repository, scope: "test" });
host.installManifest({
diff --git a/apps/web/src/lib/plugins/plugin-host.ts b/apps/web/src/lib/plugins/plugin-host.ts
index 1a070caf..7cb4f052 100644
--- a/apps/web/src/lib/plugins/plugin-host.ts
+++ b/apps/web/src/lib/plugins/plugin-host.ts
@@ -21,6 +21,8 @@ import {
type PluginPermission,
type PluginApiErrorCode,
type PluginResource,
+ type PluginSchedule,
+ type PluginScheduleInput,
type PluginSettingField,
type PluginSettingValue,
type PluginEditorSelection,
@@ -158,6 +160,12 @@ export interface PluginPanelAdapter {
openPanel(pluginId: string, panelId: string, options?: PluginPanelOpenOptions): void | Promise;
}
+export interface PluginScheduleAdapter {
+ upsert(pluginId: string, input: PluginScheduleInput): Promise;
+ list(pluginId: string): Promise;
+ remove(pluginId: string, key: string): Promise;
+}
+
export interface PluginHostSnapshot {
extensions: InstalledExtension[];
commands: RegisteredPluginCommand[];
@@ -174,6 +182,7 @@ interface PluginHostOptions {
onNotice?: (message: string) => void;
secretStorage?: PluginSecretStorage;
packageStorage?: PluginPackageStorage;
+ scheduleAdapter?: PluginScheduleAdapter;
}
interface ActivePlugin {
@@ -407,6 +416,7 @@ export class EdgeEverPluginHost {
private readonly onNotice?: (message: string) => void;
private readonly secretStorage: PluginSecretStorage;
private readonly packageStorage: PluginPackageStorage;
+ private readonly scheduleAdapter?: PluginScheduleAdapter;
private readonly listeners = new Set<() => void>();
private readonly activePlugins = new Map();
private readonly commands = new Map();
@@ -433,6 +443,7 @@ export class EdgeEverPluginHost {
this.onNotice = options.onNotice;
this.secretStorage = options.secretStorage ?? new WebPluginSecretStore();
this.packageStorage = options.packageStorage ?? new WebPluginPackageStore();
+ this.scheduleAdapter = options.scheduleAdapter;
this.recentActions = this.readRecentActions();
this.refreshSnapshot();
}
@@ -1128,6 +1139,32 @@ export class EdgeEverPluginHost {
return dispose;
},
},
+ schedules: {
+ upsert: async (input) => {
+ assertPermission(manifest, "schedules");
+ if (!this.scheduleAdapter) throw new Error("Plugin schedules are only available in the EdgeEver desktop app.");
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(input.key) || input.key.length > 120) {
+ throw new Error("Plugin schedule key is invalid.");
+ }
+ if (!this.commands.has(`${manifest.id}:${input.commandId}`)) {
+ throw new Error(`Plugin command must be registered before it can be scheduled: ${input.commandId}`);
+ }
+ return this.scheduleAdapter.upsert(manifest.id, input);
+ },
+ list: async () => {
+ assertPermission(manifest, "schedules");
+ if (!this.scheduleAdapter) throw new Error("Plugin schedules are only available in the EdgeEver desktop app.");
+ return this.scheduleAdapter.list(manifest.id);
+ },
+ remove: async (key) => {
+ assertPermission(manifest, "schedules");
+ if (!this.scheduleAdapter) throw new Error("Plugin schedules are only available in the EdgeEver desktop app.");
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(key) || key.length > 120) {
+ throw new Error("Plugin schedule key is invalid.");
+ }
+ await this.scheduleAdapter.remove(manifest.id, key);
+ },
+ },
events: {
on: (event, listener) => {
const permission = EVENT_PERMISSIONS[event];
diff --git a/apps/web/src/lib/plugins/plugin-schedule-adapter.ts b/apps/web/src/lib/plugins/plugin-schedule-adapter.ts
new file mode 100644
index 00000000..591d8a00
--- /dev/null
+++ b/apps/web/src/lib/plugins/plugin-schedule-adapter.ts
@@ -0,0 +1,51 @@
+import type { PluginSchedule, PluginScheduleInput } from "@edgeever/plugin-api";
+import type { ScheduledTask } from "@edgeever/shared";
+import { api } from "@/lib/api";
+import type { PluginScheduleAdapter } from "@/lib/plugins/plugin-host";
+
+const toPluginSchedule = (task: ScheduledTask, deviceId: string): PluginSchedule => ({
+ key: task.pluginScheduleKey ?? "",
+ name: task.name,
+ commandId: task.taskPayload.commandId,
+ cronExpression: task.cronExpression,
+ timezone: task.timezone,
+ missedRunPolicy: task.missedRunPolicy,
+ isEnabled: task.isEnabled,
+ runsOnThisDevice: task.executorDeviceId === deviceId,
+ lastRun: task.lastRun ? {
+ status: task.lastRun.status,
+ scheduledFor: task.lastRun.scheduledFor,
+ startedAt: task.lastRun.startedAt,
+ finishedAt: task.lastRun.finishedAt,
+ errorMessage: task.lastRun.errorMessage,
+ } : null,
+});
+
+export const createPluginScheduleAdapter = (
+ deviceId: string,
+ onChanged: () => void | Promise,
+): PluginScheduleAdapter => ({
+ async upsert(pluginId: string, input: PluginScheduleInput) {
+ const { task } = await api.upsertPluginScheduledTask({
+ pluginId,
+ scheduleKey: input.key,
+ name: input.name,
+ commandId: input.commandId,
+ cronExpression: input.cronExpression,
+ timezone: input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC",
+ executorDeviceId: deviceId,
+ missedRunPolicy: input.missedRunPolicy ?? "run-once",
+ isEnabled: input.isEnabled,
+ });
+ await onChanged();
+ return toPluginSchedule(task, deviceId);
+ },
+ async list(pluginId: string) {
+ const { tasks } = await api.listPluginScheduledTasks(pluginId);
+ return tasks.map((task) => toPluginSchedule(task, deviceId));
+ },
+ async remove(pluginId: string, key: string) {
+ await api.deletePluginScheduledTask(pluginId, key);
+ await onChanged();
+ },
+});
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
index 2d24419b..4c6fe4ae 100644
--- a/apps/web/src/vite-env.d.ts
+++ b/apps/web/src/vite-env.d.ts
@@ -60,6 +60,11 @@ interface EdgeEverDesktopBridge {
readResource(id: string): Promise<{ type: string; bytes: Uint8Array }>;
removeStagedResource(id: string): Promise;
onCommand(callback: (command: string) => void): () => void;
+ syncScheduledTasks(tasks: import("@edgeever/shared").ScheduledTask[]): Promise<{ scheduled: number }>;
+ onScheduledTask(callback: (payload: {
+ task: import("@edgeever/shared").ScheduledTask;
+ scheduledFor: string;
+ }) => void | Promise): () => void;
onImportMarkdown(callback: (payload: { name: string; content: string }) => void): () => void;
}
diff --git a/bun.lock b/bun.lock
index 96488ed7..c88ca0bd 100644
--- a/bun.lock
+++ b/bun.lock
@@ -36,6 +36,7 @@
"class-variance-authority": "latest",
"clsx": "latest",
"cmdk": "^1.1.1",
+ "croner": "^10.0.1",
"dexie": "latest",
"fast-xml-parser": "^5.9.3",
"fflate": "^0.8.3",
@@ -90,8 +91,9 @@
},
"apps/desktop": {
"name": "@edgeever/desktop",
- "version": "1.52.0",
+ "version": "1.53.0",
"dependencies": {
+ "croner": "^10.0.1",
"electron-updater": "^6.6.2",
},
"devDependencies": {
@@ -1954,6 +1956,8 @@
"crelt": ["crelt@1.0.7", "", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="],
+ "croner": ["croner@10.0.1", "", {}, "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g=="],
+
"cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="],
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
diff --git a/docs/openapi.json b/docs/openapi.json
index f02fc16b..b0f3b069 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -673,6 +673,140 @@
}
}
},
+ "/api/v1/scheduled-tasks": {
+ "get": {
+ "summary": "List scheduled plugin commands",
+ "parameters": [{ "name": "executorDeviceId", "in": "query", "schema": { "type": "string" } }],
+ "responses": {
+ "200": {
+ "description": "Scheduled task list",
+ "content": { "application/json": { "schema": {
+ "type": "object",
+ "properties": { "tasks": { "type": "array", "items": { "$ref": "#/components/schemas/ScheduledTask" } } },
+ "required": ["tasks"]
+ } } }
+ },
+ "403": { "$ref": "#/components/responses/Error" }
+ }
+ },
+ "post": {
+ "summary": "Create a device-bound scheduled plugin command",
+ "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScheduledTaskCreateInput" } } } },
+ "responses": {
+ "201": { "description": "Created scheduled task", "content": { "application/json": { "schema": { "type": "object", "properties": { "task": { "$ref": "#/components/schemas/ScheduledTask" } }, "required": ["task"] } } } },
+ "400": { "$ref": "#/components/responses/Error" },
+ "403": { "$ref": "#/components/responses/Error" }
+ }
+ }
+ },
+ "/api/v1/scheduled-task-runs": {
+ "get": {
+ "summary": "List system-wide scheduled task execution history",
+ "parameters": [
+ { "name": "offset", "in": "query", "schema": { "type": "integer", "minimum": 0, "default": 0 } },
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 } }
+ ],
+ "responses": {
+ "200": {
+ "description": "Workspace scheduled task execution history",
+ "content": { "application/json": { "schema": {
+ "type": "object",
+ "properties": {
+ "runs": { "type": "array", "items": { "$ref": "#/components/schemas/ScheduledTaskRunHistoryItem" } },
+ "totalCount": { "type": "integer" },
+ "nextOffset": { "type": ["integer", "null"] }
+ },
+ "required": ["runs", "totalCount", "nextOffset"]
+ } } }
+ },
+ "403": { "$ref": "#/components/responses/Error" }
+ }
+ }
+ },
+ "/api/v1/scheduled-tasks/{id}": {
+ "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
+ "patch": {
+ "summary": "Update a scheduled plugin command",
+ "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScheduledTaskUpdateInput" } } } },
+ "responses": { "200": { "description": "Updated scheduled task" }, "404": { "$ref": "#/components/responses/Error" } }
+ },
+ "delete": {
+ "summary": "Delete a scheduled plugin command",
+ "responses": { "200": { "$ref": "#/components/responses/Ok" }, "404": { "$ref": "#/components/responses/Error" } }
+ }
+ },
+ "/api/v1/scheduled-tasks/plugin-upsert": {
+ "post": {
+ "summary": "Idempotently create or update a plugin-owned schedule",
+ "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PluginScheduleUpsertInput" } } } },
+ "responses": {
+ "200": { "description": "Plugin-owned scheduled task", "content": { "application/json": { "schema": { "type": "object", "properties": { "task": { "$ref": "#/components/schemas/ScheduledTask" } }, "required": ["task"] } } } },
+ "400": { "$ref": "#/components/responses/Error" },
+ "403": { "$ref": "#/components/responses/Error" }
+ }
+ }
+ },
+ "/api/v1/scheduled-tasks/plugin/{pluginId}": {
+ "parameters": [{ "name": "pluginId", "in": "path", "required": true, "schema": { "type": "string" } }],
+ "get": {
+ "summary": "List schedules owned by one plugin",
+ "responses": { "200": { "description": "Plugin schedule list" }, "403": { "$ref": "#/components/responses/Error" } }
+ }
+ },
+ "/api/v1/scheduled-tasks/plugin/{pluginId}/{scheduleKey}": {
+ "parameters": [
+ { "name": "pluginId", "in": "path", "required": true, "schema": { "type": "string" } },
+ { "name": "scheduleKey", "in": "path", "required": true, "schema": { "type": "string" } }
+ ],
+ "delete": {
+ "summary": "Delete a schedule owned by one plugin",
+ "responses": { "200": { "$ref": "#/components/responses/Ok" }, "404": { "$ref": "#/components/responses/Error" } }
+ }
+ },
+ "/api/v1/scheduled-tasks/{id}/runs/claim": {
+ "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
+ "post": {
+ "summary": "Atomically claim one scheduled occurrence",
+ "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScheduledTaskClaimInput" } } } },
+ "responses": { "201": { "description": "Claimed task run" }, "409": { "$ref": "#/components/responses/Error" } }
+ }
+ },
+ "/api/v1/scheduled-tasks/{id}/runs": {
+ "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
+ "get": {
+ "summary": "List execution history for a scheduled task",
+ "parameters": [
+ { "name": "offset", "in": "query", "schema": { "type": "integer", "minimum": 0, "default": 0 } },
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 } }
+ ],
+ "responses": {
+ "200": {
+ "description": "Scheduled task execution history",
+ "content": { "application/json": { "schema": {
+ "type": "object",
+ "properties": {
+ "runs": { "type": "array", "items": { "$ref": "#/components/schemas/ScheduledTaskRun" } },
+ "totalCount": { "type": "integer" },
+ "nextOffset": { "type": ["integer", "null"] }
+ },
+ "required": ["runs", "totalCount", "nextOffset"]
+ } } }
+ },
+ "404": { "$ref": "#/components/responses/Error" }
+ }
+ }
+ },
+ "/api/v1/scheduled-tasks/{taskId}/runs/{runId}/finish": {
+ "parameters": [
+ { "name": "taskId", "in": "path", "required": true, "schema": { "type": "string" } },
+ { "name": "runId", "in": "path", "required": true, "schema": { "type": "string" } }
+ ],
+ "post": {
+ "summary": "Finish a scheduled task run",
+ "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScheduledTaskFinishInput" } } } },
+ "responses": { "200": { "description": "Finished task run" }, "409": { "$ref": "#/components/responses/Error" } }
+ }
+ },
"/api/v1/tags": {
"get": {
"summary": "List tag summaries",
@@ -1944,6 +2078,115 @@
},
"required": ["ok"]
},
+ "ScheduledTaskRun": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "taskId": { "type": "string" },
+ "scheduledFor": { "type": "string", "format": "date-time" },
+ "executorDeviceId": { "type": "string" },
+ "status": { "type": "string", "enum": ["running", "succeeded", "failed"] },
+ "errorMessage": { "type": ["string", "null"] },
+ "startedAt": { "type": "string", "format": "date-time" },
+ "finishedAt": { "type": ["string", "null"], "format": "date-time" }
+ },
+ "required": ["id", "taskId", "scheduledFor", "executorDeviceId", "status", "errorMessage", "startedAt", "finishedAt"]
+ },
+ "ScheduledTaskRunHistoryItem": {
+ "allOf": [
+ { "$ref": "#/components/schemas/ScheduledTaskRun" },
+ {
+ "type": "object",
+ "properties": {
+ "taskName": { "type": "string" },
+ "pluginId": { "type": "string" },
+ "ownerPluginId": { "type": ["string", "null"] },
+ "pluginScheduleKey": { "type": ["string", "null"] }
+ },
+ "required": ["taskName", "pluginId", "ownerPluginId", "pluginScheduleKey"]
+ }
+ ]
+ },
+ "ScheduledTask": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "name": { "type": "string" },
+ "taskType": { "type": "string", "const": "plugin-command" },
+ "taskPayload": { "$ref": "#/components/schemas/ScheduledTaskPayload" },
+ "ownerPluginId": { "type": ["string", "null"] },
+ "pluginScheduleKey": { "type": ["string", "null"] },
+ "cronExpression": { "type": "string" },
+ "timezone": { "type": "string" },
+ "executorDeviceId": { "type": "string" },
+ "missedRunPolicy": { "type": "string", "enum": ["run-once", "skip"] },
+ "isEnabled": { "type": "boolean" },
+ "createdAt": { "type": "string", "format": "date-time" },
+ "updatedAt": { "type": "string", "format": "date-time" },
+ "lastRun": { "oneOf": [{ "$ref": "#/components/schemas/ScheduledTaskRun" }, { "type": "null" }] }
+ },
+ "required": ["id", "name", "taskType", "taskPayload", "ownerPluginId", "pluginScheduleKey", "cronExpression", "timezone", "executorDeviceId", "missedRunPolicy", "isEnabled", "createdAt", "updatedAt", "lastRun"]
+ },
+ "ScheduledTaskPayload": {
+ "type": "object",
+ "properties": { "pluginId": { "type": "string" }, "commandId": { "type": "string" } },
+ "required": ["pluginId", "commandId"]
+ },
+ "ScheduledTaskCreateInput": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string", "maxLength": 120 },
+ "taskType": { "type": "string", "const": "plugin-command" },
+ "taskPayload": { "$ref": "#/components/schemas/ScheduledTaskPayload" },
+ "cronExpression": { "type": "string", "maxLength": 160 },
+ "timezone": { "type": "string", "maxLength": 80 },
+ "executorDeviceId": { "type": "string" },
+ "missedRunPolicy": { "type": "string", "enum": ["run-once", "skip"], "default": "run-once" },
+ "isEnabled": { "type": "boolean", "default": true }
+ },
+ "required": ["name", "taskType", "taskPayload", "cronExpression", "timezone", "executorDeviceId"]
+ },
+ "ScheduledTaskUpdateInput": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string", "maxLength": 120 },
+ "taskPayload": { "$ref": "#/components/schemas/ScheduledTaskPayload" },
+ "cronExpression": { "type": "string", "maxLength": 160 },
+ "timezone": { "type": "string", "maxLength": 80 },
+ "executorDeviceId": { "type": "string" },
+ "missedRunPolicy": { "type": "string", "enum": ["run-once", "skip"] },
+ "isEnabled": { "type": "boolean" }
+ }
+ },
+ "PluginScheduleUpsertInput": {
+ "type": "object",
+ "properties": {
+ "pluginId": { "type": "string" },
+ "scheduleKey": { "type": "string", "maxLength": 120 },
+ "name": { "type": "string", "maxLength": 120 },
+ "commandId": { "type": "string", "maxLength": 200 },
+ "cronExpression": { "type": "string", "maxLength": 160 },
+ "timezone": { "type": "string", "maxLength": 80 },
+ "executorDeviceId": { "type": "string" },
+ "missedRunPolicy": { "type": "string", "enum": ["run-once", "skip"], "default": "run-once" },
+ "isEnabled": { "type": "boolean" }
+ },
+ "required": ["pluginId", "scheduleKey", "name", "commandId", "cronExpression", "timezone", "executorDeviceId"]
+ },
+ "ScheduledTaskClaimInput": {
+ "type": "object",
+ "properties": { "scheduledFor": { "type": "string", "format": "date-time" }, "executorDeviceId": { "type": "string" } },
+ "required": ["scheduledFor", "executorDeviceId"]
+ },
+ "ScheduledTaskFinishInput": {
+ "type": "object",
+ "properties": {
+ "executorDeviceId": { "type": "string" },
+ "status": { "type": "string", "enum": ["succeeded", "failed"] },
+ "errorMessage": { "type": ["string", "null"], "maxLength": 2000 }
+ },
+ "required": ["executorDeviceId", "status"]
+ },
"ApiError": {
"type": "object",
"properties": {
diff --git a/docs/plugin-development.md b/docs/plugin-development.md
index 92e86e52..5f918ef3 100644
--- a/docs/plugin-development.md
+++ b/docs/plugin-development.md
@@ -1,6 +1,6 @@
# EdgeEver Plugin Development (P0 Preview)
-EdgeEver's P0 extension API supports trusted client plugins and no-code theme packages. Users can install extensions from the verified marketplace, a public GitHub repository, or a manifest URL. Extensions are installed per device and run only while EdgeEver is open. Scheduled/background jobs, webhooks, unrestricted TipTap extensions, and a hard JavaScript sandbox are not part of this preview.
+EdgeEver's P0 extension API supports trusted client plugins and no-code theme packages. Users can install extensions from the verified marketplace, a public GitHub repository, or a manifest URL. Extensions are installed per device and run only while EdgeEver is open. On desktop, users can schedule a registered plugin command while EdgeEver is running. Webhooks, an always-on server background runtime, unrestricted TipTap extensions, and a hard JavaScript sandbox are not part of this preview.
## Security model
@@ -102,6 +102,7 @@ Supported permissions:
- `network`
- `storage`
- `secrets`
+- `schedules`
- `editor:read`
- `editor:write`
- `ui:commands`
@@ -146,6 +147,36 @@ export default definePlugin({
Every registration returns a disposer. The host also disposes registered commands and events automatically when a plugin is disabled.
+## Schedules API
+
+Desktop plugins can persistently schedule one of their own registered commands. Declare both `ui:commands` and `schedules`, register the command first, then use a stable plugin-local key with `upsert()`:
+
+```js
+export default {
+ async activate(context) {
+ context.commands.register({
+ id: "refresh-feeds",
+ title: "Refresh feeds",
+ async run() {
+ // Long-running plugin work is allowed while the desktop app remains open.
+ }
+ });
+
+ await context.schedules.upsert({
+ key: "hourly-refresh",
+ name: "Hourly feed refresh",
+ commandId: "refresh-feeds",
+ cronExpression: "0 * * * *",
+ missedRunPolicy: "run-once"
+ });
+ }
+};
+```
+
+`upsert()` is idempotent for the pair of plugin ID and schedule key, so calling it on every activation does not create duplicates. The first desktop device that creates the schedule remains its executor; activation on another device updates the same definition without stealing execution. Omitting `isEnabled` preserves the user's enabled/disabled choice. Plugins can inspect and remove their own schedules with `context.schedules.list()` and `context.schedules.remove(key)`.
+
+The execution center retains scheduled-task run history for the most recent 30 days only. Expired records are removed immediately during upgrade and continue to be physically pruned when runs execute or history is viewed.
+
### SDK package
`@edgeever/plugin-api` is a publish-ready ESM package with generated JavaScript and TypeScript declarations. In the EdgeEver repository, rebuild it after changing public contracts:
@@ -432,7 +463,8 @@ The first demonstrates note queries, selection replacement, commands, and a cust
- Plugins are installed on one device and are not synchronized.
- Plugins run only while the app is open.
-- There is no Cron, webhook receiver, background runtime, marketplace submission backend, or automated review pipeline.
+- Desktop plugins can persistently schedule their own registered commands, and users can manage those schedules and inspect paginated run history from the plugin page. A schedule is synced through the workspace, bound to one desktop device, and runs only while EdgeEver is open on that device. A missed occurrence can either be skipped or coalesced into one recovery run. This is not an always-on server background runtime.
+- There is no webhook receiver, server background runtime, marketplace submission backend, or automated review pipeline.
- Declared permissions are API capability checks, not a hard sandbox for trusted JavaScript.
- Custom panels open from the unified desktop plugin menu or extension settings and cannot yet be pinned to the main navigation or editor sidebar.
- Secret storage is device-local and does not sync to other devices.
diff --git a/docs/plugin-development.zh-CN.md b/docs/plugin-development.zh-CN.md
index adc22462..9b162312 100644
--- a/docs/plugin-development.zh-CN.md
+++ b/docs/plugin-development.zh-CN.md
@@ -1,6 +1,6 @@
# EdgeEver 插件开发(P0 预览版)
-EdgeEver P0 扩展 API 支持受信任的客户端插件和无代码主题包。用户可以从已验证插件市场、公开 GitHub 仓库或 Manifest 地址安装扩展;扩展安装在当前设备,并且只在 EdgeEver 打开期间运行。当前预览版不包含定时或后台任务、Webhook、不受约束的 TipTap 扩展和严格的 JavaScript 沙箱。
+EdgeEver P0 扩展 API 支持受信任的客户端插件和无代码主题包。用户可以从已验证插件市场、公开 GitHub 仓库或 Manifest 地址安装扩展;扩展安装在当前设备,并且只在 EdgeEver 打开期间运行。桌面端用户可以为已注册的插件命令设置定时计划,并在 EdgeEver 运行期间执行。当前预览版不包含 Webhook、服务端常驻后台运行时、不受约束的 TipTap 扩展和严格的 JavaScript 沙箱。
## 安全模型
@@ -102,6 +102,7 @@ Registry 格式:
- `network`
- `storage`
- `secrets`
+- `schedules`
- `editor:read`
- `editor:write`
- `ui:commands`
@@ -146,6 +147,36 @@ export default definePlugin({
每次注册都会返回清理函数。插件停用时,宿主也会自动清理已注册的命令和事件。
+## 定时任务 API
+
+桌面插件可以持久化定时执行自己的已注册命令。Manifest 需要同时声明 `ui:commands` 和 `schedules`,先注册命令,再用稳定的插件内计划键调用 `upsert()`:
+
+```js
+export default {
+ async activate(context) {
+ context.commands.register({
+ id: "refresh-feeds",
+ title: "刷新订阅源",
+ async run() {
+ // 只要桌面端保持运行,插件命令就可以执行耗时任务。
+ }
+ });
+
+ await context.schedules.upsert({
+ key: "hourly-refresh",
+ name: "每小时刷新订阅源",
+ commandId: "refresh-feeds",
+ cronExpression: "0 * * * *",
+ missedRunPolicy: "run-once"
+ });
+ }
+};
+```
+
+`upsert()` 以“插件 ID + 计划键”为幂等标识,因此插件每次激活时调用也不会重复创建。第一台创建计划的桌面设备会保持为执行设备;同一插件在另一台电脑激活时只更新同一份计划定义,不会抢走执行权。省略 `isEnabled` 会保留用户的启停选择。插件可以通过 `context.schedules.list()` 查看自己的计划,并用 `context.schedules.remove(key)` 删除。
+
+执行中心只保留最近 30 天的定时任务执行记录。过期记录会在升级时立即清理,并在后续执行或查看记录时持续物理删除。
+
### SDK 包
`@edgeever/plugin-api` 是可发布的 ESM 包,包含生成后的 JavaScript 和 TypeScript 声明。在 EdgeEver 仓库中修改公开契约后,需要重新构建:
@@ -432,7 +463,8 @@ await context.ui.panels.open("dashboard", { state: { resourceId } });
- 插件只安装在当前设备,不参与同步。
- 插件只在应用打开期间运行。
-- 暂无 Cron、Webhook 接收端、后台运行环境、市场投稿后台和自动审核流水线。
+- 桌面插件可以持久化定时执行自己的已注册命令,用户则可以在插件页面管理这些计划并分页查看执行记录。计划通过工作区同步、绑定一台桌面设备,并且只在该设备运行 EdgeEver 时执行;错过的计划可以选择跳过,或在恢复后合并补跑一次。这不是服务端常驻后台运行时。
+- 暂无 Webhook 接收端、服务端后台运行环境、市场投稿后台和自动审核流水线。
- 权限声明属于 API 能力检查,不是针对受信任 JavaScript 的严格沙箱。
- 自定义面板可以从桌面端统一插件菜单或插件管理页打开,尚未支持固定到主导航或编辑器侧栏。
- Secret Storage 仅保存在当前设备,不会同步到其他设备。
diff --git a/migrations/0037_scheduled_plugin_tasks.sql b/migrations/0037_scheduled_plugin_tasks.sql
new file mode 100644
index 00000000..ace5c9c7
--- /dev/null
+++ b/migrations/0037_scheduled_plugin_tasks.sql
@@ -0,0 +1,44 @@
+PRAGMA foreign_keys = ON;
+
+CREATE TABLE scheduled_tasks (
+ id TEXT PRIMARY KEY,
+ workspace_id TEXT NOT NULL,
+ name TEXT NOT NULL CHECK (length(trim(name)) > 0 AND length(name) <= 120),
+ task_type TEXT NOT NULL CHECK (task_type IN ('plugin-command')),
+ task_payload_json TEXT NOT NULL CHECK (json_valid(task_payload_json)),
+ cron_expression TEXT NOT NULL CHECK (length(trim(cron_expression)) > 0 AND length(cron_expression) <= 160),
+ timezone TEXT NOT NULL CHECK (length(trim(timezone)) > 0 AND length(timezone) <= 80),
+ executor_device_id TEXT NOT NULL CHECK (length(trim(executor_device_id)) >= 16 AND length(executor_device_id) <= 160),
+ missed_run_policy TEXT NOT NULL DEFAULT 'run-once' CHECK (missed_run_policy IN ('run-once', 'skip')),
+ is_enabled INTEGER NOT NULL DEFAULT 1 CHECK (is_enabled IN (0, 1)),
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ FOREIGN KEY (workspace_id) REFERENCES workspaces(id)
+ ON UPDATE CASCADE
+ ON DELETE CASCADE
+);
+
+CREATE INDEX idx_scheduled_tasks_workspace_device
+ ON scheduled_tasks(workspace_id, executor_device_id, is_enabled, updated_at DESC);
+
+CREATE TABLE scheduled_task_runs (
+ id TEXT PRIMARY KEY,
+ task_id TEXT NOT NULL,
+ workspace_id TEXT NOT NULL,
+ scheduled_for TEXT NOT NULL,
+ executor_device_id TEXT NOT NULL,
+ status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed')),
+ error_message TEXT,
+ started_at TEXT NOT NULL,
+ finished_at TEXT,
+ FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id)
+ ON UPDATE CASCADE
+ ON DELETE CASCADE,
+ FOREIGN KEY (workspace_id) REFERENCES workspaces(id)
+ ON UPDATE CASCADE
+ ON DELETE CASCADE,
+ UNIQUE (task_id, scheduled_for)
+);
+
+CREATE INDEX idx_scheduled_task_runs_workspace_task
+ ON scheduled_task_runs(workspace_id, task_id, scheduled_for DESC);
diff --git a/migrations/0038_plugin_owned_schedules.sql b/migrations/0038_plugin_owned_schedules.sql
new file mode 100644
index 00000000..6149342e
--- /dev/null
+++ b/migrations/0038_plugin_owned_schedules.sql
@@ -0,0 +1,6 @@
+ALTER TABLE scheduled_tasks ADD COLUMN owner_plugin_id TEXT;
+ALTER TABLE scheduled_tasks ADD COLUMN plugin_schedule_key TEXT;
+
+CREATE UNIQUE INDEX idx_scheduled_tasks_plugin_schedule
+ ON scheduled_tasks(workspace_id, owner_plugin_id, plugin_schedule_key)
+ WHERE owner_plugin_id IS NOT NULL AND plugin_schedule_key IS NOT NULL;
diff --git a/migrations/0039_scheduled_task_run_retention.sql b/migrations/0039_scheduled_task_run_retention.sql
new file mode 100644
index 00000000..ff95b609
--- /dev/null
+++ b/migrations/0039_scheduled_task_run_retention.sql
@@ -0,0 +1,2 @@
+DELETE FROM scheduled_task_runs
+WHERE started_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 days');
diff --git a/package.json b/package.json
index b4458597..61244c68 100644
--- a/package.json
+++ b/package.json
@@ -59,7 +59,7 @@
"release": "bun scripts/release.mjs",
"publish:stores": "bun scripts/store-delivery.mjs",
"test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs",
- "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
+ "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
"verify:desktop-renderer": "bun scripts/verify-desktop-renderer.mjs",
"verify:packaged-desktop-startup": "bun scripts/verify-packaged-desktop-startup.mjs",
"verify:desktop-package": "bun scripts/verify-desktop-package.mjs",
@@ -144,6 +144,7 @@
"class-variance-authority": "latest",
"clsx": "latest",
"cmdk": "^1.1.1",
+ "croner": "^10.0.1",
"dexie": "latest",
"fast-xml-parser": "^5.9.3",
"fflate": "^0.8.3",
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
index a1f5ccb2..7448ae99 100644
--- a/packages/client/src/index.ts
+++ b/packages/client/src/index.ts
@@ -15,6 +15,8 @@ import type {
MemoSummary,
MemoShare,
MemoTemplate,
+ ScheduledTask,
+ ScheduledTaskRun,
Notebook,
Resource,
ResourceListItem,
@@ -140,6 +142,37 @@ export type TemplateResponse = {
template: MemoTemplate;
};
+export type ListScheduledTasksResponse = {
+ tasks: ScheduledTask[];
+};
+
+export type ScheduledTaskResponse = {
+ task: ScheduledTask;
+};
+
+export type ScheduledTaskRunResponse = {
+ run: ScheduledTaskRun;
+};
+
+export type ScheduledTaskRunsResponse = {
+ runs: ScheduledTaskRun[];
+ totalCount: number;
+ nextOffset: number | null;
+};
+
+export type ScheduledTaskRunHistoryItem = ScheduledTaskRun & {
+ taskName: string;
+ pluginId: string;
+ ownerPluginId: string | null;
+ pluginScheduleKey: string | null;
+};
+
+export type ScheduledTaskRunHistoryResponse = {
+ runs: ScheduledTaskRunHistoryItem[];
+ totalCount: number;
+ nextOffset: number | null;
+};
+
export type MemoShareResponse = {
share: MemoShare | null;
};
@@ -894,6 +927,98 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
deleteTemplate: (templateId: string) =>
request<{ ok: true }>(`/api/v1/templates/${templateId}`, { method: "DELETE" }),
+ listScheduledTasks: (executorDeviceId?: string) => {
+ const search = new URLSearchParams();
+ if (executorDeviceId) search.set("executorDeviceId", executorDeviceId);
+ const suffix = search.size > 0 ? `?${search.toString()}` : "";
+ return request(`/api/v1/scheduled-tasks${suffix}`);
+ },
+
+ listScheduledTaskRunHistory: (offset = 0, limit = 50) => {
+ const search = new URLSearchParams({ offset: String(offset), limit: String(limit) });
+ return request(`/api/v1/scheduled-task-runs?${search.toString()}`);
+ },
+
+ listPluginScheduledTasks: (pluginId: string) => request(
+ `/api/v1/scheduled-tasks/plugin/${encodeURIComponent(pluginId)}`,
+ ),
+
+ upsertPluginScheduledTask: (payload: {
+ pluginId: string;
+ scheduleKey: string;
+ name: string;
+ commandId: string;
+ cronExpression: string;
+ timezone: string;
+ executorDeviceId: string;
+ missedRunPolicy?: "run-once" | "skip";
+ isEnabled?: boolean;
+ }) => request("/api/v1/scheduled-tasks/plugin-upsert", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ }),
+
+ deletePluginScheduledTask: (pluginId: string, scheduleKey: string) => request<{ ok: true }>(
+ `/api/v1/scheduled-tasks/plugin/${encodeURIComponent(pluginId)}/${encodeURIComponent(scheduleKey)}`,
+ { method: "DELETE" },
+ ),
+
+ createScheduledTask: (payload: {
+ name: string;
+ taskType: "plugin-command";
+ taskPayload: { pluginId: string; commandId: string };
+ cronExpression: string;
+ timezone: string;
+ executorDeviceId: string;
+ missedRunPolicy?: "run-once" | "skip";
+ isEnabled?: boolean;
+ }) => request("/api/v1/scheduled-tasks", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ }),
+
+ updateScheduledTask: (taskId: string, payload: {
+ name?: string;
+ taskPayload?: { pluginId: string; commandId: string };
+ cronExpression?: string;
+ timezone?: string;
+ executorDeviceId?: string;
+ missedRunPolicy?: "run-once" | "skip";
+ isEnabled?: boolean;
+ }) => request(`/api/v1/scheduled-tasks/${encodeURIComponent(taskId)}`, {
+ method: "PATCH",
+ body: JSON.stringify(payload),
+ }),
+
+ deleteScheduledTask: (taskId: string) => request<{ ok: true }>(
+ `/api/v1/scheduled-tasks/${encodeURIComponent(taskId)}`,
+ { method: "DELETE" },
+ ),
+
+ listScheduledTaskRuns: (taskId: string, offset = 0, limit = 50) => {
+ const search = new URLSearchParams({ offset: String(offset), limit: String(limit) });
+ return request(
+ `/api/v1/scheduled-tasks/${encodeURIComponent(taskId)}/runs?${search.toString()}`,
+ );
+ },
+
+ claimScheduledTaskRun: (taskId: string, payload: {
+ scheduledFor: string;
+ executorDeviceId: string;
+ }) => request(
+ `/api/v1/scheduled-tasks/${encodeURIComponent(taskId)}/runs/claim`,
+ { method: "POST", body: JSON.stringify(payload) },
+ ),
+
+ finishScheduledTaskRun: (taskId: string, runId: string, payload: {
+ executorDeviceId: string;
+ status: "succeeded" | "failed";
+ errorMessage?: string | null;
+ }) => request(
+ `/api/v1/scheduled-tasks/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(runId)}/finish`,
+ { method: "POST", body: JSON.stringify(payload) },
+ ),
+
moveMemos: (payload: { memoIds: string[]; notebookId: string }) =>
request<{ ok: true; moved: number }>("/api/v1/memos/batch/move", {
method: "POST",
diff --git a/packages/plugin-api/README.md b/packages/plugin-api/README.md
index d2adf3f3..d850412f 100644
--- a/packages/plugin-api/README.md
+++ b/packages/plugin-api/README.md
@@ -18,6 +18,18 @@ export default definePlugin({
The package ships ESM JavaScript and TypeScript declarations. Plugin bundles must produce a single `main.js` file without relative imports before distribution. See the EdgeEver plugin development guide for the Manifest format, permissions, settings Schema, release assets, and marketplace verification rules.
+Desktop plugins can declare the `schedules` permission and idempotently schedule one of their own registered commands:
+
+```ts
+context.commands.register({ id: "refresh", title: "Refresh", run: refresh });
+await context.schedules.upsert({
+ key: "hourly-refresh",
+ name: "Hourly refresh",
+ commandId: "refresh",
+ cronExpression: "0 * * * *",
+});
+```
+
Conflict-safe Markdown edits use the note baseline returned by `context.notes.get()`:
```ts
diff --git a/packages/plugin-api/dist/index.d.ts b/packages/plugin-api/dist/index.d.ts
index 620b4e21..b3f6eedf 100644
--- a/packages/plugin-api/dist/index.d.ts
+++ b/packages/plugin-api/dist/index.d.ts
@@ -1,6 +1,6 @@
export declare const PLUGIN_API_VERSION: "1";
export declare const THEME_API_VERSION: "1";
-export declare const PLUGIN_PERMISSIONS: readonly ["notes:read", "notes:write", "notes:delete", "metadata:read", "metadata:write", "resources:read", "resources:write", "templates:read", "templates:write", "network", "storage", "secrets", "editor:read", "editor:write", "ui:commands", "ui:navigation", "ui:notices", "ui:panels", "ui:embeds"];
+export declare const PLUGIN_PERMISSIONS: readonly ["notes:read", "notes:write", "notes:delete", "metadata:read", "metadata:write", "resources:read", "resources:write", "templates:read", "templates:write", "network", "storage", "secrets", "schedules", "editor:read", "editor:write", "ui:commands", "ui:navigation", "ui:notices", "ui:panels", "ui:embeds"];
export type PluginPermission = (typeof PLUGIN_PERMISSIONS)[number];
export type ExtensionPlatform = "web" | "desktop" | "android" | "ios";
export interface PluginManifest {
@@ -248,6 +248,30 @@ export interface PluginCommand {
title: string;
run: () => void | Promise;
}
+export type PluginScheduleMissedRunPolicy = "run-once" | "skip";
+export interface PluginScheduleInput {
+ /** Stable plugin-local identifier. Reusing it updates the same persistent schedule. */
+ key: string;
+ name: string;
+ commandId: string;
+ cronExpression: string;
+ timezone?: string;
+ missedRunPolicy?: PluginScheduleMissedRunPolicy;
+ isEnabled?: boolean;
+}
+export interface PluginSchedule extends Required> {
+ timezone: string;
+ missedRunPolicy: PluginScheduleMissedRunPolicy;
+ isEnabled: boolean;
+ runsOnThisDevice: boolean;
+ lastRun: {
+ status: "running" | "succeeded" | "failed";
+ scheduledFor: string;
+ startedAt: string;
+ finishedAt: string | null;
+ errorMessage: string | null;
+ } | null;
+}
export interface PluginEditorSelection {
noteId: string;
from: number;
@@ -367,6 +391,12 @@ export interface PluginContext {
commands: {
register(command: PluginCommand): () => void;
};
+ schedules: {
+ /** Creates or updates one persistent schedule owned by this plugin. Desktop only. */
+ upsert(input: PluginScheduleInput): Promise;
+ list(): Promise;
+ remove(key: string): Promise;
+ };
events: {
on(event: K, listener: (payload: PluginEventMap[K]) => void): () => void;
};
diff --git a/packages/plugin-api/dist/index.js b/packages/plugin-api/dist/index.js
index a252dddf..fdce6ff0 100644
--- a/packages/plugin-api/dist/index.js
+++ b/packages/plugin-api/dist/index.js
@@ -14,6 +14,7 @@ var PLUGIN_PERMISSIONS = [
"network",
"storage",
"secrets",
+ "schedules",
"editor:read",
"editor:write",
"ui:commands",
diff --git a/packages/plugin-api/src/index.test.mjs b/packages/plugin-api/src/index.test.mjs
index 8b66c163..cd485b45 100644
--- a/packages/plugin-api/src/index.test.mjs
+++ b/packages/plugin-api/src/index.test.mjs
@@ -10,8 +10,8 @@ describe("extension manifests", () => {
version: "1.0.0",
apiVersion: "1",
entry: "./main.js",
- permissions: ["notes:read", "notes:read", "templates:read", "templates:write", "ui:commands", "ui:navigation", "ui:embeds"],
- })).toMatchObject({ permissions: ["notes:read", "templates:read", "templates:write", "ui:commands", "ui:navigation", "ui:embeds"] });
+ permissions: ["notes:read", "notes:read", "templates:read", "templates:write", "schedules", "ui:commands", "ui:navigation", "ui:embeds"],
+ })).toMatchObject({ permissions: ["notes:read", "templates:read", "templates:write", "schedules", "ui:commands", "ui:navigation", "ui:embeds"] });
});
test("rejects undeclared permissions", () => {
diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts
index ef18d703..e9a1a859 100644
--- a/packages/plugin-api/src/index.ts
+++ b/packages/plugin-api/src/index.ts
@@ -14,6 +14,7 @@ export const PLUGIN_PERMISSIONS = [
"network",
"storage",
"secrets",
+ "schedules",
"editor:read",
"editor:write",
"ui:commands",
@@ -272,6 +273,33 @@ export interface PluginCommand {
run: () => void | Promise;
}
+export type PluginScheduleMissedRunPolicy = "run-once" | "skip";
+
+export interface PluginScheduleInput {
+ /** Stable plugin-local identifier. Reusing it updates the same persistent schedule. */
+ key: string;
+ name: string;
+ commandId: string;
+ cronExpression: string;
+ timezone?: string;
+ missedRunPolicy?: PluginScheduleMissedRunPolicy;
+ isEnabled?: boolean;
+}
+
+export interface PluginSchedule extends Required> {
+ timezone: string;
+ missedRunPolicy: PluginScheduleMissedRunPolicy;
+ isEnabled: boolean;
+ runsOnThisDevice: boolean;
+ lastRun: {
+ status: "running" | "succeeded" | "failed";
+ scheduledFor: string;
+ startedAt: string;
+ finishedAt: string | null;
+ errorMessage: string | null;
+ } | null;
+}
+
export interface PluginEditorSelection {
noteId: string;
from: number;
@@ -378,6 +406,12 @@ export interface PluginContext {
commands: {
register(command: PluginCommand): () => void;
};
+ schedules: {
+ /** Creates or updates one persistent schedule owned by this plugin. Desktop only. */
+ upsert(input: PluginScheduleInput): Promise;
+ list(): Promise;
+ remove(key: string): Promise;
+ };
events: {
on(event: K, listener: (payload: PluginEventMap[K]) => void): () => void;
};
diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts
index a5e64c5d..cf7ad015 100644
--- a/packages/shared/src/i18n/en-US.ts
+++ b/packages/shared/src/i18n/en-US.ts
@@ -416,6 +416,62 @@ export const enUS = {
empty: "Enabled plugins have not provided any commands or panels yet.",
manage: "Manage plugins and themes",
},
+ schedules: {
+ title: "Plugin schedules",
+ description: "Plugins can register schedules themselves, or you can add one manually. Runs on the assigned desktop while EdgeEver is open.",
+ command: "Plugin command",
+ chooseCommand: "Choose a command",
+ name: "Task name",
+ cron: "Cron expression",
+ timezone: "Time zone",
+ missedRun: "When a run is missed",
+ runOnce: "Run once after recovery",
+ skip: "Skip it",
+ create: "Create schedule",
+ created: "Scheduled task created.",
+ empty: "No scheduled plugin commands are assigned to this device.",
+ noNextRun: "This schedule has no future run.",
+ toggle: "Enable or disable {{name}}",
+ delete: "Delete {{name}}",
+ pluginManaged: "Registered by {{plugin}} · {{key}}",
+ history: "Run history",
+ historyFor: "View run history for {{name}}",
+ historyTitle: "{{name}} · Run history",
+ historyDescription: "{{cron}} · {{timezone}}",
+ status: {
+ running: "Running",
+ succeeded: "Succeeded",
+ failed: "Failed",
+ },
+ },
+ },
+ executionHistory: {
+ centerTitle: "Scheduled task logs",
+ centerDescription: "View status, duration, and error details for system background tasks.",
+ openCenter: "Scheduled task logs",
+ pluginSchedule: "Plugin {{plugin}} · {{key}}",
+ pagination: "Run history pagination",
+ range: "{{start}}–{{end}} of {{total}}",
+ page: "Page {{page}} of {{total}}",
+ previousPage: "Previous",
+ nextPage: "Next",
+ retry: "Retry",
+ loading: "Loading",
+ loadFailed: "Failed to load run history.",
+ empty: "No runs yet.",
+ total: "{{count}} runs",
+ loadMore: "Load more",
+ currentDevice: "Current device",
+ device: "Device ···{{id}}",
+ scheduledFor: "Scheduled for",
+ startedAt: "Started at",
+ duration: "Duration",
+ running: "In progress",
+ status: {
+ running: "Running",
+ succeeded: "Succeeded",
+ failed: "Failed",
+ },
},
objectStorage: {
title: "OSS object storage",
diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts
index 4fc3c613..d96d4933 100644
--- a/packages/shared/src/i18n/zh-CN.ts
+++ b/packages/shared/src/i18n/zh-CN.ts
@@ -416,6 +416,62 @@ export const zhCN = {
empty: "已启用的插件还没有提供命令或面板。",
manage: "管理插件与主题",
},
+ schedules: {
+ title: "插件定时任务",
+ description: "插件可以自行注册计划,你也可以手动添加。EdgeEver 在绑定的桌面设备运行时执行。",
+ command: "插件命令",
+ chooseCommand: "选择一个命令",
+ name: "任务名称",
+ cron: "Cron 表达式",
+ timezone: "时区",
+ missedRun: "错过计划时",
+ runOnce: "恢复后补跑一次",
+ skip: "直接跳过",
+ create: "创建定时任务",
+ created: "定时任务已创建。",
+ empty: "当前设备尚未绑定定时插件命令。",
+ noNextRun: "这个计划没有下一次执行时间。",
+ toggle: "启用或停用 {{name}}",
+ delete: "删除 {{name}}",
+ pluginManaged: "由插件 {{plugin}} 注册 · {{key}}",
+ history: "执行记录",
+ historyFor: "查看 {{name}} 的执行记录",
+ historyTitle: "{{name}} · 执行记录",
+ historyDescription: "{{cron}} · {{timezone}}",
+ status: {
+ running: "执行中",
+ succeeded: "执行成功",
+ failed: "执行失败",
+ },
+ },
+ },
+ executionHistory: {
+ centerTitle: "定时任务日志",
+ centerDescription: "查看系统后台任务的执行状态、耗时和错误详情。",
+ openCenter: "定时任务日志",
+ pluginSchedule: "插件 {{plugin}} · {{key}}",
+ pagination: "执行记录分页",
+ range: "第 {{start}}–{{end}} 条,共 {{total}} 条",
+ page: "第 {{page}} / {{total}} 页",
+ previousPage: "上一页",
+ nextPage: "下一页",
+ retry: "重试",
+ loading: "正在加载",
+ loadFailed: "执行记录加载失败。",
+ empty: "还没有执行记录。",
+ total: "共 {{count}} 次执行",
+ loadMore: "加载更多",
+ currentDevice: "当前设备",
+ device: "设备 ···{{id}}",
+ scheduledFor: "计划时间",
+ startedAt: "开始时间",
+ duration: "耗时",
+ running: "进行中",
+ status: {
+ running: "执行中",
+ succeeded: "成功",
+ failed: "失败",
+ },
},
objectStorage: {
title: "OSS 对象存储",
diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts
index bb45dce0..80366814 100644
--- a/packages/shared/src/schemas.ts
+++ b/packages/shared/src/schemas.ts
@@ -72,6 +72,56 @@ export const TemplateUseSchema = z.object({
notebookId: z.string().trim().min(1),
});
+const ScheduledPluginCommandPayloadSchema = z.object({
+ pluginId: z.string().trim().min(3).max(200),
+ commandId: z.string().trim().min(1).max(200),
+});
+
+export const ScheduledTaskCreateSchema = z.object({
+ name: z.string().trim().min(1).max(120),
+ taskType: z.literal("plugin-command"),
+ taskPayload: ScheduledPluginCommandPayloadSchema,
+ cronExpression: z.string().trim().min(1).max(160),
+ timezone: z.string().trim().min(1).max(80),
+ executorDeviceId: z.string().trim().min(16).max(160),
+ missedRunPolicy: z.enum(["run-once", "skip"]).default("run-once"),
+ isEnabled: z.boolean().default(true),
+});
+
+export const ScheduledTaskUpdateSchema = ScheduledTaskCreateSchema.omit({
+ taskType: true,
+ taskPayload: true,
+ executorDeviceId: true,
+}).partial().extend({
+ taskPayload: ScheduledPluginCommandPayloadSchema.optional(),
+ executorDeviceId: z.string().trim().min(16).max(160).optional(),
+}).refine((input) => Object.values(input).some((value) => value !== undefined), {
+ message: "At least one field is required.",
+});
+
+export const PluginScheduleUpsertSchema = z.object({
+ pluginId: z.string().trim().min(3).max(200),
+ scheduleKey: z.string().trim().regex(/^[a-z0-9][a-z0-9._-]*$/i).max(120),
+ name: z.string().trim().min(1).max(120),
+ commandId: z.string().trim().min(1).max(200),
+ cronExpression: z.string().trim().min(1).max(160),
+ timezone: z.string().trim().min(1).max(80),
+ executorDeviceId: z.string().trim().min(16).max(160),
+ missedRunPolicy: z.enum(["run-once", "skip"]).default("run-once"),
+ isEnabled: z.boolean().optional(),
+});
+
+export const ScheduledTaskClaimSchema = z.object({
+ scheduledFor: z.string().datetime(),
+ executorDeviceId: z.string().trim().min(16).max(160),
+});
+
+export const ScheduledTaskFinishSchema = z.object({
+ executorDeviceId: z.string().trim().min(16).max(160),
+ status: z.enum(["succeeded", "failed"]),
+ errorMessage: z.string().trim().max(2_000).nullable().optional(),
+});
+
export const MoveMemosSchema = z.object({
memoIds: z.array(z.string().trim().min(1)).min(1).max(100),
notebookId: z.string().trim().min(1),
@@ -311,6 +361,11 @@ export type MemoCreateInput = z.infer;
export type MemoUpdateInput = z.infer;
export type TemplateCreateInput = z.infer;
export type TemplateUpdateInput = z.infer;
+export type ScheduledTaskCreateInput = z.input;
+export type ScheduledTaskUpdateInput = z.infer;
+export type PluginScheduleUpsertInput = z.input;
+export type ScheduledTaskClaimInput = z.infer;
+export type ScheduledTaskFinishInput = z.infer;
export type MoveMemosInput = z.infer;
export type DeleteMemosInput = z.infer;
export type MergeMemosInput = z.infer;
diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts
index c9479508..22cecb44 100644
--- a/packages/shared/src/types.ts
+++ b/packages/shared/src/types.ts
@@ -52,6 +52,41 @@ export type MemoTemplate = {
updatedAt: string;
};
+export type ScheduledTaskMissedRunPolicy = "run-once" | "skip";
+
+export type ScheduledPluginCommandPayload = {
+ pluginId: string;
+ commandId: string;
+};
+
+export type ScheduledTask = {
+ id: string;
+ name: string;
+ taskType: "plugin-command";
+ taskPayload: ScheduledPluginCommandPayload;
+ ownerPluginId: string | null;
+ pluginScheduleKey: string | null;
+ cronExpression: string;
+ timezone: string;
+ executorDeviceId: string;
+ missedRunPolicy: ScheduledTaskMissedRunPolicy;
+ isEnabled: boolean;
+ createdAt: string;
+ updatedAt: string;
+ lastRun: ScheduledTaskRun | null;
+};
+
+export type ScheduledTaskRun = {
+ id: string;
+ taskId: string;
+ scheduledFor: string;
+ executorDeviceId: string;
+ status: "running" | "succeeded" | "failed";
+ errorMessage: string | null;
+ startedAt: string;
+ finishedAt: string | null;
+};
+
export type MemoEditSession = {
id: string;
memoId: string;