fix: bound mdtodo task windows
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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, "\\$&");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user