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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions backend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1423,8 +1423,9 @@ async function handleGet(
}

if (path === "/api/tasks") {
const summary = url.searchParams.get("mode") === "summary";
return jsonResponse(
ctx.db.get_all_tasks().map((t) => attachDependencyMetadata(ctx.db, t)),
ctx.db.get_all_tasks_with_dependencies(summary),
200,
origin,
);
Expand Down Expand Up @@ -1512,7 +1513,7 @@ async function handleGet(
return jsonResponse({ csrf_token: CSRF_TOKEN }, 200, origin);
if (path === "/api/health")
return jsonResponse(
{ status: "ok", tasks: ctx.db.get_all_tasks().length },
{ status: "ok", tasks: ctx.db.count_tasks() },
200,
origin,
);
Expand Down
79 changes: 79 additions & 0 deletions backend/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ export class TaskDB {
this._migrate("ALTER TABLE tasks ADD COLUMN image_paths TEXT DEFAULT '[]'");
this._migrate("ALTER TABLE tasks ADD COLUMN notify_slack_channel TEXT");
this._migrate("ALTER TABLE tasks ADD COLUMN notify_telegram_chat_id TEXT");
this.conn.run(`
CREATE INDEX IF NOT EXISTS idx_tasks_status_next_run
ON tasks(status, next_run_at)
`);

this.conn.run(`
CREATE TABLE IF NOT EXISTS settings (
Expand Down Expand Up @@ -174,6 +178,10 @@ export class TaskDB {
)
`);
this._migrate("ALTER TABLE task_runs ADD COLUMN raw_output TEXT");
this.conn.run(`
CREATE INDEX IF NOT EXISTS idx_task_runs_task_started
ON task_runs(task_id, started_at DESC)
`);

this.conn.run(`
CREATE TABLE IF NOT EXISTS heartbeats (
Expand Down Expand Up @@ -265,6 +273,10 @@ export class TaskDB {
CREATE INDEX IF NOT EXISTS idx_task_output_events_timestamp
ON task_output_events(timestamp)
`);
this.conn.run(`
CREATE INDEX IF NOT EXISTS idx_task_output_events_task_timestamp
ON task_output_events(task_id, timestamp DESC)
`);

// DAG dependency table
this.conn.run(`
Expand Down Expand Up @@ -1286,6 +1298,73 @@ export class TaskDB {
return rows.map((r) => this._deserialize_task(r));
}

/**
* Return the task list with dependency metadata using three fixed queries.
*
* The legacy list keeps the same shape as get_task() plus dependencies and
* dependents. Summary mode omits large detail-only columns and truncates the
* prompt for TaskCard rendering.
*/
get_all_tasks_with_dependencies(summary: boolean = false): Row[] {
const taskSql = summary
? `SELECT id, title, substr(prompt, 1, 240) AS prompt_preview,
status, schedule_type, cron_expr, delay_seconds, next_run_at,
last_run_at, run_count, max_runs, tags, agent, dag_id
FROM tasks ORDER BY created_at DESC`
: "SELECT * FROM tasks ORDER BY created_at DESC";
const rows = this.conn.query(taskSql).all() as Row[];
if (rows.length === 0) return [];

const taskIds = new Set(rows.map((row) => Number(row["id"])));
const dependenciesByTask = new Map<number, Row[]>();
const dependentsByTask = new Map<number, number[]>();
const dependencies = this.conn
.query(
`SELECT td.*, t.title AS depends_on_title, t.status AS depends_on_status
FROM task_dependencies td
JOIN tasks t ON t.id = td.depends_on_task_id`,
)
.all() as Row[];
const dependents = this.conn
.query(
`SELECT td.*, t.title AS task_title, t.status AS task_status
FROM task_dependencies td
JOIN tasks t ON t.id = td.task_id`,
)
.all() as Row[];

for (const dependency of dependencies) {
const taskId = Number(dependency["task_id"]);
if (!taskIds.has(taskId)) continue;
const value = summary
? { depends_on_task_id: dependency["depends_on_task_id"] }
: { ...dependency };
const current = dependenciesByTask.get(taskId) ?? [];
current.push(value);
dependenciesByTask.set(taskId, current);
}
for (const dependent of dependents) {
const upstreamId = Number(dependent["depends_on_task_id"]);
if (!taskIds.has(upstreamId)) continue;
const current = dependentsByTask.get(upstreamId) ?? [];
current.push(Number(dependent["task_id"]));
dependentsByTask.set(upstreamId, current);
}

return rows.map((row) => ({
...(summary ? row : this._deserialize_task(row)),
dependencies: dependenciesByTask.get(Number(row["id"])) ?? [],
dependents: dependentsByTask.get(Number(row["id"])) ?? [],
}));
}

count_tasks(): number {
const row = this.conn
.query("SELECT COUNT(*) AS count FROM tasks")
.get() as Row;
return Number(row["count"]);
}

get_due_tasks(): Row[] {
const rows = this.conn
.query(
Expand Down
71 changes: 71 additions & 0 deletions backend/tests/api-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,77 @@ describe("api handler", () => {
expect(tasks[0]["dependents"]).toEqual([]);
});

test("GET /api/tasks batches dependency metadata without per-task queries", async () => {
const upstream = db.add_task(
makeTask({ title: "Upstream", prompt: "prepare", working_dir: "." }),
);
const downstream = db.add_task(
makeTask({ title: "Downstream", prompt: "finish", working_dir: "." }),
);
db.add_dependency(downstream, upstream, true);
db.get_dependencies = () => {
throw new Error("per-task dependency query should not run");
};
db.get_dependents = () => {
throw new Error("per-task dependent query should not run");
};

const tasks = await json(new Request("http://127.0.0.1:9712/api/tasks"));

expect(tasks).toHaveLength(2);
expect(
tasks.find((task: Record<string, any>) => task.id === upstream)
.dependents,
).toEqual([downstream]);
expect(
tasks.find((task: Record<string, any>) => task.id === downstream)
.dependencies[0],
).toMatchObject({
task_id: downstream,
depends_on_task_id: upstream,
inject_result: 1,
depends_on_title: "Upstream",
});
expect(
tasks.find((task: Record<string, any>) => task.id === upstream).prompt,
).toBe("prepare");
});

test("GET /api/tasks summary returns only board fields", async () => {
db.add_task(
makeTask({
title: "Summary",
prompt: "x".repeat(400),
working_dir: "/private/project",
}),
);

const tasks = await json(
new Request("http://127.0.0.1:9712/api/tasks?mode=summary"),
);
const task = tasks[0];

expect(task.prompt_preview).toHaveLength(240);
expect(task.dependencies).toEqual([]);
expect(task.dependents).toEqual([]);
expect(task).not.toHaveProperty("prompt");
expect(task).not.toHaveProperty("working_dir");
expect(task).not.toHaveProperty("result");
expect(task).not.toHaveProperty("error");
expect(task).not.toHaveProperty("prompt_images");
});

test("GET /api/health counts tasks without loading task rows", async () => {
db.add_task(makeTask({ title: "Count me", prompt: "p" }));
db.get_all_tasks = () => {
throw new Error("health should use COUNT");
};

const health = await json(new Request("http://127.0.0.1:9712/api/health"));

expect(health).toEqual({ status: "ok", tasks: 1 });
});

test("GET task output falls back to latest persisted raw output", async () => {
const created = await json(
new Request("http://127.0.0.1:9712/api/tasks", {
Expand Down
23 changes: 23 additions & 0 deletions backend/tests/taskdb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,29 @@ describe("TaskDB", () => {
expect(db.get_setting("k")).toBe("v2");
});

test("test_task_count_and_polling_indexes_are_idempotent", () => {
db.add_task(makeTask({ title: "one", prompt: "p" }));
expect(db.count_tasks()).toBe(1);

const dbPath = db.db_path;
db.conn.close();
db = new TaskDB(dbPath);

const indexNames = (table: string) =>
new Set(
(
db.conn.query(`PRAGMA index_list('${table}')`).all() as Array<{
name: string;
}>
).map((row) => row.name),
);
expect(indexNames("tasks")).toContain("idx_tasks_status_next_run");
expect(indexNames("task_runs")).toContain("idx_task_runs_task_started");
expect(indexNames("task_output_events")).toContain(
"idx_task_output_events_task_timestamp",
);
});

// ── run history ────────────────────────────────────────────────────────────
test("test_run_lifecycle_and_ordering", () => {
const tid = db.add_task(
Expand Down
Loading