diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 87b2feb2..f7fa3f53 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,7 @@ 最新增量证据(2026-08-13): +- D-308/ADR-0400(已接受):首个只读 Copilot 的“最近成功/失败运行对比”不再依赖 Prompt 自由拼接两个查询。`qinglong.run.compare@1.0.0` 作为第二个受信内建 Tool,固定 `read/low`、`run.read`、`database.read` 和 5 秒 deadline;只有当前 Project Tool snapshot 显式包含 reviewed Definition、产品 composition 显式绑定 adapter 后才可执行。它按 baseline→candidate 串行复用两次有界 Run 点查,absent 与 cross-Project 均为 `found:false`,只输出低敏 Run projection、固定 changed fields 和可证明的 queue/execution/total duration delta;任一时间戳不完整或结束早于开始时不生成对应差值。输出明确标记 `ordered_independent_point_reads`,不冒充数据库事务快照。实现位于既有 `runtime-core/tool-execution/builtin-run-compare/`,只提供两个显式 subpath,不从 package root 导出,不新增 workspace package、依赖、表、migration、连接、timer、listener、watcher、cache 或低配设备常驻开销。最终 18-package clean build/test 退出 0;backend 1,208 项为 1,206 pass/2 条件 skip/0 fail;package/dependency/Edge/Cluster deployment 审计零 finding。Edge、Edge AI、Edge MCP 制品为 2,589,812 / 3,121,108 / 7,209,862 bytes,均低于各自上限且未装配 subpath 被发布投影裁掉。 - D-307/ADR-0399(已接受):物理 Edge release archive Gate 使用外部 Ed25519 签发、QingLong verify-only 的两阶段协议。`prepare` exact 重建 owner-private 统一物理报告,要求 direct release service start 已通过,并把 repository、40 位 Git revision、设备/boot、物理报告、release archive、实机 artifact tree/metadata/entrypoint 与 Node digest/version 编入无换行 canonical payload;私钥始终位于 HSM/KMS/离线 operator。`finalize` 以 operator-pinned SPKI 公钥重算 fingerprint,稳定读取并复核所有输入后验证 64-byte detached signature,任一 source/archive/evidence 漂移均失败关闭,输出 `0600` no-replace envelope。通过只把 `release_archive_signature` 替换为 `release_archive_signature_or_attestation`,`supported:false` 与其余 firmware、整机 flash、migration、断电、固定实机采集和 Cluster 容量 Gate 全部保留。基础 importer 同时把 Edge、SQLite 与 Plugin Package 三个 workload 的 platform/architecture 精确绑定到统一物理观测,release verifier 再要求 observed/Edge/SQLite 为完整 recorder shape 且 Node identity 相同,拒绝跨主机拼接与重算外层摘要后的最小伪造。实现不新增 package、依赖、daemon、listener、timer、watcher 或设备常驻负担;18-package clean build/test 退出 0,backend 1,208 项为 1,206 pass/2 条件 skip/0 fail,package/dependency/Edge/service bridge/Cluster deployment 审计零 finding。 - D-303/ADR-0391(已接受) Cluster operator context 在离线 `validate` 之后增加显式、只读的 `ql3-cluster-admin context probe`。probe 先完整预检全部 @@ -6896,6 +6897,13 @@ Copilot 默认不能直接提交修改或运行命令。写操作通过 Tool Reg - 限制单次日志上下文大小,优先在本地提取错误片段和统计摘要。 - 远程模型不可用时,普通任务管理、调度、日志和通知仍完整可用。 +`next` 已由 ADR-0162/ADR-0400 提供两个 profile-neutral 受信只读原语: +`qinglong.run.get@1.0.0` 和 `qinglong.run.compare@1.0.0`。后者只接受两个不同 Run ID, +按固定顺序执行 Project-scoped 点查并返回低敏差异;它明确不声称事务快照,也不读取 +日志、error、command、Artifact 或 Secret。两者目前仍是产品组合显式装配的 Tool, +不是默认向所有 Project、HTTP、MCP 或模型开放的 ambient capability。“最近成功与失败” +的有界选择、日志 range/redaction 和最终 Copilot Prompt 仍需后续产品 Gate。 + ## 17. Tool Registry ### 17.1 ToolDefinition @@ -6960,9 +6968,12 @@ ApprovalRequest、immutable dispatch、StepRun、Audit 和受审 handler registr SQLite v27 与 PostgreSQL v28 都只允许同 Project、同 Run、`ready` 或 `waiting_approval` 的 Tool StepRun 执行 append/exact replay。PostgreSQL runtime 仍不能 SELECT 全量 Audit,只能在同一事务写 Audit、Trace 和保存完整 audit JSON 的 -receipt,再由 foreign key 与不可变 ACL 证明审计事实存在。该 repository 没有 execute -seam;plan/dispatch、StepRun、Trace、Audit 尚未进入同一个 start barrier,因此本节 -规则仍是 production gate,而不是已经开放的 Tool 调用路径。 +receipt,再由 foreign key 与不可变 ACL 证明审计事实存在。后续 ADR-0158 至 ADR-0163 +已经把 immutable plan/input/preview、同事务 start barrier、snapshot-specific executable +binding、加密 result Artifact、StepRun terminal transition、RunEvent、Trace/Audit completion +串成双方言耐久链;response loss 只读取已提交 result,不重复执行 adapter。该完成事实 +不等于默认开放产品入口:Project snapshot、Policy、binding、repository、Artifact key 与 +transport/MCP/Copilot composition 仍必须逐 Profile 显式装配。 ## 18. MCP diff --git a/docs/adr/ADR-0400-bounded-trusted-run-comparison-tool.md b/docs/adr/ADR-0400-bounded-trusted-run-comparison-tool.md new file mode 100644 index 00000000..382098f8 --- /dev/null +++ b/docs/adr/ADR-0400-bounded-trusted-run-comparison-tool.md @@ -0,0 +1,96 @@ +# ADR-0400:有界、受信的 Run 对比 Tool + +- 状态:Accepted +- 日期:2026-08-14 +- 关联 RFC:QL-RFC-0001 D-308、Phase 2 +- 关联 ADR:ADR-0133、ADR-0162、ADR-0163 + +## 问题 + +QingLong 3.0 首个稳定版本要求只读 Copilot 能比较最近成功与失败运行,但现有受信内建 +Tool 只有 `qinglong.run.get@1.0.0`。让模型连续自由调用两次 `run.get` 会把选择、字段 +裁剪、差值算法和一致性声明留给 Prompt;另建 Copilot 专用数据库查询又会绕过 Tool +Definition、Project Policy、durable start barrier、结果 Artifact 与 Trace/Audit 链。 + +对比能力还必须同时适用于低配路由设备和 Cluster。它不能引入新 package、连接池、 +缓存、watcher 或后台采集器,也不能把 error、command、Artifact、PID、lease、Secret +或跨 Project 存在性送入模型上下文。 + +## 决策 + +1. 在既有 `@qinglong/runtime-core` 的 + `tool-execution/builtin-run-compare/` 领域目录内实现 + `qinglong.run.compare@1.0.0`,只从 + `/builtin-run-compare-tool` 与 `/builtin-run-compare-projection` 两个显式 subpath + 导出。它不进入 package 根入口,也不新增 workspace package。 +2. Definition 固定为 `effect=read`、`risk=low`、权限 `run.read`、5 秒 deadline; + executable binding 只允许 `database.read`,适用于 `edge|standalone|cluster-control`, + recovery mode 为 `retry_safe_read`。Definition 必须先作为 `qinglong` Package 的普通 + contribution 进入当前 Project Tool snapshot;平台不会隐式授予任何 Project。 +3. 输入只接受两个不同、各不超过 128 字符的 `baselineRunId` 与 `candidateRunId`。 + adapter 复用现有 `RunRepositoryReader.findRunById`,按 baseline → candidate 固定顺序 + 串行执行两次有界点查;不新增 transaction 或并发读。任一点查损坏或不可用时,整个 + 调用返回稳定 unavailable。 +4. absent 与 cross-Project 对调用方统一为 `{found:false}`。可见 Run 只包含既有低敏 + projection:Run/Task revision identity、状态/version/event sequence/priority、执行来源/ + owner 与生命周期时间;request、trigger、input/output Artifact、executor handle、PID、 + lease、error 和 Secret 一律不输出。 +5. 只有两侧都可见时才计算 `taskId|taskRevision|status|priority|executionOrigin| + executionOwner` 的固定顺序 changed fields,以及可证明的 queue、execution、total + duration delta。任一时间戳缺失、非安全整数或结束早于开始时,对应差值字段不出现, + 不以 `0` 冒充未知。 +6. 输出永久携带 `consistency=ordered_independent_point_reads`。本协议不声称两次读取属于 + 同一数据库 snapshot;每个 projection 的 version/eventSequence 供上层展示观察边界。 + 若未来需要事务快照,必须由 repository 新契约独立评审,不能静默改变 v1 语义。 +7. 该 Tool 复用既有 durable start → current binding → encrypted input Artifact → key → + adapter → encrypted result Artifact → StepRun/RunEvent/Trace/Audit completion 链。本 ADR + 不开放新的 HTTP/MCP/Copilot route;产品组合仍必须显式装配 current snapshot、binding、 + repository 和 key authority。 + +## 低配与集群影响 + +- Edge/Standalone 空闲时零新增模块加载、timer、listener、数据库写入或后台唤醒;每次 + 调用最多增加两次串行 Run 点查和一个调用期 deadline。 +- Cluster 复用现有 PostgreSQL Run reader 与调用方 Pool;不新增表、migration、role、 + Pool、controller、Job 或缓存。 +- package 数保持 18;新源码位于有稳定领域名的二级目录,不回到 `src` 根平铺,也不为 + 两个文件制造微型 package。Runtime Core source 由 160 增至 162,新增量全部为 nested, + 根层仍精确为一个 160 行公开 export 文件。 + +## 被否决方案 + +1. **让 Prompt 自由组合两次 `run.get`**:不能冻结字段、顺序、差值和一致性声明。 +2. **在 AI package 直连 Run repository**:会让模型路径绕过受信 Tool 和 durable evidence。 +3. **一次并发读取两个 Run**:没有事务快照时并发不能提供更强一致性,却增加低配设备 + 瞬时数据库并发。 +4. **返回 error summary、日志或 Artifact**:它们需要独立授权、范围、脱敏和字节预算。 +5. **找不到一侧时区分 absent/cross-Project**:会泄露跨租户 Run 存在性。 +6. **新增 `ql3-run-compare` package**:没有独立部署、依赖或权限生命周期收益。 + +## 当前验证 + +- 定向 5/5:覆盖有界差值、固定读取顺序、跨 Project/absent 等价、时间戳缺失、别名与 + unknown input、损坏/不可用 repository、Definition/binding/authority 漂移、显式 subpath + 和根入口零导出。 +- Runtime Core 全量 553/553 通过;TypeScript clean build 通过。 +- 最终 18-package clean build/test 退出 0;backend 1,208 项为 1,206 pass、2 条平台条件 + skip、0 fail。package boundary 保持 18 个 package,`singleSourcePackages=[]`、 + `shallowSourcePackages=[]`;cluster dependency、Edge import 与 Cluster deployment 审计 + 均无 finding。 +- 基础 Edge、Edge AI、Edge MCP 最终制品分别为 2,589,812 / 3,121,108 / + 7,209,862 bytes,低于各自 4 / 5 / 16 MiB 上限;未装配的 compare subpath 由 Profile + runtime export projection 裁掉,不产生默认常驻模块或发布闭包强依赖。 +- GitNexus 对复用的 `executeBoundedRunReadProjection` 报告 LOW:1 个直接调用者、4 个 + 总影响、0 条 execution flow。`RunRepositoryReader` 接口覆盖 11 个直接/33 个总依赖, + 为 MEDIUM,但本切片没有修改该接口,只消费既有 `findRunById`。 +- 刷新索引后,新 `executeBuiltInRunCompareTool` 为 LOW:1 个直接调用者、6 个总影响、 + 0 条 execution flow;adapter class 与 binding factory 均为 0 个产品调用者,精确反映 + “契约已提供、产品组合尚未默认装配”的状态。 + +## 后续门禁 + +1. 在 Local 与 Cluster 产品 composition 中装配 Definition/binding,并以真实 SQLite/ + PostgreSQL 完成 durable start→result completion 纵切面; +2. 增加受 Policy 保护的“按 Task 选择最近成功/失败 Run”,但不得把无界 search 交给模型; +3. 日志解释必须另设 range、redaction、Artifact 与 Prompt-injection 边界; +4. 固定物理 Edge 的单次对比延迟/RSS,以及 Cluster 多副本读取与 PostgreSQL failover 证据。 diff --git a/docs/adr/README.md b/docs/adr/README.md index a6c7bea8..b700f8b0 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -403,6 +403,7 @@ | [ADR-0397](./ADR-0397-explicit-cluster-secret-action-manual-recovery.md) | Cluster Secret Action 显式人工恢复 | Accepted(实现、单节点 PostgreSQL、完整 workspace/后端/边界与 physical HA 门完成) | | [ADR-0398](./ADR-0398-pre-activation-plugin-package-candidate-qualification.md) | Plugin Package 激活前候选资格校验与自动保留旧版本 | Proposed(v2 私有报告/离线审计已编排、全量/HA 门完成;远端 Kubernetes 成功记录与物理低配门待闭合) | | [ADR-0399](./ADR-0399-external-release-attestation-for-physical-edge-evidence.md) | 物理 Edge 证据的外部发布归档证明 | Accepted(exact recorder 重建与离线验签已实现;固定实机 ceremony 待执行) | +| [ADR-0400](./ADR-0400-bounded-trusted-run-comparison-tool.md) | 有界、受信的 Run 对比 Tool | Accepted | ## 规则 diff --git a/packages/ql3-runtime-core/package.json b/packages/ql3-runtime-core/package.json index 5fa54a42..86e3d755 100644 --- a/packages/ql3-runtime-core/package.json +++ b/packages/ql3-runtime-core/package.json @@ -236,6 +236,12 @@ "builtin-run-read-projection": [ "dist/tool-execution/builtin-run-read/builtInRunReadProjection.d.ts" ], + "builtin-run-compare-tool": [ + "dist/tool-execution/builtin-run-compare/builtInRunCompareTool.d.ts" + ], + "builtin-run-compare-projection": [ + "dist/tool-execution/builtin-run-compare/builtInRunCompareProjection.d.ts" + ], "run": [ "dist/run/run.d.ts" ], @@ -652,6 +658,16 @@ "require": "./dist/tool-execution/builtin-run-read/builtInRunReadProjection.js", "default": "./dist/tool-execution/builtin-run-read/builtInRunReadProjection.js" }, + "./builtin-run-compare-tool": { + "types": "./dist/tool-execution/builtin-run-compare/builtInRunCompareTool.d.ts", + "require": "./dist/tool-execution/builtin-run-compare/builtInRunCompareTool.js", + "default": "./dist/tool-execution/builtin-run-compare/builtInRunCompareTool.js" + }, + "./builtin-run-compare-projection": { + "types": "./dist/tool-execution/builtin-run-compare/builtInRunCompareProjection.d.ts", + "require": "./dist/tool-execution/builtin-run-compare/builtInRunCompareProjection.js", + "default": "./dist/tool-execution/builtin-run-compare/builtInRunCompareProjection.js" + }, "./secret-reference": { "types": "./dist/secret/secretReference.d.ts", "require": "./dist/secret/secretReference.js", diff --git a/packages/ql3-runtime-core/src/tool-execution/builtin-run-compare/builtInRunCompareProjection.ts b/packages/ql3-runtime-core/src/tool-execution/builtin-run-compare/builtInRunCompareProjection.ts new file mode 100644 index 00000000..2a04695f --- /dev/null +++ b/packages/ql3-runtime-core/src/tool-execution/builtin-run-compare/builtInRunCompareProjection.ts @@ -0,0 +1,305 @@ +import { EXECUTION_ORIGINS, RUN_STATUSES } from '../../run/run'; +import type { RunRepositoryReader } from '../../run/runRepository'; +import { + BoundedRunReadProjectionUnavailableError, + executeBoundedRunReadProjection, + type BoundedRunReadProjection, +} from '../../run/projection/boundedRunReadProjection'; +import { + normalizeToolDefinition, + type ToolJsonValue, +} from '../tool-registry/toolRegistry'; + +export const BUILTIN_RUN_COMPARE_TOOL = Object.freeze({ + name: 'qinglong.run.compare', + version: '1.0.0', +}); +export const BUILTIN_RUN_COMPARE_TIMEOUT_SECONDS = 5; + +const MAX_INT = 2_147_483_647; +const MIN_INT = -2_147_483_648; +const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/; +const COMPARABLE_FIELDS = Object.freeze([ + 'taskId', + 'taskRevision', + 'status', + 'priority', + 'executionOrigin', + 'executionOwner', +] as const); + +const RUN_PROJECTION_SCHEMA = Object.freeze({ + type: 'object' as const, + properties: { + found: { type: 'boolean' as const }, + id: { type: 'string' as const, minLength: 1, maxLength: 128 }, + taskId: { type: 'string' as const, minLength: 1, maxLength: 255 }, + taskRevision: { type: 'string' as const, minLength: 1, maxLength: 255 }, + status: { + type: 'string' as const, + maxLength: 32, + enum: RUN_STATUSES, + }, + version: { type: 'integer' as const, minimum: 0, maximum: MAX_INT }, + eventSequence: { + type: 'integer' as const, + minimum: 0, + maximum: MAX_INT, + }, + priority: { + type: 'integer' as const, + minimum: MIN_INT, + maximum: MAX_INT, + }, + executionOrigin: { + type: 'string' as const, + maxLength: 32, + enum: EXECUTION_ORIGINS, + }, + executionOwner: { + type: 'string' as const, + maxLength: 16, + enum: ['legacy', 'runtime'] as const, + }, + createdAtMs: { + type: 'integer' as const, + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, + }, + queuedAtMs: { + type: 'integer' as const, + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, + }, + startedAtMs: { + type: 'integer' as const, + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, + }, + finishedAtMs: { + type: 'integer' as const, + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, + }, + }, + required: ['found'] as const, + additionalProperties: false, +}); + +export const BUILTIN_RUN_COMPARE_TOOL_DEFINITION = normalizeToolDefinition({ + name: BUILTIN_RUN_COMPARE_TOOL.name, + version: BUILTIN_RUN_COMPARE_TOOL.version, + description: + 'Compare two low-sensitive Project-scoped Run projections using bounded point reads', + inputSchema: { + type: 'object', + properties: { + baselineRunId: { type: 'string', minLength: 1, maxLength: 128 }, + candidateRunId: { type: 'string', minLength: 1, maxLength: 128 }, + }, + required: ['baselineRunId', 'candidateRunId'], + additionalProperties: false, + }, + outputSchema: { + type: 'object', + properties: { + baseline: RUN_PROJECTION_SCHEMA, + candidate: RUN_PROJECTION_SCHEMA, + comparable: { type: 'boolean' }, + sameTask: { type: 'boolean' }, + sameTaskRevision: { type: 'boolean' }, + changedFields: { + type: 'array', + items: { + type: 'string', + maxLength: 32, + enum: COMPARABLE_FIELDS, + }, + maxItems: COMPARABLE_FIELDS.length, + }, + queueDelayDeltaMs: { + type: 'integer', + minimum: -Number.MAX_SAFE_INTEGER, + maximum: Number.MAX_SAFE_INTEGER, + }, + executionDurationDeltaMs: { + type: 'integer', + minimum: -Number.MAX_SAFE_INTEGER, + maximum: Number.MAX_SAFE_INTEGER, + }, + totalDurationDeltaMs: { + type: 'integer', + minimum: -Number.MAX_SAFE_INTEGER, + maximum: Number.MAX_SAFE_INTEGER, + }, + consistency: { + type: 'string', + maxLength: 32, + enum: ['ordered_independent_point_reads'], + }, + }, + required: [ + 'baseline', + 'candidate', + 'comparable', + 'sameTask', + 'sameTaskRevision', + 'changedFields', + 'consistency', + ], + additionalProperties: false, + }, + effect: 'read', + risk: 'low', + requiredPermissions: ['run.read'], + timeoutSeconds: BUILTIN_RUN_COMPARE_TIMEOUT_SECONDS, +}); + +export class InvalidBuiltInRunCompareToolError extends TypeError { + readonly code = 'BUILTIN_RUN_COMPARE_TOOL_INVALID'; + + constructor(message: string) { + super(`Built-in Run compare Tool is invalid: ${message}`); + this.name = 'InvalidBuiltInRunCompareToolError'; + } +} + +export class BuiltInRunCompareToolUnavailableError extends Error { + readonly code = 'BUILTIN_RUN_COMPARE_TOOL_UNAVAILABLE'; + + constructor() { + super('Built-in Run compare Tool is unavailable'); + this.name = 'BuiltInRunCompareToolUnavailableError'; + } +} + +function invalid(message: string): never { + throw new InvalidBuiltInRunCompareToolError(message); +} + +function boundedText(value: unknown, maximum: number): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= maximum && + !CONTROL_PATTERN.test(value) + ); +} + +function timestampDelta( + baselineStart: unknown, + baselineEnd: unknown, + candidateStart: unknown, + candidateEnd: unknown, +): number | undefined { + if ( + !Number.isSafeInteger(baselineStart) || + !Number.isSafeInteger(baselineEnd) || + !Number.isSafeInteger(candidateStart) || + !Number.isSafeInteger(candidateEnd) + ) { + return undefined; + } + if ( + Number(baselineEnd) < Number(baselineStart) || + Number(candidateEnd) < Number(candidateStart) + ) { + return undefined; + } + const baselineDuration = Number(baselineEnd) - Number(baselineStart); + const candidateDuration = Number(candidateEnd) - Number(candidateStart); + const delta = candidateDuration - baselineDuration; + return Number.isSafeInteger(delta) ? delta : undefined; +} + +function compareProjections( + baseline: BoundedRunReadProjection, + candidate: BoundedRunReadProjection, +): Readonly> { + const comparable = baseline.found === true && candidate.found === true; + const changedFields = Object.freeze( + comparable + ? COMPARABLE_FIELDS.filter((field) => baseline[field] !== candidate[field]) + : [], + ); + const queueDelayDeltaMs = comparable + ? timestampDelta( + baseline.createdAtMs, + baseline.queuedAtMs, + candidate.createdAtMs, + candidate.queuedAtMs, + ) + : undefined; + const executionDurationDeltaMs = comparable + ? timestampDelta( + baseline.startedAtMs, + baseline.finishedAtMs, + candidate.startedAtMs, + candidate.finishedAtMs, + ) + : undefined; + const totalDurationDeltaMs = comparable + ? timestampDelta( + baseline.createdAtMs, + baseline.finishedAtMs, + candidate.createdAtMs, + candidate.finishedAtMs, + ) + : undefined; + return Object.freeze({ + baseline, + candidate, + comparable, + sameTask: comparable && baseline.taskId === candidate.taskId, + sameTaskRevision: + comparable && baseline.taskRevision === candidate.taskRevision, + changedFields, + ...(queueDelayDeltaMs === undefined ? {} : { queueDelayDeltaMs }), + ...(executionDurationDeltaMs === undefined + ? {} + : { executionDurationDeltaMs }), + ...(totalDurationDeltaMs === undefined ? {} : { totalDurationDeltaMs }), + consistency: 'ordered_independent_point_reads', + }); +} + +export async function executeBuiltInRunCompareTool( + runs: Pick, + projectId: string, + input: ToolJsonValue, +): Promise>> { + const inputRecord = + input && typeof input === 'object' && !Array.isArray(input) + ? (input as Readonly>) + : null; + if ( + !runs || + typeof runs.findRunById !== 'function' || + !boundedText(projectId, 128) || + !inputRecord || + Reflect.ownKeys(inputRecord).length !== 2 || + !boundedText(inputRecord.baselineRunId, 128) || + !boundedText(inputRecord.candidateRunId, 128) || + inputRecord.baselineRunId === inputRecord.candidateRunId + ) { + return invalid('execution context or input is invalid'); + } + try { + const baseline = await executeBoundedRunReadProjection( + runs, + projectId, + inputRecord.baselineRunId, + ); + const candidate = await executeBoundedRunReadProjection( + runs, + projectId, + inputRecord.candidateRunId, + ); + return compareProjections(baseline, candidate); + } catch (error) { + if (!(error instanceof BoundedRunReadProjectionUnavailableError)) { + return invalid('execution context or input is invalid'); + } + throw new BuiltInRunCompareToolUnavailableError(); + } +} diff --git a/packages/ql3-runtime-core/src/tool-execution/builtin-run-compare/builtInRunCompareTool.ts b/packages/ql3-runtime-core/src/tool-execution/builtin-run-compare/builtInRunCompareTool.ts new file mode 100644 index 00000000..1f84e38d --- /dev/null +++ b/packages/ql3-runtime-core/src/tool-execution/builtin-run-compare/builtInRunCompareTool.ts @@ -0,0 +1,158 @@ +import type { DeploymentProfile } from '../../cluster-control/clusterControlActivation'; +import type { RunRepositoryReader } from '../../run/runRepository'; +import { + normalizeProjectToolDefinitionSnapshot, + type ProjectToolDefinitionSnapshot, +} from '../tool-registry/projectToolDefinitionSnapshot'; +import { + ToolDefinitionRegistry, + type ToolJsonValue, +} from '../tool-registry/toolRegistry'; +import { + createTrustedToolHandlerBinding, + normalizeTrustedToolHandlerBinding, + type TrustedToolHandlerBinding, +} from '../trustedToolInvocation'; +import type { + TrustedToolExecutionAdapter, + TrustedToolExecutionAdapterContext, +} from '../trustedToolExecution'; +import { + BUILTIN_RUN_COMPARE_TOOL, + BUILTIN_RUN_COMPARE_TOOL_DEFINITION, + BUILTIN_RUN_COMPARE_TIMEOUT_SECONDS, + InvalidBuiltInRunCompareToolError, + executeBuiltInRunCompareTool, +} from './builtInRunCompareProjection'; + +export { + BUILTIN_RUN_COMPARE_TOOL, + BUILTIN_RUN_COMPARE_TOOL_DEFINITION, + BUILTIN_RUN_COMPARE_TIMEOUT_SECONDS, + BuiltInRunCompareToolUnavailableError, + InvalidBuiltInRunCompareToolError, + executeBuiltInRunCompareTool, +} from './builtInRunCompareProjection'; + +export const BUILTIN_RUN_COMPARE_ADAPTER = Object.freeze({ + id: 'builtin.qinglong.run-compare', + version: '1.0.0', +}); +export const BUILTIN_RUN_COMPARE_REDACTION_CONTRACT = Object.freeze({ + id: 'redaction.qinglong.run-compare', + version: '1.0.0', +}); +export const BUILTIN_RUN_COMPARE_AUDIT_CONTRACT = Object.freeze({ + id: 'audit.qinglong.tool-call', + version: '1.0.0', +}); +const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/; + +function invalid(message: string): never { + throw new InvalidBuiltInRunCompareToolError(message); +} + +function sameValue(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function boundedText(value: unknown, maximum: number): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= maximum && + !CONTROL_PATTERN.test(value) + ); +} + +export function createBuiltInRunCompareToolHandlerBinding( + snapshotValue: ProjectToolDefinitionSnapshot, + profiles: readonly DeploymentProfile[], +): Readonly { + const snapshot = normalizeProjectToolDefinitionSnapshot(snapshotValue); + const definition = snapshot.definitions.find( + (entry) => + entry.definition.name === BUILTIN_RUN_COMPARE_TOOL.name && + entry.definition.version === BUILTIN_RUN_COMPARE_TOOL.version, + )?.definition; + if (!definition || !sameValue(definition, BUILTIN_RUN_COMPARE_TOOL_DEFINITION)) { + return invalid('reviewed Tool definition is absent or changed'); + } + return createTrustedToolHandlerBinding(snapshot, { + tool: BUILTIN_RUN_COMPARE_TOOL, + adapter: BUILTIN_RUN_COMPARE_ADAPTER, + executionClass: 'builtin_in_process', + profiles, + authorities: ['database.read'], + timeoutSeconds: BUILTIN_RUN_COMPARE_TIMEOUT_SECONDS, + redactionContract: BUILTIN_RUN_COMPARE_REDACTION_CONTRACT, + auditContract: BUILTIN_RUN_COMPARE_AUDIT_CONTRACT, + }); +} + +export class BuiltInRunCompareToolAdapter + implements TrustedToolExecutionAdapter +{ + readonly binding!: Readonly; + readonly profile!: DeploymentProfile; + readonly recoveryMode = 'retry_safe_read' as const; + readonly #runs!: Pick; + + constructor( + bindingValue: TrustedToolHandlerBinding, + profile: DeploymentProfile, + definitions: ToolDefinitionRegistry, + runs: Pick, + ) { + const binding = normalizeTrustedToolHandlerBinding(bindingValue); + if (!(definitions instanceof ToolDefinitionRegistry)) { + return invalid('Tool Definition registry is invalid'); + } + let definition; + try { + definition = definitions.resolve( + BUILTIN_RUN_COMPARE_TOOL.name, + BUILTIN_RUN_COMPARE_TOOL.version, + ); + } catch { + return invalid('reviewed Tool definition is unavailable'); + } + if ( + !sameValue(binding.tool, BUILTIN_RUN_COMPARE_TOOL) || + !sameValue(binding.adapter, BUILTIN_RUN_COMPARE_ADAPTER) || + binding.executionClass !== 'builtin_in_process' || + !sameValue(binding.authorities, ['database.read']) || + binding.timeoutSeconds !== BUILTIN_RUN_COMPARE_TIMEOUT_SECONDS || + !sameValue( + binding.redactionContract, + BUILTIN_RUN_COMPARE_REDACTION_CONTRACT, + ) || + !sameValue(binding.auditContract, BUILTIN_RUN_COMPARE_AUDIT_CONTRACT) || + !binding.profiles.includes(profile) || + !sameValue(definition, BUILTIN_RUN_COMPARE_TOOL_DEFINITION) + ) { + return invalid('binding does not match the reviewed adapter contract'); + } + if (!runs || typeof runs.findRunById !== 'function') { + return invalid('Run repository is invalid'); + } + this.binding = binding; + this.profile = profile; + this.#runs = runs; + Object.freeze(this); + } + + async execute( + context: Readonly, + input: ToolJsonValue, + ): Promise { + if ( + !context || + typeof context !== 'object' || + !boundedText(context.projectId, 128) + ) { + return invalid('execution context or input is invalid'); + } + return executeBuiltInRunCompareTool(this.#runs, context.projectId, input); + } +} diff --git a/packages/ql3-runtime-core/test/builtInRunCompareTool.test.cjs b/packages/ql3-runtime-core/test/builtInRunCompareTool.test.cjs new file mode 100644 index 00000000..e55524f3 --- /dev/null +++ b/packages/ql3-runtime-core/test/builtInRunCompareTool.test.cjs @@ -0,0 +1,280 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + BUILTIN_RUN_COMPARE_ADAPTER, + BUILTIN_RUN_COMPARE_TOOL, + BUILTIN_RUN_COMPARE_TOOL_DEFINITION, + BuiltInRunCompareToolAdapter, + BuiltInRunCompareToolUnavailableError, + InvalidBuiltInRunCompareToolError, + createBuiltInRunCompareToolHandlerBinding, + executeBuiltInRunCompareTool, +} = require('../dist/tool-execution/builtin-run-compare/builtInRunCompareTool'); +const { + createPluginPackageResourceGenerationFromReferences, +} = require('../dist/plugin-package/pluginPackageResourceGeneration'); +const { + createProjectToolDefinitionSnapshot, + projectToolDefinitionRegistry, +} = require('../dist/tool-execution/tool-registry/projectToolDefinitionSnapshot'); + +const DIGEST_A = 'a'.repeat(64); +const DIGEST_B = 'b'.repeat(64); +const DIGEST_C = 'c'.repeat(64); + +function run(id, overrides = {}) { + return { + id, + projectId: 'project-compare', + taskId: 'task-backup', + taskRevision: 'task-backup@4', + triggerType: 'schedule', + executionOrigin: 'scheduled_system', + executionOwner: 'runtime', + status: 'succeeded', + version: 4, + eventSequence: 8, + priority: 10, + createdAtMs: 1_000, + queuedAtMs: 1_020, + startedAtMs: 1_050, + finishedAtMs: 1_150, + requestId: 'must-not-cross-tool-output', + inputRef: 'artifact:must-not-cross-tool-output', + ...overrides, + }; +} + +function repository(records, calls = []) { + return { + async findRunById(runId) { + calls.push(runId); + return records.get(runId) ?? null; + }, + }; +} + +function snapshot(definition = BUILTIN_RUN_COMPARE_TOOL_DEFINITION) { + const generation = createPluginPackageResourceGenerationFromReferences({ + installationId: 'install-qinglong-compare', + projectId: 'project-compare', + packageName: 'qinglong', + lockDigest: DIGEST_A, + generation: 1, + previousActiveLockDigest: null, + contentDigest: DIGEST_B, + resources: [], + }); + return createProjectToolDefinitionSnapshot({ + projectId: 'project-compare', + contributions: [ + { + generation, + revisionDigest: DIGEST_C, + definitions: [definition], + }, + ], + }); +} + +test('compares two ordered Project-scoped Run point reads with bounded deltas', async () => { + const calls = []; + const baseline = run('run-success'); + const candidate = run('run-failure', { + taskRevision: 'task-backup@5', + status: 'failed', + version: 6, + eventSequence: 12, + createdAtMs: 2_000, + queuedAtMs: 2_040, + startedAtMs: 2_100, + finishedAtMs: 2_350, + requestId: 'must-also-not-cross-tool-output', + }); + const output = await executeBuiltInRunCompareTool( + repository( + new Map([ + [baseline.id, baseline], + [candidate.id, candidate], + ]), + calls, + ), + 'project-compare', + { baselineRunId: baseline.id, candidateRunId: candidate.id }, + ); + + assert.deepEqual(calls, ['run-success', 'run-failure']); + assert.equal(output.comparable, true); + assert.equal(output.sameTask, true); + assert.equal(output.sameTaskRevision, false); + assert.deepEqual(output.changedFields, ['taskRevision', 'status']); + assert.equal(Object.isFrozen(output.changedFields), true); + assert.equal(output.queueDelayDeltaMs, 20); + assert.equal(output.executionDurationDeltaMs, 150); + assert.equal(output.totalDurationDeltaMs, 200); + assert.equal(output.consistency, 'ordered_independent_point_reads'); + assert.equal(output.baseline.requestId, undefined); + assert.equal(output.candidate.inputRef, undefined); + + const registry = projectToolDefinitionRegistry(snapshot()); + assert.deepEqual( + registry.normalizeOutput( + BUILTIN_RUN_COMPARE_TOOL.name, + BUILTIN_RUN_COMPARE_TOOL.version, + output, + ), + output, + ); +}); + +test('does not compare an absent or cross-Project Run and omits duration claims', async () => { + const output = await executeBuiltInRunCompareTool( + repository( + new Map([ + ['run-visible', run('run-visible')], + [ + 'run-foreign', + run('run-foreign', { projectId: 'project-someone-else' }), + ], + ]), + ), + 'project-compare', + { baselineRunId: 'run-visible', candidateRunId: 'run-foreign' }, + ); + + assert.equal(output.baseline.found, true); + assert.deepEqual(output.candidate, { found: false }); + assert.equal(output.comparable, false); + assert.equal(output.sameTask, false); + assert.equal(output.sameTaskRevision, false); + assert.deepEqual(output.changedFields, []); + assert.equal(output.queueDelayDeltaMs, undefined); + assert.equal(output.executionDurationDeltaMs, undefined); + assert.equal(output.totalDurationDeltaMs, undefined); + + const invalidTimeline = await executeBuiltInRunCompareTool( + repository( + new Map([ + ['run-baseline', run('run-baseline')], + [ + 'run-reversed', + run('run-reversed', { startedAtMs: 3_000, finishedAtMs: 2_900 }), + ], + ]), + ), + 'project-compare', + { baselineRunId: 'run-baseline', candidateRunId: 'run-reversed' }, + ); + assert.equal(invalidTimeline.comparable, true); + assert.equal(invalidTimeline.executionDurationDeltaMs, undefined); +}); + +test('rejects aliases, unknown input, malformed records, and repository failure', async () => { + const runs = repository(new Map()); + await assert.rejects( + executeBuiltInRunCompareTool(runs, 'project-compare', { + baselineRunId: 'same-run', + candidateRunId: 'same-run', + }), + InvalidBuiltInRunCompareToolError, + ); + await assert.rejects( + executeBuiltInRunCompareTool(runs, 'project-compare', { + baselineRunId: 'run-a', + candidateRunId: 'run-b', + injected: true, + }), + InvalidBuiltInRunCompareToolError, + ); + await assert.rejects( + executeBuiltInRunCompareTool( + repository( + new Map([ + ['run-a', run('run-a')], + ['run-b', run('run-b', { version: -1 })], + ]), + ), + 'project-compare', + { baselineRunId: 'run-a', candidateRunId: 'run-b' }, + ), + BuiltInRunCompareToolUnavailableError, + ); + await assert.rejects( + executeBuiltInRunCompareTool( + { + async findRunById() { + throw new Error('database DSN must not escape'); + }, + }, + 'project-compare', + { baselineRunId: 'run-a', candidateRunId: 'run-b' }, + ), + BuiltInRunCompareToolUnavailableError, + ); +}); + +test('binds the exact reviewed definition to a retry-safe database-read adapter', async () => { + const currentSnapshot = snapshot(); + const binding = createBuiltInRunCompareToolHandlerBinding(currentSnapshot, [ + 'edge', + 'standalone', + 'cluster-control', + ]); + assert.deepEqual(binding.tool, BUILTIN_RUN_COMPARE_TOOL); + assert.deepEqual(binding.adapter, BUILTIN_RUN_COMPARE_ADAPTER); + assert.deepEqual(binding.authorities, ['database.read']); + + const definitions = projectToolDefinitionRegistry(currentSnapshot); + const adapter = new BuiltInRunCompareToolAdapter( + binding, + 'edge', + definitions, + repository( + new Map([ + ['run-a', run('run-a')], + ['run-b', run('run-b', { status: 'failed' })], + ]), + ), + ); + assert.equal(adapter.recoveryMode, 'retry_safe_read'); + const output = await adapter.execute( + { projectId: 'project-compare' }, + { baselineRunId: 'run-a', candidateRunId: 'run-b' }, + ); + assert.deepEqual(output.changedFields, ['status']); + + const changed = { + ...BUILTIN_RUN_COMPARE_TOOL_DEFINITION, + description: 'unreviewed changed definition', + }; + assert.throws( + () => + createBuiltInRunCompareToolHandlerBinding(snapshot(changed), ['edge']), + InvalidBuiltInRunCompareToolError, + ); + assert.throws( + () => + new BuiltInRunCompareToolAdapter( + { ...binding, authorities: ['database.read', 'network.client'] }, + 'edge', + definitions, + repository(new Map()), + ), + /handler authority is invalid/, + ); +}); + +test('publishes only explicit compare subpaths and keeps runtime-core root unchanged', () => { + const tool = require('@qinglong/runtime-core/builtin-run-compare-tool'); + const projection = require('@qinglong/runtime-core/builtin-run-compare-projection'); + const root = require('@qinglong/runtime-core'); + + assert.equal(tool.BUILTIN_RUN_COMPARE_TOOL.name, 'qinglong.run.compare'); + assert.equal( + projection.BUILTIN_RUN_COMPARE_TOOL.name, + 'qinglong.run.compare', + ); + assert.equal(root.BUILTIN_RUN_COMPARE_TOOL, undefined); + assert.equal(root.executeBuiltInRunCompareTool, undefined); +}); diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 7f69690c..14d8f44d 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -299,10 +299,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: runtimeCore.rootSourceFileRoles, }, { - sourceFiles: 160, + sourceFiles: 162, rootSourceFiles: 1, rootSourceLines: 160, - nestedSourceFiles: 159, + nestedSourceFiles: 161, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, );