Add manual scheduled task entry

Lets users log tasks the Linux agent can't discover on its own (e.g.
Docker-based backup jobs) directly in the UI. Tasks now carry an
origin ('agent' | 'manual') so the agent's report-sync logic only
ever creates/updates/stale-marks agent-sourced rows, leaving manual
entries untouched; the API rejects edits/deletes of agent-sourced
tasks to keep that boundary enforced server-side too.
This commit is contained in:
2026-07-10 00:12:34 +02:00
parent fac491134d
commit 5269499520
14 changed files with 671 additions and 43 deletions
@@ -0,0 +1 @@
ALTER TABLE `scheduled_tasks` ADD `origin` text DEFAULT 'agent' NOT NULL;
+220
View File
@@ -0,0 +1,220 @@
{
"version": "6",
"dialect": "sqlite",
"id": "32e3ac8c-c245-4350-9446-7fc5bb66e2b6",
"prevId": "81d34268-977d-459b-b4a1-8d62b2d81a56",
"tables": {
"scheduled_tasks": {
"name": "scheduled_tasks",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"server_id": {
"name": "server_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_type": {
"name": "schedule_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"origin": {
"name": "origin",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'agent'"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"command": {
"name": "command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"schedule_expression": {
"name": "schedule_expression",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"next_run_at": {
"name": "next_run_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"raw_metadata": {
"name": "raw_metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_stale": {
"name": "is_stale",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"first_seen_at": {
"name": "first_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {
"scheduled_tasks_server_id_servers_id_fk": {
"name": "scheduled_tasks_server_id_servers_id_fk",
"tableFrom": "scheduled_tasks",
"tableTo": "servers",
"columnsFrom": [
"server_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"servers": {
"name": "servers",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hostname": {
"name": "hostname",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"os_type": {
"name": "os_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'linux'"
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"api_token_hash": {
"name": "api_token_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_token_prefix": {
"name": "api_token_prefix",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -8,6 +8,13 @@
"when": 1783628241015,
"tag": "0000_aspiring_blink",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1783634598855,
"tag": "0001_amazing_greymalkin",
"breakpoints": true
}
]
}
+2 -1
View File
@@ -20,7 +20,8 @@ export const scheduledTasks = sqliteTable("scheduled_tasks", {
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer'
scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer' | 'docker' | 'manual'
origin: text("origin").notNull().default("agent"), // 'agent' | 'manual' — manual rows are never touched by agent sync
name: text("name").notNull(),
command: text("command"),
scheduleExpression: text("schedule_expression"),
+102 -16
View File
@@ -1,5 +1,6 @@
import { Router } from "express";
import { and, eq, like, or } from "drizzle-orm";
import { z } from "zod";
import { db } from "../db/client.js";
import { scheduledTasks, servers } from "../db/schema.js";
import { requireAuth } from "../auth/middleware.js";
@@ -7,6 +8,23 @@ import { requireAuth } from "../auth/middleware.js";
export const tasksRouter = Router();
tasksRouter.use(requireAuth);
const TASK_COLUMNS = {
id: scheduledTasks.id,
serverId: scheduledTasks.serverId,
serverName: servers.name,
scheduleType: scheduledTasks.scheduleType,
origin: scheduledTasks.origin,
name: scheduledTasks.name,
command: scheduledTasks.command,
scheduleExpression: scheduledTasks.scheduleExpression,
source: scheduledTasks.source,
enabled: scheduledTasks.enabled,
nextRunAt: scheduledTasks.nextRunAt,
isStale: scheduledTasks.isStale,
firstSeenAt: scheduledTasks.firstSeenAt,
lastSeenAt: scheduledTasks.lastSeenAt,
};
tasksRouter.get("/", async (req, res) => {
const serverId = req.query.serverId ? Number(req.query.serverId) : undefined;
const scheduleType = typeof req.query.scheduleType === "string" ? req.query.scheduleType : undefined;
@@ -17,7 +35,7 @@ tasksRouter.get("/", async (req, res) => {
if (serverId && Number.isInteger(serverId)) {
conditions.push(eq(scheduledTasks.serverId, serverId));
}
if (scheduleType === "cron" || scheduleType === "systemd_timer") {
if (scheduleType) {
conditions.push(eq(scheduledTasks.scheduleType, scheduleType));
}
if (!includeStale) {
@@ -29,21 +47,7 @@ tasksRouter.get("/", async (req, res) => {
}
const rows = await db
.select({
id: scheduledTasks.id,
serverId: scheduledTasks.serverId,
serverName: servers.name,
scheduleType: scheduledTasks.scheduleType,
name: scheduledTasks.name,
command: scheduledTasks.command,
scheduleExpression: scheduledTasks.scheduleExpression,
source: scheduledTasks.source,
enabled: scheduledTasks.enabled,
nextRunAt: scheduledTasks.nextRunAt,
isStale: scheduledTasks.isStale,
firstSeenAt: scheduledTasks.firstSeenAt,
lastSeenAt: scheduledTasks.lastSeenAt,
})
.select(TASK_COLUMNS)
.from(scheduledTasks)
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
.where(conditions.length > 0 ? and(...conditions) : undefined)
@@ -51,3 +55,85 @@ tasksRouter.get("/", async (req, res) => {
res.json({ tasks: rows });
});
const manualTaskSchema = z.object({
serverId: z.number().int(),
scheduleType: z.enum(["cron", "systemd_timer", "docker", "manual"]),
name: z.string().min(1).max(200),
command: z.string().max(1000).optional(),
scheduleExpression: z.string().max(200).optional(),
nextRunAt: z.string().optional(),
enabled: z.boolean().optional(),
});
tasksRouter.post("/", async (req, res) => {
const parsed = manualTaskSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const server = await db.query.servers.findFirst({ where: eq(servers.id, parsed.data.serverId) });
if (!server) {
return res.status(400).json({ error: "unknown_server" });
}
const now = new Date().toISOString();
const [created] = await db
.insert(scheduledTasks)
.values({
serverId: parsed.data.serverId,
scheduleType: parsed.data.scheduleType,
origin: "manual",
name: parsed.data.name,
command: parsed.data.command,
scheduleExpression: parsed.data.scheduleExpression,
nextRunAt: parsed.data.nextRunAt,
enabled: parsed.data.enabled ?? true,
firstSeenAt: now,
lastSeenAt: now,
})
.returning({ id: scheduledTasks.id });
const [task] = await db.select(TASK_COLUMNS).from(scheduledTasks).innerJoin(servers, eq(scheduledTasks.serverId, servers.id)).where(eq(scheduledTasks.id, created.id));
res.status(201).json({ task });
});
const manualTaskUpdateSchema = manualTaskSchema.omit({ serverId: true }).partial();
tasksRouter.patch("/:id", async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
const parsed = manualTaskUpdateSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const existing = await db.query.scheduledTasks.findFirst({ where: eq(scheduledTasks.id, id) });
if (!existing) return res.status(404).json({ error: "not_found" });
if (existing.origin !== "manual") {
return res.status(403).json({ error: "not_editable", message: "Only manually entered tasks can be edited." });
}
await db
.update(scheduledTasks)
.set({ ...parsed.data, lastSeenAt: new Date().toISOString() })
.where(eq(scheduledTasks.id, id));
const [task] = await db.select(TASK_COLUMNS).from(scheduledTasks).innerJoin(servers, eq(scheduledTasks.serverId, servers.id)).where(eq(scheduledTasks.id, id));
res.json({ task });
});
tasksRouter.delete("/:id", async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
const existing = await db.query.scheduledTasks.findFirst({ where: eq(scheduledTasks.id, id) });
if (!existing) return res.status(404).json({ error: "not_found" });
if (existing.origin !== "manual") {
return res.status(403).json({ error: "not_editable", message: "Only manually entered tasks can be deleted." });
}
await db.delete(scheduledTasks).where(eq(scheduledTasks.id, id));
res.status(204).end();
});
+8 -5
View File
@@ -22,7 +22,7 @@ export async function syncServerTasks(serverId: number, report: AgentReport) {
const now = new Date().toISOString();
const existing = await db.query.scheduledTasks.findMany({
where: eq(scheduledTasks.serverId, serverId),
where: and(eq(scheduledTasks.serverId, serverId), eq(scheduledTasks.origin, "agent")),
});
const seenIds: number[] = [];
@@ -56,6 +56,7 @@ export async function syncServerTasks(serverId: number, report: AgentReport) {
.values({
serverId,
scheduleType: task.scheduleType,
origin: "agent",
name: task.name,
command: task.command,
scheduleExpression: task.scheduleExpression,
@@ -71,15 +72,17 @@ export async function syncServerTasks(serverId: number, report: AgentReport) {
}
}
// Anything belonging to this server that wasn't in this report is now stale,
// rather than deleted, so a bad/partial agent run doesn't wipe history.
// Anything agent-sourced for this server that wasn't in this report is now stale,
// rather than deleted, so a bad/partial agent run doesn't wipe history. Manually
// entered tasks (origin = 'manual') are never touched by agent sync.
const agentScope = and(eq(scheduledTasks.serverId, serverId), eq(scheduledTasks.origin, "agent"));
if (seenIds.length > 0) {
await db
.update(scheduledTasks)
.set({ isStale: true })
.where(and(eq(scheduledTasks.serverId, serverId), notInArray(scheduledTasks.id, seenIds)));
.where(and(agentScope, notInArray(scheduledTasks.id, seenIds)));
} else {
await db.update(scheduledTasks).set({ isStale: true }).where(eq(scheduledTasks.serverId, serverId));
await db.update(scheduledTasks).set({ isStale: true }).where(agentScope);
}
await db