diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index e272e6c6..4b611e44 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -1,5 +1,7 @@ # QingLong 3.0 Architecture RFC +- D-431/ADR-0533(原生 Console 源码候选):Task、Trigger、Run 列表接入既有 keyset API,开放下一页、回到首页和当前页刷新,每次最多 64 条并替换 DOM,不缓存历史页、不轮询或扩大后端查询。验证规范 cursor、页内/跨页顺序与继续边界;请求/选择代次防止旧列表、错误、详情和 Secret catalog 覆盖当前页面。客户端与生产 HTTP parser 集成 15/15、完整 Local API 114/114,静态资产共 111,857 bytes;默认 headless/Cluster 不变。远端 CI、浏览器证据和 exact 阶段实物分别核验,不借用 D-430 archive。 + - D-430/ADR-0532(原生 Console 源码候选):在原生运行详情中开放日志下一片段、回到开头与当前片段刷新,固定 32 KiB 替换窗口,不累计全文或后台轮询。严格验证响应身份、字节范围与前进 cursor,按不含凭据的选择代次丢弃过期响应,防止切换或断开后覆盖新详情。复用既有 API/Policy/Audit,不新增包、依赖、迁移或后端 authority;默认 headless 与 Cluster 不变。客户端行为测试和真实 Chromium 预览覆盖分页、重复点击、过期响应与字节边界;远端 CI 和 exact 双架构 Console 实物尚待验证。 - D-429/ADR-0531(exact 双架构 Console 阶段实物已交付):现有面板的 Cron “日志”入口不恢复 2.x `/api/crons/:id/log` 或文件系统日志权威,而是复用规范 v3 Run/Attempt/Artifact 链。`BoundedRunListItem` 仅增加经 128 字节无控制字符验证的可选 `triggerId`;页面按 `triggerId + taskId` 在 Edge 64/Standalone 256 条 Run 总预算中定位最新 Run,再读取 detail 的 latest Attempt 与 16/32 KiB 首个日志片段,明确呈现 pending/retired/truncated。所有请求继续走 Bearer、Project Policy、durable audit、credential reconfirm 与 Artifact retention;不暴露路径、Artifact id、Secret 或其他 Run 私有字段。QL3 Action 列只开放日志,列表关闭旧 10 秒轮询,日志关闭旧 2 秒轮询,仅由初次进入、点击与手动刷新驱动;2.x 行为保持不变。没有新增 package、dependency、migration、schema、listener、连接或后台进程,headless 与 Cluster authority 不变。本地 runtime-core 594/594、Local API 93/93、全包/后端/架构审计、Node 20 production build、240 files / 11,965,017 bytes 闭包审计和真实 Chromium journey 均通过;浏览器稳定 15 秒只有一次 Cron list,日志稳定 5 秒只有 Run list/detail/log 各一次。运行时代码提交 `09ef1745226c05521a9a44accb9d9ef95dd46c85` 的主 CI run `33787481413` 为 42 success / 3 expected skip / 0 fail,Console artifact run `33789576578` 为 43 success / 2 expected skip / 0 fail;amd64/arm64/milestone artifact `9907367021`/`9907250404`/`9907412968` 保留至 2026-10-03,下载后的 `SHA256SUMS` 与 v7 离线 auditor 均返回 `compatible=true`。 diff --git a/docs/adr/ADR-0533-native-console-ledger-pagination.md b/docs/adr/ADR-0533-native-console-ledger-pagination.md new file mode 100644 index 00000000..0c83cee0 --- /dev/null +++ b/docs/adr/ADR-0533-native-console-ledger-pagination.md @@ -0,0 +1,30 @@ +# ADR-0533:原生 Console 的任务、定时与运行列表分页 + +- 状态:Accepted(D-431 源码候选;远端 CI 与阶段实物待验证) +- 日期:2026-09-04 +- 关联:QL-RFC-0001 D-431、ADR-0532 + +## 问题 + +原生 Console 已能创建任务、配置定时、运行和读取日志,但 Task、Trigger、Run 列表在第 64 条后要求操作者自行调用 API。超过一屏的数据无法从页面到达,不能作为日常管理体验。后端已有三类规范 keyset API,不需要新增分页服务或恢复旧 Cron 权威。 + +## 决策 + +- 三类列表共用当前静态客户端中的小型分页逻辑,不新增 workspace package、依赖或后端路由。页脚提供“下一页”“回到首页”“刷新当前页”;顶部刷新、切换栏目和管理写入后的列表刷新从首页开始。 +- 每个显式动作最多一个列表 GET、64 条记录,替换当前 DOM;只保留当前请求边界,不缓存历史页、累积记录、自动翻页、轮询或增加总数扫描。Run 详情和日志仍按既有显式选择流程读取。 +- Task/Trigger 分别按 `taskId`/`triggerId` 升序;Run 按 `createdAtMs`、同时间戳下 `runId` 降序。客户端核对页内及跨页顺序、重复 ID、64 条上限、继续标志、完整 cursor 字段与最后一行一致性。异常页不显示继续操作。 +- 查询使用正式 HTTP parser 接受的 canonical ASCII ID;这些 ID 不含查询分隔符,不能把合法冒号编码成 parser 不接受的别名。新增测试将真实客户端生成的 URL 送入生产 HTTP surface,验证三类 continuation 被解析成正确的 typed cursor。 +- 每轮刷新绑定 Project、栏目与不含 credential 的请求代次;过期成功、失败和 finally 不覆盖新的列表、连接状态或 busy 状态。刷新清空旧详情选择;Task/Trigger 详情响应也绑定选择代次和列表代次。Secret 仍保持原有有界目录,但列表响应在提交 catalog/DOM 前同样检查代次。 +- 页脚在旧 DOM 被替换后不能再发起请求;重复点击同一个页脚只提交一次。失败的后续页保留当前边界重试与回首页入口,空的后续页不会被误报为整个 Project 没有数据。 + +## 明确边界 + +这不是数据库快照:浏览期间新建或更新的数据可能需要回首页重读。没有虚构总页数、随机跳页或无界上一页历史。它不包含 Secret 全目录分页、Run Event/Step 分页、筛选搜索、Cluster UI 或旧面板写操作。默认 headless 的资源和安装闭包不变。 + +## 验证 + +客户端测试直接执行发布的 `console.js`,覆盖三类第 65 条到达、刷新/回首页、同时间戳 Run、空页、503 重试、错误 cursor/顺序、重复点击、切换栏目、断开、同栏目重读、旧详情和旧 Secret catalog 丢弃;含真实 HTTP parser 集成合计 15/15。完整 Local API 为 114/114,无失败或跳过;完整后端 1685 项(1683 pass / 2 环境条件 skip / 0 fail)。Local image 与 18-package boundary audit 均 compatible。 + +原生 JS 为 80,336 bytes,三项静态资产共 111,857 bytes,仍小于单文件 96 KiB / 合计 192 KiB。D-430 的 `12ed38b7` 构建不含本切片;本地测试不等于 exact 双架构产物交付。 + +真实 Chromium 使用当前原生静态资产与合成 HTTP 数据验证了 Task、Trigger、Run 的第 65 条可达,任务详情可从第二页打开,DOM 每次只保留当前页。请求记录包含三个规范 continuation URL,浏览期间没有自动列表轮询。该浏览器证据验证交互,不冒充真实数据库、Owner 写入或双架构镜像验收。 diff --git a/docs/adr/README.md b/docs/adr/README.md index defe59cd..ff589364 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -536,6 +536,7 @@ | [ADR-0530](./ADR-0530-bounded-local-panel-capability-bootstrap.md) | 有界 Local 面板能力发现与启动适配 | Accepted(D-428 exact Console 双架构阶段实物已交付) | | [ADR-0531](./ADR-0531-canonical-run-log-bridge-for-legacy-panel.md) | 现有面板到规范 Run 日志的有界桥接 | Accepted(D-429 exact Console 双架构阶段实物已交付) | | [ADR-0532](./ADR-0532-native-console-log-window-navigation.md) | 原生 Console 日志窗口导航 | Accepted(D-430 源码候选;实物待验证) | +| [ADR-0533](./ADR-0533-native-console-ledger-pagination.md) | 原生 Console 任务、定时与运行列表分页 | Accepted(D-431 源码候选;实物待验证) | ## 规则 diff --git a/docs/operations/ql3-local-web-console.md b/docs/operations/ql3-local-web-console.md index f25bd46c..a1a6c8ec 100644 --- a/docs/operations/ql3-local-web-console.md +++ b/docs/operations/ql3-local-web-console.md @@ -24,6 +24,8 @@ D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一 上表的旧面板是 bundle 自带的适配版本,不是未修改的 2.x 静态页面;旧账号密码/JWT、现有 2.x 数据和 Cluster 管理能力不会因页面可打开而自动接通。 +D-431 源码候选进一步给原生 Task、Trigger、Run 列表增加页脚“下一页/回到首页/刷新当前页”。每次只显示最多 64 条,翻页替换旧记录和详情,不累积全文;顶部刷新与切换栏目回首页,页脚刷新保留当前边界。浏览期间数据可能变化,因此不提供固定总页数或快照承诺。Secret 目录和 Run Event/Step 仍维持现有窗口限制。此功能尚未交付到上表的 D-429 或 D-430 archive,见 [ADR-0533](../adr/ADR-0533-native-console-ledger-pagination.md)。 + D-426b2c 又补齐了 Console 镜像的 adopted-target 入口证据:切换演练使用 `ql3-local-api --cutover-probe --config `,同时绑定外层 API 配置、内层 Application 配置与 exact mounts,但该模式不会启动本页使用的 listener、credential、scheduler 或 mutation surface。正常启动仍使用下文不带 `--cutover-probe` 的命令;提交 `229c3cb4e826866a0c7c4d81cb5e52cdc3975eec` 的 [artifact run 33463415938](https://github.com/whyour/qinglong/actions/runs/33463415938) 已交付 exact amd64/arm64 Console Trial Kit 与 milestone,三份下载产物的离线 auditor 均为 `compatible=true`。 ## 前置条件 diff --git a/packages/ql3-local-api/assets/console/console.js b/packages/ql3-local-api/assets/console/console.js index d7cad2ce..d036cbb1 100644 --- a/packages/ql3-local-api/assets/console/console.js +++ b/packages/ql3-local-api/assets/console/console.js @@ -916,8 +916,16 @@ } } - async function renderSecrets() { - const value = await loadSecretCatalog(); + async function renderSecrets(request) { + const response = await api( + `/api/v3/projects/${request.project}/secrets?limit=64`, + ); + if (!request.isCurrent()) return; + state.secretCatalog = normalizeSecretMetadata(response); + const value = { + secrets: state.secretCatalog, + truncated: response.truncated === true, + }; if (value.secrets.length === 0) { empty('还没有加密 Secret。创建后可在命令 Task 中绑定固定版本。'); return; @@ -1221,11 +1229,122 @@ } } - async function renderTasks() { - const value = await api(`/api/v3/projects/${state.project}/tasks?limit=64`); - const tasks = Array.isArray(value.tasks) ? value.tasks : []; + function ledgerCursor(view, value) { + const field = + view === 'runs' ? 'runId' : view === 'tasks' ? 'taskId' : 'triggerId'; + const keys = view === 'runs' ? [field, 'createdAtMs'] : [field]; + if ( + !value || + Object.keys(value).length !== keys.length || + !keys.every((key) => Object.hasOwn(value, key)) || + typeof value[field] !== 'string' || + !TASK_PATTERN.test(value[field]) || + (view === 'runs' && + (!Number.isSafeInteger(value.createdAtMs) || value.createdAtMs < 0)) + ) { + throw new TypeError('列表分页边界无效,请回到首页重新读取。'); + } + return view === 'runs' + ? { runId: value.runId, createdAtMs: value.createdAtMs } + : { [field]: value[field] }; + } + + function ledgerCursorFollows(view, cursor, previous) { + if (!previous) return true; + if (view === 'runs') + return ( + cursor.createdAtMs < previous.createdAtMs || + (cursor.createdAtMs === previous.createdAtMs && + cursor.runId < previous.runId) + ); + const field = view === 'tasks' ? 'taskId' : 'triggerId'; + return cursor[field] > previous[field]; + } + + async function readLedgerPage(request) { + const { view, project, after } = request; + let query = 'limit=64'; + if (after) { + const cursor = ledgerCursor(view, after); + // The HTTP contract accepts canonical ASCII IDs, not percent-encoded aliases. + query += + view === 'runs' + ? `&after_created_at_ms=${cursor.createdAtMs}&after_run_id=${cursor.runId}` + : view === 'tasks' + ? `&after_task_id=${cursor.taskId}` + : `&after_trigger_id=${cursor.triggerId}`; + } + const value = await api(`/api/v3/projects/${project}/${view}?${query}`); + if (!request.isCurrent()) return null; + const rows = value[view]; + const hasMore = view === 'triggers' ? value.truncated : value.hasMore; + if ( + !Array.isArray(rows) || + rows.length > 64 || + typeof hasMore !== 'boolean' + ) { + throw new TypeError('列表响应无效,请重新读取。'); + } + let boundary = after; + for (const row of rows) { + const cursor = ledgerCursor( + view, + view === 'runs' + ? { runId: row?.id, createdAtMs: row?.createdAtMs } + : view === 'tasks' + ? { taskId: row?.taskId } + : { triggerId: row?.triggerId }, + ); + if (!ledgerCursorFollows(view, cursor, boundary)) { + throw new TypeError('列表顺序或分页边界发生异常,请回到首页。'); + } + boundary = cursor; + } + let next = null; + if (hasMore) { + next = ledgerCursor(view, value.next); + if (!rows.length || JSON.stringify(next) !== JSON.stringify(boundary)) { + throw new TypeError('列表继续边界不匹配,请回到首页。'); + } + } else if (value.next != null) { + throw new TypeError('列表终点携带异常继续边界,请回到首页。'); + } + return { rows, next }; + } + + function ledgerNavigation(request, next) { + const footer = element('section'); + const actions = element('div', 'detail-actions'); + const load = (after) => { + if (!footer.isConnected || !request.isCurrent()) return; + return refresh({ pageCursor: after }); + }; + actions.append(actionButton('刷新当前页', () => load(request.after))); + if (request.after) + actions.append(actionButton('回到首页', () => load(null))); + if (next) actions.append(actionButton('下一页', () => load(next))); + footer.append( + actions, + element( + 'p', + 'privacy-note', + '每次最多 64 条,翻页替换当前窗口。列表不是固定快照;顶部刷新或切换栏目从首页重新读取。', + ), + ); + return footer; + } + + async function renderTasks(request) { + const page = await readLedgerPage(request); + if (!page) return; + const tasks = page.rows; if (tasks.length === 0) { - empty('还没有可见任务。请先通过受信任的管理入口发布 Task。'); + empty( + request.after + ? '当前窗口没有任务,可回到首页重新读取。' + : '还没有可见任务。选择“创建任务”开始。', + ); + nodes.ledger.append(ledgerNavigation(request, page.next)); return; } const fragment = document.createDocumentFragment(); @@ -1260,20 +1379,23 @@ list.append(button); } fragment.append(list); - if (value.hasMore) { - fragment.append( - element( - 'p', - 'privacy-note', - '当前只展示前 64 条;完整分页将在后续 Console 切片开放。', - ), - ); - } + fragment.append(ledgerNavigation(request, page.next)); replace(nodes.ledger, fragment); } async function selectTask(taskId) { state.selectedId = taskId; + const selection = {}; + const project = state.project; + const listRequest = state.listRequest; + state.detailSelection = selection; + const isCurrent = () => + state.detailSelection === selection && + state.listRequest === listRequest && + state.selectedId === taskId && + state.view === 'tasks' && + state.project === project && + Boolean(state.token); const selected = nodes.ledger.querySelectorAll('.record'); for (const row of selected) { if (row.dataset.identity === taskId) { @@ -1286,12 +1408,13 @@ loadingBox.append(element('span')); replace(nodes.detail, loadingBox); try { - const value = await api( - `/api/v3/projects/${state.project}/tasks/${taskId}`, - ); + const value = await api(`/api/v3/projects/${project}/tasks/${taskId}`); + if (!isCurrent()) return; + if (value.task?.taskId !== taskId) + throw new Error('Task identity mismatch'); renderTaskDetail(value.task); } catch (error) { - detailEmpty(describeError(error)); + if (isCurrent()) detailEmpty(describeError(error)); } } @@ -1360,13 +1483,17 @@ } } - async function renderTriggers() { - const value = await api( - `/api/v3/projects/${state.project}/triggers?limit=64`, - ); - const triggers = Array.isArray(value.triggers) ? value.triggers : []; + async function renderTriggers(request) { + const page = await readLedgerPage(request); + if (!page) return; + const triggers = page.rows; if (triggers.length === 0) { - empty('还没有定时触发器。创建后,现有本地调度器会按 cron 自动生成 Run。'); + empty( + request.after + ? '当前窗口没有定时,可回到首页重新读取。' + : '还没有定时触发器。创建后,本地调度器会按 cron 自动生成 Run。', + ); + nodes.ledger.append(ledgerNavigation(request, page.next)); return; } const fragment = document.createDocumentFragment(); @@ -1404,20 +1531,23 @@ list.append(button); } fragment.append(list); - if (value.truncated) { - fragment.append( - element( - 'p', - 'privacy-note', - '当前只展示前 64 条;使用 API 可继续读取下一页。', - ), - ); - } + fragment.append(ledgerNavigation(request, page.next)); replace(nodes.ledger, fragment); } async function selectTrigger(triggerId) { state.selectedId = triggerId; + const selection = {}; + const project = state.project; + const listRequest = state.listRequest; + state.detailSelection = selection; + const isCurrent = () => + state.detailSelection === selection && + state.listRequest === listRequest && + state.selectedId === triggerId && + state.view === 'triggers' && + state.project === project && + Boolean(state.token); for (const row of nodes.ledger.querySelectorAll('.record')) { if (row.dataset.identity === triggerId) { row.setAttribute('aria-current', 'true'); @@ -1430,11 +1560,14 @@ replace(nodes.detail, loadingBox); try { const value = await api( - `/api/v3/projects/${state.project}/triggers/${triggerId}`, + `/api/v3/projects/${project}/triggers/${triggerId}`, ); + if (!isCurrent()) return; + if (value.trigger?.triggerId !== triggerId) + throw new Error('Trigger identity mismatch'); renderTriggerDetail(value.trigger); } catch (error) { - detailEmpty(describeError(error)); + if (isCurrent()) detailEmpty(describeError(error)); } } @@ -1480,11 +1613,17 @@ replace(nodes.detail, fragment); } - async function renderRuns() { - const value = await api(`/api/v3/projects/${state.project}/runs?limit=64`); - const runs = Array.isArray(value.runs) ? value.runs : []; + async function renderRuns(request) { + const page = await readLedgerPage(request); + if (!page) return; + const runs = page.rows; if (runs.length === 0) { - empty('还没有运行记录。切换到任务,选择一个已启用 Task 开始运行。'); + empty( + request.after + ? '当前窗口没有运行,可回到首页重新读取。' + : '还没有运行记录。切换到任务,选择一个已启用 Task 开始运行。', + ); + nodes.ledger.append(ledgerNavigation(request, page.next)); return; } const fragment = document.createDocumentFragment(); @@ -1513,15 +1652,7 @@ list.append(button); } fragment.append(list); - if (value.hasMore) { - fragment.append( - element( - 'p', - 'privacy-note', - '当前只展示最近 64 条;使用 API 可继续读取下一页。', - ), - ); - } + fragment.append(ledgerNavigation(request, page.next)); replace(nodes.ledger, fragment); } @@ -1856,19 +1987,36 @@ } } - async function refresh() { + async function refresh(options = {}) { if (!state.token) return; + state.selectedId = null; + detailEmpty(); + const request = { + project: state.project, + view: state.view, + after: options.pageCursor ?? null, + }; + state.listRequest = request; + request.isCurrent = () => + state.listRequest === request && + state.project === request.project && + state.view === request.view && + Boolean(state.token); loading(); try { - if (state.view === 'tasks') await renderTasks(); - else if (state.view === 'triggers') await renderTriggers(); - else if (state.view === 'secrets') await renderSecrets(); - else await renderRuns(); - setConnection('connected', `${state.project} · 已连接`); + if (request.view === 'tasks') await renderTasks(request); + else if (request.view === 'triggers') await renderTriggers(request); + else if (request.view === 'secrets') await renderSecrets(request); + else await renderRuns(request); + if (request.isCurrent()) + setConnection('connected', `${request.project} · 已连接`); } catch (error) { - errorState(error); + if (request.isCurrent()) { + errorState(error); + if (request.after) nodes.ledger.append(ledgerNavigation(request, null)); + } } finally { - setBusy(false); + if (request.isCurrent()) setBusy(false); } } diff --git a/packages/ql3-local-api/test/consoleLedger.test.cjs b/packages/ql3-local-api/test/consoleLedger.test.cjs new file mode 100644 index 00000000..84c0f13f --- /dev/null +++ b/packages/ql3-local-api/test/consoleLedger.test.cjs @@ -0,0 +1,426 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { test } = require('node:test'); + +// A minimal DOM; all navigation, requests and validation execute the shipped JS. +class Element { + constructor(tag = 'div') { + this.tag = tag; + this.children = []; + this.dataset = {}; + this.listeners = {}; + this.isConnected = true; + } + set textContent(value) { + this.text = value; + this.replaceChildren(); + } + get textContent() { + return (this.text || '') + this.children.map((x) => x.textContent).join(''); + } + append(...children) { + for (const child of children) { + if (child.tag === 'fragment') this.append(...child.children); + else { + child.parent = this; + this.children.push(child); + } + } + } + detach() { + this.isConnected = false; + this.children.forEach((child) => child.detach()); + } + replaceChildren(...children) { + this.children.forEach((child) => child.detach()); + this.children = []; + this.append(...children); + } + setAttribute(name, value) { + this[name] = value; + } + removeAttribute(name) { + delete this[name]; + } + addEventListener(name, handler) { + this.listeners[name] = handler; + } + querySelector() { + return new Element(); + } + querySelectorAll(selector) { + return this.children.flatMap((child) => [ + ...(selector === child.tag || selector === `.${child.className}` + ? [child] + : []), + ...child.querySelectorAll(selector), + ]); + } + focus() {} +} + +function fixture(view, response) { + const nodes = new Map(); + const calls = []; + const context = vm.createContext({ + TextDecoder, + Uint8Array, + URLSearchParams, + Intl, + console, + window: { atob }, + document: { + getElementById(id) { + if (!nodes.has(id)) nodes.set(id, new Element()); + return nodes.get(id); + }, + querySelector: () => new Element(), + createElement: (tag) => new Element(tag), + createDocumentFragment: () => new Element('fragment'), + addEventListener() {}, + }, + fetch: async (url, options) => { + calls.push({ url, options }); + const body = await response(url, calls.length); + return { + ok: !body.httpStatus, + status: body.httpStatus || 200, + json: async () => body, + headers: { get: () => null }, + }; + }, + }); + const source = fs.readFileSync( + path.join(__dirname, '../assets/console/console.js'), + 'utf8', + ); + assert.ok(source.endsWith('})();\n')); + vm.runInContext( + source.slice(0, -6) + + 'globalThis.client = { state, nodes, refresh, selectTask, selectTrigger, disconnect };})();', + context, + ); + Object.assign(context.client.state, { + token: 'memory-only-test-token', + project: 'default', + view, + }); + return { ...context.client, calls }; +} + +const cursor = (view, row) => + view === 'runs' + ? { runId: row.id, createdAtMs: row.createdAtMs } + : view === 'tasks' + ? { taskId: row.taskId } + : { triggerId: row.triggerId }; +const rows = (view, count = 65) => + Array.from({ length: count }, (_, index) => { + const id = `item:${String( + view === 'runs' ? count - index - 1 : index, + ).padStart(3, '0')}`; + return { + id, + taskId: id, + triggerId: id, + name: id, + revision: 1, + taskRevision: 1, + kind: 'command', + specSchema: 'qinglong/command@v1', + enabled: true, + status: 'succeeded', + executionOrigin: 'manual', + executionOwner: 'runtime', + createdAtMs: 2000, + updatedAtMs: 2000, + }; + }); +const page = (view, entries, more = false) => ({ + [view]: entries, + [view === 'triggers' ? 'truncated' : 'hasMore']: more, + ...(more + ? { next: cursor(view, entries.at(-1)) } + : view === 'triggers' + ? { next: null } + : {}), +}); +const button = (client, label) => + client.nodes.ledger + .querySelectorAll('button') + .find((entry) => entry.textContent === label); +const records = (client) => client.nodes.ledger.querySelectorAll('.record'); +const deferred = () => { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +}; + +for (const view of ['tasks', 'triggers', 'runs']) { + test(`${view}: reaches row 65, refreshes the current cursor and resets without retaining old rows`, async () => { + const entries = rows(view); + const client = fixture(view, (url) => + url.includes('&after_') + ? page(view, entries.slice(64)) + : page(view, entries.slice(0, 64), true), + ); + await client.refresh(); + assert.equal(records(client).length, 64); + const next = button(client, '下一页'); + await next.listeners.click(); + await next.listeners.click(); // A detached footer cannot issue another request. + assert.equal(records(client).length, 1); + assert.equal(records(client)[0].dataset.identity, entries[64].id); + assert.equal(button(client, '下一页'), undefined); + assert.equal(client.calls.length, 2); + const expected = + view === 'runs' + ? 'after_created_at_ms=2000&after_run_id=item:001' + : view === 'tasks' + ? 'after_task_id=item:063' + : 'after_trigger_id=item:063'; + assert.equal( + client.calls[1].url, + `/api/v3/projects/default/${view}?limit=64&${expected}`, + ); + await button(client, '刷新当前页').listeners.click(); + assert.equal(client.calls[2].url, client.calls[1].url); + await button(client, '回到首页').listeners.click(); + assert.equal(client.calls[3].url, client.calls[0].url); + assert.equal(records(client).length, 64); + assert.equal(client.calls.length, 4); + assert.ok(client.calls.every(({ options }) => options.method === 'GET')); + assert.equal(client.state.selectedId, null); + }); + + test(`${view}: rejects oversized, unordered, empty-continuation and mismatched cursor responses`, async () => { + const entries = rows(view); + const invalid = [ + page(view, entries), + page(view, [entries[1], entries[0]]), + page(view, [entries[0], entries[0]]), + { [view]: [] }, + { + ...page(view, [], false), + [view === 'triggers' ? 'truncated' : 'hasMore']: true, + next: cursor(view, entries[0]), + }, + { + ...page(view, entries.slice(0, 2), true), + next: cursor(view, entries[0]), + }, + { + ...page(view, entries.slice(0, 2), true), + next: { ...cursor(view, entries[1]), extra: 'not-allowed' }, + }, + { ...page(view, entries.slice(0, 2)), next: cursor(view, entries[1]) }, + page(view, [ + { + ...entries[0], + id: 'bad&limit=100', + taskId: 'bad&limit=100', + triggerId: 'bad&limit=100', + }, + ]), + ]; + for (const value of invalid) { + const client = fixture(view, () => value); + await client.refresh(); + assert.equal(records(client).length, 0); + assert.equal(button(client, '下一页'), undefined); + assert.equal(client.nodes.connection.dataset.state, 'error'); + assert.equal(client.calls.length, 1); + } + }); + + test(`${view}: keeps an empty later page reachable and retries failures at the same boundary`, async () => { + const entries = rows(view); + const client = fixture(view, (_url, call) => + call === 1 + ? page(view, entries.slice(0, 64), true) + : call === 2 + ? { httpStatus: 503, code: 'request_unavailable' } + : page(view, []), + ); + await client.refresh(); + await button(client, '下一页').listeners.click(); + assert.equal(client.nodes.connection.dataset.state, 'error'); + await button(client, '刷新当前页').listeners.click(); + assert.equal(client.calls[2].url, client.calls[1].url); + assert.equal(records(client).length, 0); + assert.ok(button(client, '回到首页')); + assert.equal(button(client, '下一页'), undefined); + assert.equal(client.calls.length, 3); + }); +} + +test('coalesces footer clicks and prevents a stale page or error from replacing another view', async () => { + for (const result of [ + page('tasks', rows('tasks', 1)), + { httpStatus: 401, code: 'authentication_required' }, + ]) { + const pending = deferred(); + const client = fixture('tasks', (url, call) => + call === 1 + ? page('tasks', rows('tasks').slice(0, 64), true) + : url.includes('/tasks?') + ? pending.promise + : page('runs', rows('runs', 1)), + ); + await client.refresh(); + const next = button(client, '下一页'); + const reading = next.listeners.click(); + await next.listeners.click(); + assert.equal(client.calls.length, 2); + client.state.view = 'runs'; + await client.refresh(); + const displayed = client.nodes.ledger.textContent; + pending.resolve(result); + await reading; + assert.equal(client.nodes.ledger.textContent, displayed); + assert.equal(client.nodes.connection.dataset.state, 'connected'); + assert.equal(client.nodes.refresh.disabled, false); + } +}); + +test('discarding a response after disconnect/reconnect does not change new session state', async () => { + const old = deferred(); + const client = fixture('tasks', (_url, call) => + call === 1 ? old.promise : page('tasks', rows('tasks', 1)), + ); + const reading = client.refresh(); + client.disconnect(); + const disconnected = client.nodes.ledger.textContent; + old.resolve(page('tasks', rows('tasks'))); + await reading; + assert.equal(client.nodes.ledger.textContent, disconnected); + assert.equal(client.state.token, null); + client.state.token = 'new-session-token'; + await client.refresh(); + assert.equal(records(client).length, 1); + assert.equal( + JSON.stringify(client.state.listRequest).includes('token'), + false, + ); +}); + +test('same-view refresh rejects stale success and leaves current loading state intact', async () => { + const first = deferred(); + const second = deferred(); + const client = fixture('tasks', (_url, call) => + call === 1 ? first.promise : second.promise, + ); + const old = client.refresh(); + const current = client.refresh(); + first.resolve(page('tasks', rows('tasks', 1))); + await old; + assert.equal(records(client).length, 0); + assert.equal(client.nodes.refresh.disabled, true); + second.resolve(page('tasks', rows('tasks', 2))); + await current; + assert.equal(records(client).length, 2); +}); + +test('Task and Trigger details cannot overwrite a later list generation', async () => { + for (const view of ['tasks', 'triggers']) { + const pending = deferred(); + const client = fixture(view, (url) => + url.includes('?') ? page(view, rows(view, 1)) : pending.promise, + ); + await client.refresh(); + const select = view === 'tasks' ? client.selectTask : client.selectTrigger; + const reading = select('item:000'); + await client.refresh(); + client.nodes.detail.textContent = 'current detail'; + pending.resolve( + view === 'tasks' + ? { task: rows(view, 1)[0] } + : { trigger: rows(view, 1)[0] }, + ); + await reading; + assert.equal(client.nodes.detail.textContent, 'current detail'); + } +}); + +test('stale Secret metadata cannot replace the new ledger or catalog', async () => { + const pending = deferred(); + const client = fixture('secrets', (url) => + url.includes('/secrets?') ? pending.promise : page('tasks', []), + ); + const reading = client.refresh(); + client.state.view = 'tasks'; + await client.refresh(); + client.state.secretCatalog = ['current-session-catalog']; + const displayed = client.nodes.ledger.textContent; + pending.resolve({ secrets: [], truncated: false }); + await reading; + assert.equal(client.nodes.ledger.textContent, displayed); + assert.deepEqual(client.state.secretCatalog, ['current-session-catalog']); +}); + +test('generated continuation URLs pass the production HTTP parser for all three ledgers', async (t) => { + const net = require('node:net'); + const { + startLocalApiHttpSurface, + } = require('../dist/transport/httpSurface.js'); + const port = await new Promise((resolve, reject) => { + const listener = net.createServer(); + listener.once('error', reject); + listener.listen(0, '127.0.0.1', () => { + const port = listener.address().port; + listener.close((error) => (error ? reject(error) : resolve(port))); + }); + }); + const observed = []; + const surface = await startLocalApiHttpSurface({ + profile: 'edge', + host: '127.0.0.1', + port, + admission: { + async prepare({ operation }) { + observed.push(operation); + const view = { + 'task.list': 'tasks', + 'trigger.list': 'triggers', + 'run.list': 'runs', + }[operation.operationId]; + assert.ok(view); + const input = operation.input || operation; + assert.equal(input.limit, 64); + const entries = rows(view); + if (input.after) + assert.deepEqual(input.after, cursor(view, entries[63])); + return { + bodyMode: 'none', + maximumBodyBytes: 0, + async handle() { + return { + statusCode: 200, + body: input.after + ? page(view, entries.slice(64)) + : page(view, entries.slice(0, 64), true), + }; + }, + }; + }, + }, + }); + t.after(() => surface.stopAndDrain()); + for (const view of ['tasks', 'triggers', 'runs']) { + const client = fixture(view, async (url) => { + const response = await fetch(`http://127.0.0.1:${port}${url}`, { + headers: { authorization: 'Bearer test' }, + }); + assert.equal(response.status, 200); + return response.json(); + }); + await client.refresh(); + await button(client, '下一页').listeners.click(); + assert.equal(records(client).length, 1); + } + assert.equal(observed.length, 6); +});