Merge pull request #2655 from pikasTech/fix/tasktree-cli-validation
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success

fix: 收紧 TaskTree CLI 与领域校验合同
This commit is contained in:
Lyon
2026-07-18 15:21:40 +08:00
committed by GitHub
6 changed files with 271 additions and 30 deletions
+21 -8
View File
@@ -43,20 +43,28 @@ export function createTaskTreeDispatcher(options: TaskTreeDispatcherOptions) {
const tasks = await store.listTasks(task.groupId);
data = { ...taskNode(task, tasks), reports: await store.listReports(task.id) };
}
else if (operation === "task.create") data = await store.createTask({
...command,
title: validTitle(command.title),
startAt: optionalDate(command.startAt, "startAt"),
dueAt: optionalDate(command.dueAt, "dueAt")
});
else if (operation === "task.create") {
const startAt = optionalDate(command.startAt, "startAt");
const dueAt = optionalDate(command.dueAt, "dueAt");
validTimeRange(startAt, dueAt);
data = await store.createTask({
...command,
title: validTitle(command.title),
startAt,
dueAt
});
}
else if (operation === "task.create-batch") {
const titles = command.titles.map(validTitle);
if (titles.length < 1) throw codedError("invalid_input", "at least one title is required");
const startAt = optionalDate(command.startAt, "startAt");
const dueAt = optionalDate(command.dueAt, "dueAt");
validTimeRange(startAt, dueAt);
data = { tasks: await store.createTasks({
...command,
titles,
startAt: optionalDate(command.startAt, "startAt"),
dueAt: optionalDate(command.dueAt, "dueAt")
startAt,
dueAt
}) };
}
else if (operation === "task.update") {
@@ -122,6 +130,11 @@ function optionalDate(value: string | undefined, field: string): string | undefi
function nullableDate(value: string | null | undefined, field: string): string | null | undefined { return value === null || value === undefined ? value : validDate(value, field); }
function validDate(value: unknown, field: string): string { const date = new Date(String(value ?? "")); if (Number.isNaN(date.valueOf())) throw codedError("invalid_input", `${field} must be an ISO date`); return date.toISOString(); }
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
function validTimeRange(startAt?: string, dueAt?: string) {
if (startAt !== undefined && dueAt !== undefined && new Date(startAt).valueOf() > new Date(dueAt).valueOf()) {
throw codedError("invalid_time_range", "startAt must be before or equal to dueAt");
}
}
type TaskNode = TaskItem & { children: TaskNode[] };
+1
View File
@@ -2,6 +2,7 @@ import { TaskTreeStore } from "./store.ts";
export function taskTreeRuntime(env: Record<string, string | undefined> = process.env) {
const databaseUrl = env.TASKTREE_DATABASE_URL || env.DATABASE_URL || "";
if (!databaseUrl) throw Object.assign(new Error("TASKTREE_DATABASE_URL or DATABASE_URL is required"), { code: "missing_database_url" });
const store = new TaskTreeStore(databaseUrl);
return {
store,
+33 -6
View File
@@ -155,6 +155,7 @@ export class TaskTreeStore {
async createTasks(input: { groupId: string; titles: string[]; description?: string; startAt?: string; dueAt?: string; parentId?: string }): Promise<TaskItem[]> {
await this.ensureSchema();
ensureTimeRange(input.startAt ?? null, input.dueAt ?? null);
const client = await this.pool.connect();
try {
await client.query("BEGIN");
@@ -195,10 +196,13 @@ export class TaskTreeStore {
const current = await this.pool.query("SELECT * FROM tasktree_tasks WHERE id=$1", [id]);
if (!current.rows[0]) return null;
const row = taskRow(current.rows[0]);
const startAt = patch.startAt === undefined ? row.startAt : patch.startAt;
const dueAt = patch.dueAt === undefined ? row.dueAt : patch.dueAt;
ensureTimeRange(startAt, dueAt);
const result = await this.pool.query(
`UPDATE tasktree_tasks SET title=$2,description=$3,status=$4,start_at=$5,due_at=$6,updated_at=now() WHERE id=$1 RETURNING *`,
[id, patch.title ?? row.title, patch.description ?? row.description, patch.status ?? row.status,
patch.startAt === undefined ? row.startAt : patch.startAt, patch.dueAt === undefined ? row.dueAt : patch.dueAt]
startAt, dueAt]
);
await this.pool.query("UPDATE tasktree_groups SET updated_at=now() WHERE id=$1", [row.groupId]);
return taskRow(result.rows[0]);
@@ -239,11 +243,29 @@ export class TaskTreeStore {
async createMilestone(input: { groupId: string; title: string; occursAt: string; taskId?: string }): Promise<Milestone> {
await this.ensureSchema();
const result = await this.pool.query(
"INSERT INTO tasktree_milestones (id,group_id,task_id,title,occurs_at) VALUES ($1,$2,$3,$4,$5) RETURNING *",
[`tm_${randomUUID()}`, input.groupId, input.taskId ?? null, input.title, input.occursAt]
);
return milestoneRow(result.rows[0]);
const client = await this.pool.connect();
try {
await client.query("BEGIN");
const group = await client.query("SELECT id FROM tasktree_groups WHERE id=$1 FOR UPDATE", [input.groupId]);
if (!group.rows[0]) throw domainError("group_not_found", "taskgroup was not found");
if (input.taskId) {
const task = await client.query("SELECT group_id FROM tasktree_tasks WHERE id=$1", [input.taskId]);
if (!task.rows[0]) throw domainError("task_not_found", "task was not found");
if (task.rows[0].group_id !== input.groupId) throw domainError("task_group_mismatch", "task belongs to another taskgroup");
}
const result = await client.query(
"INSERT INTO tasktree_milestones (id,group_id,task_id,title,occurs_at) VALUES ($1,$2,$3,$4,$5) RETURNING *",
[`tm_${randomUUID()}`, input.groupId, input.taskId ?? null, input.title, input.occursAt]
);
await client.query("UPDATE tasktree_groups SET updated_at=now() WHERE id=$1", [input.groupId]);
await client.query("COMMIT");
return milestoneRow(result.rows[0]);
} catch (error) {
await client.query("ROLLBACK").catch(() => {});
throw error;
} finally {
client.release();
}
}
async createReport(input: { taskId: string; title: string; body: string; status?: string }): Promise<ExecutionReport> {
@@ -355,3 +377,8 @@ function reportRow(row: any): ExecutionReport { return { id: row.id, taskId: row
function iso(value: unknown): string { return new Date(value as any).toISOString(); }
function nullableIso(value: unknown): string | null { return value == null ? null : iso(value); }
function domainError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
function ensureTimeRange(startAt: string | null, dueAt: string | null) {
if (startAt !== null && dueAt !== null && new Date(startAt).valueOf() > new Date(dueAt).valueOf()) {
throw domainError("invalid_time_range", "startAt must be before or equal to dueAt");
}
}
+45
View File
@@ -31,8 +31,10 @@ test("HTTP command endpoint returns the same dispatcher DTO", async () => {
test("dispatcher keeps batch validation atomic and completion gated", async () => {
let batchCalls = 0;
let createCalls = 0;
let updateCalls = 0;
const store = {
async createTask() { createCalls += 1; return null; },
async createTasks() { batchCalls += 1; return []; },
async updateTask() { updateCalls += 1; return null; },
async deleteTask() { return false; }
@@ -44,6 +46,17 @@ test("dispatcher keeps batch validation atomic and completion gated", async () =
assert.equal(batch.error?.code, "invalid_title");
assert.equal(batchCalls, 0);
const invalidRange = await dispatch({
operation: "task.create",
groupId: "tg_1",
title: "Invalid range",
startAt: "2026-07-19T00:00:00.000Z",
dueAt: "2026-07-18T00:00:00.000Z"
});
assert.equal(invalidRange.ok, false);
assert.equal(invalidRange.error?.code, "invalid_time_range");
assert.equal(createCalls, 0);
const bypass = await dispatch({ operation: "task.update", taskId: "tt_1", status: "completed" });
assert.equal(bypass.ok, false);
assert.equal(bypass.error?.code, "completion_command_required");
@@ -52,6 +65,10 @@ test("dispatcher keeps batch validation atomic and completion gated", async () =
const missing = await dispatch({ operation: "task.delete", taskId: "tt_missing" });
assert.equal(missing.ok, false);
assert.equal(missing.error?.code, "task_not_found");
const workflow = await dispatch({ operation: "workflow.start", taskId: "tt_1" });
assert.equal(workflow.ok, false);
assert.equal(workflow.error?.code, "temporal_address_required");
});
test("native PostgreSQL store exposes overview and three task levels", async () => {
@@ -59,6 +76,7 @@ test("native PostgreSQL store exposes overview and three task levels", async ()
assert.ok(databaseUrl, "TASKTREE_DATABASE_URL is required for the TaskTree store integration test");
const store = new TaskTreeStore(databaseUrl);
const group = await store.createGroup(`TaskTree test ${Date.now()}`);
const otherGroup = await store.createGroup(`TaskTree cross-group test ${Date.now()}`);
try {
const task = await store.createTask({
groupId: group.id,
@@ -71,6 +89,32 @@ test("native PostgreSQL store exposes overview and three task levels", async ()
const batch = await store.createTasks({ groupId: group.id, parentId: task.id, titles: ["Batch one", "Batch two"] });
assert.deepEqual(batch.map((item) => item.title), ["Batch one", "Batch two"]);
await store.createMilestone({ groupId: group.id, taskId: task.id, title: "Review", occursAt: "2026-07-17T00:00:00.000Z" });
await assert.rejects(
store.createMilestone({ groupId: otherGroup.id, taskId: task.id, title: "Wrong group", occursAt: "2026-07-17T00:00:00.000Z" }),
(error: any) => error?.code === "task_group_mismatch"
);
await assert.rejects(
store.createMilestone({ groupId: "tg_missing", title: "Missing group", occursAt: "2026-07-17T00:00:00.000Z" }),
(error: any) => error?.code === "group_not_found"
);
await assert.rejects(
store.createMilestone({ groupId: group.id, taskId: "tt_missing", title: "Missing task", occursAt: "2026-07-17T00:00:00.000Z" }),
(error: any) => error?.code === "task_not_found"
);
await assert.rejects(
store.createTask({
groupId: group.id,
title: "Invalid range",
startAt: "2026-07-19T00:00:00.000Z",
dueAt: "2026-07-18T00:00:00.000Z"
}),
(error: any) => error?.code === "invalid_time_range"
);
await assert.rejects(
store.updateTask(task.id, { dueAt: "2026-07-15T00:00:00.000Z" }),
(error: any) => error?.code === "invalid_time_range"
);
assert.equal((await store.getTask(task.id))?.dueAt, "2026-07-18T00:00:00.000Z");
await assert.rejects(
store.completeTask(task.id),
(error: any) => error?.code === "execution_report_required"
@@ -117,6 +161,7 @@ test("native PostgreSQL store exposes overview and three task levels", async ()
assert.equal(overview?.startAt, "2026-07-16T00:00:00.000Z");
assert.equal(overview?.dueAt, "2026-07-18T00:00:00.000Z");
} finally {
await store.deleteGroup(otherGroup.id);
await store.deleteGroup(group.id);
await store.close();
}