From 884912d1c86ae1513b713e6804c1493ac2378149 Mon Sep 17 00:00:00 2001
From: whyour
Date: Sat, 29 Aug 2026 03:51:35 +0800
Subject: [PATCH] feat(ql3): add request-scoped console task creation
---
docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 6 +-
...uest-scoped-local-console-task-mutation.md | 75 +++
docs/adr/README.md | 3 +
docs/operations/ql3-alpha-candidate.md | 5 +-
docs/operations/ql3-local-web-console.md | 22 +-
docs/ql3-package-boundaries.json | 9 +-
.../adopted-profile/localAdoptedProfile.ts | 5 +
.../ql3-local-api/assets/console/console.css | 175 ++++++-
.../ql3-local-api/assets/console/console.js | 215 +++++++-
.../ql3-local-api/assets/console/index.html | 94 +++-
packages/ql3-local-api/package.json | 1 +
.../src/admission/localApiAdmission.ts | 30 ++
.../localApiProductSurface.ts | 61 ++-
.../authentication/credentialAuthenticator.ts | 24 +-
.../src/authentication/localPresenceProof.ts | 400 +++++++++++++++
.../ql3-local-api/src/task/taskPutRoute.ts | 462 ++++++++++++++++++
.../src/transport/httpSurface.ts | 28 ++
.../ql3-local-api/test/admission.test.cjs | 40 ++
.../ql3-local-api/test/consoleAssets.test.cjs | 9 +
.../ql3-local-api/test/httpSurface.test.cjs | 40 +-
.../test/localPresenceProof.test.cjs | 136 ++++++
.../test/sqliteHttp.integration.test.cjs | 66 ++-
.../ql3-local-api/test/taskPutRoute.test.cjs | 292 +++++++++++
.../src/application-runtime/activation.ts | 2 +
.../src/application-runtime/contract.ts | 5 +-
.../src/profile/localProfile.ts | 3 +
.../src/runtime/runtimeDatabase.ts | 31 ++
.../taskDefinitionAdministration.ts | 2 +-
.../test/taskDefinitionRequestFence.test.cjs | 283 +++++++++++
pnpm-lock.yaml | 3 +
scripts/ql3-cluster-dependency-audit.cjs | 10 +
test/back/ql3PackageBoundaryAudit.test.cjs | 4 +-
32 files changed, 2485 insertions(+), 56 deletions(-)
create mode 100644 docs/adr/ADR-0516-request-scoped-local-console-task-mutation.md
create mode 100644 packages/ql3-local-api/src/authentication/localPresenceProof.ts
create mode 100644 packages/ql3-local-api/src/task/taskPutRoute.ts
create mode 100644 packages/ql3-local-api/test/localPresenceProof.test.cjs
create mode 100644 packages/ql3-local-api/test/taskPutRoute.test.cjs
create mode 100644 packages/ql3-local-sqlite/test/taskDefinitionRequestFence.test.cjs
diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md
index e5a9c326..6c926e7f 100644
--- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md
+++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md
@@ -6,10 +6,12 @@
- 目标版本:QingLong 3.x
- 作者:QingLong Maintainers
- 创建日期:2026-07-17
-- 最后更新:2026-08-28
+- 最后更新:2026-08-29
- 讨论范围:架构与演进路线,不包含最终 UI 视觉方案
-最新增量证据(2026-08-28):
+最新增量证据(2026-08-29):
+
+- D-421/ADR-0516(实现与本地验收已完成,阶段提交/远端 milestone 待闭合):Local Web Task mutation 不再受限于短生命周期 CLI 的进程级 active credential。`PUT /api/v3/projects/:projectId/tasks/:taskId` 把 Bearer 限定为 session credential,第一次 exact request 只在 deployment root 内 `0700` 目录发布 `0600`、当前 UID、两分钟、一次性的私有 proof file;HTTP 只返回 authorization ID、canonical request digest、expiry 与 basename,不返回 proof 或宿主绝对路径。proof 绑定 Task 全内容、credential ID/version 与 User subject;Edge/Standalone pending 上限为 8/32,按请求惰性清理,不新增 timer/watcher/daemon。验证后 principal 提升为短期 `local_console`,既有 Task administration service 重跑 Policy;SQLite runtime 为每个请求建立独立 credential-fenced repository,并在 Task append 事务内复验 credential/Identity/pepper、actor、Project/RoleBinding fence,原子提交 allowed audit 与 mutation。并发测试证明两个 User repository 不共享 ambient authority,撤销 A 不影响 B,RoleBinding 漂移仍原子拒绝;真实 loopback HTTP→proof file→SQLite create 与 Chromium 编辑器/proof ticket 已通过。Console 当前完成 command Task 创建;HTTP 与 CLI 支持完整 create/update,Web update 等待强认证 authoring read/lease,避免用不含 spec 的 bounded read 覆盖未知字段。三资产合计 62,632 bytes;Local API 56/56、18-package clean build/test 3,030 total/3,008 pass/22 条件跳过/0 fail,package/source、Local image、122-module Edge import 与 Cluster dependency audit 均 compatible。默认 Edge 为 2,736,982 bytes/329 files/83 modules,opt-in Edge/Standalone Console 为 4,040,893/4,041,037 bytes、472 files/12 packages/94 modules,均保有门禁余量;默认 headless 与 Cluster 路径不变。完整 backend 仍由原生 CI 闭合,新的可下载双架构产物必须在本阶段提交后由显式 Console milestone run 重新生成,不能沿用 D-420 的旧 archive 冒充。
- D-420/ADR-0515(已实现,首份真实双架构 Console v5 Trial Kit 已交付):阶段可用的首个自动化从“原生 CI 能通过 API 看到 bounded log marker”推进为“部署者能在 Console 直接观察实际输出”。Local Run HTTP 详情只追加严格验证的 latest Attempt 低敏摘要(ID、序号、状态、时间和 `logAvailable`),不返回 executor handle、Artifact ID、路径、Worker、PID 或错误明细;共享 HIGH 风险 `executeBoundedRunReadProjection` 保持不变,避免 Local UI 字段漂移到内建 Run read/compare Tool。Console 使用既有 `artifact.read` Policy/Audit/credential re-confirm 链,每次固定读取首个 32 KiB base64 窗口,分别显示 available/pending/retired/not-found/unavailable、range 与 truncation,不新增轮询、WebSocket、timer、缓存或整文件下载。三资产增至 48,318 bytes;`edge-application-api|standalone-application-api` 为 3,960,535 / 3,960,679 bytes、467 files、12 packages、90 loaded modules,仍低于 6 MiB/640-file 门,默认 headless Edge 保持 2,669,390 bytes/325 files/58 modules,Cluster 零变化。Local API 49/49、完整 backend `1,650 total / 1,648 pass / 2 Linux conditional skip / 0 fail`、18-package clean build/test 退出 0;package/source、Local image、122-module Edge import 和 Cluster dependency audit 均 compatible。提交 `57953ec8` 的远端 QingLong 3.0 CI 为 41 success / 3 expected artifact-finalizer skip / 0 fail,独立 Kubernetes deployment live contract 为 1/1 success。现有 `task.put` 的进程级 active credential fence 不适合常驻并发 HTTP,Web Task 创建/修订必须以后续“每请求 credential fence + 同事务 Policy/Audit/mutation”切片完成,D-420 不用单因子 Bearer 绕过强认证。
diff --git a/docs/adr/ADR-0516-request-scoped-local-console-task-mutation.md b/docs/adr/ADR-0516-request-scoped-local-console-task-mutation.md
new file mode 100644
index 00000000..f7139028
--- /dev/null
+++ b/docs/adr/ADR-0516-request-scoped-local-console-task-mutation.md
@@ -0,0 +1,75 @@
+# ADR-0516:request-scoped Local Console Task mutation
+
+- 状态:Accepted
+- 日期:2026-08-29
+- 对应 RFC 切片:D-421
+- 关联:ADR-0256、ADR-0377、ADR-0512、ADR-0513、ADR-0514、ADR-0515
+
+## 背景
+
+Local Console 已能读取 Task、显式启动 Run、查看 Event/Step/有界日志并请求取消,但部署者仍必须离开 Console,使用短生命周期 `ql3-task` command file 才能创建或修订 Task。该 CLI 的安全性依赖“每个进程只激活一个 credential fence”;把同一个可变 active fence 搬进常驻、多请求 HTTP 会让并发用户互相覆盖凭据,并在 credential 撤销或 RoleBinding 漂移时产生错误授权。
+
+单因子 Bearer 只能证明浏览器持有 API credential,不能替代现有 Task 管理要求的 strong User。本切片必须在不扩大默认 headless 路由设备常驻成本、不把 proof secret 返回 HTTP、不削弱 SQLite 原子审计、也不把本机 POSIX authority 误用到 Cluster 的前提下,形成部署者可实际操作的 Web 创建链路。
+
+## 决策
+
+### 1. PUT 使用 request-scoped credential fence
+
+Local API 新增固定路由:
+
+```text
+PUT /api/v3/projects/:projectId/tasks/:taskId
+```
+
+请求体使用既有 immutable TaskDefinition command:`expectedRevision=null` 创建,`expectedRevision=current` 修订;mutation ID、occurredAt、name/kind/spec/labels/enabled 都参与规范化。Bearer 只建立 `single_factor` session,并交付该次认证解析出的 exact credential fence;服务不设置进程级 active credential。
+
+SQLite runtime 以 `taskDefinitionAdministrationForCredential(fence)` 为每个请求创建独立 repository。factory 建立时先复验 credential/Identity/pepper;写事务内再次复验同一 exact fence、actor subject、Project version 与 latest RoleBinding version/state。allowed audit、Task head/revision、mutation replay 与适用的 local execution revision仍在一个事务中提交。两个同时存活的 repository 不共享可变 credential 状态。
+
+### 2. 本机存在证明是第二权威,不经 HTTP 交付 secret
+
+第一次 exact PUT 不带 proof 时,服务在 `/console-presence/` 发布一次性 challenge file:目录 `0700`,文件 `0600`,当前 POSIX UID owner,exclusive/no-follow、原子 rename、文件和目录 `fsync`。响应只返回 authorization ID、请求摘要、过期时间和 basename;32-byte 随机 proof 只存在私有文件,不进入 HTTP response、URL、Cookie、Web Storage、日志或 challenge audit。
+
+proof 精确绑定:
+
+- canonical Task command SHA-256;
+- credential ID/version;
+- User subject;
+- 两分钟有效期与单次消费。
+
+Edge 最多保留 8 个待确认 challenge,Standalone 最多 32 个;过期文件按请求惰性清理,不新增 timer、watcher、daemon 或后台 I/O。错误 proof、不同 Task 内容、不同 credential/subject 和过期 proof 都不能消费原 challenge。用户提交 proof 后,服务先重新确认 Bearer credential authority,再消费 proof,并把 principal 提升为短期 `local_console` assurance;既有 Task administration service 随后重新执行 Project Policy。
+
+### 3. Challenge audit 与最终 mutation audit 分层
+
+生成 challenge 时记录 `approval_required/local_presence_required`,但不记录 proof、命令内容或路径。错误 proof 记录无 subject 的 `authentication_rejected/local_presence_rejected`,符合既有 audit outcome identity contract。最终 `allowed` audit 不由 HTTP 先写,而是与 Task mutation 一同进入 SQLite 事务;Policy、credential、RoleBinding 或 mutation fence 漂移都不会留下“允许但未写入”的孤立记录。
+
+### 4. Console 先提供可完成的 command Task 创建旅程
+
+离线 Console 新增“创建任务”编辑器:Task ID、名称、说明、argv file、逐行 args 和 enabled。第一次保存后展示只包含 `console-presence/`、两分钟时效和 password proof 输入的本机证明票据;页面内存只保留同一 immutable request,验证成功后刷新并选中新 Task。页面继续禁止 CDN、前端框架、inline script、`innerHTML`、Cookie 与 Web Storage。
+
+HTTP contract 已同时支持 create/update。当前 bounded Task read 有意不返回完整 spec/config,因此 Console 不伪造不完整 update:Web 修订编辑器要等后续受强认证的 authoring lease/read contract,或由调用方提供完整 exact definition。既有 `ql3-task` CLI 继续作为完整 create/update/enable/disable 入口。
+
+### 5. 部署档位保持分层
+
+- 默认 `edge`/`standalone` headless 不加载 Local API、Console 或 challenge manager,资源零增量;
+- opt-in `edge-application-api` 使用 8 个 pending 上限,适合低内存路由/NAS;
+- opt-in `standalone-application-api` 使用 32 个 pending 上限;
+- Cluster 不复用 POSIX proof、SQLite fence 或 Local credential。集群 Task mutation 后续必须使用 Cluster Control 的 TLS/RBAC/多副本 authority。
+
+## 不采用的方案
+
+- 不允许 Bearer 直接 `task.put`:它会把 strong User 降级为单因子远程 secret possession。
+- 不复用 CLI 的进程级 `activateUserCredentialFence`:常驻并发 HTTP 会发生 ambient authority 串线。
+- 不把 proof 放进 challenge response、Console HTML 或 quickstart stdout:这会让第二权威退化为同一网络通道内的 bearer。
+- 不为低配设备增加 WebSocket、轮询 challenge、timer 或长期 session store:显式读取私有文件已经形成可审计的本机动作。
+- 不让 Console 用 bounded read 投影拼装 update:投影刻意不含 spec/config,猜测会覆盖调用方未知字段。
+- 不把 Local proof 抽象为 Cluster 通用插件:POSIX owner/mode 不能证明 Kubernetes/多节点身份与审批。
+
+## 结果与验证边界
+
+定向验证覆盖:proof 文件权限与无身份泄漏、exact request/credential/subject 绑定、一次性消费、Edge 容量与过期惰性清理;Task route 的 challenge→confirm→strong Policy→事务 mutation、内容漂移、非 User、过期和失败关闭;两个同时存活的 credential repository、单方 credential revoke、另一方继续写入与 RoleBinding 漂移原子拒绝;真实 loopback HTTP→私有 proof file→SQLite Task/audit 创建;真实 Chromium 的 Task 编辑器与 proof ticket 可访问性/布局。
+
+本地 18-package clean build/test 已通过:`3,030 total / 3,008 pass / 22 conditional、platform 或 external-service skip / 0 fail`;Local API 完整 loopback/SQLite/Console 回归为 `56/56`,新增双 credential request-fence 为 `1/1`,package boundary 契约为 `10/10`。package/source、Local image、122-module Edge import 与 Cluster dependency audit 均 `compatible=true`。离线 Console 三资产合计 62,632 bytes;默认 Edge 为 2,736,982 bytes/329 files/3 packages/83 loaded modules,仍低于 4 MiB/512-file/20 MiB RSS-delta 门;opt-in `edge-application-api|standalone-application-api` 为 4,040,893/4,041,037 bytes、472 files、12 packages、94 loaded modules,仍低于 6 MiB/640-file/28 MiB RSS-delta 门。真实 Chromium 的创建编辑器与 proof ticket 已完成桌面布局、键盘焦点与可访问树检查。
+
+本机 2.x backend 兼容门首次运行得到 `1,348 total / 1,293 pass / 53 fail / 2 skip`,其中 52 个文件在加载 Sequelize 前统一因锁定的 `@whyour/sqlite3` 原生绑定缺失而失败,另一个 loopback 用例受当前 sandbox 拒绝;依赖重建先因 GitHub 预编译包下载超时、再因本机 C++ SDK 缺少 `` 失败,未伪装成源码回归。唯一实际源码契约漂移是 Local API 文件计数 18/17→20/19,修正后 package-boundary `10/10` 通过。完整 backend 与原生 Linux loopback 必须由阶段提交的远端 CI 闭合。
+
+该 ADR 证明源码切片的安全与产品旅程,不自动声明已有新的双架构 Trial Kit:新的可下载阶段产物仍必须由同源显式 milestone run 重新生成并进入 Local milestone index。
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 894e400b..3c6aaa68 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -517,6 +517,9 @@
| [ADR-0511](./ADR-0511-runnable-local-alpha-quickstart.md) | 可直接试运行的 Local Alpha Quickstart | Accepted(首份实际 v3 Trial Kit 待维护者授权) |
| [ADR-0512](./ADR-0512-bounded-offline-local-web-console.md) | 有界、离线的 Local Web Console | Accepted(Trial Kit 交付已由 ADR-0513 闭合) |
| [ADR-0513](./ADR-0513-selectable-local-console-trial-kit.md) | 可选择的 Local Console Trial Kit | Accepted(首份实际双架构产物待维护者授权) |
+| [ADR-0514](./ADR-0514-stage-usable-first-automation-journey.md) | 阶段可用的首个自动化旅程 | Accepted(Console v5 双架构实物已由 D-420 闭合) |
+| [ADR-0515](./ADR-0515-bounded-local-console-run-log.md) | Local Console 的有界 Run 日志观察面 | Accepted(首份实际 Console v5 双架构 milestone 已交付) |
+| [ADR-0516](./ADR-0516-request-scoped-local-console-task-mutation.md) | request-scoped Local Console Task mutation | Accepted(新双架构 Trial Kit 待本阶段 milestone) |
## 规则
diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md
index f43ad47a..16d29a8a 100644
--- a/docs/operations/ql3-alpha-candidate.md
+++ b/docs/operations/ql3-alpha-candidate.md
@@ -15,7 +15,7 @@
当维护者显式选择 `alpha_artifact_scope=all` 时,还会生成 `Alpha stage index`。它把同一次 run 的 Local/Cluster milestone 交叉绑定,并为 Edge、Standalone、Cluster 给出目标架构的最小 artifact 选择;这是阶段交付导航,不是正式 release catalog。只生成 Local 或 Cluster 时,各自 milestone 仍可独立成立,不制造一个不完整的总索引。
-## 当前阶段实物(2026-08-28)
+## 当前阶段实物(2026-08-29)
在下面保留的历史 exact-image 证据之外,2026-08-28 的源码阶段已把 headless 用户旅程与 opt-in Console 合并为一条可选择的交付链:
@@ -25,6 +25,9 @@
| Local headless Trial Kit v5 | 默认低配变体;同一源码支持 POSIX shell + Docker 一条命令完成 fresh setup、首 Owner与 Application active/stop;无 listener、示例 Task 或 Console 增量 | 尚未为当前提交单独触发 headless 双架构 milestone;不能把 Console archive 改名复用 |
| D-419 Console 首自动化闭环 | quickstart 创建无网络/SecretRef/Trigger 的示例 Task;原生 CI 使用真实 Owner credential 完成 read、fenced start、`succeeded` 与 bounded log marker | 仍不提供 Web Task 编辑、2.x 升级或生产远程管理 |
| D-420 Console Run 日志观察面 | 选择 Run 后经既有认证/Policy/Audit 链读取 latest Attempt 首个 32 KiB,展示 range、truncation、pending/retired 等明确状态 | 不自动轮询、不提供整文件下载;Web Task 创建/修订仍待独立强认证事务切片 |
+| D-421 Console Task 创建切片 | request-scoped credential fence、两分钟一次性本机 proof、同事务 Policy/Audit/Task mutation 已完成;Console 可创建 command Task | 当前源码尚未重新生成双架构 Trial Kit;Web update 等待 authoring read/lease,Cluster 不复用 Local proof |
+
+D-421 已关闭 D-420 记录的“Web Task mutation 必须独立设计”缺口,但不能据此把 run `33173769047` 的旧 archive 改名为新产物。只有 D-421 阶段提交通过远端 CI,并由同源显式 Local Console milestone run 重新生成 amd64/arm64 Trial Kit 与 milestone index 后,下载者才能把该 Web 创建能力视为新的阶段实物;在此之前,旧 Console v5 仍是最新可下载实物,本工作树/提交只是下一候选源码。
D-418 防止把“20 天代码和测试”冒充“用户已经能下载并完整操作”:源码与普通 CI 已具备生成、审计和实跑两种 Trial Kit 的能力,但只有显式 artifact run 生成且被同 run 的双架构 milestone 收录后,才是可下载阶段产物。操作说明见 [Local Alpha Trial Kit](./ql3-local-alpha-trial-kit.md) 与 [Local Web Console](./ql3-local-web-console.md)。
diff --git a/docs/operations/ql3-local-web-console.md b/docs/operations/ql3-local-web-console.md
index 583024f2..29fa7435 100644
--- a/docs/operations/ql3-local-web-console.md
+++ b/docs/operations/ql3-local-web-console.md
@@ -1,6 +1,6 @@
# QingLong 3.0 Local Web Console
-Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来查看 Task、Run 和执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。
+Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创建 command Task、查看 Task/Run/执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。
## 选择部署档位
@@ -11,14 +11,14 @@ Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来查
| 普通单节点服务器 | 选择 `standalone-application-api` |
| Kubernetes/Cluster 节点 | 不使用本 Local Console;继续使用 Cluster Control/Console 路径 |
-D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。D-420 又把该 Run 的 latest Attempt 首个 32 KiB 日志带到 Console,并明确展示 pending、retired、missing 与 truncation 状态。实际大 archive 仍只由维护者显式 artifact run 生成;普通 push 的源码和 CI 不是公开下载物。
+D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。D-420 又把该 Run 的 latest Attempt 首个 32 KiB 日志带到 Console。D-421 增加 request-scoped strong-auth Task PUT 与 Console command Task 创建器;它不复用 CLI 的进程级 active credential,也不让单因子 Bearer 直接写 Task。实际大 archive 仍只由维护者显式 artifact run 生成;普通 push 的源码和 CI 不是公开下载物。
## 前置条件
- 已完成 Local fresh setup,并有受支持的 Application config;
- Owner pepper keyring 与 SQLite active pepper 一致;
- 已通过 [`ql3-identity`](./ql3-local-identity-credential.md) 为 active Identity 签发 API credential;
-- credential 对目标 Project 至少有读取 Task/Run 的权限;启动和取消分别还需要 `run.start` 与 `run.stop`;
+- credential 对目标 Project 至少有读取 Task/Run 的权限;创建 Task、启动和取消分别还需要 `task.create`、`run.start` 与 `run.stop`;
- config、keyring、database 和 credential delivery 保持既有 `0700/0600`、no-symlink 和同 UID authority。
## 启动
@@ -54,19 +54,21 @@ ssh -L 5701:127.0.0.1:5701 router.example
## 使用
1. 输入 Project ID 和 `ql3c_…` API credential,选择“连接本机”。
-2. fresh Console Trial Kit 可先选择 `alpha-first-automation`;核对 revision/content fence 后才能“运行一次”。
-3. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。
-4. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。
-5. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。
-6. 完成后选择“断开并清除凭据”,再关闭页面。
+2. 选择“创建任务”,填写 Task ID、名称、argv 可执行文件和逐行参数,再选择“保存并生成本机证明”。
+3. 在部署设备上以 QingLong 数据目录 owner 读取 `/console-presence/<页面显示的 basename>`;把 JSON 的 `proof` 值粘贴回页面。文件为 `0600`、两分钟有效且只能用于这份 exact Task 一次。不要通过聊天、日志或 URL 转发 proof。
+4. 创建成功后核对 revision/content fence,再选择“运行一次”。fresh Console Trial Kit 也可直接使用 `alpha-first-automation`。
+5. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。
+6. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。
+7. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。
+8. 完成后选择“断开并清除凭据”,再关闭页面。
Credential 只存在当前页面内存,不进入 URL、Cookie 或 Web Storage。页面刷新会丢失 credential,需要重新输入;这是当前安全边界,不是缺陷。
## 当前阶段可用边界
-当前可操作闭环是 Task list/read/start 与 Run list/read/events/steps/log/cancel。页面不负责:
+当前可操作闭环是 command Task create/list/read/start 与 Run list/read/events/steps/log/cancel。HTTP `PUT` 也支持提供完整 exact definition 的 update;页面暂不负责:
-- 创建、编辑、启停 Task;
+- 编辑/启停现有 Task(bounded read 不返回完整 spec,不能据此安全覆盖;继续使用 `ql3-task`,后续由 authoring lease/read 切片补齐);
- Identity、Policy、Secret、Plugin Package 或 AI 配置管理;
- 日志整文件下载、终端、文件管理或 2.x 数据迁移;
- LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。
diff --git a/docs/ql3-package-boundaries.json b/docs/ql3-package-boundaries.json
index c3e39d84..395e5973 100644
--- a/docs/ql3-package-boundaries.json
+++ b/docs/ql3-package-boundaries.json
@@ -118,13 +118,18 @@
},
"criteria": ["authority", "shared_leaf"],
"profiles": ["local-owner", "edge-adopted", "standalone-adopted"],
- "consumers": ["@qinglong/local-application", "@qinglong/local-owner-cli"],
+ "consumers": [
+ "@qinglong/local-api",
+ "@qinglong/local-application",
+ "@qinglong/local-owner-cli"
+ ],
"authorities": [
"short-lived SQLite administration",
+ "request-scoped Local Console Task mutation",
"legacy adoption fence",
"reviewed adopted Profile activation"
],
- "rationale": "短生命周期写 authority 与 adopted Profile activation 共享完全相同的部署闭包;后者通过 adopted-profile 子路径与惰性 runtime import 隔离,不再用三文件微型 workspace package 表达。"
+ "rationale": "短生命周期写 authority、request-scoped Local Console Task mutation 与 adopted Profile activation 共享完全相同的部署闭包;Local API 仅允许调用 task-definition-administration 精确子路径,adopted Profile 通过 adopted-profile 子路径与惰性 runtime import 隔离,不再用三文件微型 workspace package 表达。"
},
{
"path": "packages/ql3-local-api",
diff --git a/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts b/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts
index cd15e77d..38250562 100644
--- a/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts
+++ b/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts
@@ -17,6 +17,8 @@ type LocalSqliteRunCancellationRepository =
ReadyLocalStorage['runCancellationRepository'];
type LocalSqliteTaskStartRepository = ReadyLocalStorage['taskStartRepository'];
type TaskDefinitionRepository = ReadyLocalStorage['taskDefinitions'];
+type TaskDefinitionAdministrationForCredential =
+ ReadyLocalStorage['taskDefinitionAdministrationForCredential'];
type LocalScheduleStore = ReadyLocalStorage['schedules'];
type LocalDispatchStore = ReadyLocalStorage['dispatch'];
type LocalSecretEnvelopeRepository = ReadyLocalStorage['localSecrets'];
@@ -87,6 +89,7 @@ export type LocalAdoptedProfileBootstrapResult =
readonly runCancellationRepository: LocalSqliteRunCancellationRepository;
readonly taskStartRepository: LocalSqliteTaskStartRepository;
readonly taskDefinitions: TaskDefinitionRepository;
+ readonly taskDefinitionAdministrationForCredential: TaskDefinitionAdministrationForCredential;
readonly schedules: LocalScheduleStore;
readonly dispatch: LocalDispatchStore;
readonly executionControl: ReadyLocalStorage['executionControl'];
@@ -235,6 +238,8 @@ export async function bootstrapLocalAdoptedProfileStorage(
runCancellationRepository: readyStorage.runCancellationRepository,
taskStartRepository: readyStorage.taskStartRepository,
taskDefinitions: readyStorage.taskDefinitions,
+ taskDefinitionAdministrationForCredential:
+ readyStorage.taskDefinitionAdministrationForCredential,
schedules: readyStorage.schedules,
dispatch: readyStorage.dispatch,
executionControl: readyStorage.executionControl,
diff --git a/packages/ql3-local-api/assets/console/console.css b/packages/ql3-local-api/assets/console/console.css
index c87f6ebd..34335ea9 100644
--- a/packages/ql3-local-api/assets/console/console.css
+++ b/packages/ql3-local-api/assets/console/console.css
@@ -35,7 +35,8 @@ body {
}
button,
-input {
+input,
+textarea {
font: inherit;
}
@@ -45,6 +46,7 @@ button {
button:focus-visible,
input:focus-visible,
+textarea:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid var(--amber);
outline-offset: 3px;
@@ -321,6 +323,14 @@ input:focus-visible,
line-height: 1.6;
}
+.masthead-actions {
+ flex: 0 0 auto;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
.refresh-button {
flex: 0 0 auto;
color: var(--deep);
@@ -700,6 +710,155 @@ input:focus-visible,
box-shadow: var(--shadow);
}
+.task-editor-dialog,
+.presence-dialog {
+ padding: 0;
+ color: var(--ink);
+ border: 1px solid var(--line);
+ border-radius: 0;
+ background: var(--paper);
+ box-shadow: var(--shadow);
+}
+
+.task-editor-dialog {
+ width: min(720px, calc(100vw - 32px));
+}
+
+.presence-dialog {
+ width: min(520px, calc(100vw - 32px));
+}
+
+.task-editor-dialog::backdrop,
+.presence-dialog::backdrop {
+ background: rgba(11, 31, 36, 0.76);
+}
+
+.task-editor-dialog form,
+.presence-dialog form {
+ padding: clamp(22px, 4vw, 34px);
+}
+
+.dialog-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 24px;
+}
+
+.task-editor-dialog h2,
+.presence-dialog h2 {
+ margin: 0;
+ font-family: 'Avenir Next Condensed', 'Arial Narrow', sans-serif;
+ font-size: 34px;
+ line-height: 1;
+}
+
+.editor-intro,
+.presence-dialog form > p:not(.eyebrow),
+.editor-note,
+.presence-expiry {
+ color: var(--muted);
+ line-height: 1.6;
+}
+
+.editor-grid {
+ margin-top: 24px;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px;
+}
+
+.editor-grid label,
+.presence-input {
+ display: grid;
+ gap: 7px;
+}
+
+.editor-grid label > span,
+.presence-input > span {
+ color: var(--muted);
+ font: 700 10px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.editor-grid input:not([type='checkbox']),
+.editor-grid textarea,
+.presence-input input {
+ width: 100%;
+ padding: 11px 12px;
+ color: var(--ink);
+ border: 1px solid var(--line);
+ border-radius: 0;
+ background: var(--white);
+ font: 500 13px/1.45 ui-monospace, 'SFMono-Regular', Consolas, monospace;
+}
+
+.editor-grid textarea {
+ resize: vertical;
+}
+
+.editor-wide {
+ grid-column: 1 / -1;
+}
+
+.editor-check {
+ grid-column: 1 / -1;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ justify-content: start;
+}
+
+.editor-check input {
+ width: 18px;
+ height: 18px;
+ accent-color: var(--signal);
+}
+
+.editor-note {
+ margin: 18px 0 0;
+ padding-left: 12px;
+ border-left: 3px solid var(--amber);
+ font-size: 11px;
+}
+
+.proof-ticket {
+ position: relative;
+ margin: 22px 0;
+ padding: 18px 16px 18px 22px;
+ display: block;
+ color: var(--white);
+ border-left: 6px solid var(--signal);
+ background: var(--deep);
+ font: 650 12px/1.5 ui-monospace, 'SFMono-Regular', Consolas, monospace;
+ overflow-wrap: anywhere;
+}
+
+.proof-ticket::after {
+ content: 'HOST FILE';
+ position: absolute;
+ top: 8px;
+ right: 10px;
+ color: rgba(255, 255, 255, 0.35);
+ font-size: 8px;
+ letter-spacing: 0.12em;
+}
+
+.presence-expiry {
+ margin-bottom: 0;
+ font-size: 11px;
+}
+
+.presence-error {
+ margin: 14px 0 0;
+ padding: 10px 12px;
+ color: var(--danger);
+ border-left: 3px solid var(--danger);
+ background: rgba(186, 74, 67, 0.08);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
.confirmation-dialog::backdrop {
background: rgba(11, 31, 36, 0.7);
}
@@ -813,6 +972,11 @@ input:focus-visible,
flex-direction: column;
}
+ .masthead-actions {
+ width: 100%;
+ justify-content: flex-start;
+ }
+
.masthead h2 {
font-size: 40px;
}
@@ -836,6 +1000,15 @@ input:focus-visible,
.record-side {
justify-items: start;
}
+
+ .editor-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .editor-wide,
+ .editor-check {
+ grid-column: auto;
+ }
}
@media (prefers-reduced-motion: reduce) {
diff --git a/packages/ql3-local-api/assets/console/console.js b/packages/ql3-local-api/assets/console/console.js
index 34a8f76d..07ff02c9 100644
--- a/packages/ql3-local-api/assets/console/console.js
+++ b/packages/ql3-local-api/assets/console/console.js
@@ -2,8 +2,10 @@
'use strict';
const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
+ const TASK_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const TOKEN_PATTERN =
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
+ const PRESENCE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
const LOG_READ_BYTES = 32 * 1024;
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']);
const STATUS_LABELS = Object.freeze({
@@ -30,6 +32,16 @@
run_step_list_unavailable: 'Workflow Step 暂时不可用。',
task_start_fence_rejected:
'任务在确认期间发生变化,本次启动已安全拒绝。请刷新后重试。',
+ local_presence_rejected:
+ '本机证明不匹配或已过期。请核对文件;过期后关闭窗口并重新保存。',
+ local_presence_unavailable:
+ '暂时无法生成本机证明。请检查部署数据目录权限。',
+ strong_authentication_required:
+ '当前凭据不能执行管理操作;请使用本机 User API Credential。',
+ task_definition_fence_rejected:
+ 'Task 或授权在确认期间发生变化。请刷新后重新创建。',
+ invalid_task_definition: 'Task 定义无效。请检查 ID、命令与参数。',
+ task_definition_unavailable: 'Task 暂时无法保存。请检查数据库状态。',
run_cancellation_fence_rejected:
'运行在确认期间发生变化,本次取消已安全拒绝。请刷新后重试。',
request_unavailable: '本次请求没有完成,请确认服务仍在运行。',
@@ -49,10 +61,29 @@
title: document.getElementById('section-title'),
description: document.getElementById('section-description'),
refresh: document.getElementById('refresh-button'),
+ createTask: document.getElementById('create-task-button'),
dialog: document.getElementById('confirmation-dialog'),
dialogTitle: document.getElementById('confirmation-title'),
dialogCopy: document.getElementById('confirmation-copy'),
dialogAccept: document.getElementById('confirmation-accept'),
+ taskEditor: document.getElementById('task-editor-dialog'),
+ taskEditorForm: document.getElementById('task-editor-form'),
+ taskEditorClose: document.getElementById('task-editor-close'),
+ taskEditorSave: document.getElementById('task-editor-save'),
+ taskId: document.getElementById('task-id-input'),
+ taskName: document.getElementById('task-name-input'),
+ taskDescription: document.getElementById('task-description-input'),
+ taskCommand: document.getElementById('task-command-input'),
+ taskArgs: document.getElementById('task-args-input'),
+ taskEnabled: document.getElementById('task-enabled-input'),
+ presenceDialog: document.getElementById('presence-dialog'),
+ presenceForm: document.getElementById('presence-form'),
+ presenceFile: document.getElementById('presence-file'),
+ presenceProof: document.getElementById('presence-proof-input'),
+ presenceExpiry: document.getElementById('presence-expiry'),
+ presenceError: document.getElementById('presence-error'),
+ presenceCancel: document.getElementById('presence-cancel'),
+ presenceSubmit: document.getElementById('presence-submit'),
toast: document.getElementById('toast'),
});
@@ -62,6 +93,7 @@
view: 'tasks',
selectedId: null,
pendingAction: null,
+ pendingTaskMutation: null,
toastTimer: null,
};
@@ -169,6 +201,9 @@
body = JSON.stringify(options.body);
headers['content-type'] = 'application/json';
}
+ if (options.presence !== undefined) {
+ headers['x-qinglong-local-presence'] = options.presence;
+ }
let response;
try {
response = await fetch(path, {
@@ -193,7 +228,7 @@
response.headers.get('x-request-id'),
);
}
- if (!response.ok) {
+ if (!response.ok && response.status !== options.acceptStatus) {
throw new ConsoleRequestError(
typeof value.code === 'string' ? value.code : 'request_unavailable',
response.status,
@@ -310,6 +345,148 @@
nodes.dialog.showModal();
}
+ function openTaskEditor() {
+ nodes.taskEditorForm.reset();
+ nodes.taskCommand.value = '/bin/echo';
+ nodes.taskEnabled.checked = true;
+ nodes.taskEditor.returnValue = '';
+ nodes.taskEditor.showModal();
+ nodes.taskId.focus();
+ }
+
+ function taskDraft() {
+ const taskId = nodes.taskId.value.trim();
+ const name = nodes.taskName.value.trim();
+ const description = nodes.taskDescription.value.trim();
+ const file = nodes.taskCommand.value.trim();
+ const args = nodes.taskArgs.value
+ .split(/\r?\n/u)
+ .map((value) => value.trim())
+ .filter((value) => value.length > 0);
+ if (!TASK_PATTERN.test(taskId)) {
+ throw new TypeError('Task ID 格式无效。');
+ }
+ if (!name || !file || args.length > 128) {
+ throw new TypeError('名称、命令或参数数量无效。');
+ }
+ return Object.freeze({
+ taskId,
+ body: Object.freeze({
+ expectedRevision: null,
+ mutationId: newMutationId(),
+ name,
+ ...(description ? { description } : {}),
+ kind: 'command',
+ spec: Object.freeze({
+ schema: 'qinglong/command@v1',
+ config: Object.freeze({
+ command: Object.freeze({ kind: 'argv', file, args }),
+ }),
+ }),
+ labels: Object.freeze({ 'qinglong.source': 'local-console' }),
+ enabled: nodes.taskEnabled.checked,
+ occurredAtMs: Date.now(),
+ }),
+ });
+ }
+
+ function showPresenceChallenge(mutation, challenge) {
+ if (
+ challenge?.code !== 'local_presence_required' ||
+ typeof challenge.proofFileName !== 'string' ||
+ !/^[0-9a-f-]{36}\.json$/u.test(challenge.proofFileName) ||
+ !Number.isSafeInteger(challenge.expiresAtMs)
+ ) {
+ throw new ConsoleRequestError('response_unavailable', 503, null);
+ }
+ state.pendingTaskMutation = Object.freeze({ mutation, challenge });
+ nodes.presenceFile.textContent = `console-presence/${challenge.proofFileName}`;
+ nodes.presenceExpiry.textContent = `证明将在 ${formatTime(
+ challenge.expiresAtMs,
+ )} 失效;内容改变后必须重新生成。`;
+ nodes.presenceProof.value = '';
+ nodes.presenceError.textContent = '';
+ nodes.presenceError.hidden = true;
+ nodes.taskEditor.close();
+ nodes.presenceDialog.returnValue = '';
+ nodes.presenceDialog.showModal();
+ nodes.presenceProof.focus();
+ }
+
+ async function saveTaskDraft() {
+ let mutation;
+ try {
+ mutation = taskDraft();
+ } catch (error) {
+ showToast(
+ error instanceof Error ? error.message : 'Task 定义无效。',
+ 'error',
+ );
+ return;
+ }
+ nodes.taskEditorSave.disabled = true;
+ try {
+ const value = await api(
+ `/api/v3/projects/${state.project}/tasks/${mutation.taskId}`,
+ {
+ method: 'PUT',
+ body: mutation.body,
+ acceptStatus: 428,
+ },
+ );
+ if (value.code === 'local_presence_required') {
+ showPresenceChallenge(mutation, value);
+ return;
+ }
+ throw new ConsoleRequestError('response_unavailable', 503, null);
+ } catch (error) {
+ showToast(describeError(error), 'error');
+ } finally {
+ nodes.taskEditorSave.disabled = false;
+ }
+ }
+
+ async function completeTaskMutation() {
+ const pending = state.pendingTaskMutation;
+ const proof = nodes.presenceProof.value.trim();
+ if (!pending || !PRESENCE_PATTERN.test(proof)) {
+ nodes.presenceError.textContent =
+ 'proof 格式无效。请完整复制私有文件中的 proof 字段。';
+ nodes.presenceError.hidden = false;
+ nodes.presenceProof.focus();
+ return;
+ }
+ nodes.presenceSubmit.disabled = true;
+ nodes.presenceError.hidden = true;
+ try {
+ const value = await api(
+ `/api/v3/projects/${state.project}/tasks/${pending.mutation.taskId}`,
+ {
+ method: 'PUT',
+ body: pending.mutation.body,
+ presence: proof,
+ },
+ );
+ state.pendingTaskMutation = null;
+ nodes.presenceProof.value = '';
+ nodes.presenceDialog.close();
+ showToast(
+ value.status === 'existing'
+ ? '已找到同一 Task 请求。'
+ : 'Task 已创建。',
+ );
+ state.selectedId = pending.mutation.taskId;
+ await refresh();
+ await selectTask(pending.mutation.taskId);
+ } catch (error) {
+ nodes.presenceError.textContent = describeError(error);
+ nodes.presenceError.hidden = false;
+ nodes.presenceProof.select();
+ } finally {
+ nodes.presenceSubmit.disabled = false;
+ }
+ }
+
async function renderTasks() {
const value = await api(`/api/v3/projects/${state.project}/tasks?limit=64`);
const tasks = Array.isArray(value.tasks) ? value.tasks : [];
@@ -595,7 +772,11 @@
unavailable: '日志暂时不可用;Run 状态与 Event 仍可独立核验。',
};
section.append(
- element('p', 'run-log-placeholder', labels[logView.status] || labels.unavailable),
+ element(
+ 'p',
+ 'run-log-placeholder',
+ labels[logView.status] || labels.unavailable,
+ ),
);
return section;
}
@@ -702,11 +883,13 @@
else button.removeAttribute('aria-current');
}
if (state.view === 'tasks') {
+ nodes.createTask.hidden = false;
nodes.kicker.textContent = 'Project task authority';
nodes.title.textContent = '任务调度台';
nodes.description.textContent =
- '查看当前 Task revision 与内容围栏。运行前会再次读取详情并要求显式确认。';
+ '创建命令 Task,查看当前 revision 与内容围栏。管理写入需要部署设备上的一次性本机证明。';
} else {
+ nodes.createTask.hidden = true;
nodes.kicker.textContent = 'Durable run evidence';
nodes.title.textContent = '运行事实账本';
nodes.description.textContent =
@@ -750,6 +933,9 @@
state.token = null;
state.selectedId = null;
state.pendingAction = null;
+ state.pendingTaskMutation = null;
+ if (nodes.taskEditor.open) nodes.taskEditor.close();
+ if (nodes.presenceDialog.open) nodes.presenceDialog.close();
nodes.token.value = '';
nodes.token.disabled = false;
nodes.project.disabled = false;
@@ -757,6 +943,7 @@
nodes.disconnect.hidden = true;
nodes.nav.hidden = true;
nodes.refresh.hidden = true;
+ nodes.createTask.hidden = true;
setConnection('idle', '等待凭据');
nodes.kicker.textContent = 'Connection gate';
nodes.title.textContent = '先建立一条本机连接';
@@ -793,6 +980,23 @@
nodes.disconnect.addEventListener('click', disconnect);
nodes.refresh.addEventListener('click', refresh);
+ nodes.createTask.addEventListener('click', openTaskEditor);
+ nodes.taskEditorClose.addEventListener('click', () =>
+ nodes.taskEditor.close(),
+ );
+ nodes.taskEditorForm.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ await saveTaskDraft();
+ });
+ nodes.presenceCancel.addEventListener('click', () => {
+ state.pendingTaskMutation = null;
+ nodes.presenceProof.value = '';
+ nodes.presenceDialog.close();
+ });
+ nodes.presenceForm.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ await completeTaskMutation();
+ });
for (const button of nodes.nav.querySelectorAll('button[data-view]')) {
button.addEventListener('click', () => {
@@ -820,7 +1024,10 @@
event.ctrlKey ||
event.altKey ||
event.target instanceof HTMLInputElement ||
- nodes.dialog.open
+ event.target instanceof HTMLTextAreaElement ||
+ nodes.dialog.open ||
+ nodes.taskEditor.open ||
+ nodes.presenceDialog.open
) {
return;
}
diff --git a/packages/ql3-local-api/assets/console/index.html b/packages/ql3-local-api/assets/console/index.html
index af027a9e..f2748209 100644
--- a/packages/ql3-local-api/assets/console/index.html
+++ b/packages/ql3-local-api/assets/console/index.html
@@ -87,9 +87,14 @@
输入 quickstart 交付的 Owner API Credential。连接成功后才能读取或执行操作。
-
+
+
+
+
@@ -125,6 +130,89 @@
+
+
+
+