fix: bound mdtodo task windows

This commit is contained in:
lyon
2026-06-26 18:05:05 +08:00
parent 9a367582d5
commit 034b4c8ac1
8 changed files with 226 additions and 53 deletions
@@ -36,6 +36,11 @@ test("project management app exposes MDTODO read model and Workbench link writes
expect(tasks.tasks.map((task) => task.taskId)).toEqual(["R1", "R2"]);
expect(tasks.tasks.map((task) => task.status)).toEqual(["open", "done"]);
expect(tasks.tasks.every((task) => task.projectId === "project_test")).toBe(true);
expect(tasks.page).toMatchObject({ offset: 0, limit: 200, total: 2, hasMore: false });
const taskWindow = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=test-mdtodo&limit=1&offset=1");
expect(taskWindow.tasks.map((task) => task.taskId)).toEqual(["R2"]);
expect(taskWindow.page).toMatchObject({ offset: 1, limit: 1, total: 2, hasMore: false });
const links = await json(app, "/v1/project-management/workbench-links?projectId=project_test");
expect(links.links).toEqual([]);
@@ -64,6 +69,39 @@ test("project management app exposes MDTODO read model and Workbench link writes
}
});
test("project management task list defaults to a bounded window", async () => {
const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-task-window-"));
try {
const body = ["# Demo", "", ...Array.from({ length: 5 }, (_, index) => `## R${index + 1} Task ${index + 1}`)].join("\n");
await writeFile(join(root, "MDTODO.md"), `${body}\n`, "utf8");
const app = createProjectManagementApp({
env: {
HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_DEFAULT_LIMIT: "2",
HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_MAX_LIMIT: "3"
},
store: createProjectManagementStore({ kind: "memory" }),
sourceRoot: root,
sourceId: "bounded-mdtodo",
projectId: "project_test"
});
const ready = await app.fetch(new Request("http://service/health/ready"));
expect(ready.status).toBe(200);
const defaultWindow = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=bounded-mdtodo");
expect(defaultWindow.tasks.map((task) => task.taskId)).toEqual(["R1", "R2"]);
expect(defaultWindow.page).toMatchObject({ offset: 0, limit: 2, maxLimit: 3, total: 5, hasMore: true });
const cappedWindow = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=bounded-mdtodo&limit=99");
expect(cappedWindow.tasks.map((task) => task.taskId)).toEqual(["R1", "R2", "R3"]);
expect(cappedWindow.page).toMatchObject({ offset: 0, limit: 3, maxLimit: 3, total: 5, hasMore: true });
await app.close();
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("project management app configures HWPOD source and writes MDTODO content", async () => {
const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-hwpod-"));
const defaultRoot = join(root, "default");
+40 -6
View File
@@ -17,6 +17,8 @@ export function createProjectManagementApp(options = {}) {
const projectId = safeToken(options.projectId ?? env.HWLAB_PROJECT_MANAGEMENT_PROJECT_ID, "project_hwlab_v03");
const displayName = String(options.displayName ?? env.HWLAB_PROJECT_MANAGEMENT_SOURCE_NAME ?? "HWLAB MDTODO").trim() || "HWLAB MDTODO";
const maxFiles = positiveInteger(options.maxFiles ?? env.HWLAB_PROJECT_MANAGEMENT_MDTODO_MAX_FILES, 300);
const taskMaxLimit = positiveInteger(options.taskMaxLimit ?? env.HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_MAX_LIMIT, 500);
const taskDefaultLimit = Math.min(taskMaxLimit, positiveInteger(options.taskDefaultLimit ?? env.HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_DEFAULT_LIMIT, 200));
const sourceAdapter = options.sourceAdapter ?? createMdtodoSourceAdapter({ env, hwpodNodeOpsHandler: options.hwpodNodeOpsHandler, fetchImpl: options.fetchImpl });
const defaultSource = normalizeProjectSourceInput({
sourceId,
@@ -146,12 +148,22 @@ export function createProjectManagementApp(options = {}) {
return await handleReadMdtodoFile(url, store, sourceAdapter, fileContent.fileRef);
}
if (url.pathname === "/v1/project-management/mdtodo/tasks") {
const taskFilters = {
sourceId: queryToken(url, "sourceId"),
fileRef: queryToken(url, "fileRef"),
projectId: queryToken(url, "projectId"),
parentTaskRef: queryOpaque(url, "parentTaskRef"),
status: queryToken(url, "status"),
search: querySearch(url, "search")
};
const page = taskListPage(url, { defaultLimit: taskDefaultLimit, maxLimit: taskMaxLimit });
const [total, tasks] = await Promise.all([
store.countTasks(taskFilters),
store.listTasks({ ...taskFilters, limit: page.limit, offset: page.offset })
]);
return json(200, envelope({
tasks: await store.listTasks({
sourceId: queryToken(url, "sourceId"),
fileRef: queryToken(url, "fileRef"),
projectId: queryToken(url, "projectId")
})
tasks,
page: { ...page, total, hasMore: page.offset + tasks.length < total }
}));
}
if (url.pathname === "/v1/project-management/workbench-links") {
@@ -458,7 +470,8 @@ async function mutateTaskDocument({ store, sourceAdapter, sourceId, fileRef, exp
async function getTaskByRef(store, taskRef) {
const safeTaskRef = safeOpaqueValue(taskRef);
if (!safeTaskRef) return null;
return (await store.listTasks({})).find((task) => task.taskRef === safeTaskRef) ?? null;
if (typeof store.getTask === "function") return store.getTask(safeTaskRef);
return (await store.listTasks({ taskRef: safeTaskRef, limit: 1 }))[0] ?? null;
}
function fileSummary(file) {
@@ -608,6 +621,13 @@ function queryOpaque(url, name) {
return /^[A-Za-z0-9_.:/#-]{1,320}$/u.test(String(value ?? "")) ? value : null;
}
function querySearch(url, name) {
const value = url.searchParams.get(name);
if (value === null) return null;
const text = shortText(value, 120);
return text && /^[\p{L}\p{N}\s_.:/#-]{1,120}$/u.test(text) ? text : null;
}
function safeToken(value, fallback) {
const text = String(value ?? "").trim();
return /^[A-Za-z0-9_.:-]{1,96}$/u.test(text) ? text : fallback;
@@ -617,3 +637,17 @@ function positiveInteger(value, fallback) {
const number = Number(value);
return Number.isInteger(number) && number > 0 ? number : fallback;
}
function nonNegativeInteger(value, fallback) {
const number = Number(value);
return Number.isInteger(number) && number >= 0 ? number : fallback;
}
function taskListPage(url, options) {
const limit = Math.min(options.maxLimit, positiveInteger(url.searchParams.get("limit"), options.defaultLimit));
return {
offset: nonNegativeInteger(url.searchParams.get("offset"), 0),
limit,
maxLimit: options.maxLimit
};
}
+99 -25
View File
@@ -261,34 +261,39 @@ export class PostgresProjectManagementStore {
await this.ensureSchema();
const params = [];
const clauses = [];
for (const [column, value] of [["source_id", filters.sourceId], ["file_ref", filters.fileRef], ["project_id", filters.projectId]]) {
if (!value) continue;
params.push(value);
clauses.push(`${column} = $${params.length}`);
appendTaskFilterClauses(params, clauses, filters);
let sql = `SELECT task_ref, project_id, source_id, file_ref, task_id, title, status, parent_task_ref, depth, line_number, ordinal, link_count, source_fingerprint, task_json, updated_at
FROM mdtodo_task_projection ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}
ORDER BY project_id, source_id, file_ref, ordinal`;
if (Number.isInteger(filters.limit) && filters.limit > 0) {
params.push(filters.limit);
sql += ` LIMIT $${params.length}`;
}
if (Number.isInteger(filters.offset) && filters.offset > 0) {
params.push(filters.offset);
sql += ` OFFSET $${params.length}`;
}
const result = await this.pool.query(
`SELECT task_ref, project_id, source_id, file_ref, task_id, title, status, parent_task_ref, depth, line_number, ordinal, link_count, source_fingerprint, task_json, updated_at
FROM mdtodo_task_projection ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}
ORDER BY project_id, source_id, file_ref, ordinal`,
sql,
params
);
return result.rows.map((row) => ({
taskRef: row.task_ref,
projectId: row.project_id,
sourceId: row.source_id,
fileRef: row.file_ref,
taskId: row.task_id,
title: row.title,
status: row.status,
parentTaskRef: row.parent_task_ref,
depth: row.depth,
lineNumber: row.line_number,
ordinal: row.ordinal,
linkCount: row.link_count,
sourceFingerprint: row.source_fingerprint,
updatedAt: row.updated_at,
task: row.task_json ?? {}
}));
return result.rows.map(taskFromRow);
}
async countTasks(filters = {}) {
await this.ensureSchema();
const params = [];
const clauses = [];
appendTaskFilterClauses(params, clauses, filters);
const result = await this.pool.query(
`SELECT COUNT(*)::int AS total FROM mdtodo_task_projection ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}`,
params
);
return Number(result.rows[0]?.total ?? 0);
}
async getTask(taskRef) {
return (await this.listTasks({ taskRef, limit: 1 }))[0] ?? null;
}
async listWorkbenchLinks(filters = {}) {
@@ -378,7 +383,16 @@ class MemoryProjectManagementStore {
async getSource(sourceId) { return this.sources.get(sourceId) ?? null; }
async listDocuments(filters = {}) { return this.documents.filter((item) => !filters.sourceId || item.sourceId === filters.sourceId); }
async listTasks(filters = {}) {
return this.tasks.filter((item) => (!filters.sourceId || item.sourceId === filters.sourceId) && (!filters.fileRef || item.fileRef === filters.fileRef) && (!filters.projectId || item.projectId === filters.projectId));
const offset = Number.isInteger(filters.offset) && filters.offset > 0 ? filters.offset : 0;
const limit = Number.isInteger(filters.limit) && filters.limit > 0 ? filters.limit : null;
const rows = filterMemoryTasks(this.tasks, filters).sort(compareProjectTasks);
return limit ? rows.slice(offset, offset + limit) : rows.slice(offset);
}
async countTasks(filters = {}) {
return filterMemoryTasks(this.tasks, filters).length;
}
async getTask(taskRef) {
return this.tasks.find((task) => task.taskRef === taskRef) ?? null;
}
async listWorkbenchLinks(filters = {}) {
return this.links.filter((item) => (!filters.taskRef || item.taskRef === filters.taskRef) && (!filters.projectId || item.projectId === filters.projectId) && (!filters.sessionId || item.sessionId === filters.sessionId));
@@ -419,6 +433,8 @@ class UnconfiguredProjectManagementStore {
async listSources() { await this.ensureSchema(); }
async listDocuments() { await this.ensureSchema(); }
async listTasks() { await this.ensureSchema(); }
async countTasks() { await this.ensureSchema(); }
async getTask() { await this.ensureSchema(); }
async listWorkbenchLinks() { await this.ensureSchema(); }
async upsertWorkbenchLink() { await this.ensureSchema(); }
async recordAuditEvent() { await this.ensureSchema(); }
@@ -438,3 +454,61 @@ function workbenchLinkFromRow(row) {
link: row.link_json ?? {}
};
}
function appendTaskFilterClauses(params, clauses, filters = {}) {
for (const [column, value] of [["task_ref", filters.taskRef], ["source_id", filters.sourceId], ["file_ref", filters.fileRef], ["project_id", filters.projectId], ["parent_task_ref", filters.parentTaskRef], ["status", filters.status]]) {
if (!value) continue;
params.push(value);
clauses.push(`${column} = $${params.length}`);
}
const search = String(filters.search ?? "").trim();
if (search) {
params.push(`%${escapeSqlLike(search)}%`);
clauses.push(`(task_id ILIKE $${params.length} ESCAPE '\\' OR title ILIKE $${params.length} ESCAPE '\\' OR task_ref ILIKE $${params.length} ESCAPE '\\')`);
}
}
function taskFromRow(row) {
return {
taskRef: row.task_ref,
projectId: row.project_id,
sourceId: row.source_id,
fileRef: row.file_ref,
taskId: row.task_id,
title: row.title,
status: row.status,
parentTaskRef: row.parent_task_ref,
depth: row.depth,
lineNumber: row.line_number,
ordinal: row.ordinal,
linkCount: row.link_count,
sourceFingerprint: row.source_fingerprint,
updatedAt: row.updated_at,
task: row.task_json ?? {}
};
}
function filterMemoryTasks(tasks, filters = {}) {
const search = String(filters.search ?? "").trim().toLowerCase();
return tasks.filter((item) => {
if (filters.taskRef && item.taskRef !== filters.taskRef) return false;
if (filters.sourceId && item.sourceId !== filters.sourceId) return false;
if (filters.fileRef && item.fileRef !== filters.fileRef) return false;
if (filters.projectId && item.projectId !== filters.projectId) return false;
if (filters.parentTaskRef && item.parentTaskRef !== filters.parentTaskRef) return false;
if (filters.status && item.status !== filters.status) return false;
if (search && ![item.taskId, item.title, item.taskRef].some((value) => String(value ?? "").toLowerCase().includes(search))) return false;
return true;
});
}
function compareProjectTasks(a, b) {
return String(a.projectId ?? "").localeCompare(String(b.projectId ?? ""))
|| String(a.sourceId ?? "").localeCompare(String(b.sourceId ?? ""))
|| String(a.fileRef ?? "").localeCompare(String(b.fileRef ?? ""))
|| Number(a.ordinal ?? 0) - Number(b.ordinal ?? 0);
}
function escapeSqlLike(value) {
return String(value).replace(/[\\%_]/gu, "\\$&");
}