feat(ql3): manage canonical task runs from the existing panel

This commit is contained in:
whyour
2026-09-04 04:27:42 +08:00
parent 2005cb6ba9
commit e4ba5d405f
13 changed files with 1199 additions and 6 deletions
+3 -1
View File
@@ -1,8 +1,10 @@
# QingLong 3.0 Architecture RFC
- D-433/ADR-0535(现有面板执行管理源码候选):通过可选 `panel.runControl=task_run_v1` 开放逐项 Task start 和确切 Run cancellation,不恢复旧 Cron 写接口。确认前分别展示定时绑定版本与当前任务版本;每次项目 Run 窗口最多 64 条,只显示当前 Task 匹配项,支持显式选择、状态刷新和该次执行首片日志。确认绑定 mutation UUID,结果不明时只显式重试同一请求;关闭或重登后的响应不得继续操作。客户端 14 项含规范 body parser 与新旧 capability 回归,Node 20 面板构建与 240 files / 11,993,647 bytes 裁剪审计通过;全量前端类型诊断与基线同为 41 项,无新增。此切片仍待同源远端 CI/双架构实物,不代表旧面板已完整迁移,见 [ADR-0535](./adr/ADR-0535-canonical-panel-run-control.md)。
- D-432/ADR-0534(原生 Console 源码候选):修复断开/重连后的异步结果污染。每次连接使用独立内存代次,统一请求与编辑/执行链拒绝旧成功、失败和 finally;旧响应不得重开编辑器、恢复 Secret catalog、覆盖 proof 或在新连接下发出后续请求。16 项针对性回归、完整 Local API 130/130、后端 1683 pass/2 环境 skip/0 fail,静态资产合计 114,042 bytes。断开不撤销已提交的服务端写入,重连须核对 durable 状态;不新增包、后台服务或后端权限。此修复尚未通过同源远端 CI/阶段实物验收,见 [ADR-0534](./adr/ADR-0534-native-console-session-isolation.md)。
- 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-431/ADR-0533exact 双架构 Console 阶段实物已交付):Task、Trigger、Run 列表接入既有 keyset API,开放下一页、回到首页和当前页刷新,每次最多 64 条并替换 DOM,不缓存历史页、不轮询或扩大后端查询。验证规范 cursor、页内/跨页顺序与继续边界;请求/选择代次防止旧列表、错误、详情和 Secret catalog 覆盖当前页面。客户端与生产 HTTP parser 集成 15/15、完整 Local API 114/114,静态资产共 111,857 bytes;默认 headless/Cluster 不变。提交 `ce8c3a7d` 的主 CI `33796190607` 与 Console artifact `33797144811` 已成功;amd64/arm64/milestone 实际下载校验、bundle/milestone auditor 与 archive 页面源码逐字节复核均通过,不借用 D-430 archive。它不包含后续 D-432 会话修复或 D-433 执行管理
- D-430/ADR-0532exact Console 双架构阶段实物已交付):在原生运行详情中开放日志下一片段、回到开头与当前片段刷新,固定 32 KiB 替换窗口,不累计全文或后台轮询。严格验证响应身份、字节范围与前进 cursor,按不含凭据的选择代次丢弃过期响应,防止切换或断开后覆盖新详情。复用既有 API/Policy/Audit,不新增包、依赖、迁移或后端 authority;默认 headless 与 Cluster 不变。提交 `12ed38b7d72fe6366ad8f1ac84aebb1bb772f3b5` 主 CI run `33793623200` 为 42 success/3 expected skip/0 fail,独立 Kubernetes run `33793623184` 成功;Console artifact run `33793687627` 为 43 success/2 expected skip/0 fail。amd64/arm64/milestone artifact `9909229721`/`9909131639`/`9909265138` 实际下载后的 checksum、bundle v8 与 milestone v7 auditor 均通过;两个 archive 内的原生 JS 都与该提交逐字节一致。该实物不包含后续 D-431 列表分页,不等同完整 3.0 或 Public Release。
@@ -1,6 +1,6 @@
# ADR-0533:原生 Console 的任务、定时与运行列表分页
- 状态:AcceptedD-431 远端 CI 与产物构建通过;下载实物复核待完成
- 状态:AcceptedD-431 exact 双架构 Console 阶段实物已交付并复核
- 日期:2026-09-04
- 关联:QL-RFC-0001 D-431、ADR-0532
@@ -29,4 +29,6 @@
真实 Chromium 使用当前原生静态资产与合成 HTTP 数据验证了 Task、Trigger、Run 的第 65 条可达,任务详情可从第二页打开,DOM 每次只保留当前页。请求记录包含三个规范 continuation URL,浏览期间没有自动列表轮询。该浏览器证据验证交互,不冒充真实数据库、Owner 写入或双架构镜像验收。
提交 `ce8c3a7d2afdbb11b2a33f4884702454d1d22a53` 的主 CI [run 33796190607](https://github.com/whyour/qinglong/actions/runs/33796190607) 与 Console artifact [run 33797144811](https://github.com/whyour/qinglong/actions/runs/33797144811) 均已完成并成功。上传清单包含 amd64 `9910148271`239,038,659 bytes)、arm64 `9910153237`234,438,339 bytes)及 milestone `9910185111`6,797 bytes;本记录仅确认远端成功与上传存在,下载后的 checksum、离线 auditor 和 archive 源码复核尚未完成。这些归档不包含后续 ADR-0534 的客户端连接代次修复
提交 `ce8c3a7d2afdbb11b2a33f4884702454d1d22a53` 的主 CI [run 33796190607](https://github.com/whyour/qinglong/actions/runs/33796190607) 与 Console artifact [run 33797144811](https://github.com/whyour/qinglong/actions/runs/33797144811) 均已完成并成功。上传清单包含 amd64 `9910148271`239,038,659 bytes)、arm64 `9910153237`234,438,339 bytes)及 milestone `9910185111`6,797 bytes。三份已实际下载,SHA256SUMS、双 bundle v8 auditor、milestone v7 auditor 均通过,两份 bundle manifest digest 与 milestone 一致。Docker archive 内 application 最终层的 `console.js` 均为 80,336 bytes、SHA256 `febac949f0f72ebeda41fe743909074762d5add0804895ca6802b54d0d51a6a5`,与该提交逐字节一致。amd64 archive SHA256 为 `dffa8606ee30e6cbf45bd1166ad0c716450e15ac21c07e7e15e539db140d3281`arm64 为 `25cad723858afea09f2787cf42160db4150e48690e03afdbb0ac396eb1bcb8c9`
因本机空间不足,完成审计后已删除本轮两份临时 Docker archive,保留 manifest 等小文件;归档可从上述 run 重新下载。这些实物仍为 Alpha,不包含后续 ADR-0534 的客户端连接代次修复或 ADR-0535 的旧面板执行管理。
@@ -33,3 +33,5 @@
真实 Chromium 使用当前静态资产和仅监听本机的合成 HTTP 服务完成连接、创建 Task、确认 Secret GET 挂起、断开、放行旧响应的交互;页面保持等待凭据且没有重开编辑器。测试没有真实数据库、生产 credential 或写入 authority,不能代替镜像端到端验收。
上述本地验证不代表修复已进入既有下载产物。D-431 的 `ce8c3a7d2afdbb11b2a33f4884702454d1d22a53` 产物也不包含本修复;本切片必须以自己的 source revision、CI 和阶段归档证据验收。
提交 `03afd7e8` 的独立 Kubernetes deployment 验证通过,但主 CI [run 33799350582](https://github.com/whyour/qinglong/actions/runs/33799350582) 在 CloudNativePG live gate 失败:rollout 已完成,旧 operator Pod 仍为 Terminating`verifyImageIds` 纳入其空 imageID 后拒绝。`2005cb6b` 单独修正 operator 取样,排除 deletionTimestamp 已设置的 Pod;存活 Pod 仍执行原摘要校验,空集合仍失败,针对性回归 11/11。此本地修复不改写旧 run 的失败结论,须由后续同源 CI 验证。
@@ -0,0 +1,30 @@
# ADR-0535:现有面板的规范 Task/Run 执行管理
- 状态:AcceptedD-433 源码候选;同源 CI 与实物待验证)
- 日期:2026-09-04
- 关联:QL-RFC-0001 D-433、ADR-0530、ADR-0531、ADR-0534
## 背景
现有面板已可登录、查看定时条目和定时执行日志,但不能实际管理运行。旧 Cron 的运行状态与 ID 不是 3.0 Task revision 或 durable Run authority;直接恢复旧运行/停止按钮会混淆定时绑定版本、当前任务版本,以及多个并发运行。
## 决策
- Local capability v1 增加可选 `panel.runControl: task_run_v1`。缺省保持旧只读体验,未知值拒绝,旧客户端可忽略新增字段。`cronList=bounded_read_only``legacyMutations=false` 不变:不开放旧 Cron 写接口、Task 编辑、Trigger 编辑、批量操作或其他旧页面。
- 定时行新增“执行管理”,复用当前 Ant Design 页面和同源 API。在确认前读取 Task 当前 revision/content digest,分别展示定时绑定版本和本次运行版本;显式确认后才 POST `/api/v3/projects/:project/tasks/:task/runs`,不自动重绑 Trigger。
- Run 浏览复用规范项目 keyset API,每个显式动作至多读取 64 条并仅展示当前 Task 的匹配项。页内及跨页顺序、重复条目、next cursor 和 canonical ID 均校验;翻页替换窗口,没有总数扫描、全文累积、自动查找或轮询。空页不证明更早没有匹配运行。
- 必须读取并选择具体 Run 才能请求取消,取消 POST 指向该 Run,不按 Cron 或“最新一次”猜测目标。取消 receipt 只证明请求已登记;运行终态仍以手动刷新后的 durable projection 为准。
- 手动启动返回的 Run 可直接选择、刷新并读取 latest Attempt 首片日志;不使用定时 Trigger 匹配,以免看错另外一次运行。Edge 16 KiB、Standalone 32 KiB,日志不累计或轮询。
- 每份确认绑定一个 mutation UUID、确切版本或 Run ID。传输结果不明时保留原确认,显式重试复用同一请求体;禁止并发确认,不静默创建另一份启动请求。关闭或重连不撤销已发送的服务端操作。
- 新客户端使用不含 credential 的连接代次和窗口生命周期标记,关闭、登出或同 credential 重登后的响应不得继续产生操作。凭据仍只保留内存,不写 URL、Cookie 或 Web Storage;服务端 `run.start`/`run.stop` Policy、audit 与 mutation fence 不变。
- 新模块位于 `src/components/qinglong3`,避免 Umi 将辅助模块注册为独立页面。不新建 package、依赖、listener、timer 或数据库权威;默认 headless 和 Cluster 不携带这个 Local 面板。
## 验证与边界
客户端测试执行真实 TypeScript 模块,覆盖确认前无 POST、current revision 与旧 pin 区分、实际规范 body parser、相同 mutation 重试、并发确认拒绝、权限/版本错误、错误 receipt、准确 Run 取消、分页顺序和预算、关闭/重登、延迟解码、明确 Run 日志与新旧 capability 兼容。
客户端专项 14/14、Local API 130/130,最终后端 1700 项(1698 pass / 2 环境条件 skip / 0 fail);Local image 与 18-package boundary audit 均 compatible。真实 Chromium 在构建后的裁剪面板和合成服务上完成登录、版本 1/2 差异展示、确认运行、选中返回 Run、读取该次日志、确认取消与 cancelled 展示。服务记录只有一个启动 POST 和一个针对相同 Run 的取消 POST;整个交互期间只有一次 Cron 列表和一次项目 Run 列表,没有后台轮询。桌面 1280 与手机 390 宽度截图复核了弹窗滚动、表格横向窗口与底部关闭入口。上述浏览器证据不包含真实执行进程或数据库写入。
Node 20 legacy migration toolchain 的 production panel build 通过;裁剪包为 240 files / 11,993,647 bytes,仍在 256 files / 13 MiB 边界。全量前端 TypeScript 检查存在 41 项错误;通过 compiler host 读取提交前源码作对比,确认本次没有新增诊断,不将存量类型错误称为通过。
本切片不是完整旧面板迁移:任务/定时创建编辑、脚本、订阅、环境变量、依赖与多用户远程 Web 会话仍未闭合。Local 仍仅 loopback/SSH tunnel;不可将本地 mock 或 unit tests 当成实际 Owner 写入、公开发布或双架构镜像证据。D-431 实物不含本切片,交付必须绑定自己的 source revision。
+2 -1
View File
@@ -536,8 +536,9 @@
| [ADR-0530](./ADR-0530-bounded-local-panel-capability-bootstrap.md) | 有界 Local 面板能力发现与启动适配 | AcceptedD-428 exact Console 双架构阶段实物已交付) |
| [ADR-0531](./ADR-0531-canonical-run-log-bridge-for-legacy-panel.md) | 现有面板到规范 Run 日志的有界桥接 | AcceptedD-429 exact Console 双架构阶段实物已交付) |
| [ADR-0532](./ADR-0532-native-console-log-window-navigation.md) | 原生 Console 日志窗口导航 | AcceptedD-430 exact Console 双架构阶段实物已交付) |
| [ADR-0533](./ADR-0533-native-console-ledger-pagination.md) | 原生 Console 任务、定时与运行列表分页 | AcceptedD-431 源码候选;实物待验证 |
| [ADR-0533](./ADR-0533-native-console-ledger-pagination.md) | 原生 Console 任务、定时与运行列表分页 | AcceptedD-431 exact 双架构 Console 阶段实物已交付并复核 |
| [ADR-0534](./ADR-0534-native-console-session-isolation.md) | 原生 Console 异步操作的连接代次隔离 | Accepted(D-432 源码候选;同源 CI 与实物待验证) |
| [ADR-0535](./ADR-0535-canonical-panel-run-control.md) | 现有面板的规范 Task/Run 执行管理 | AcceptedD-433 源码候选;同源 CI 与实物待验证) |
## 规则
+4 -1
View File
@@ -21,10 +21,13 @@ D-418 已闭合独立 Console image/Trial KitD-419 的 v5 quickstart 进一
| --- | --- | --- |
| D-429 | 原生 Task/Trigger/Secret/Run 操作;适配后旧面板登录、只读 Cron 列表与首片日志 | `09ef1745226c05521a9a44accb9d9ef95dd46c85`[Console milestone run 33789576578](https://github.com/whyour/qinglong/actions/runs/33789576578),已交付,见 [ADR-0531](../adr/ADR-0531-canonical-run-log-bridge-for-legacy-panel.md) |
| D-430 | 原生 `/console` 日志“下一片段”“回到开头”“刷新当前片段” | `12ed38b7d72fe6366ad8f1ac84aebb1bb772f3b5`[Console artifact run 33793687627](https://github.com/whyour/qinglong/actions/runs/33793687627) 已交付,两个下载 bundle 与 milestone 均通过离线审计,见 [ADR-0532](../adr/ADR-0532-native-console-log-window-navigation.md) |
| D-431 | 原生 Task/Trigger/Run 列表下一页、回到首页与当前页刷新 | `ce8c3a7d2afdbb11b2a33f4884702454d1d22a53`[Console artifact run 33797144811](https://github.com/whyour/qinglong/actions/runs/33797144811) 已下载并完成双架构 checksum、auditor 和 archive 源码复核,见 [ADR-0533](../adr/ADR-0533-native-console-ledger-pagination.md) |
上表的旧面板是 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-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-433 源码候选为旧面板定时行增加“执行管理”:先展示当前 Task revision,再显式确认运行;选择确切 Run 后可以刷新、读该次日志或请求取消。它不恢复旧 Cron 写接口,也不修改定时绑定版本。网络结果不明时只重试同一 mutation,关闭窗口不会撤销已提交请求。此功能需服务端 capability 显式开放,不包含在上表任何旧归档中,见 [ADR-0535](../adr/ADR-0535-canonical-panel-run-control.md)。
D-426b2c 又补齐了 Console 镜像的 adopted-target 入口证据:切换演练使用 `ql3-local-api --cutover-probe --config <local-api.json>`,同时绑定外层 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`
@@ -51,6 +51,7 @@ export function panelCapabilities(
panel: Object.freeze({
bootstrap: true,
cronList: 'bounded_read_only',
runControl: 'task_run_v1',
taskRead: true,
triggerRead: true,
runRead: true,
@@ -33,6 +33,7 @@ test('publishes an exact profile-aware capability contract', () => {
panel: {
bootstrap: true,
cronList: 'bounded_read_only',
runControl: 'task_run_v1',
taskRead: true,
triggerRead: true,
runRead: true,
+372
View File
@@ -0,0 +1,372 @@
import {
qingLong3Credential,
qingLong3Session,
type QingLong3Capabilities,
} from '@/utils/qinglong3';
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const DIGEST = /^[a-f0-9]{64}$/;
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']);
const STATUSES = new Set([
...TERMINAL,
'created',
'queued',
'dispatching',
'running',
'waiting_approval',
'retry_wait',
'lost',
]);
export interface PanelTask {
taskId: string;
name: string;
revision: number;
contentDigest: string;
enabled: boolean;
}
export interface PanelRun {
id: string;
taskId: string;
taskRevision: string;
status: string;
createdAtMs: number;
latestAttempt?: { id: string; logAvailable: boolean } | null;
}
export interface RunCursor {
createdAtMs: number;
runId: string;
}
export interface RunPage {
runs: PanelRun[];
next?: RunCursor;
scanned: number;
}
export interface PreparedRunAction {
kind: 'start' | 'cancel';
target: string;
mutationId: string;
execute(): Promise<{ runId: string; status: string }>;
}
export class PanelRunControlError extends Error {
constructor(readonly code: string, readonly uncertain = false) {
super(code);
}
}
function validId(value: unknown): value is string {
return typeof value === 'string' && ID.test(value);
}
function validTime(value: unknown): value is number {
return Number.isSafeInteger(value) && Number(value) >= 0;
}
function cursorOf(value: any): RunCursor {
if (
!value ||
!validTime(value.createdAtMs) ||
!validId(value.runId) ||
Object.keys(value).sort().join(',') !== 'createdAtMs,runId'
) {
throw new PanelRunControlError('invalid_run_page');
}
return Object.freeze({ createdAtMs: value.createdAtMs, runId: value.runId });
}
function follows(left: RunCursor, right: RunCursor): boolean {
return (
right.createdAtMs < left.createdAtMs ||
(right.createdAtMs === left.createdAtMs && right.runId < left.runId)
);
}
function runOf(value: any): PanelRun {
if (
!value ||
!validId(value.id) ||
!validId(value.taskId) ||
typeof value.taskRevision !== 'string' ||
value.taskRevision.length > 255 ||
!value.taskRevision ||
!STATUSES.has(value.status) ||
!validTime(value.createdAtMs)
) {
throw new PanelRunControlError('invalid_run_response');
}
if (
value.latestAttempt != null &&
(!validId(value.latestAttempt.id) ||
typeof value.latestAttempt.logAvailable !== 'boolean')
) {
throw new PanelRunControlError('invalid_run_response');
}
return Object.freeze({
id: value.id,
taskId: value.taskId,
taskRevision: value.taskRevision,
status: value.status,
createdAtMs: value.createdAtMs,
...(value.latestAttempt === undefined
? {}
: {
latestAttempt:
value.latestAttempt === null
? null
: Object.freeze({
id: value.latestAttempt.id,
logAvailable: value.latestAttempt.logAvailable,
}),
}),
});
}
export function createPanelRunControl(
cron: any,
capabilities: Readonly<QingLong3Capabilities>,
) {
const projectId = cron?.ql3?.projectId,
taskId = cron?.ql3?.taskId;
if (
!validId(projectId) ||
!validId(taskId) ||
capabilities.panel.runControl !== 'task_run_v1'
) {
throw new PanelRunControlError('run_control_unavailable');
}
const session = qingLong3Session();
let disposed = false,
writing = false;
let currentTask: PanelTask | null = null,
currentRun: PanelRun | null = null;
const base = `/api/v3/projects/${projectId}`;
const isCurrent = () =>
!disposed &&
session === qingLong3Session() &&
Boolean(qingLong3Credential());
const assertCurrent = () => {
if (!isCurrent()) throw new PanelRunControlError('session_changed');
};
const request = async (
path: string,
body?: object,
accepted: number[] = [],
) => {
assertCurrent();
let response: Response, value: any;
try {
response = await fetch(path, {
method: body ? 'POST' : 'GET',
cache: 'no-store',
credentials: 'omit',
redirect: 'error',
referrerPolicy: 'no-referrer',
headers: {
accept: 'application/json',
authorization: `Bearer ${qingLong3Credential()}`,
...(body ? { 'content-type': 'application/json' } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
});
value = await response.json();
} catch {
assertCurrent();
throw new PanelRunControlError('request_unavailable', Boolean(body));
}
assertCurrent();
if (!response.ok && !accepted.includes(response.status)) {
throw new PanelRunControlError(
typeof value?.code === 'string' ? value.code : 'request_unavailable',
Boolean(body) && response.status >= 500,
);
}
return { value, status: response.status };
};
const prepared = (
kind: 'start' | 'cancel',
target: string,
fields: object,
validate: (value: any) => boolean,
): PreparedRunAction => {
const mutationId = crypto.randomUUID();
const body = Object.freeze({ ...fields, mutationId });
const path =
kind === 'start'
? `${base}/tasks/${taskId}/runs`
: `${base}/runs/${target}/cancellation`;
return Object.freeze({
kind,
target,
mutationId,
async execute() {
assertCurrent();
if (writing) throw new PanelRunControlError('operation_pending');
writing = true;
try {
const { value } = await request(path, body);
if (!validate(value))
throw new PanelRunControlError('invalid_mutation_response', true);
return Object.freeze({
runId: value.runId as string,
status: value.status as string,
});
} finally {
writing = false;
}
},
});
};
return Object.freeze({
isCurrent,
dispose() {
disposed = true;
currentTask = null;
currentRun = null;
},
async readTask(): Promise<PanelTask> {
const { value } = await request(`${base}/tasks/${taskId}`);
const task = value?.task;
if (
!task ||
task.taskId !== taskId ||
typeof task.name !== 'string' ||
task.name.length > 512 ||
!Number.isSafeInteger(task.revision) ||
task.revision < 1 ||
!DIGEST.test(task.contentDigest) ||
typeof task.enabled !== 'boolean'
) {
throw new PanelRunControlError('invalid_task_response');
}
currentTask = Object.freeze({
taskId,
name: task.name,
revision: task.revision,
contentDigest: task.contentDigest,
enabled: task.enabled,
});
return currentTask;
},
async listRuns(after?: RunCursor): Promise<RunPage> {
const cursor = after ? cursorOf(after) : undefined;
// Canonical IDs contain no query separators; the HTTP parser rejects encoded colon aliases.
const query = cursor
? `&after_created_at_ms=${cursor.createdAtMs}&after_run_id=${cursor.runId}`
: '';
const { value } = await request(`${base}/runs?limit=64${query}`);
if (
!Array.isArray(value?.runs) ||
value.runs.length > 64 ||
typeof value.hasMore !== 'boolean' ||
value.hasMore !== Boolean(value.next)
)
throw new PanelRunControlError('invalid_run_page');
const runs: PanelRun[] = value.runs.map(runOf);
let previous = cursor;
for (const run of runs) {
const next = { createdAtMs: run.createdAtMs, runId: run.id };
if (previous && !follows(previous, next))
throw new PanelRunControlError('invalid_run_page');
previous = next;
}
const next = value.next ? cursorOf(value.next) : undefined;
if (
next &&
(!runs.length ||
next.runId !== previous?.runId ||
next.createdAtMs !== previous?.createdAtMs)
) {
throw new PanelRunControlError('invalid_run_page');
}
return {
runs: runs.filter((run) => run.taskId === taskId),
scanned: runs.length,
next,
};
},
async readRun(runId: string): Promise<PanelRun> {
if (!validId(runId))
throw new PanelRunControlError('invalid_run_identity');
const { value } = await request(`${base}/runs/${runId}`);
const run = runOf(value?.run);
if (
run.id !== runId ||
run.taskId !== taskId ||
value.run.projectId !== projectId
) {
throw new PanelRunControlError('invalid_run_identity');
}
currentRun = run;
return run;
},
prepareStart(task: PanelTask) {
assertCurrent();
if (task !== currentTask || !task.enabled)
throw new PanelRunControlError('task_not_ready');
return prepared(
'start',
taskId,
{
schema: 'qinglong/task-start@v1',
expectedRevision: task.revision,
expectedContentDigest: task.contentDigest,
},
(value) =>
value?.schema === 'qinglong/task-start@v1' &&
value.projectId === projectId &&
value.taskId === taskId &&
value.taskRevision === task.revision &&
value.taskContentDigest === task.contentDigest &&
validId(value.runId) &&
['accepted', 'existing'].includes(value.status),
);
},
prepareCancel(run: PanelRun) {
assertCurrent();
if (run !== currentRun || TERMINAL.has(run.status))
throw new PanelRunControlError('run_not_active');
return prepared(
'cancel',
run.id,
{ schema: 'qinglong/run-cancellation@v1' },
(value) =>
value?.schema === 'qinglong/run-cancellation@v1' &&
value.projectId === projectId &&
value.runId === run.id &&
['accepted', 'already_requested', 'already_terminal'].includes(
value.status,
),
);
},
async readLog(run: PanelRun): Promise<string> {
if (run !== currentRun)
throw new PanelRunControlError('invalid_run_identity');
if (!run.latestAttempt?.logAvailable)
return '当前运行尚无可读日志,请刷新运行状态后重试。';
const length = capabilities.limits.logChunkBytes;
const attemptId = run.latestAttempt.id;
const { value, status } = await request(
`${base}/runs/${run.id}/attempts/${attemptId}/log?offset=0&length=${length}`,
undefined,
[202, 410],
);
if (status === 202) return '日志尚未就绪。';
if (status === 410) return '日志已按保留策略清理。';
if (
value?.status !== 'available' ||
value.encoding !== 'base64' ||
value.runId !== run.id ||
value.attemptId !== attemptId ||
typeof value.content !== 'string' ||
value.content.length > Math.ceil(length / 3) * 4
) {
throw new PanelRunControlError('invalid_log_response');
}
const bytes = Uint8Array.from(atob(value.content), (c) =>
c.charCodeAt(0),
);
if (bytes.length > length)
throw new PanelRunControlError('invalid_log_response');
return `${new TextDecoder().decode(
bytes,
)}\n\n[本次仅显示首个至多 ${length} 字节;刷新不自动续读]`;
},
});
}
@@ -0,0 +1,384 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Alert,
Button,
Descriptions,
Modal,
Space,
Table,
Typography,
} from 'antd';
import type { QingLong3Capabilities } from '@/utils/qinglong3';
import {
createPanelRunControl,
PanelRunControlError,
type PanelTask,
type PanelRun,
type PreparedRunAction,
type RunPage,
type RunCursor,
} from './runControl';
const terminal = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']);
const labels: Record<string, string> = {
succeeded: '成功',
failed: '失败',
cancelled: '已取消',
timed_out: '已超时',
created: '已创建',
queued: '排队中',
dispatching: '派发中',
running: '运行中',
waiting_approval: '等待审批',
retry_wait: '等待重试',
lost: '执行状态待核对',
};
const errors: Record<string, string> = {
authentication_required: '凭据已失效,请重新登录。',
authorization_denied: '当前身份没有执行该操作的权限。',
task_start_fence_rejected:
'任务版本或权限在确认期间已改变。本次启动被拒绝,请刷新任务后重新确认。',
session_changed: '连接已改变,请关闭窗口并重新登录。',
task_not_ready: '请先读取当前任务,已停用的任务不能启动。',
run_not_active: '请选择并刷新一个尚未结束的运行。',
};
export default function QingLong3RunControlModal({
cron,
capabilities,
onClose,
}: {
cron: any;
capabilities: Readonly<QingLong3Capabilities>;
onClose(): void;
}) {
const clientRef = useRef<ReturnType<typeof createPanelRunControl>>();
const busyRef = useRef(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [task, setTask] = useState<PanelTask>();
const [page, setPage] = useState<RunPage>();
const [cursor, setCursor] = useState<RunCursor>();
const [run, setRun] = useState<PanelRun>();
const [log, setLog] = useState('');
const [prepared, setPrepared] = useState<PreparedRunAction>();
const [uncertain, setUncertain] = useState(false);
const work = async (
action: (client: ReturnType<typeof createPanelRunControl>) => Promise<void>,
) => {
const client = clientRef.current;
if (!client || busyRef.current) return;
busyRef.current = true;
setBusy(true);
setError('');
try {
await action(client);
} catch (caught) {
if (clientRef.current !== client) return;
const failure = caught instanceof PanelRunControlError ? caught : null;
if (!client.isCurrent()) {
setTask(undefined);
setRun(undefined);
setPage(undefined);
setLog('');
setPrepared(undefined);
setError(errors.session_changed);
} else {
setError(
failure?.uncertain
? '服务端可能已收到请求。请重试同一请求以核对结果;不要新建另一份启动请求。'
: errors[failure?.code || ''] ||
'读取或操作失败,请检查服务与权限后手动重试。',
);
setUncertain(Boolean(failure?.uncertain));
}
} finally {
if (clientRef.current === client) {
busyRef.current = false;
setBusy(false);
}
}
};
const loadRuns = async (
client: ReturnType<typeof createPanelRunControl>,
after?: RunCursor,
) => {
const result = await client.listRuns(after);
if (!client.isCurrent()) return;
setPage(result);
setCursor(after);
setRun(undefined);
setLog('');
};
const selectRun = async (
client: ReturnType<typeof createPanelRunControl>,
id: string,
) => {
const result = await client.readRun(id);
if (client.isCurrent()) {
setRun(result);
setLog('');
}
};
useEffect(() => {
let client: ReturnType<typeof createPanelRunControl>;
try {
client = createPanelRunControl(cron, capabilities);
} catch {
setError('当前服务未开放执行管理,或条目标识无效。');
return;
}
clientRef.current = client;
void work(async () => {
const current = await client.readTask();
if (client.isCurrent()) setTask(current);
await loadRuns(client);
});
return () => {
client.dispose();
clientRef.current = undefined;
busyRef.current = false;
};
}, [cron, capabilities]);
const prepare = (kind: 'start' | 'cancel') =>
void work(async (client) => {
const action =
kind === 'start'
? client.prepareStart(task!)
: client.prepareCancel(run!);
setPrepared(action);
setNotice('');
setUncertain(false);
});
const execute = () =>
void work(async (client) => {
if (!prepared) return;
const receipt = await prepared.execute();
if (!client.isCurrent()) return;
setPrepared(undefined);
setUncertain(false);
setNotice(
prepared.kind === 'start'
? `运行已登记:${receipt.runId}。排队不代表已经执行成功。`
: `取消请求结果:${receipt.status}。只有运行进入终态才表示已结束。`,
);
await selectRun(client, receipt.runId);
});
const close = () => {
if (busy || uncertain) {
Modal.confirm({
title: '关闭执行管理?',
content:
'关闭窗口不会撤销已发送的请求。再次打开时请先核对运行记录,避免重复启动。',
onOk: onClose,
});
} else onClose();
};
const blocked = busy || Boolean(prepared);
return (
<Modal
open
title="执行管理 · QingLong 3.0"
width={860}
onCancel={close}
footer={<Button onClick={close}></Button>}
destroyOnClose
>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="手动运行使用 Task 当前版本,不修改定时计划。"
description="取消针对选中的 Run;没有后台轮询,请手动刷新确认状态。实际操作仍受项目权限控制。"
/>
{error && <Alert type="error" showIcon message={error} />}
{notice && <Alert type="success" showIcon message={notice} />}
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="任务">
{task?.name || cron.name} ·{' '}
<Typography.Text code>{cron.ql3.taskId}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="定时绑定版本">
{cron.ql3.taskRevision}
</Descriptions.Item>
<Descriptions.Item label="本次运行版本">
{task
? `${task.revision}${task.enabled ? '' : '(已停用)'}`
: '未读取'}
</Descriptions.Item>
</Descriptions>
<Space wrap>
<Button
disabled={blocked}
onClick={() =>
void work(async (client) => {
const current = await client.readTask();
if (client.isCurrent()) setTask(current);
})
}
>
</Button>
<Button
type="primary"
disabled={blocked || !task?.enabled}
onClick={() => prepare('start')}
>
</Button>
</Space>
{prepared && (
<Alert
type="warning"
showIcon
message={
prepared.kind === 'start'
? `确认运行 Task ${prepared.target} · revision ${task?.revision}`
: `确认请求取消 Run ${prepared.target}`
}
description={
<Space direction="vertical" style={{ width: '100%' }}>
<Typography.Text type="secondary">
ID{prepared.mutationId} ID
</Typography.Text>
<Space wrap>
<Button
danger={prepared.kind === 'cancel'}
type="primary"
loading={busy}
onClick={execute}
>
{uncertain
? '重试同一请求'
: prepared.kind === 'start'
? '确认运行一次'
: '确认请求取消'}
</Button>
<Button
disabled={busy || uncertain}
onClick={() => setPrepared(undefined)}
>
</Button>
</Space>
</Space>
}
/>
)}
<Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
64
</Typography.Text>
<Table<PanelRun>
size="small"
rowKey="id"
pagination={false}
loading={busy}
dataSource={page?.runs || []}
scroll={{ x: 600 }}
locale={{
emptyText: '当前窗口没有匹配运行,可继续查找或回到最近记录。',
}}
columns={[
{
title: 'Run ID',
dataIndex: 'id',
render: (id: string) => (
<Button
type="link"
disabled={blocked}
style={{ padding: 0, maxWidth: '100%' }}
onClick={() => void work((client) => selectRun(client, id))}
>
{id}
</Button>
),
},
{ title: '版本', dataIndex: 'taskRevision' },
{
title: '状态',
dataIndex: 'status',
render: (status: string) => labels[status],
},
]}
/>
<Space wrap>
<Button
disabled={blocked}
onClick={() => void work((client) => loadRuns(client, cursor))}
>
</Button>
<Button
disabled={blocked || !cursor}
onClick={() => void work((client) => loadRuns(client))}
>
</Button>
<Button
disabled={blocked || !page?.next}
onClick={() => void work((client) => loadRuns(client, page?.next))}
>
</Button>
</Space>
{run && (
<>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="已选 Run">
<Typography.Text code>{run.id}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="状态">
{labels[run.status]}
</Descriptions.Item>
</Descriptions>
<Space wrap>
<Button
disabled={blocked}
onClick={() => void work((client) => selectRun(client, run.id))}
>
</Button>
<Button
disabled={blocked}
onClick={() =>
void work(async (client) => {
const text = await client.readLog(run);
if (client.isCurrent()) setLog(text);
})
}
>
</Button>
<Button
danger
disabled={blocked || terminal.has(run.status)}
onClick={() => prepare('cancel')}
>
</Button>
</Space>
</>
)}
{log && (
<pre
style={{
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
maxHeight: 320,
overflow: 'auto',
}}
>
{log}
</pre>
)}
</Space>
</Modal>
);
}
+26 -1
View File
@@ -53,6 +53,7 @@ import { getScheduleType } from './const';
import CronDetailModal from './detail';
import './index.less';
import CronLogModal from './logModal';
import QingLong3RunControlModal from '@/components/qinglong3/runControlModal';
import CronModal, { CronLabelModal } from './modal';
import {
CrontabStatus,
@@ -305,6 +306,16 @@ const Crontab = () => {
const isPc = !isPhone;
return (
<Space size="middle">
{qingLong3?.panel.runControl === 'task_run_v1' && (
<a
onClick={(e) => {
e.stopPropagation();
setRunControlCron(record);
}}
>
</a>
)}
{!qingLong3ReadOnly && record.status === CrontabStatus.idle && (
<a
onClick={(e) => {
@@ -350,6 +361,7 @@ const Crontab = () => {
const [searchText, setSearchText] = useState('');
const [isLogModalVisible, setIsLogModalVisible] = useState(false);
const [logCron, setLogCron] = useState<any>();
const [runControlCron, setRunControlCron] = useState<any>();
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
const [pageConf, setPageConf] = useState<{
page: number;
@@ -992,7 +1004,13 @@ const Crontab = () => {
title={intl.get('定时任务')}
extra={
qingLong3ReadOnly
? [<Tag key="ql3-read-only">QingLong 3.0 · </Tag>]
? [
<Tag key="ql3-read-only">
{qingLong3?.panel.runControl === 'task_run_v1'
? 'QingLong 3.0 · 定时只读 / 执行可管理'
: 'QingLong 3.0 · 有界只读'}
</Tag>,
]
: [
<Search
key="search"
@@ -1145,6 +1163,13 @@ const Crontab = () => {
qingLong3={qingLong3}
/>
)}
{runControlCron && qingLong3?.panel.runControl === 'task_run_v1' && (
<QingLong3RunControlModal
cron={runControlCron}
capabilities={qingLong3}
onClose={() => setRunControlCron(undefined)}
/>
)}
{!qingLong3ReadOnly && isModalVisible && (
<CronModal handleCancel={handleCancel} cron={editedCron} />
)}
+10
View File
@@ -15,6 +15,7 @@ export interface QingLong3Capabilities {
readonly panel: Readonly<{
bootstrap: true;
cronList: 'bounded_read_only';
runControl?: 'task_run_v1';
legacyMutations: false;
legacyLogin: false;
subscriptions: false;
@@ -34,6 +35,11 @@ const CREDENTIAL_PATTERN =
let capabilities: Readonly<QingLong3Capabilities> | null = null;
let credential: string | null = null;
let session: Readonly<object> = Object.freeze({});
export function qingLong3Session(): Readonly<object> {
return session;
}
function validCapabilities(value: any): value is QingLong3Capabilities {
const profile = value?.deployment?.profile;
@@ -50,6 +56,8 @@ function validCapabilities(value: any): value is QingLong3Capabilities {
value?.authentication?.loginEndpoint === null &&
value?.panel?.bootstrap === true &&
value?.panel?.cronList === 'bounded_read_only' &&
(value?.panel?.runControl === undefined ||
value.panel.runControl === 'task_run_v1') &&
value?.panel?.legacyMutations === false &&
value?.panel?.legacyLogin === false &&
value?.panel?.subscriptions === false &&
@@ -96,6 +104,7 @@ export function qingLong3Capabilities(): Readonly<QingLong3Capabilities> | null
export function setQingLong3Credential(value: string): boolean {
if (!CREDENTIAL_PATTERN.test(value)) return false;
session = Object.freeze({});
credential = value;
return true;
}
@@ -105,6 +114,7 @@ export function qingLong3Credential(): string | null {
}
export function clearQingLong3Credential(): void {
session = Object.freeze({});
credential = null;
}
+360
View File
@@ -0,0 +1,360 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require('typescript');
const { test } = require('node:test');
require('ts-node/register/transpile-only');
const credential = `ql3c_test_${'A'.repeat(43)}`;
const task = {
taskId: 'task:a',
revision: 2,
contentDigest: 'a'.repeat(64),
enabled: true,
name: 'Task A',
};
const run = {
id: 'run:a',
projectId: 'default',
taskId: 'task:a',
taskRevision: '2',
status: 'running',
createdAtMs: 100,
latestAttempt: { id: 'attempt:a', logAvailable: true },
};
const cron = {
ql3: { projectId: 'default', taskId: task.taskId, taskRevision: 1 },
};
const capabilities = {
panel: { runControl: 'task_run_v1' },
limits: { logChunkBytes: 16384 },
};
const startReceipt = {
schema: 'qinglong/task-start@v1',
projectId: 'default',
taskId: task.taskId,
taskRevision: 2,
taskContentDigest: task.contentDigest,
runId: run.id,
status: 'accepted',
};
function fixture(response) {
const calls = [];
const context = vm.createContext({
TextDecoder,
Uint8Array,
atob,
crypto: require('node:crypto').webcrypto,
fetch: async (url, options) => {
calls.push({ url, options });
const value = await response(url, options, calls.length);
return {
ok: !value.httpStatus || value.httpStatus < 400,
status: value.httpStatus || 200,
json: value.json || (async () => value),
};
},
});
const load = (relative, imports = {}) => {
const code = ts.transpileModule(
fs.readFileSync(path.resolve(__dirname, '../..', relative), 'utf8'),
{
compilerOptions: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.CommonJS,
},
},
).outputText;
const mod = { exports: {} };
vm.runInContext(`(function(require,exports,module){${code}\n})`, context)(
(id) => {
assert.ok(imports[id], `Unexpected import ${id}`);
return imports[id];
},
mod.exports,
mod,
);
return mod.exports;
};
const auth = load('src/utils/qinglong3.ts');
auth.setQingLong3Credential(credential);
const control = load('src/components/qinglong3/runControl.ts', {
'@/utils/qinglong3': auth,
});
return {
auth,
control,
calls,
client: control.createPanelRunControl(cron, capabilities),
};
}
const deferred = () => {
let resolve, reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
};
test('preparing explicit current revision does not send a write and start uses only canonical fields', async () => {
const f = fixture((_url, options) =>
options.method === 'GET' ? { task } : startReceipt,
);
const current = await f.client.readTask();
const action = f.client.prepareStart(current);
assert.equal(f.calls.length, 1);
await action.execute();
assert.equal(f.calls[1].url, '/api/v3/projects/default/tasks/task:a/runs');
const body = JSON.parse(f.calls[1].options.body);
assert.deepEqual(body, {
schema: 'qinglong/task-start@v1',
mutationId: action.mutationId,
expectedRevision: 2,
expectedContentDigest: task.contentDigest,
});
assert.equal(f.calls[1].options.credentials, 'omit');
assert.equal(f.calls[1].options.redirect, 'error');
assert.equal(
f.calls[1].options.headers.authorization,
`Bearer ${credential}`,
);
});
test('ambiguous transport failure retries exactly the same mutation rather than creating another run', async () => {
const f = fixture((_url, options, count) => {
if (options.method === 'GET') return { task };
if (count === 2) throw new Error('response lost after commit');
return { ...startReceipt, status: 'existing' };
});
const action = f.client.prepareStart(await f.client.readTask());
await assert.rejects(action.execute(), (e) => e.uncertain === true);
assert.equal((await action.execute()).status, 'existing');
assert.equal(f.calls[1].options.body, f.calls[2].options.body);
});
test('concurrent confirmation is rejected without a second POST', async () => {
const response = deferred();
const f = fixture((_url, options) =>
options.method === 'GET' ? { task } : response.promise,
);
const action = f.client.prepareStart(await f.client.readTask());
const first = action.execute();
await assert.rejects(action.execute(), (e) => e.code === 'operation_pending');
response.resolve(startReceipt);
await first;
assert.equal(f.calls.length, 2);
});
test('stale revision and denied identity never fall back to legacy mutation endpoints', async () => {
for (const code of ['task_start_fence_rejected', 'authorization_denied']) {
const f = fixture((_url, options) =>
options.method === 'GET' ? { task } : { httpStatus: 409, code },
);
const action = f.client.prepareStart(await f.client.readTask());
await assert.rejects(
action.execute(),
(e) => e.code === code && !e.uncertain,
);
assert.equal(f.calls.length, 2);
assert.ok(
f.calls.every((x) => x.url.startsWith('/api/v3/projects/default/')),
);
}
});
test('mismatched mutation receipts remain uncertain rather than reporting false success', async () => {
const f = fixture((_url, options) =>
options.method === 'GET' ? { task } : { ...startReceipt, taskRevision: 99 },
);
await assert.rejects(
f.client.prepareStart(await f.client.readTask()).execute(),
(e) => e.uncertain,
);
});
test('cancellation names a previously read Run and terminal or foreign runs cannot be submitted', async () => {
const f = fixture((_url, options) =>
options.method === 'GET'
? { run }
: {
schema: 'qinglong/run-cancellation@v1',
projectId: 'default',
runId: run.id,
status: 'accepted',
},
);
const current = await f.client.readRun(run.id);
const action = f.client.prepareCancel(current);
assert.equal(f.calls.length, 1);
await action.execute();
assert.equal(
f.calls[1].url,
'/api/v3/projects/default/runs/run:a/cancellation',
);
assert.deepEqual(Object.keys(JSON.parse(f.calls[1].options.body)).sort(), [
'mutationId',
'schema',
]);
assert.throws(() =>
f.client.prepareCancel({ ...current, id: 'someone:else' }),
);
const terminal = fixture(() => ({ run: { ...run, status: 'succeeded' } }));
const terminalRun = await terminal.client.readRun(run.id);
assert.throws(() => terminal.client.prepareCancel(terminalRun));
const foreign = fixture(() => ({ run: { ...run, taskId: 'task:other' } }));
await assert.rejects(foreign.client.readRun(run.id));
});
test('project paging filters task records after validating monotonic canonical cursors', async () => {
const f = fixture(() => ({
runs: [run, { ...run, id: 'run:0', taskId: 'another' }],
hasMore: true,
next: { createdAtMs: 100, runId: 'run:0' },
}));
const page = await f.client.listRuns();
assert.equal(page.scanned, 2);
assert.equal(page.runs.length, 1);
await assert.rejects(f.client.listRuns(page.next)); // repeated page cannot advance
assert.equal(
f.calls[1].url,
'/api/v3/projects/default/runs?limit=64&after_created_at_ms=100&after_run_id=run:0',
);
await assert.rejects(
f.client.listRuns({ createdAtMs: 100, runId: 'x&limit=999' }),
);
assert.equal(f.calls.length, 2);
});
test('over-budget pages and forged next cursors fail closed', async () => {
for (const body of [
{ runs: Array(65).fill(run), hasMore: false },
{ runs: [run], hasMore: true, next: { createdAtMs: 99, runId: 'wrong' } },
{ runs: [run, run], hasMore: false },
])
await assert.rejects(fixture(() => body).client.listRuns());
});
test('closing or reconnecting with the same credential rejects pending results and prepared writes', async () => {
for (const finish of [
(f) => f.client.dispose(),
(f) => f.auth.setQingLong3Credential(credential),
(f) => f.auth.clearQingLong3Credential(),
]) {
const response = deferred();
const f = fixture((_url, _options, count) =>
count === 1 ? { task } : response.promise,
);
const action = f.client.prepareStart(await f.client.readTask());
const reading = f.client.readTask();
finish(f);
response.resolve({ task });
await assert.rejects(reading, (e) => e.code === 'session_changed');
await assert.rejects(action.execute(), (e) => e.code === 'session_changed');
assert.equal(f.calls.length, 2);
}
});
test('same-credential reconnect while JSON decoding discards response and never follows up', async () => {
const body = deferred(),
decoding = deferred();
const f = fixture(() => ({
json() {
decoding.resolve();
return body.promise;
},
}));
const reading = f.client.readTask();
await decoding.promise;
f.auth.setQingLong3Credential(credential);
body.resolve({ task });
await assert.rejects(reading, (e) => e.code === 'session_changed');
assert.equal(f.calls.length, 1);
});
test('explicit Run log reads one bounded first chunk without using scheduled Trigger matching', async () => {
const f = fixture((url) =>
url.includes('/log?')
? {
status: 'available',
encoding: 'base64',
projectId: 'default',
runId: run.id,
attemptId: run.latestAttempt.id,
content: Buffer.from('manual execution 日志').toString('base64'),
}
: { run },
);
const current = await f.client.readRun(run.id);
assert.match(await f.client.readLog(current), /manual execution 日志/);
assert.equal(
f.calls[1].url,
'/api/v3/projects/default/runs/run:a/attempts/attempt:a/log?offset=0&length=16384',
);
assert.equal(f.calls.length, 2);
});
test('old capabilities do not silently enable canonical writes', () => {
const f = fixture(() => ({}));
assert.throws(() =>
f.control.createPanelRunControl(cron, {
panel: {},
limits: capabilities.limits,
}),
);
});
test('capability discovery supports old read-only servers and rejects an unknown execution contract', async () => {
const {
panelCapabilities,
} = require('../../packages/ql3-local-api/src/panel-compatibility/panelBootstrapRoute');
for (const supported of [undefined, 'task_run_v1']) {
const value = panelCapabilities('edge');
const f = fixture(() => ({
capabilities: {
...value,
panel: { ...value.panel, runControl: supported },
},
}));
const discovered = await f.auth.discoverQingLong3('/api/v3/capabilities');
assert.ok(discovered);
assert.equal(discovered.panel.runControl, supported);
assert.equal(discovered.panel.legacyMutations, false);
}
const value = panelCapabilities('edge');
const f = fixture(() => ({
capabilities: {
...value,
panel: { ...value.panel, runControl: 'allow_everything' },
},
}));
assert.equal(await f.auth.discoverQingLong3('/api/v3/capabilities'), null);
});
test('browser mutation bodies pass the actual profile-neutral request parsers', async () => {
const {
parseTaskStartRequestBody,
} = require('../../packages/ql3-runtime-core/src/task-start/taskStart');
const {
parseRunCancellationRequestBody,
} = require('../../packages/ql3-runtime-core/src/run/clusterRunCancellation');
const f = fixture((url, options) => {
if (options.method === 'GET')
return url.includes('/tasks/') ? { task } : { run };
const body = JSON.parse(options.body);
if (url.endsWith('/cancellation')) {
assert.deepEqual(parseRunCancellationRequestBody(body), body);
return {
schema: 'qinglong/run-cancellation@v1',
projectId: 'default',
runId: run.id,
status: 'accepted',
};
}
assert.deepEqual(parseTaskStartRequestBody(body), body);
return startReceipt;
});
await f.client.prepareStart(await f.client.readTask()).execute();
await f.client.prepareCancel(await f.client.readRun(run.id)).execute();
});