diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index 797ce445..e684a663 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -807,6 +807,8 @@ lanes: HWLAB_PROJECT_MANAGEMENT_SOURCE_ID: hwlab-v03-mdtodo HWLAB_PROJECT_MANAGEMENT_SOURCE_NAME: HWLAB v0.3 MDTODO HWLAB_PROJECT_MANAGEMENT_PROJECT_ID: project_hwlab_v03 + HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_DEFAULT_LIMIT: "200" + HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_MAX_LIMIT: "500" HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_URL: http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667/v1/hwpod-node-ops HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_API_KEY: secretRef:hwlab-v03-master-server-admin-api-key/api-key HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_PATH: /etc/hwlab/project-management/sources.json diff --git a/internal/project-management/server.test.ts b/internal/project-management/server.test.ts index 0969ab4c..9bc0f57e 100644 --- a/internal/project-management/server.test.ts +++ b/internal/project-management/server.test.ts @@ -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"); diff --git a/internal/project-management/server.ts b/internal/project-management/server.ts index c1e74d78..da17c44a 100644 --- a/internal/project-management/server.ts +++ b/internal/project-management/server.ts @@ -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 + }; +} diff --git a/internal/project-management/store.ts b/internal/project-management/store.ts index 3532243f..9525b91f 100644 --- a/internal/project-management/store.ts +++ b/internal/project-management/store.ts @@ -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, "\\$&"); +} diff --git a/web/hwlab-cloud-web/src/api/index.ts b/web/hwlab-cloud-web/src/api/index.ts index 76dae965..e7e4add0 100644 --- a/web/hwlab-cloud-web/src/api/index.ts +++ b/web/hwlab-cloud-web/src/api/index.ts @@ -14,7 +14,7 @@ export { apiKeysAPI } from "./apiKeys"; export { usageAPI } from "./usage"; export { billingAPI } from "./billing"; export { systemAPI, type SkillUploadFileInput } from "./system"; -export { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement"; +export { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement"; import { fetchJson } from "./client"; import { accessAPI } from "./access"; diff --git a/web/hwlab-cloud-web/src/api/projectManagement.ts b/web/hwlab-cloud-web/src/api/projectManagement.ts index 78ac0a08..32609565 100644 --- a/web/hwlab-cloud-web/src/api/projectManagement.ts +++ b/web/hwlab-cloud-web/src/api/projectManagement.ts @@ -135,7 +135,14 @@ export interface ProjectSourceMutationResponse extends ProjectManagementEnvelope export interface ProjectSourceProbeResponse extends ProjectManagementEnvelope { probe?: { ok?: boolean; status?: string; sourceId?: string; sourceKind?: string; entries?: number; valuesRedacted?: boolean } } export interface ProjectSourceReindexResponse extends ProjectManagementEnvelope { projection?: ProjectProjectionSummary & { sourceKind?: string; rootRef?: string; valuesRedacted?: boolean } } export interface MdtodoFileListResponse extends ProjectManagementEnvelope { files?: MdtodoFileRecord[] } -export interface MdtodoTaskListResponse extends ProjectManagementEnvelope { tasks?: MdtodoTaskRecord[] } +export interface MdtodoTaskPage { + offset?: number; + limit?: number; + maxLimit?: number; + total?: number; + hasMore?: boolean; +} +export interface MdtodoTaskListResponse extends ProjectManagementEnvelope { tasks?: MdtodoTaskRecord[]; page?: MdtodoTaskPage } export interface MdtodoTaskMutationInput { sourceId?: string; fileRef?: string; @@ -160,6 +167,11 @@ export interface MdtodoTaskQuery { sourceId?: string | null; fileRef?: string | null; projectId?: string | null; + parentTaskRef?: string | null; + status?: string | null; + search?: string | null; + limit?: number | null; + offset?: number | null; } export interface ProjectWorkbenchLinkQuery { @@ -177,17 +189,17 @@ export const projectManagementAPI = { probeSource: (sourceId: string): Promise> => fetchJson(`/v1/project-management/mdtodo/sources/${encodeURIComponent(sourceId)}/probe`, { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "project mdtodo source probe" }), reindexSource: (sourceId: string): Promise> => fetchJson(`/v1/project-management/mdtodo/sources/${encodeURIComponent(sourceId)}/reindex`, { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "project mdtodo source reindex" }), files: (sourceId?: string | null): Promise> => fetchJson(`/v1/project-management/mdtodo/files${queryString({ sourceId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo files" }), - tasks: (query: MdtodoTaskQuery = {}): Promise> => fetchJson(`/v1/project-management/mdtodo/tasks${queryString({ sourceId: query.sourceId, fileRef: query.fileRef, projectId: query.projectId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo tasks" }), + tasks: (query: MdtodoTaskQuery = {}): Promise> => fetchJson(`/v1/project-management/mdtodo/tasks${queryString({ sourceId: query.sourceId, fileRef: query.fileRef, projectId: query.projectId, parentTaskRef: query.parentTaskRef, status: query.status, search: query.search, limit: query.limit, offset: query.offset })}`, { timeoutMs: 12000, timeoutName: "project mdtodo tasks" }), updateTask: (taskRef: string, input: MdtodoTaskMutationInput): Promise> => fetchJson(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(taskRef)}`, { method: "PATCH", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task update" }), createTask: (input: MdtodoTaskMutationInput): Promise> => fetchJson("/v1/project-management/mdtodo/tasks", { method: "POST", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task create" }), deleteTask: (taskRef: string, input: MdtodoTaskMutationInput): Promise> => fetchJson(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(taskRef)}`, { method: "DELETE", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task delete" }), workbenchLinks: (query: ProjectWorkbenchLinkQuery = {}): Promise> => fetchJson(`/v1/project-management/workbench-links${queryString({ taskRef: query.taskRef, projectId: query.projectId, sessionId: query.sessionId })}`, { timeoutMs: 12000, timeoutName: "project workbench links" }) }; -function queryString(values: Record): string { +function queryString(values: Record): string { const params = new URLSearchParams(); for (const [key, value] of Object.entries(values)) { - if (value) params.set(key, value); + if (value !== null && value !== undefined && value !== "") params.set(key, String(value)); } const text = params.toString(); return text ? `?${text}` : ""; diff --git a/web/hwlab-cloud-web/src/views/projects/MdtodoView.vue b/web/hwlab-cloud-web/src/views/projects/MdtodoView.vue index 2b569495..a38ffb57 100644 --- a/web/hwlab-cloud-web/src/views/projects/MdtodoView.vue +++ b/web/hwlab-cloud-web/src/views/projects/MdtodoView.vue @@ -4,7 +4,7 @@