diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index a597bf42..e4c7f79a 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -34,6 +34,7 @@ - 真实 K3s `v1.34.3+k3s1` 已完成 admission 编译与现场门:合规 Job 的 server dry-run 通过,篡改镜像被策略拒绝,删除参数 ConfigMap 后创建被拒绝;controller SA 的 `list|watch|delete jobs`、Pod 创建和 Secret 读取均被拒绝,action SA 的 Job/Pod 创建与 Secret 读取也均被拒绝。实现仍保持 18 个 package,未新增 workspace package、Edge daemon/timer/watcher 或低配设备常驻负担;短生命周期 controller 与按需 action Job 仅属于 Cluster profile。完整 18-package clean build/test 退出 0;backend 1196 项为 1194 pass、2 条条件 skip、0 fail;cluster-admin 339 pass/3 skip、cluster-postgres 328 pass/2 skip,package boundary、cluster dependency、edge import 和 cluster deployment 审计均无 finding。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 为 `a3d34e61ea2064e1cde574e533137186e09fdce9048455da64f582906037fa0d`,临时 Docker 资源已清理。ADR 仍为 Proposed:升级失败自动回滚、终态 Job 的 durable 恢复决议和固定物理低配设备证据尚未完成。 - 2026-08-14 终态恢复更新:Secret Action controller 不再把所有终态 Job 仅计为瞬时 `recoveryRequired`。Job 到达 Complete/Failed 后已停止执行,controller 会用 started execution 的原 lease fence 复验不可变业务结果:首次 binding 必须与 approval plan、`startedAtMs` 推导出的 binding 完全一致;transition 必须与 plan、authority evidence、commit time 推导出的 receipt 完全一致。精确 durable result 存在时补写 `succeeded`,即使 Job 已被 TTL 清理也能收敛;Failed 且无 durable mutation 时写 `failed`;Complete 但无 receipt 时以 `indeterminate` 写 `blocked`。Job 在 start barrier 前终态或审批过期且尚未创建时,controller 复用既有 claim→release fence 写 `blocked`,不让坏 Job 永久占据 reconciler 页首。任何 stored result 漂移继续抛出 conflict,`executing + Job 缺失 + receipt 缺失` 继续要求人工处理,绝不自动重建可能已产生副作用的动作。该切片不修改共享 execution schema、PostgreSQL migration 或角色权限,不新增 package、连接与常驻进程;Cluster controller 复用现有 package-executor Pool,Edge 零变化。controller/process 定向 21/21,cluster-admin 全包 348 项为 345 pass/3 条件 skip/0 fail;完整 18-package 串行 build/test 退出 0;backend 1196 项为 1194 pass/2 条件 skip/0 fail;package boundary、cluster dependency、edge import、cluster deployment 均无 finding,部署/包边界聚焦测试 61/61。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 为 `bec512767fbbd7774baa9366698f60c25c8b017ed66f459b154d143fe86293bc`,临时 Docker 资源已清理。 - 2026-08-14 人工恢复更新(ADR-0397,已接受):上述唯一保留的 `executing + Job/receipt 均缺失` 不确定窗口现在具有显式 Cluster 产品处置路径。既有 Approval management mTLS/OIDC endpoint 新增 `approval.recover.inspect|resolve`,只接受五分钟内 `multi_factor|hardware` User、独立 `approval.recover` 权限、二次认证、exact execution version/digest 和外部 evidence SHA-256。只允许 Secret binding/transition action;`confirm_failed` 写 failed,`abandon_unknown` 写 blocked,永远禁止人工 succeeded、Job 重建或 execution 重置。PostgreSQL `pg-0065`/capability v64 新增不可变 resolution ledger 与单个 SECURITY DEFINER resolver,在同一事务内锁 Policy/execution fence、写 allowed audit、推进终态并写 receipt;Approval manager 只有 dispatch/execution/resolution SELECT 与函数 EXECUTE,没有 execution UPDATE。通用 execution repository 与 Worker Credential 调用链保持不变。真实 PostgreSQL 18.4 已从空库完成 65 migration,证明原子提交、exact replay 不重复审计和 direct UPDATE `42501`;实现不新增 package、依赖、Pod、Pool、daemon、timer、watcher 或 Edge/Standalone 负担。18-package clean build/test 退出 0,backend 1,194 pass/2 skip/0 fail,package/dependency/edge/deployment 审计零 finding;新 migration 与 repository 内聚到 `approved-action` 领域,migration ledger 直属源码保持审定上限 65。PostgreSQL 18.4 arm64 physical HA 125 项 gate、timeline `1→2` 通过,报告 SHA-256 为 `6d4921cba74475d15722a13c6a8034793c0ee25681bc7dcaf91024927c5752fe`,临时 Docker 资源已清理。 + - 2026-08-14 升级失败收口(ADR-0398,进行中):已存在旧 active 的 `upgrade|reinstall|rollback` 不再先切 active pointer 再物化 Package 资源。共享 activation prerequisite sequence 固定为 Secret binding/transition receipt 就绪后,从 staged install 与 immutable lock 构建候选 generation,按既有字节上限完成 Manifest、Task、Workflow、Prompt、Tool 语义物化,并以 generation digest 预发布 immutable revision;只有成功后才进入 active pointer CAS。确定性候选错误把本次 install 写为 `failed(activation_fact_conflict)`,`activeLockDigest` 保持 `previousActiveLockDigest` 且 publisher 不被调用;瞬时文件/OCI/数据库故障保持 staged 重试。generation 1 没有旧版本可保留,Secret-aware 首次安装继续使用 ADR-0395 的 post-activation B1 binding ceremony,不被候选门错误拒绝。激活后的 publication recovery 只复用 revision 做 generation-fenced reconciliation。实现复用既有 18 个 package、双方言 repository、Local 单 SQLite authority 与 Cluster caller-driven recovery Job/单 Pool,不新增 migration、表、依赖、daemon、timer、watcher、listener、连接池或常驻 cache。定向 Runtime Core 21/21、Runtime Core 全量 548/548、Local Application 47 pass/4 条件 skip、Cluster Admin 347 pass/3 条件 skip;18-package clean build/test 退出 0,backend 1196 项为 1194 pass/2 条件 skip/0 fail,package boundary、cluster dependency、cluster deployment 与 edge import 审计均通过。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 `75d7a52be75c22b2aacf32f2d7e2c432a467ebaab4d639668ff3a4b98767a17e`。真实 Kubernetes 失败升级未移动 active pointer/head 的现场门与固定物理低配设备证据仍待闭合。 - D-302/ADR-0390(已接受) Cluster operator context 增加无网络、无 mutation 的内建 `ql3-cluster-admin context validate` 预检。它先复用 owner-private context reader,再让每个 entry 经过与真实请求相同的 production HTTPS/Kubernetes configuration preparation,验证精确 route、hostname、CA、 diff --git a/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md b/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md index 5a67d023..eeb26567 100644 --- a/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md +++ b/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md @@ -51,3 +51,8 @@ D-306B1 只允许给当前 active 且尚未绑定的 Package generation 做首 - start barrier 前的终态 Job、以及 approval 过期且 Job 尚未创建,复用共享 execution repository 的 claim→release-before-start 转换持久化为 `blocked`;controller 崩溃后 lease 可回收,不增加新状态、表、migration 或专用恢复 daemon。Durable result 漂移仍是全局 conflict;`executing + Job 缺失 + receipt 缺失` 不能排除孤儿 Pod 或未知副作用,继续保持 `recoveryRequired` 且绝不重建。 - 本切片定向 controller/process 21/21;cluster-admin 全包 348 项为 345 pass、3 条件 skip、0 fail;完整 18-package 串行 build/test 退出 0;backend 1196 项为 1194 pass、2 条件 skip、0 fail;package boundary、cluster dependency、edge import、cluster deployment 均无 finding,部署/包边界聚焦测试 61/61。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 `bec512767fbbd7774baa9366698f60c25c8b017ed66f459b154d143fe86293bc`,临时 Docker 资源已清理。共享 `approved_action_executions` contract、PostgreSQL 权限与 Worker Credential 调用链均未修改;实现继续位于既有 `cluster-admin/plugin-package/executor`,Edge/Standalone 不加载 controller,也没有新增 workspace package、连接、timer、watcher 或常驻内存。 - ADR 继续保持 Proposed:升级失败自动回滚、`executing + Job/receipt 均缺失` 的显式人工处置产品路径,以及固定物理低配设备证据仍待完成。 +- 后续 ADR-0397 已完成上述 Secret Action 显式人工处置。ADR-0398 进一步把 Local/Cluster 安装恢复顺序改为“Secret transition prerequisite → staged candidate materialization/publish → active pointer CAS → active-only reconciliation”。确定性候选错误在 pointer 发布前把当前安装写为 failed,`activeLockDigest` 保持上一 lock,旧 generation/binding/revision 继续服务;可用性错误只重试。该实现不新增 package、migration、连接或常驻组件。 +- ADR-0398 的 18-package clean build/test、backend、边界/依赖/部署/edge import 审计与 PostgreSQL + `18.4` arm64 physical HA 已闭合;HA 通过 125 项门、timeline `1→2`,报告 SHA-256 + `75d7a52be75c22b2aacf32f2d7e2c432a467ebaab4d639668ff3a4b98767a17e`。真实 Kubernetes + 失败升级未移动 active pointer/head 的现场门与固定物理低配设备证据仍阻断 ADR Accepted。 diff --git a/docs/adr/ADR-0398-pre-activation-plugin-package-candidate-qualification.md b/docs/adr/ADR-0398-pre-activation-plugin-package-candidate-qualification.md new file mode 100644 index 00000000..3c0258ad --- /dev/null +++ b/docs/adr/ADR-0398-pre-activation-plugin-package-candidate-qualification.md @@ -0,0 +1,75 @@ +# ADR-0398:Plugin Package 激活前候选资格校验与自动保留旧版本 + +- 状态:Proposed +- 日期:2026-08-14 +- 关联 RFC:QL-RFC-0001 D-306B2 +- 关联 ADR:ADR-0153、ADR-0394、ADR-0396 + +## 问题 + +现有 Local 与 Cluster 启动恢复先发布 active pointer、把安装记录推进为 +`active`,随后才读取 staged bytes、物化 Package Task/Workflow/Prompt/Tool 资源。 +因此一个摘要正确但资源语义无效的升级可能先替换健康版本,再在任务发布阶段失败, +迫使整个启动门失败。安装状态机虽然保留 `previousActiveLockDigest`,却没有在指针切换前 +使用该事实形成真正的失败隔离。 + +“先切换、失败后再把指针写回去”也不安全:回写会与并发发布竞争,Kubernetes +ConfigMap 和数据库 head 之间会出现第二次分布式提交窗口,并且历史 generation 可能被 +静默重新激活。自动恢复应避免制造需要补偿的外部事实,而不是依赖补偿事务。 + +## 决策 + +1. 已存在旧 active 的 `upgrade|reinstall|rollback` 在 `staged → activating` 之前必须依次通过所有前置条件。Secret binding/transition + receipt 先完成;随后从 staged install、immutable lock 和 content-addressed bytes + 构建目标 resource generation,读取有硬上限的 Manifest/资源并执行完整语义物化。 + generation 1 没有可回退的旧指针,并且 Secret-aware 首次安装仍需 ADR-0395 的 + post-activation B1 binding ceremony,因此不进入本 ADR 的候选物化门。 +2. 候选 materialized revision 以既有 `generationDigest` repository key 在激活前发布。 + 相同 revision exact replay 返回 existing;不同事实冲突。该 revision 尚不构成 active, + Task reconciliation、Automation publication 与 Tool snapshot 仍只消费 active generation。 +3. 确定性的候选语义错误或 durable revision 冲突把当前安装从 `staged` 原子推进为 + `failed(reason=activation_fact_conflict)`。状态机必须保留 + `activeLockDigest=previousActiveLockDigest`,并且 activation publisher 调用次数为零。 +4. OCI、文件、SQLite/PostgreSQL 或 reader close 的瞬时不可用不写失败事实,安装保持 + `staged` 并由既有有界 recovery 重试。不得在不可区分时把可用性故障伪装成坏包。 +5. active pointer 发布成功后,既有 Task publication recovery 复用预先持久化的 revision, + 只执行 generation-fenced reconciliation;它继续承担响应丢失与并发 superseded 检查。 +6. Local 与 Cluster 必须使用同一个 runtime-core prerequisite sequence 和候选物化实现。 + Local 复用单 SQLite authority 与本地 staging reader;Cluster 复用 caller-driven recovery + Job、单 PostgreSQL Pool、OCI reader 与 Kubernetes CAS publisher。 +7. 本决策不增加 workspace package、migration、表、第三方依赖、daemon、timer、watcher、 + listener、连接池或常驻 cache。Edge/Standalone 只在已有启动恢复遇到 staged install 时 + 按需读取候选字节;没有待恢复安装时只创建少量短生命周期对象,不增加后台 cadence。 + +## 接受条件 + +- 共享测试证明有效候选在激活前发布且 exact replay 不重复写;语义无效候选不发布 revision。 +- 升级恢复测试证明 rejected 候选进入 failed、旧 active lock 保留且 publisher 未调用。 +- Local 与 Cluster 组合测试证明恢复顺序一致,既有 active publication/reconciliation 不回归。 +- 完整 18-package、backend、package/dependency/deployment/edge/import 审计通过。 +- 真实 PostgreSQL/Kubernetes 门证明失败升级没有移动 active ConfigMap/head;physical HA 门通过。 +- 固定物理低配设备证据仍由 ADR-0396 单独阻断,不能用开发机观测替代。 + +## 影响与替代方案 + +- 失败候选可能留下一个不可达、不可变的 materialized revision。它按 generation 有界,保留 + 失败取证事实;物理清理由独立 retention/GC receipt 决定,不在失败路径同步删除。 +- 不把 materialization 塞入 Kubernetes publisher。Publisher 只拥有 pointer CAS 与投影 + evidence;让它读取 OCI/PostgreSQL 会聚合执行和发布 authority。 +- 不新增 `rolling_back` 状态。指针从未移动时,健康旧版本本来就仍是 active;新增补偿状态 + 只会扩大恢复矩阵并让低配设备承担无收益的持久化协议。 + +## 当前验证 + +- Runtime Core 定向 21/21 通过,覆盖前置条件顺序、候选预物化、exact replay、无效语义拒绝、generation 1 B1 兼容和升级失败保留 + 旧 `activeLockDigest`;拒绝路径的 activation publisher 调用次数为零。 +- Local Application 47 pass/4 条件 skip;Cluster Admin 347 pass/3 条件 skip。Cluster 首次在 + 沙箱内运行的 32 个失败均为回环监听 `EPERM`,在允许本机监听的环境重跑后全部通过。 +- 18-package clean build/test 退出 0;Runtime Core 548/548;backend 1196 项为 + 1194 pass/2 条件 skip/0 fail。package boundary 保持 18 个 package 且 + `singleSourcePackages=[]`、`shallowSourcePackages=[]`;cluster dependency、cluster deployment + 与 edge import 审计均无 finding。 +- PostgreSQL `18.4` arm64 physical HA 通过 125 项门,timeline `1→2`,报告 SHA-256 + `75d7a52be75c22b2aacf32f2d7e2c432a467ebaab4d639668ff3a4b98767a17e`,临时 Docker + 资源已清理。真实 Kubernetes 失败升级未移动 active pointer/head 的现场门与固定物理低配设备 + 证据仍待完成,因此本 ADR 保持 Proposed。 diff --git a/docs/adr/README.md b/docs/adr/README.md index beb72d72..14de7d6e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -401,6 +401,7 @@ | [ADR-0395](./ADR-0395-owner-confirmed-plugin-package-secret-binding.md) | Owner 确认的 Plugin Package Secret 首次绑定 | Proposed | | [ADR-0396](./ADR-0396-generation-transition-plugin-package-secret-binding.md) | 按 Package Generation 切换 Plugin Package Secret Binding | Proposed | | [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(实现、全量/HA 门完成,Kubernetes 失败升级与物理低配门待闭合) | ## 规则 diff --git a/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageRecovery.ts b/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageRecovery.ts index 0b96a5a9..5a47d698 100644 --- a/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageRecovery.ts +++ b/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageRecovery.ts @@ -13,12 +13,16 @@ import { PluginPackageRecoveryCoordinator, type PluginPackageRecoveryCycleResult, } from '@qinglong/runtime-core/plugin-package-recovery'; +import { sequencePluginPackageActivationPrerequisites } from '@qinglong/runtime-core/plugin-package-installation'; import { PluginPackageAutomationPublicationCoordinator, PluginPackageAutomationPublicationRecoveryCoordinator, type PluginPackageAutomationPublicationRecoveryCycleResult, } from '@qinglong/runtime-core/plugin-package-automation-publication'; -import type { PluginPackageResourceByteSource } from '@qinglong/runtime-core/plugin-package-resource-materialization'; +import { + PluginPackageResourceActivationPrerequisite, + type PluginPackageResourceByteSource, +} from '@qinglong/runtime-core/plugin-package-resource-materialization'; import { MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGES, MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGE_SIZE, @@ -326,14 +330,31 @@ export async function recoverClusterPluginPackages( authority: stageAuthority as ClusterPluginPackageOciStageAuthority, lockSource: repository, }); + const taskSpecSemanticRegistry = createBuiltInTaskSpecSemanticRegistry(); + const materializedRepository = + new PostgresPluginPackageMaterializedRevisionRepository( + database.pool, + taskSpecSemanticRegistry, + ); + const secretBindingRepository = + new PostgresPluginPackageSecretBindingRepository(database.pool); + const secretBindingActivationPrerequisite = + new PostgresPluginPackageSecretBindingActivationPrerequisite( + database.pool, + ); const recovery = await new PluginPackageRecoveryCoordinator({ repository, stageProvider: stageAuthority, publisher, - activationPrerequisite: - new PostgresPluginPackageSecretBindingActivationPrerequisite( - database.pool, - ), + activationPrerequisite: sequencePluginPackageActivationPrerequisites([ + secretBindingActivationPrerequisite, + new PluginPackageResourceActivationPrerequisite({ + byteSource: resourceByteSource, + materializedRepository, + secretBindingSource: secretBindingRepository, + taskSpecSemanticRegistry, + }), + ]), now: options.now, }).recover({ ...(options.pageSize === undefined ? {} : { pageSize: options.pageSize }), @@ -342,19 +363,11 @@ export async function recoverClusterPluginPackages( if (!recovery.safeToAdmit) { throw new ClusterPluginPackageRecoveryRequiredError(recovery); } - const taskSpecSemanticRegistry = createBuiltInTaskSpecSemanticRegistry(); const taskReconciliationRepository = new PostgresPluginPackageTaskReconciliationRepository( database.pool, taskSpecSemanticRegistry, ); - const materializedRepository = - new PostgresPluginPackageMaterializedRevisionRepository( - database.pool, - taskSpecSemanticRegistry, - ); - const secretBindingRepository = - new PostgresPluginPackageSecretBindingRepository(database.pool); const taskPublicationRecovery = await new PluginPackageTaskPublicationRecoveryCoordinator({ source: taskReconciliationRepository, diff --git a/packages/ql3-local-admin/src/plugin-package/pluginPackageResourceMaterialization.ts b/packages/ql3-local-admin/src/plugin-package/pluginPackageResourceMaterialization.ts index 97ba070d..711fd1e8 100644 --- a/packages/ql3-local-admin/src/plugin-package/pluginPackageResourceMaterialization.ts +++ b/packages/ql3-local-admin/src/plugin-package/pluginPackageResourceMaterialization.ts @@ -11,9 +11,10 @@ import { normalizePluginPackageResourceGeneration, type PluginPackageResourceGeneration, } from '@qinglong/runtime-core/plugin-package-resource-generation'; -import type { - PluginPackageResourceByteReader, - PluginPackageResourceByteSource, +import { + PluginPackageResourceActivationPrerequisite, + type PluginPackageResourceByteReader, + type PluginPackageResourceByteSource, } from '@qinglong/runtime-core/plugin-package-resource-materialization'; const STAGE_RECEIPT_SCHEMA = 'qinglong/plugin-package-stage-receipt@v1'; @@ -27,6 +28,15 @@ export interface LocalPluginPackageResourceByteSourceOptions { readonly stagingRoot: string; } +export type LocalPluginPackageResourceActivationPrerequisiteOptions = + ConstructorParameters[0]; + +export function createLocalPluginPackageResourceActivationPrerequisite( + options: LocalPluginPackageResourceActivationPrerequisiteOptions, +): PluginPackageResourceActivationPrerequisite { + return new PluginPackageResourceActivationPrerequisite(options); +} + export class InvalidLocalPluginPackageResourceSourceError extends Error { readonly code = 'LOCAL_PLUGIN_PACKAGE_RESOURCE_SOURCE_INVALID'; diff --git a/packages/ql3-local-application/src/application-runtime/pluginPackageStartup.ts b/packages/ql3-local-application/src/application-runtime/pluginPackageStartup.ts index 287b27a5..d906e7c6 100644 --- a/packages/ql3-local-application/src/application-runtime/pluginPackageStartup.ts +++ b/packages/ql3-local-application/src/application-runtime/pluginPackageStartup.ts @@ -1,9 +1,13 @@ import { LocalPluginPackageActivationPublisher } from '@qinglong/local-admin/package-activation'; -import { LocalPluginPackageResourceByteSource } from '@qinglong/local-admin/package-resource-materialization'; +import { + createLocalPluginPackageResourceActivationPrerequisite, + LocalPluginPackageResourceByteSource, +} from '@qinglong/local-admin/package-resource-materialization'; import { PluginPackageRecoveryCoordinator, type PluginPackageRecoveryCycleResult, } from '@qinglong/runtime-core/plugin-package-recovery'; +import { sequencePluginPackageActivationPrerequisites } from '@qinglong/runtime-core/plugin-package-installation'; import { PluginPackageAutomationPublicationCoordinator, PluginPackageAutomationPublicationRecoveryCoordinator, @@ -47,6 +51,15 @@ export async function recoverLocalApplicationPluginPackages( const pluginPackageInstalls = await storage.pluginPackageInstalls(); const pluginPackageActivationPrerequisite = await storage.pluginPackageActivationPrerequisite(); + const taskSpecSemanticRegistry = createBuiltInTaskSpecSemanticRegistry(); + const pluginPackageMaterializedRevisions = + await storage.pluginPackageMaterializedRevisions(); + const pluginPackageSecretBindings = + await storage.pluginPackageSecretBindings(); + const pluginPackageResourceByteSource = + new LocalPluginPackageResourceByteSource({ + stagingRoot: options.pluginPackages.stagingRoot, + }); const pluginPackageActivation = new LocalPluginPackageActivationPublisher({ stagingRoot: options.pluginPackages.stagingRoot, activationRoot: options.pluginPackages.activationRoot, @@ -56,7 +69,15 @@ export async function recoverLocalApplicationPluginPackages( repository: pluginPackageInstalls, stageProvider: options.pluginPackages.stageProvider, publisher: pluginPackageActivation, - activationPrerequisite: pluginPackageActivationPrerequisite, + activationPrerequisite: sequencePluginPackageActivationPrerequisites([ + pluginPackageActivationPrerequisite, + createLocalPluginPackageResourceActivationPrerequisite({ + byteSource: pluginPackageResourceByteSource, + materializedRepository: pluginPackageMaterializedRevisions, + secretBindingSource: pluginPackageSecretBindings, + taskSpecSemanticRegistry, + }), + ]), now: options.pluginPackages.now, }).recover({ ...(options.pluginPackages.pageSize === undefined @@ -77,22 +98,15 @@ export async function recoverLocalApplicationPluginPackages( pluginPackageRecovery, }); - const taskSpecSemanticRegistry = createBuiltInTaskSpecSemanticRegistry(); const pluginPackageTaskReconciliations = await storage.pluginPackageTaskReconciliations(); - const pluginPackageMaterializedRevisions = - await storage.pluginPackageMaterializedRevisions(); - const pluginPackageSecretBindings = - await storage.pluginPackageSecretBindings(); const pluginPackageTaskPublicationRecovery = await new PluginPackageTaskPublicationRecoveryCoordinator({ source: pluginPackageTaskReconciliations, publisher: new PluginPackageTaskPublicationCoordinator({ generationSource: pluginPackageActivation, lockSource: pluginPackageInstalls, - byteSource: new LocalPluginPackageResourceByteSource({ - stagingRoot: options.pluginPackages.stagingRoot, - }), + byteSource: pluginPackageResourceByteSource, materializedRepository: pluginPackageMaterializedRevisions, secretBindingSource: pluginPackageSecretBindings, reconciliationRepository: pluginPackageTaskReconciliations, diff --git a/packages/ql3-runtime-core/src/plugin-package/installation/pluginPackageInstallation.ts b/packages/ql3-runtime-core/src/plugin-package/installation/pluginPackageInstallation.ts index dbe36502..5f4beb75 100644 --- a/packages/ql3-runtime-core/src/plugin-package/installation/pluginPackageInstallation.ts +++ b/packages/ql3-runtime-core/src/plugin-package/installation/pluginPackageInstallation.ts @@ -40,6 +40,10 @@ export type PluginPackageActivationPrerequisiteObservation = | Readonly<{ status: 'deferred'; reason: 'secret_binding_transition_required'; + }> + | Readonly<{ + status: 'rejected'; + reason: 'activation_fact_conflict'; }>; export interface PluginPackageActivationPrerequisite { @@ -49,6 +53,37 @@ export interface PluginPackageActivationPrerequisite { ): Promise>; } +export function sequencePluginPackageActivationPrerequisites( + prerequisites: readonly PluginPackageActivationPrerequisite[], +): PluginPackageActivationPrerequisite { + if ( + !Array.isArray(prerequisites) || + prerequisites.length < 1 || + prerequisites.length > 8 || + prerequisites.some( + (prerequisite) => + !prerequisite || typeof prerequisite.inspect !== 'function', + ) + ) { + throw new InvalidPluginPackageInstallError( + 'activation prerequisite sequence is invalid', + ); + } + const sequence = Object.freeze([...prerequisites]); + return Object.freeze({ + async inspect( + record: Readonly, + lock: Readonly, + ): Promise> { + for (const prerequisite of sequence) { + const observation = await prerequisite.inspect(record, lock); + if (observation.status !== 'ready') return observation; + } + return Object.freeze({ status: 'ready' as const }); + }, + }); +} + export interface InstallPluginPackageOptions { readonly lock: PluginPackageLock; readonly proposalDigest: string; @@ -224,6 +259,19 @@ export class PluginPackageInstallationCoordinator { lock, ); if (prerequisite?.status === 'deferred') return record; + if (prerequisite?.status === 'rejected') { + const failed = transitionPluginPackageInstall(lock, record, { + type: 'failed', + mutationId: options.activationFailedMutationId, + occurredAtMs: options.activationObservedAtMs, + reason: prerequisite.reason, + }); + return ( + await this.#repository.commit( + pluginPackageInstallCommit(record, failed), + ) + ).record; + } return this.#activation.activate({ ...identity, activationStartedMutationId: options.activationStartedMutationId, diff --git a/packages/ql3-runtime-core/src/plugin-package/installation/pluginPackageRecovery.ts b/packages/ql3-runtime-core/src/plugin-package/installation/pluginPackageRecovery.ts index 0167ebbf..ec092946 100644 --- a/packages/ql3-runtime-core/src/plugin-package/installation/pluginPackageRecovery.ts +++ b/packages/ql3-runtime-core/src/plugin-package/installation/pluginPackageRecovery.ts @@ -311,6 +311,25 @@ export class PluginPackageRecoveryCoordinator { lock, ); if (prerequisite?.status === 'deferred') return record; + if (prerequisite?.status === 'rejected') { + const failed = transitionPluginPackageInstall(lock, record, { + type: 'failed', + mutationId: mutationId( + 'activation-preparation-failed', + record, + occurredAtMs, + ), + occurredAtMs, + reason: prerequisite.reason, + }); + return normalizePluginPackageInstallRecord( + ( + await this.#repository.commit( + pluginPackageInstallCommit(record, failed), + ) + ).record, + ); + } return this.#activation.activate({ ...identity, activationStartedMutationId: mutationId( diff --git a/packages/ql3-runtime-core/src/plugin-package/pluginPackageResourceMaterialization.ts b/packages/ql3-runtime-core/src/plugin-package/pluginPackageResourceMaterialization.ts index 1826fa49..84a16436 100644 --- a/packages/ql3-runtime-core/src/plugin-package/pluginPackageResourceMaterialization.ts +++ b/packages/ql3-runtime-core/src/plugin-package/pluginPackageResourceMaterialization.ts @@ -10,12 +10,21 @@ import { type PluginPackageContentEntryDescriptor, } from './pluginPackageBundle'; import { + PluginPackageInstallUnavailableError, + assertPluginPackageInstallMatchesLock, + normalizePluginPackageInstallRecord, normalizePluginPackageLock, pluginPackageManifestDigest, serializePluginPackageManifest, + type PluginPackageInstallRecord, type PluginPackageLock, } from './installation/pluginPackageInstall'; +import type { + PluginPackageActivationPrerequisite, + PluginPackageActivationPrerequisiteObservation, +} from './installation/pluginPackageInstallation'; import { + createPluginPackageResourceGenerationFromReferences, normalizePluginPackageResourceGeneration, pluginPackageResourceReferencesFromContents, type PluginPackageResourceGeneration, @@ -185,6 +194,17 @@ export interface MaterializeActivePluginPackageResourcesOptions { readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry; } +export interface MaterializePluginPackageResourceGenerationOptions { + readonly generation: Readonly; + readonly lock: Readonly; + readonly byteSource: PluginPackageResourceByteSource; + readonly secretBindingSource?: Pick< + PluginPackageSecretBindingRepository, + 'find' + >; + readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry; +} + export interface PluginPackageMaterializedRevisionRepository { find( generationDigest: string, @@ -197,6 +217,115 @@ export interface PluginPackageMaterializedRevisionRepository { >; } +export class PluginPackageResourceActivationPrerequisite + implements PluginPackageActivationPrerequisite +{ + constructor( + private readonly options: { + readonly byteSource: PluginPackageResourceByteSource; + readonly materializedRepository: PluginPackageMaterializedRevisionRepository; + readonly secretBindingSource?: Pick< + PluginPackageSecretBindingRepository, + 'find' + >; + readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry; + }, + ) { + const authorities = dataRecord(options, 'activation prerequisite options'); + exactKeys( + authorities, + ['byteSource', 'materializedRepository', 'taskSpecSemanticRegistry'], + ['secretBindingSource'], + 'activation prerequisite options', + ); + if ( + !options.byteSource || + typeof options.byteSource.open !== 'function' || + !options.materializedRepository || + typeof options.materializedRepository.find !== 'function' || + typeof options.materializedRepository.publish !== 'function' || + (options.secretBindingSource !== undefined && + (!options.secretBindingSource || + typeof options.secretBindingSource.find !== 'function')) || + !(options.taskSpecSemanticRegistry instanceof TaskSpecSemanticRegistry) + ) { + invalid('activation prerequisite authority is invalid'); + } + } + + async inspect( + recordValue: Readonly, + lockValue: Readonly, + ): Promise> { + try { + const record = normalizePluginPackageInstallRecord(recordValue); + const lock = normalizePluginPackageLock(lockValue); + assertPluginPackageInstallMatchesLock(lock, record); + if (record.state !== 'staged') { + invalid('activation prerequisite requires a staged install'); + } + // Generation one may require the post-activation B1 binding ceremony. + // There is no healthy previous pointer to preserve in that flow. + if (record.previousActiveLockDigest === null) { + return Object.freeze({ status: 'ready' as const }); + } + const generation = createPluginPackageResourceGenerationFromReferences({ + installationId: record.installationId, + projectId: record.projectId, + packageName: record.packageName, + lockDigest: lock.lockDigest, + generation: lock.targetGeneration, + previousActiveLockDigest: record.previousActiveLockDigest, + contentDigest: lock.source.contentDigest, + resources: lock.resources, + }); + let revision = await this.options.materializedRepository.find( + generation.generationDigest, + ); + if (revision === null) { + const candidate = await materializePluginPackageResourceGeneration({ + generation, + lock, + byteSource: this.options.byteSource, + ...(this.options.secretBindingSource === undefined + ? {} + : { secretBindingSource: this.options.secretBindingSource }), + taskSpecSemanticRegistry: this.options.taskSpecSemanticRegistry, + }); + revision = ( + await this.options.materializedRepository.publish(candidate) + ).revision; + } + const durable = normalizePluginPackageMaterializedRevision( + revision, + this.options.taskSpecSemanticRegistry, + ); + if ( + durable.generation.generationDigest !== generation.generationDigest || + durable.generation.installationId !== record.installationId || + durable.generation.lockDigest !== lock.lockDigest + ) { + throw new PluginPackageResourceMaterializationConflictError( + 'durable candidate revision does not match the staged generation', + ); + } + return Object.freeze({ status: 'ready' as const }); + } catch (error) { + if ( + error instanceof InvalidPluginPackageResourceMaterializationError || + error instanceof PluginPackageResourceMaterializationConflictError + ) { + return Object.freeze({ + status: 'rejected' as const, + reason: 'activation_fact_conflict' as const, + }); + } + if (error instanceof PluginPackageInstallUnavailableError) throw error; + throw new PluginPackageInstallUnavailableError(); + } + } +} + export interface PluginPackageTaskDefinitionDraft { readonly projectId: string; readonly taskId: string; @@ -1282,32 +1411,39 @@ function materializationSources( } } -export async function materializeActivePluginPackageResources( - value: MaterializeActivePluginPackageResourcesOptions, -): Promise | null> { - materializationSources(value); +function generationMaterializationSources( + value: MaterializePluginPackageResourceGenerationOptions, +): void { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + !value.byteSource || + typeof value.byteSource.open !== 'function' || + (value.secretBindingSource !== undefined && + (!value.secretBindingSource || + typeof value.secretBindingSource.find !== 'function')) || + !(value.taskSpecSemanticRegistry instanceof TaskSpecSemanticRegistry) + ) { + invalid('generation materialization sources are invalid'); + } +} + +/** + * Qualifies one immutable candidate generation without consulting or moving + * the active pointer. Callers may durably publish the returned revision before + * activation so deterministic Package errors cannot replace a healthy head. + */ +export async function materializePluginPackageResourceGeneration( + value: MaterializePluginPackageResourceGenerationOptions, +): Promise> { + generationMaterializationSources(value); try { - const first = await value.generationSource.findActiveResourceGeneration( - value.projectId, - value.packageName, + const generation = normalizePluginPackageResourceGeneration( + value.generation, ); - if (first === null) return null; - const generation = normalizePluginPackageResourceGeneration(first); - if ( - generation.projectId !== value.projectId || - generation.packageName !== value.packageName - ) { - throw new PluginPackageResourceMaterializationConflictError( - 'generation source returned another Package identity', - ); - } - const lockValue = await value.lockSource.findLock(generation.lockDigest); - if (lockValue === null) { - throw new PluginPackageResourceMaterializationConflictError( - 'active generation lock is missing', - ); - } - const lock = normalizePluginPackageLock(lockValue); + const lock = normalizePluginPackageLock(value.lock); + assertGenerationMatchesLock(generation, lock); const reader = await value.byteSource.open(generation); if ( !reader || @@ -1350,22 +1486,70 @@ export async function materializeActivePluginPackageResources( }); } } - const activeManifest = normalizeManifestBytes(manifestBytes, lock); - const activeSecretBindingValue = - activeManifest.spec.permissions.secrets.length === 0 + const manifest = normalizeManifestBytes(manifestBytes, lock); + const bindingValue = + manifest.spec.permissions.secrets.length === 0 ? undefined : await value.secretBindingSource?.find(generation.generationDigest); - const activeSecretBinding = activeSecretBindingValue ?? undefined; - const revision = materializePluginPackageResources({ + return materializePluginPackageResources({ generation, lock, manifestBytes, - ...(activeSecretBinding === undefined + ...(bindingValue === undefined || bindingValue === null ? {} - : { secretBinding: activeSecretBinding }), + : { secretBinding: bindingValue }), resources: Object.freeze(resources), taskSpecSemanticRegistry: value.taskSpecSemanticRegistry, }); + } catch (error) { + if ( + error instanceof InvalidPluginPackageResourceMaterializationError || + error instanceof PluginPackageResourceMaterializationConflictError || + error instanceof PluginPackageResourceMaterializationUnavailableError + ) { + throw error; + } + throw new PluginPackageResourceMaterializationUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } +} + +export async function materializeActivePluginPackageResources( + value: MaterializeActivePluginPackageResourcesOptions, +): Promise | null> { + materializationSources(value); + try { + const first = await value.generationSource.findActiveResourceGeneration( + value.projectId, + value.packageName, + ); + if (first === null) return null; + const generation = normalizePluginPackageResourceGeneration(first); + if ( + generation.projectId !== value.projectId || + generation.packageName !== value.packageName + ) { + throw new PluginPackageResourceMaterializationConflictError( + 'generation source returned another Package identity', + ); + } + const lockValue = await value.lockSource.findLock(generation.lockDigest); + if (lockValue === null) { + throw new PluginPackageResourceMaterializationConflictError( + 'active generation lock is missing', + ); + } + const lock = normalizePluginPackageLock(lockValue); + const revision = await materializePluginPackageResourceGeneration({ + generation, + lock, + byteSource: value.byteSource, + ...(value.secretBindingSource === undefined + ? {} + : { secretBindingSource: value.secretBindingSource }), + taskSpecSemanticRegistry: value.taskSpecSemanticRegistry, + }); const secondValue = await value.generationSource.findActiveResourceGeneration( value.projectId, diff --git a/packages/ql3-runtime-core/test/pluginPackageRecovery.test.cjs b/packages/ql3-runtime-core/test/pluginPackageRecovery.test.cjs index 2191ac69..bb04d568 100644 --- a/packages/ql3-runtime-core/test/pluginPackageRecovery.test.cjs +++ b/packages/ql3-runtime-core/test/pluginPackageRecovery.test.cjs @@ -20,13 +20,18 @@ const { const { PluginPackageRecoveryCoordinator, } = require('../dist/plugin-package/installation/pluginPackageRecovery'); +const { + sequencePluginPackageActivationPrerequisites, +} = require('../dist/plugin-package/installation/pluginPackageInstallation'); const ARTIFACT_DIGEST = 'a'.repeat(64); const CONTENT_DIGEST = 'b'.repeat(64); +const PREVIOUS_LOCK_DIGEST = 'c'.repeat(64); function fixture( packageName = 'example-monitor', installationId = 'install-001', + options = {}, ) { const manifest = { apiVersion: PLUGIN_PACKAGE_API_VERSION, @@ -65,13 +70,24 @@ function fixture( availableMemoryBytes: 128 * 1024 * 1024, availableDiskBytes: 256 * 1024 * 1024, }; - const plan = planPluginPackageInstall(manifest, environment); + const previousManifest = options.upgrade + ? { + ...manifest, + metadata: { ...manifest.metadata, version: '1.1.0' }, + } + : undefined; + const plan = planPluginPackageInstall( + manifest, + environment, + previousManifest, + ); const action = { lockId: `lock-${packageName}`, projectId: 'default', manifest, plan, environment, + ...(previousManifest === undefined ? {} : { previousManifest }), source: { kind: 'offline', locator: `offline:sha256:${ARTIFACT_DIGEST}`, @@ -81,7 +97,8 @@ function fixture( }, architecture: 'arm64', deploymentProfile: 'edge', - targetGeneration: 1, + targetGeneration: options.upgrade ? 2 : 1, + ...(options.upgrade ? { previousLockDigest: PREVIOUS_LOCK_DIGEST } : {}), }; const lock = createPluginPackageLock({ ...action, @@ -116,8 +133,8 @@ function stageEvidence(lock) { }; } -function stagedFixture(packageName, installationId) { - const value = fixture(packageName, installationId); +function stagedFixture(packageName, installationId, options) { + const value = fixture(packageName, installationId, options); const staged = transitionPluginPackageInstall(value.lock, value.queued, { type: 'stage_completed', mutationId: `mutation-stage-${value.lock.packageName}`, @@ -353,6 +370,80 @@ test('stages but defers activation until the exact prerequisite is ready', async }); }); +test('orders activation prerequisites and stops before later authorities', async () => { + const value = stagedFixture(); + const calls = []; + let first = { + status: 'deferred', + reason: 'secret_binding_transition_required', + }; + const sequence = sequencePluginPackageActivationPrerequisites([ + { + async inspect() { + calls.push('secret'); + return first; + }, + }, + { + async inspect() { + calls.push('candidate'); + return { + status: 'rejected', + reason: 'activation_fact_conflict', + }; + }, + }, + ]); + + assert.deepEqual(await sequence.inspect(value.staged, value.lock), first); + assert.deepEqual(calls, ['secret']); + + first = { status: 'ready' }; + assert.deepEqual(await sequence.inspect(value.staged, value.lock), { + status: 'rejected', + reason: 'activation_fact_conflict', + }); + assert.deepEqual(calls, ['secret', 'secret', 'candidate']); +}); + +test('rejects an invalid upgrade before publication and retains the previous active lock', async () => { + const value = stagedFixture('example-monitor', 'install-upgrade', { + upgrade: true, + }); + const repository = new MemoryRepository([{ ...value, record: value.staged }]); + const calls = { stage: 0, publish: 0, inspect: 0 }; + const coordinator = new PluginPackageRecoveryCoordinator({ + repository, + stageProvider: { + async stage() { + calls.stage += 1; + throw new Error('stage must not run'); + }, + }, + publisher: publisherFor(repository, calls), + activationPrerequisite: { + async inspect() { + return { + status: 'rejected', + reason: 'activation_fact_conflict', + }; + }, + }, + now: () => 250, + }); + + const page = await coordinator.recoverPage({ limit: 1 }); + const durable = await repository.find('default', 'example-monitor'); + + assert.equal(page.items[0].status, 'settled'); + assert.equal(durable.state, 'failed'); + assert.equal(durable.previousActiveLockDigest, PREVIOUS_LOCK_DIGEST); + assert.equal(durable.activeLockDigest, PREVIOUS_LOCK_DIGEST); + assert.equal(durable.failure.reason, 'activation_fact_conflict'); + assert.equal(durable.failure.failedFrom, 'staged'); + assert.deepEqual(calls, { stage: 0, publish: 0, inspect: 0 }); +}); + test('inspects an activating install without republishing it', async () => { const value = activatingFixture(); const repository = new MemoryRepository([ diff --git a/packages/ql3-runtime-core/test/pluginPackageResourceMaterialization.test.cjs b/packages/ql3-runtime-core/test/pluginPackageResourceMaterialization.test.cjs index 105998d7..9d625184 100644 --- a/packages/ql3-runtime-core/test/pluginPackageResourceMaterialization.test.cjs +++ b/packages/ql3-runtime-core/test/pluginPackageResourceMaterialization.test.cjs @@ -12,9 +12,11 @@ const { } = require('../dist/plugin-package/pluginPackage'); const { createPluginPackageLock, + createPluginPackageInstall, pluginPackageInstallActionDigest, pluginPackageInstallPlanDigest, serializePluginPackageManifest, + transitionPluginPackageInstall, } = require('../dist/plugin-package/installation/pluginPackageInstall'); const { pluginPackageContentTreeDigest, @@ -30,6 +32,7 @@ const { InvalidPluginPackageResourceMaterializationError, MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES, PLUGIN_PACKAGE_MATERIALIZED_REVISION_SCHEMA, + PluginPackageResourceActivationPrerequisite, PluginPackageResourceMaterializationConflictError, materializeActivePluginPackageResources, materializePluginPackageResources, @@ -40,6 +43,7 @@ const { const ARTIFACT_DIGEST = 'a'.repeat(64); const OCI_MANIFEST_DIGEST = 'f'.repeat(64); +const PREVIOUS_LOCK_DIGEST = 'c'.repeat(64); function resourceValues(overrides = {}) { return { @@ -208,13 +212,24 @@ function fixture(options = {}) { .sort((left, right) => left.path.localeCompare(right.path)); const contentDigest = pluginPackageContentTreeDigest(descriptors); const installEnvironment = environment(); - const plan = planPluginPackageInstall(packageManifest, installEnvironment); + const previousManifest = options.upgrade + ? { + ...packageManifest, + metadata: { ...packageManifest.metadata, version: '0.9.0' }, + } + : undefined; + const plan = planPluginPackageInstall( + packageManifest, + installEnvironment, + previousManifest, + ); const actionInput = { lockId: 'lock-001', projectId: 'project-001', manifest: packageManifest, plan, environment: installEnvironment, + ...(previousManifest === undefined ? {} : { previousManifest }), source: { kind: 'oci', locator: @@ -226,7 +241,10 @@ function fixture(options = {}) { }, architecture: 'arm64', deploymentProfile: 'edge', - targetGeneration: 1, + targetGeneration: options.upgrade ? 2 : 1, + ...(options.upgrade + ? { previousLockDigest: PREVIOUS_LOCK_DIGEST } + : {}), }; const lock = createPluginPackageLock({ ...actionInput, @@ -249,7 +267,9 @@ function fixture(options = {}) { packageName: lock.packageName, lockDigest: lock.lockDigest, generation: lock.targetGeneration, - previousActiveLockDigest: null, + previousActiveLockDigest: options.upgrade + ? PREVIOUS_LOCK_DIGEST + : null, contentDigest, resources: lock.resources, }); @@ -268,6 +288,24 @@ function fixture(options = {}) { }; } +function stagedRecord(value) { + const queued = createPluginPackageInstall(value.lock, { + installationId: value.generation.installationId, + mutationId: 'candidate-created', + occurredAtMs: 201, + }); + return transitionPluginPackageInstall(value.lock, queued, { + type: 'stage_completed', + mutationId: 'candidate-staged', + occurredAtMs: 202, + stageRef: `stage:${value.lock.lockDigest}`, + artifactDigest: value.lock.source.artifactDigest, + manifestDigest: value.lock.manifestDigest, + contentDigest: value.lock.source.contentDigest, + evidenceDigest: 'e'.repeat(64), + }); +} + test('materializes exact Task, Workflow, Prompt and Tool JSON into one immutable revision', () => { const value = fixture(); const revision = materializePluginPackageResources({ @@ -707,6 +745,134 @@ test('reads active bytes sequentially with explicit bounds and rejects a generat ); }); +test('qualifies and durably publishes a staged candidate before activation', async () => { + const value = fixture({ upgrade: true }); + const record = stagedRecord(value); + let durable = null; + let publications = 0; + const prerequisite = new PluginPackageResourceActivationPrerequisite({ + byteSource: { + async open(generation) { + assert.equal( + generation.generationDigest, + value.generation.generationDigest, + ); + return { + async read(path) { + return path === 'package.json' + ? value.manifestBytes + : value.resourceBytes[path]; + }, + async close() {}, + }; + }, + }, + materializedRepository: { + async find(generationDigest) { + return durable?.generation.generationDigest === generationDigest + ? durable + : null; + }, + async publish(revision) { + publications += 1; + durable = revision; + return { status: 'created', revision }; + }, + }, + taskSpecSemanticRegistry: value.registry, + }); + + assert.deepEqual(await prerequisite.inspect(record, value.lock), { + status: 'ready', + }); + assert.equal(publications, 1); + assert.equal( + durable.generation.generationDigest, + value.generation.generationDigest, + ); + assert.deepEqual(await prerequisite.inspect(record, value.lock), { + status: 'ready', + }); + assert.equal(publications, 1); +}); + +test('rejects a semantically invalid staged candidate without publishing it', async () => { + const value = fixture({ + upgrade: true, + resourceValues: { + 'workflows/daily.json': { + schema: 'qinglong/plugin-package-workflow-resource@v1', + id: 'daily', + name: 'Daily report', + enabled: true, + steps: [{ id: 'missing', task: 'missing', needs: [] }], + }, + }, + }); + const record = stagedRecord(value); + let publications = 0; + const prerequisite = new PluginPackageResourceActivationPrerequisite({ + byteSource: { + async open() { + return { + async read(path) { + return path === 'package.json' + ? value.manifestBytes + : value.resourceBytes[path]; + }, + async close() {}, + }; + }, + }, + materializedRepository: { + async find() { + return null; + }, + async publish(revision) { + publications += 1; + return { status: 'created', revision }; + }, + }, + taskSpecSemanticRegistry: value.registry, + }); + + assert.deepEqual(await prerequisite.inspect(record, value.lock), { + status: 'rejected', + reason: 'activation_fact_conflict', + }); + assert.equal(publications, 0); +}); + +test('leaves generation-one qualification to the post-activation B1 binding ceremony', async () => { + const value = fixture(); + const record = stagedRecord(value); + let authorityCalls = 0; + const prerequisite = new PluginPackageResourceActivationPrerequisite({ + byteSource: { + async open() { + authorityCalls += 1; + throw new Error('generation one must not read candidate bytes'); + }, + }, + materializedRepository: { + async find() { + authorityCalls += 1; + return null; + }, + async publish(revision) { + authorityCalls += 1; + return { status: 'created', revision }; + }, + }, + taskSpecSemanticRegistry: value.registry, + }); + + assert.deepEqual(await prerequisite.inspect(record, value.lock), { + status: 'ready', + }); + assert.equal(authorityCalls, 0); +}); + test('publishes materialization only through the explicit runtime-core subpath', () => { assert.equal(require('../dist').materializePluginPackageResources, undefined); assert.equal(