Compare commits

...
Author SHA1 Message Date
ChatGPT Release Governance 3dd622b5a5 v2r1c2_guard_plan_oid_supersession 2026-07-22 04:24:27 +08:00
ChatGPT Release Governance f73052cd5c v2r1c2_audit_manifest_supersession 2026-07-22 04:19:55 +08:00
ChatGPT Release Governance 1b69e89718 v2r1c2_add_audited_not_started_retry 2026-07-22 04:17:53 +08:00
ChatGPT Release Governance f3c00d7294 v2r1c2_harden_provenance_caches 2026-07-22 03:53:22 +08:00
ChatGPT Release Governance 80e3f65cd6 v2r1c2_takeover_gate 2026-07-22 03:46:34 +08:00
ChatGPT Release Governance 0cf769ef67 fix(v2r1c2): auto-issue Owner provenance for protected dispatch 2026-07-22 03:33:41 +08:00
ChatGPT Release Governance 5e53203a61 feat(v2r1c2): add legal ChatGPT takeover provider 2026-07-22 03:25:49 +08:00
ChatGPT Release Governance cf5afdf4c0 docs(task): add concise OpenClaw online execution handoff 2026-07-22 02:46:26 +08:00
ChatGPT Release Governance b6e714e3fc docs(v2r1c2): add unified portal and portable executor contract 2026-07-22 02:36:31 +08:00
ChatGPT Release Governance 0022adf958 test(release): add V2R1C2 offline and online acceptance gate 2026-07-22 02:33:37 +08:00
ChatGPT Release Governance 22888c3d53 test(orchestration): cover portable executor release gates 2026-07-22 02:29:33 +08:00
ChatGPT Release Governance c2c9eb8d81 fix(orchestration): retry failed owner notifications idempotently 2026-07-22 02:26:12 +08:00
ChatGPT Release Governance 6df9c92020 fix(web): repair execution center script and truth projection 2026-07-22 02:23:56 +08:00
ChatGPT Release Governance 6bb8c530a4 feat(web): render portable executor execution center 2026-07-22 02:18:31 +08:00
ChatGPT Release Governance 024143efa5 feat(deploy): add V2R1C2 candidate environment template 2026-07-22 02:10:29 +08:00
ChatGPT Release Governance 06c6374126 feat(deploy): add isolated V2R1C2 portal candidate service 2026-07-22 02:07:44 +08:00
ChatGPT Release Governance 82177403f6 feat(orchestration): add portable execution plan lifecycle CLI 2026-07-22 02:04:52 +08:00
ChatGPT Release Governance ef074a0c97 feat(release): add V2R1C2 portable multi-provider execution plan 2026-07-22 02:00:00 +08:00
ChatGPT Release Governance 00fd375e4c feat(web): wire portable executor plans into V2R1C2 candidate 2026-07-22 01:54:45 +08:00
ChatGPT Release Governance 5b0c3eb404 feat(web): expose provider-neutral execution plan APIs 2026-07-22 01:51:12 +08:00
ChatGPT Release Governance 3161470bd2 fix(orchestration): activate dependent stages only after upstream gates pass 2026-07-22 01:46:28 +08:00
ChatGPT Release Governance 1852701236 feat(web): add execution-center capability and navigation 2026-07-22 01:39:46 +08:00
ChatGPT Release Governance 21fc8c60ed refactor(orchestration): authorize task API from dynamic Provider trust 2026-07-22 01:33:56 +08:00
ChatGPT Release Governance 77395fa9a0 feat(orchestration): add official portable executor providers 2026-07-22 01:29:46 +08:00
ChatGPT Release Governance 490ed4f65a feat(orchestration): add provider-neutral executor worker runtime 2026-07-22 01:26:54 +08:00
ChatGPT Release Governance d6d2407a0d feat(orchestration): add portable multi-provider execution plans 2026-07-22 01:22:48 +08:00
ChatGPT Release Governance db3fcb89a8 feat(orchestration): persist and migrate executor provider catalogs 2026-07-22 01:18:40 +08:00
ChatGPT Release Governance 20b7a41104 refactor(orchestration): make executor protocol provider-based and portable 2026-07-22 01:10:32 +08:00
ChatGPT Release Governance e739e67052 feat(orchestration): unify OpenClaw Hermes and Codex executor protocol 2026-07-22 01:05:56 +08:00
ChatGPT Release Governance 1e6c7b9ad8 feat(orchestration): add protected external task lifecycle API 2026-07-22 00:59:25 +08:00
ChatGPT Release Governance 1afbb11d3b feat(orchestration): add OpenClaw-compatible external task CLI 2026-07-22 00:54:49 +08:00
ChatGPT Release Governance 850beec8d7 feat(orchestration): add external task discovery read model 2026-07-22 00:49:45 +08:00
ChatGPT Release Governance fb11c71ec0 feat(orchestration): require owner notification receipts in external task gate 2026-07-22 00:42:50 +08:00
ChatGPT Release Governance 8acdd104bb feat(orchestration): add durable external task truth registry 2026-07-22 00:38:48 +08:00
ChatGPT Release Governance 43d77e520f feat(web): add tenant and Agent resource center to candidate portal 2026-07-22 00:34:16 +08:00
ChatGPT Release Governance a3f731d01c fix(web): bridge portal-only roles to domain read policy 2026-07-22 00:13:45 +08:00
ChatGPT Release Governance 8dba5b8c2e fix(web): provide unified portal HTML package boundary 2026-07-22 00:06:22 +08:00
ChatGPT Release Governance 74158908e8 feat(web): add V2R1C2 portal release entrypoint and resource aggregation 2026-07-22 00:04:12 +08:00
ChatGPT Release Governance ab36e36d5d feat(web): expose unified portal package 2026-07-22 00:00:17 +08:00
ChatGPT Release Governance c1b14753a3 feat(web): add tenant and Agent resource capabilities 2026-07-21 23:54:08 +08:00
ChatGPT Release Governance c62c8ee9f6 feat(web): replace read-only console with unified role-aware portal 2026-07-21 23:49:52 +08:00
ChatGPT Release Governance 9626e66e5b feat(web): add unified portal aggregation and protected action API 2026-07-21 23:44:06 +08:00
ChatGPT Release Governance b68e3c85a3 feat(web): add server-side role capability matrix 2026-07-21 23:39:20 +08:00
ChatGPT Release Governance 74014514da feat(web): create unified role-aware portal branch from V2R1C1 2026-07-21 23:17:54 +08:00
Codex 4e0e6dcb90 chore(release): close V2R1C1 companion validation metadata 2026-07-21 19:05:28 +08:00
Codex 0778a31ec0 chore(release): record OpenClaw v2 companion validation 2026-07-21 18:29:45 +08:00
Codex 80c4cbde03 release:prepare-V2R1C1-validation-metadata 2026-07-21 17:04:24 +08:00
Codex fb739f6e8f docs(hermes): preserve formal acceptance blocker 2026-07-21 13:59:01 +08:00
Codex e6da39a3cd docs(hermes): record final evidence and worktree blocker 2026-07-21 13:55:08 +08:00
Codex 95d2e52cb5 fix(hermes): reserve cancellation for control plane 2026-07-21 13:41:34 +08:00
Codex 283dec58ab fix(hermes): enforce minimum heartbeat lease duration 2026-07-21 13:21:09 +08:00
Codex 7de1fe80ac docs(hermes): retain key-id and rollback contracts 2026-07-21 13:19:21 +08:00
Codex 89a502b39b test(hermes): preserve validated ten-case release gate 2026-07-21 13:19:21 +08:00
Codex 61bec280e2 deploy(hermes): preserve bounded rollback readiness 2026-07-21 13:19:21 +08:00
Codex 1791c91c5b docs(hermes): define lifecycle contract and validation state 2026-07-21 13:13:15 +08:00
Codex e8c0f0250b test(hermes): cover live lifecycle and recovery matrix 2026-07-21 13:09:06 +08:00
Codex 94718308fc fix(hermes): normalize trusted runtime headers and conflicts 2026-07-21 13:09:06 +08:00
Codex a4cbed0f5f feat(hermes): persist and protect full runtime lifecycle 2026-07-21 13:09:06 +08:00
Codex 25c7740739 docs(hardening): refresh Gate A report with verified execution results 2026-07-21 12:39:53 +08:00
Codex 8bbb049c39 docs(hardening): index ADR-009 without inventing missing history 2026-07-21 12:36:22 +08:00
Codex 1707cc4953 docs(hardening): rebuild complete UR-001 to UR-022 traceability 2026-07-21 12:34:50 +08:00
Codex e397594200 docs(hardening): add Run Provenance and scoped Grant requirements 2026-07-21 12:31:58 +08:00
Codex 68b7f2c1d2 docs(hardening): align module contracts with hardened runtime and Grant model 2026-07-21 12:28:25 +08:00
Codex fda98e61f5 docs(hardening): record least-privilege Provenance decision 2026-07-21 12:16:22 +08:00
Codex ea9f599522 fix(hardening): scope CLI authorization to one Grant 2026-07-21 12:13:21 +08:00
Codex 1aabcd8b62 test(hardening): add least-privilege Grant isolation coverage 2026-07-21 12:04:02 +08:00
Codex 4d68af9524 test(hardening): upgrade runtime flows and add dispatch recovery coverage 2026-07-21 12:01:17 +08:00
Codex 467b127804 fix(hardening): give application a safe default Grant scope 2026-07-21 11:57:32 +08:00
Codex 50a73b211b fix(hardening): bind dispatch and runtime results to principal 2026-07-21 11:55:07 +08:00
Codex f14d4b6e14 feat(hardening): persist principal and runtime lookup in dispatch store 2026-07-21 11:51:01 +08:00
Codex fc8bd8c8d4 feat(hardening): build scoped Grant writers per service 2026-07-21 11:48:11 +08:00
Codex 382fe662a3 feat(hardening): add service-scoped least-privilege writer 2026-07-21 11:45:46 +08:00
Codex 4754e5762a fix(hardening): use constant-time fingerprint comparison 2026-07-21 11:42:08 +08:00
Codex ce7d35a811 config(hardening): add seven least-privilege Grant references 2026-07-21 11:37:58 +08:00
Codex 60ac7d9d0b refactor(hardening): make evidence tracking use least-privilege authorization interface 2026-07-21 11:34:43 +08:00
Codex 2a10d1cdd9 feat(hardening): add workflow and least-privilege Grant CLI 2026-07-21 11:31:51 +08:00
Codex 677ea738e7 fix(hardening): authorize Git content before modifying index 2026-07-21 11:27:28 +08:00
Codex 7154d62cca fix(hardening): export OpenClaw Ed25519 contract types 2026-07-21 11:24:35 +08:00
Codex 4e154c8baf feat(hardening): wire durable dispatch least-privilege grants and internal reconcile 2026-07-21 11:20:39 +08:00
Codex b848361079 feat(hardening): require least-privilege grants in production writer 2026-07-21 11:17:39 +08:00
Codex 74224cfbd5 feat(hardening): add least-privilege provenance child grants 2026-07-21 11:14:42 +08:00
Codex a7c09ba646 feat(hardening): upgrade OpenClaw command contract to Ed25519 v2 2026-07-21 11:10:21 +08:00
Codex f055cb74bb feat(hardening): persist dispatches and strictly bind runtime results 2026-07-21 11:04:59 +08:00
Codex 071a378de6 fix(hardening): close public runtime lifecycle bypass 2026-07-21 11:00:28 +08:00
Codex a492b15df6 feat(hardening): add durable dispatch repository 2026-07-21 10:53:32 +08:00
Codex a9f0a4359b patch(chatgpt): start full Gate A hardening 2026-07-21 10:46:59 +08:00
Codex 6a9fab894a docs(traceability): add run provenance implementation matrix 2026-07-21 10:30:54 +08:00
Codex e9111dea47 deploy(provenance): add mandatory operation authorization wrapper 2026-07-21 10:30:40 +08:00
Codex d25fbfa61e test(provenance): add signature lifecycle replay and seven-path gate coverage 2026-07-21 10:21:01 +08:00
Codex 6b03c47625 build(provenance): install Ed25519 runtime dependency 2026-07-21 10:16:28 +08:00
Codex 770fe1ef66 docs(aios-v2): add ADR-007 run provenance control 2026-07-21 10:12:03 +08:00
Codex 7a025430f9 config(provenance): add production environment references 2026-07-21 10:11:56 +08:00
Codex d079da4029 feat(provenance): export trusted provenance APIs 2026-07-21 10:11:49 +08:00
Codex b8f7dc4112 feat(provenance): wire production gate into API application 2026-07-21 10:07:19 +08:00
Codex 947cdaff42 feat(provenance): add issuer authorization revoke and status CLI 2026-07-21 10:00:02 +08:00
Codex 83b0c8bd74 feat(provenance): add deployment and rollback operation gate factory 2026-07-21 09:56:50 +08:00
Codex fbd880cf8c feat(provenance): add controlled Git writer with operation policy 2026-07-21 09:46:40 +08:00
Codex 819ac94c9f feat(provenance): enforce authorization before evidence writes 2026-07-21 09:42:25 +08:00
Codex b02401e60d feat(provenance): enforce signed provenance before runtime dispatch 2026-07-21 09:39:33 +08:00
Codex cbb2e232f5 feat(provenance): implement Ed25519 issuer persistent store and seven-path writer 2026-07-21 09:33:07 +08:00
Codex 9e46ac0504 patch(codex): start trusted run provenance issuer store and writer blocker resolution 2026-07-20 20:11:59 +08:00
Codex ba7669c3b4 docs(aios-v2): 更新监督状态与阻塞处置清单 2026-07-20 17:22:32 +08:00
Codex 634f6b164f docs(candidate): 新增完整中文生产候选报告 2026-07-20 12:06:55 +08:00
Codex c9ce12d102 deploy(candidate): 新增中文全量验收入口 2026-07-20 12:05:38 +08:00
Codex db50d2fa5a tasks(aios-v2): 完整中文化 Codex 生产监督任务 2026-07-20 12:04:43 +08:00
Codex 63fd23a464 tasks(aios-v2): 完整中文化 OpenClaw 生产部署任务 2026-07-20 12:04:11 +08:00
Codex 2e03ed74f6 feat(governance): 新增文档与代码匹配门禁 2026-07-20 12:03:36 +08:00
Codex b86f51c9ab docs(aios-v2): 新增 ADR-006 候选持久化策略 2026-07-20 12:01:49 +08:00
Codex 09448a5d3d docs(aios-v2): 完整中文化 ADR-005 2026-07-20 12:01:05 +08:00
Codex 18c719b2c6 docs(aios-v2): 完整中文化 ADR-004 2026-07-20 12:00:39 +08:00
Codex b0a78fa9a8 docs(aios-v2): 完整中文化 ADR-003 2026-07-20 12:00:07 +08:00
Codex 0aca7dbcfc docs(aios-v2): 完整中文化 ADR-002 2026-07-20 11:59:34 +08:00
Codex 3f84d8991e docs(aios-v2): 完整中文化 ADR-001 2026-07-20 11:59:10 +08:00
Codex 5ac8f3ea6f docs(aios-v2): 完整中文化 ADR 索引 2026-07-20 11:58:39 +08:00
Codex bfb1c2fd52 docs(aios-v2): 新增完整中文文档治理规范 2026-07-20 11:57:51 +08:00
Codex cbfe1211c1 docs(aios-v2): 将文档索引完整转换为中文 2026-07-20 11:56:30 +08:00
Codex efd99e620c docs(aios-v2): add ADR-004 tenant isolation decision 2026-07-20 11:53:04 +08:00
Codex f23b694ad3 tasks(aios-v2): add Codex production supervisor task 2026-07-20 11:46:43 +08:00
Codex f97dea55ac tasks(aios-v2): add OpenClaw production deployment task 2026-07-20 11:46:39 +08:00
Codex efd08d7771 docs(aios-v2): add ADR-003 GitOps-only flow 2026-07-20 11:46:31 +08:00
Codex ff6cb2e217 docs(aios-v2): add ADR-005 documentation sync gate 2026-07-20 11:45:16 +08:00
Codex d774620fea docs(aios-v2): add ADR-002 runtime decoupling decision 2026-07-20 11:45:12 +08:00
Codex b26ff010fc docs(aios-v2): add ADR-001 control plane decision 2026-07-20 11:45:09 +08:00
Codex 4b49f2ba4b docs(aios-v2): add ADR index 2026-07-20 11:45:04 +08:00
Codex b4b8fe8828 docs(aios-v2): add traceability matrix 2026-07-20 11:43:46 +08:00
Codex 4e8ab051a0 docs(aios-v2): add module contracts 2026-07-20 11:41:39 +08:00
Codex f61e35b218 docs(aios-v2): add migration and delivery strategy 2026-07-20 11:34:33 +08:00
Codex b2f90f24a3 docs(aios-v2): add target architecture 2026-07-20 11:32:16 +08:00
Codex 6721eb4752 docs(aios-v2): add user requirements baseline 2026-07-20 11:29:07 +08:00
Codex a9e4fd1e40 docs(aios-v2): establish architecture source of truth 2026-07-20 11:24:12 +08:00
Codex 621750976a deploy(candidate): add guarded release rollback 2026-07-20 11:22:04 +08:00
Codex 5359e88806 feat(candidate): expose sanitized GitOps status API 2026-07-20 11:19:37 +08:00
Codex 76636931ba feat(candidate): add sanitized GitOps status provider 2026-07-20 11:17:18 +08:00
Codex 180c1fc66e deploy(candidate): add end-to-end healthcheck 2026-07-20 11:14:53 +08:00
Codex c0ff052dab deploy(candidate): add Docker Compose runtime 2026-07-20 11:12:42 +08:00
Codex 94cffb6ba3 deploy(candidate): add hardened container image 2026-07-20 11:10:36 +08:00
Codex 9ad5628e69 deploy(candidate): add environment configuration template 2026-07-20 11:08:37 +08:00
Codex 6d397a7894 deploy(candidate): add hardened systemd service 2026-07-20 11:07:06 +08:00
Codex 165fea6ba9 fix(candidate): prevent Hermes completion heartbeat revision race 2026-07-20 11:05:45 +08:00
Codex d079e2087c test(candidate): add end-to-end runtime and persistence tests 2026-07-20 11:01:07 +08:00
Codex e3b36ebdcc feat(candidate): add production application entrypoint 2026-07-20 10:57:42 +08:00
Codex 5e791b11c0 feat(candidate): add tenant-safe web console 2026-07-20 10:54:43 +08:00
Codex 50be65b76e feat(candidate): add durable SQLite control-plane persistence 2026-07-20 10:50:31 +08:00
Codex 7bb86e30bf feat(candidate): export orchestration and execution tracking 2026-07-20 10:42:49 +08:00
Codex ea56b45b7e feat(candidate): add persistent agent execution tracking 2026-07-20 10:40:22 +08:00
Codex f53682da7a feat(candidate): add unified AIOS execution orchestrator 2026-07-20 10:38:06 +08:00
Codex 71884ac088 feat(candidate): add Hermes worker loop and lease heartbeat 2026-07-20 10:35:55 +08:00
Codex 969c278cb0 feat(candidate): implement Hermes queue and lease adapter 2026-07-20 10:34:25 +08:00
Codex 6fe2a1e036 feat(candidate): expose Hermes runtime package 2026-07-20 10:31:07 +08:00
Codex 9a60e0c68e feat(candidate): add Hermes runtime contracts 2026-07-20 10:27:47 +08:00
Codex 46f553ca11 feat(candidate): expose Hermes runtime boundary package 2026-07-20 10:17:02 +08:00
Codex 4f0a0c2184 feat(candidate): add OpenClaw audit callback sink 2026-07-20 10:07:54 +08:00
Codex c107b193d9 feat(candidate): add OpenClaw runtime health policy 2026-07-20 10:06:36 +08:00
Codex 2db7ced7a4 feat(candidate): add OpenClaw runtime transports 2026-07-20 10:01:59 +08:00
Codex 6361d9a334 feat(candidate): implement OpenClaw execution adapter 2026-07-20 09:59:53 +08:00
Codex 2cf54f04ef feat(candidate): expose OpenClaw adapter package 2026-07-20 09:56:06 +08:00
Codex f840ce5c94 feat(integration): add OpenClaw command contract 2026-07-20 09:49:15 +08:00
Codex 35e7b55490 integration(aios-v2): create CTO production candidate branch 2026-07-20 09:47:01 +08:00
Codex 1f8d1f23b3 feat(aios-v2): add tenant-aware HTTP control plane gateway 2026-07-20 02:24:19 +08:00
Codex e93011791e feat(aios-v2): add tenant-isolated control plane domain 2026-07-20 02:19:16 +08:00
Codex 4460564a48 feat(aios-v2): initialize control plane package 2026-07-20 01:11:11 +08:00
Codex 4d6658136e docs(task): add AIOS control plane gateway task contract 2026-07-20 01:06:11 +08:00
Codex 5767e665c2 task(codex): implement AIOS V2 control plane gateway v1 2026-07-20 01:06:04 +08:00
openclaw 3cf9958cb9 integrate(gate1): replay accepted clean-room evidence onto current main 2026-07-14 01:33:53 +08:00
openclaw 6b70a33080 fix(weixin): resolve outbound quotes by exact create time 2026-07-12 21:41:10 +08:00
openclaw 8d23692320 fix(weixin): preserve exact server message ids 2026-07-12 19:58:26 +08:00
openclaw 33d3947da6 fix(weixin): resolve quotes from exact message id cache 2026-07-12 17:09:33 +08:00
openclaw 806163b8ba docs(weixin): record visible quote parser release 2026-07-12 15:55:38 +08:00
openclaw 13c2a18286 fix(weixin): resolve visible quoted text summaries 2026-07-12 15:50:03 +08:00
openclaw b8f10951ea docs(weixin): record owner quoted text acceptance 2026-07-12 14:11:27 +08:00
openclaw 2fcf0879c6 docs(weixin): record quoted text fail-closed release 2026-07-12 13:46:14 +08:00
openclaw 1136b4186d fix(weixin): fail closed on unavailable quoted text 2026-07-12 13:42:32 +08:00
openclaw cca73ad52f docs(weixin): design quoted text fail-closed handling 2026-07-12 13:42:32 +08:00
openclaw 424bf36c03 Merge remote-tracking branch 'origin/audit' 2026-07-12 12:57:36 +08:00
Steel Dan (AIOS) 9eb00da0c4 docs(weixin): record mainline production release 2026-07-12 02:21:21 +08:00
Steel Dan (AIOS) 9269e81997 docs(weixin): record quoted text reference fix 2026-07-12 01:18:25 +08:00
Steel Dan (AIOS) 6642820aa0 fix(weixin): ignore unconfirmed text references 2026-07-12 01:18:25 +08:00
Steel Dan (AIOS) 8374c6b1c7 docs(weixin): update confirmed image reference recovery 2026-07-12 01:18:25 +08:00
Steel Dan (AIOS) c8a3251834 fix(weixin): require confirmed image reference context 2026-07-12 01:18:25 +08:00
Steel Dan (AIOS) 2418f56273 docs(weixin): record text cache isolation recovery 2026-07-12 01:18:11 +08:00
Steel Dan (AIOS) 297582941f fix(weixin): add process-only runtime patch mode 2026-07-12 01:18:11 +08:00
Steel Dan (AIOS) 958a61cbc4 fix(weixin): isolate text from stale image cache 2026-07-12 01:18:11 +08:00
V1R4C6 Deployment Audit 3096212919 audit: record V1R4C6 production deployment blocker 2026-07-12 00:57:05 +08:00
openclaw aa2b091966 start V1R4C6 production deployment and verification 2026-07-11 21:10:51 +08:00
gito 1a9cb9095e Merge pull request 'reconcile(v1r4c6): finalize baseline metadata — previous_baseline V1R4C5, git e204a90' (#9) from reconcile/v1r4c6-baseline-finalize-20260711 into main 2026-07-11 20:55:54 +08:00
openclaw 0312159168 feat(reconcile): write V1R4C6 baseline metadata — previous_baseline V1R4C5, git e204a90 2026-07-11 20:54:46 +08:00
openclaw e204a90398 docs: baseline V1R4C6 promotion 2026-07-11 20:47:44 +08:00
openclaw d8cf6058e9 docs: promote VERSION V1R4C3 -> V1R4C6 ahead of main promotion 2026-07-11 20:47:28 +08:00
openclaw 2aefc19e3d authorize OpenClaw to execute V1R4C6 merge tests deploy pipeline 2026-07-11 20:41:31 +08:00
gito 28a430066f Merge pull request 'V1R4C6: merge candidate → main (release control artifacts)' (#8) from candidate/v1r4c6-integration-main-20260711 into main 2026-07-11 19:30:58 +08:00
openclaw 1013aef461 Merge branch 'release/v1r4c6-admin-search-agent-integration-20260711' into candidate/v1r4c6-integration-main-20260711
Resolve merge conflicts per V1R4C6 release control merge:

- baseline/current.json: keep feature branch version (updates to V1R4C5,
  adds constraints block). This was the intended runtime state that main
  was missing after V1R4C5 backfill (PR #7). Feature branch correctly has
  the V1R4C5 state; main still had V1R4C3.

- versions/V1R4C5.json: keep main version (complete fields with codename,
  tag_ref, tag_object, constraints, promoted_at/by). Feature branch had an
  older copy lacking these fields.

- docs/releases/V1R4C6-acceptance-evidence.md: keep main version (blank
  'NOT RUN' status). Feature branch had draft content.

- docs/releases/V1R4C6-release-gate-status.md: keep main version (blank
  'NOT RUN' status). Feature branch had draft content.
2026-07-11 19:29:47 +08:00
gito bb3210d74f Merge pull request 'docs: update V1R4C6 metadata to NOT RUN after control artifacts merge' (#7) from reconcile/v1r4c6-gate-status-notrun-docs-20260711 into main 2026-07-11 17:07:03 +08:00
openclaw 745395e93b docs: update V1R4C6 gate status, acceptance checklist, and acceptance evidence to NOT RUN
All engineering gates remain pending. This updates the release documents
to reflect the current integration-pending status after PR #6 merged.
2026-07-11 17:06:31 +08:00
gito ae49bf0fa3 Merge pull request 'reconcile: merge V1R4C6 release candidate into main' (#6) from candidate/v1r4c6-merge-to-main-20260711 into main 2026-07-11 16:45:28 +08:00
openclaw 8410cd895e reconcile: merge V1R4C6 release candidate into main
V1R4C6 merge candidate (squashed).
Contains 10 files, ~1440 lines added across:
- docs/releases/ (6 files: checklist, evidence, blocker, resume, merge-matrix, gate-status)
- ops/openclaw/ (2 files: integrate-deploy-verify, run-to-owner-gate-wechat)
- patches/ (1 file: branch patch)
- versions/ (1 file: V1R4C6.json)

Task: admin workbench, SR-03 Search Gateway, Agent Platform Integration
Baseline: V1R4C5
V1R4C5 tag semantics:
- tag refs/tags/V1R4C5 target d457ac3: immutable release snapshot
- baseline git a32c76a: runtime baseline promotion commit
No tag creation, baseline promotion, or memory search activation in this merge.
2026-07-11 16:34:03 +08:00
gito dc1642c2e8 Merge pull request 'reconcile: V1R4C5 baseline metadata (current.json + V1R4C5.json)' (#5) from reconcile/v1r4c5-baseline-metadata-20260711 into main 2026-07-11 13:48:30 +08:00
openclaw c75b865710 reconcile: update baseline registry to V1R4C5 and create V1R4C5 version metadata
Root cause:
The V1R4C5 baseline promotion commit (a32c76a) was made on a feature branch
that was never merged to main. As a result, both baseline/current.json and
versions/V1R4C5.json on main still reflect V1R4C3 state. Runtime
getBaseline() continues to return V1R4C3 with commit f5435e3.

Changes:
- baseline/current.json: update current_baseline -> V1R4C5, git ->
  a32c76a3c1 (the actual promotion commit),
  add constraints from the original promotion
- versions/V1R4C5.json: create the missing version metadata file with status
  active-baseline, referencing V1R4C5 tag (object ac9bdac...)

Why this does not affect V1R4C5 release content:
- V1R4C5 release documents (docs/releases/*.md) are untouched
- V1R4C5 tag is unchanged and not moved
- No V1R4C6 functionality introduced
- No memory search or vector index enabled

Rollback method:
  git revert <this commit hash>
  # Or restore baseline/current.json from V1R4C3 content:
  # git checkout main~1 baseline/current.json
  # And delete versions/V1R4C5.json:
  # git rm versions/V1R4C5.json
  # Then commit the revert

Constraints honored:
  - No direct writes to main (using PR)
  - No force push
  - V1R4C5 tag not moved/rebuild
  - No V1R4C6 content
  - Memory search remains disabled
2026-07-11 13:46:13 +08:00
gito e992b624e0 V1R4C5 → main: backfill release docs (acceptance evidence + owner approval) 2026-07-11 13:37:58 +08:00
openclaw c60e4cd02c reconcile: backfill V1R4C5 release documents (acceptance evidence + final owner approval) for main merge
Source: V1R4C5 tag based on unique publication commit a32c76a
- docs/releases/V1R4C5-acceptance-evidence.md (from V1R4C5 tag)
- docs/releases/V1R4C5-final-owner-approval.md (from V1R4C5 tag)

Constraints honored:
- Not writing directly to main
- No V1R4C6 content introduced
- Memory search remains disabled per owner approval

Generated merge candidate branch: reconcile/v1r4c5-release-to-main-20260711
2026-07-11 13:36:18 +08:00
openclaw 4b2996ca9f release: resume V1R4C6 after scoped WeChat notification verification 2026-07-11 07:47:05 +08:00
openclaw 086076b74f ops: direct OpenClaw to complete V1R4C6 and request owner approval via WeChat 2026-07-11 03:13:32 +08:00
openclaw 7b2d694272 ops: queue actual V1R4C6 integration deployment and verification 2026-07-11 03:10:28 +08:00
openclaw 0583060c7c release: update V1R4C6 gate status 2026-07-11 03:07:13 +08:00
openclaw 20e707fb52 release: add V1R4C6 acceptance evidence 2026-07-11 03:05:52 +08:00
openclaw 6c75ec6bb2 release: add V1R4C6 acceptance checklist 2026-07-11 03:04:02 +08:00
openclaw e9fefd4c18 release: add V1R4C6 integration merge matrix 2026-07-11 03:01:59 +08:00
openclaw 3ece481c74 fix(runtime): point GitOps API to active aios.git bare repository 2026-07-11 02:56:49 +08:00
openclaw 33177548c3 fix(baseline): stabilize V1R4C5 git pointer 2026-07-11 02:55:05 +08:00
openclaw ed1413a05e V1R4C5 baseline promotion: V1R4C3 -> V1R4C5
promoted_by: openclaw

promotion_reason: V1R4C5 SR-01 Gitea branch sync fix and SR-02 Watcher state cleanup

memory_search: disabled
2026-07-11 02:46:00 +08:00
openclaw 05d1e64228 release: record V1R4C6 baseline visibility blocker 2026-07-11 02:46:00 +08:00
openclaw 64bbe1b51c Create V1R4C6 integration release patch 2026-07-11 02:46:00 +08:00
openclaw 3c6f2c8c65 release: start V1R4C6 admin search agent integration 2026-07-11 02:26:31 +08:00
openclaw f6d9bb1510 Promote baseline to V1R4C3
- baseline/current.json updated from V1R3C1 to V1R4C3
- Fixed test_single_provider_failure_is_not_terminal (provider_order override)
- All 38 tests passing

AIOS_V1R4C3_BASELINE_PROMOTED
AIOS_BASELINE_CURRENT_UPDATED
2026-07-09 17:45:09 +08:00
openclaw 8ca4db3e8c Close completed autonomous controller CMD
- Moved CMD-20260709-enable-autonomous-controller.md from active to completed
- All autonomous controller tasks verified complete

AIOS_AUTONOMOUS_CONTROLLER_CMD_CLOSED
AIOS_RUNNING_COMMAND_QUEUE_CLEAN
2026-07-09 17:38:04 +08:00
openclaw d194d3a4d7 Add Owner Gate Web UI panels with approve/reject functionality 2026-07-09 17:23:23 +08:00
openclaw f5435e3087 release: version bump to V1R4C3 2026-07-09 16:13:42 +08:00
gito 88087dc7ff Merge pull request 'V1R4C1 review fixes: arch change API, tests, owner gate, governance' (#3) from patch/v1r4c1-review-risk-fixes into main 2026-07-09 16:13:08 +08:00
openclaw 2f04b9f46c release: version bump to V1R4C2 2026-07-09 16:10:29 +08:00
openclaw 06d3b4cda6 Add Task Center V1R4C2 proposal 2026-07-09 14:55:22 +08:00
openclaw 09186fe551 Add Task Center policy acceptance tests 2026-07-09 14:55:16 +08:00
openclaw c634f2e23b Add Task Center governance policy 2026-07-09 14:55:11 +08:00
openclaw b3ccae4c2b Add Task Center visual dashboard deployment note 2026-07-09 14:55:08 +08:00
openclaw 85ac09110f Add Task Dashboard view tests 2026-07-09 14:52:01 +08:00
openclaw 6622ebb30e Add Task Center visualization model 2026-07-09 14:51:58 +08:00
openclaw 824b079fea Add Task Center source sync deployment command 2026-07-09 14:51:51 +08:00
openclaw 2e5a8bfb5a Add Task orchestrator tests 2026-07-09 14:48:48 +08:00
openclaw ce36b790ea Add Task Center orchestrator 2026-07-09 14:48:44 +08:00
openclaw 8d60aa921a Add Task Center source ingestor 2026-07-09 14:48:33 +08:00
openclaw 78ce96a8db Add OpenClaw command for complete Task Center closed loop 2026-07-09 14:45:26 +08:00
openclaw ce308f84ba Add Task Center event and statistics tests 2026-07-09 14:45:22 +08:00
openclaw 7459f24696 Add Task Center statistics 2026-07-09 14:45:18 +08:00
openclaw 8f90744ba0 Add Task Center persistent store 2026-07-09 14:45:14 +08:00
openclaw 4db407345c Add Task Center event bridge 2026-07-09 14:45:09 +08:00
openclaw 4a09eacfe4 Add OpenClaw command for Task Center governance upgrade 2026-07-09 14:39:12 +08:00
openclaw a9e1b26652 Add task lifecycle tests 2026-07-09 14:39:08 +08:00
openclaw 513d90bb8a Add Task Center action API 2026-07-09 14:39:05 +08:00
openclaw d03f06aa82 Add governed task lifecycle state machine 2026-07-09 14:39:00 +08:00
openclaw c657057826 Add OpenClaw command for Task Center Web UI 2026-07-09 14:36:42 +08:00
openclaw 84717341d7 Add Task Center tests 2026-07-09 14:36:37 +08:00
openclaw f631a61330 Add Task Center API 2026-07-09 14:36:32 +08:00
openclaw 38222c9fec Add AIOS Task Center domain model 2026-07-09 14:36:24 +08:00
openclaw 055fc895a6 Dispatch Search Gateway production deployment to OpenClaw 2026-07-09 14:26:10 +08:00
openclaw 8ab38f142a Add OpenClaw command for architecture doc permission fix 2026-07-09 14:08:02 +08:00
openclaw 1b3921f9c8 Add Search Architect integration tests 2026-07-09 14:07:52 +08:00
openclaw c15453db1a Add Search Gateway Architect integration 2026-07-09 14:07:46 +08:00
openclaw 83641c2491 Add V1R4C2 Search Gateway capability proposal 2026-07-09 14:04:02 +08:00
openclaw e1f4d1f267 Add Search agent adapter tests 2026-07-09 14:03:59 +08:00
openclaw 00d37b2d1a Add Search Gateway agent adapters 2026-07-09 14:03:53 +08:00
openclaw 887061e65b Add complete Search Gateway runtime deployment command 2026-07-09 13:27:49 +08:00
openclaw bb2b301c4e Add Search Gateway orchestrator tests 2026-07-09 13:27:45 +08:00
openclaw 2cc1acebd2 Add Search Gateway runtime orchestrator 2026-07-09 13:27:42 +08:00
openclaw 9dd2d33cbb Add Search Gateway event bridge 2026-07-09 13:27:37 +08:00
openclaw f648e19d4d Add Search provider registry tests 2026-07-09 13:26:37 +08:00
openclaw 9793239962 Add Search Gateway audit store 2026-07-09 13:26:34 +08:00
openclaw 81d5744e8e Add Search Gateway provider registry 2026-07-09 13:26:28 +08:00
openclaw 5d37ecfff0 Add OpenClaw deployment command for general Search Gateway 2026-07-09 12:48:23 +08:00
openclaw 64577a1eb8 Expose Search Gateway overview API 2026-07-09 12:48:14 +08:00
openclaw 720a7026b4 Generalize Search Gateway policy for group and user customization 2026-07-09 12:48:06 +08:00
openclaw e4a83388ae Add general Search Gateway tests 2026-07-09 12:47:54 +08:00
openclaw 2e9b749ce2 Generalize Search Gateway with profile-based query planning 2026-07-09 12:47:46 +08:00
openclaw a074b13a7a Add general Search Gateway profile resolver 2026-07-09 12:47:23 +08:00
openclaw 16877032ac Add OpenClaw deployment command for Search Gateway 2026-07-09 12:45:14 +08:00
openclaw 21dd8d10c0 Add Search Gateway dashboard tests 2026-07-09 12:45:05 +08:00
openclaw a2e9dab162 Add Search Gateway overview API 2026-07-09 12:45:01 +08:00
openclaw 99d49b5d85 Add Search Gateway fallback and compliance tests 2026-07-09 12:44:55 +08:00
openclaw 024bfc52f2 Add Search Gateway dashboard model 2026-07-09 12:44:45 +08:00
openclaw 4588cdf2d9 Add Search Gateway governance policy 2026-07-09 12:44:35 +08:00
openclaw 81147e44bb Add OpenClaw deployment command for Search Gateway 2026-07-09 12:41:12 +08:00
openclaw 0f41034b78 Add Search Gateway tests 2026-07-09 12:41:04 +08:00
openclaw 2c5d9995f9 Add Search Gateway governance policy 2026-07-09 12:40:55 +08:00
openclaw aa71ce9062 Add Search Gateway dashboard model 2026-07-09 12:40:49 +08:00
openclaw 967ef3bb3c Add Search Gateway API 2026-07-09 12:40:41 +08:00
openclaw d05055abb6 Add governed Search Gateway core 2026-07-09 12:40:35 +08:00
openclaw a905b92fcc Create governed Search Gateway capability branch 2026-07-09 12:40:14 +08:00
gito 797e301968 Merge pull request 'chore: merge agent-observability-webui patch branch' (#2) from patch/agent-observability-webui into main 2026-07-09 12:32:29 +08:00
openclaw d8a426cea1 Clean stale runtime state before PR #2 merge 2026-07-09 12:27:58 +08:00
openclaw d5ded1e48a release: version bump to V1R4C2 2026-07-09 12:27:04 +08:00
openclaw 22d48bad4d v1r4c1: batch commit - arch change api + tests + owner gate 2026-07-09 12:12:02 +08:00
openclaw 99c50464d9 Add architecture change governance dashboard 2026-07-09 11:49:30 +08:00
openclaw d0141450fe Add OpenClaw command for tiered architecture owner gate 2026-07-09 11:47:58 +08:00
openclaw 84c67fa743 Add architecture change classification tests 2026-07-09 11:47:55 +08:00
openclaw d0536dbe14 Update owner gate to support small auto and large approval 2026-07-09 11:47:53 +08:00
openclaw 4177a22936 Add architecture change classifier for owner gate levels 2026-07-09 11:47:48 +08:00
openclaw 5c38b23810 Add OpenClaw command for architecture owner gate 2026-07-09 11:42:54 +08:00
openclaw aa0f721ce8 Add architecture owner gate tests 2026-07-09 11:42:51 +08:00
openclaw 2e94439f49 Add architecture owner gate policy 2026-07-09 11:42:48 +08:00
openclaw 961c45c825 Add owner approval gate for architecture changes 2026-07-09 11:42:43 +08:00
openclaw 20f1f9820d Add OpenClaw command for architect research timeline UI 2026-07-09 11:39:28 +08:00
openclaw a6f08472dd Add architect research timeline tests 2026-07-09 11:39:22 +08:00
openclaw 9f95029225 Add architect research timeline API 2026-07-09 11:39:13 +08:00
openclaw 4ad4651ffa Add architect research timeline dashboard model 2026-07-09 11:39:05 +08:00
openclaw c0d008fae7 Add OpenClaw command for architect weekly research 2026-07-09 11:33:25 +08:00
openclaw 1974327e32 Add architect research policy 2026-07-09 11:33:19 +08:00
openclaw 110817b60c Add Architect weekly research tests 2026-07-09 11:33:12 +08:00
openclaw 95070ea029 Add Architect research dashboard model 2026-07-09 11:33:09 +08:00
openclaw 44aea3a9a1 Add Architect weekly external research agent 2026-07-09 11:33:03 +08:00
openclaw ce42691be5 Add OpenClaw command for governance timeline UI 2026-07-09 11:31:36 +08:00
openclaw 6f823de67d Add governance timeline tests 2026-07-09 11:31:32 +08:00
openclaw 7443fc7f26 Add governance timeline API 2026-07-09 11:31:28 +08:00
openclaw e1d484655f Add governance timeline dashboard model 2026-07-09 11:31:24 +08:00
openclaw 362a41bff8 Add OpenClaw command for governance event loop 2026-07-09 11:26:22 +08:00
openclaw 62974e879e Add governance event bridge tests 2026-07-09 11:26:19 +08:00
openclaw cd713cd73a Add governance event bus policy 2026-07-09 11:26:16 +08:00
openclaw bd8afc3cc4 Bridge governance agents into unified event bus 2026-07-09 11:26:12 +08:00
openclaw 1688639d7d Add OpenClaw command for architect governance UI 2026-07-09 11:21:41 +08:00
openclaw 60d07f9b17 Add architect governance tests 2026-07-09 11:21:38 +08:00
openclaw 824ff5dc90 Add Architect Governance API 2026-07-09 11:21:35 +08:00
openclaw 3d27f2eb43 Add Architect Governance Web UI dashboard model 2026-07-09 11:21:30 +08:00
openclaw a168610826 Add OpenClaw command for architect governance loop 2026-07-09 11:20:25 +08:00
openclaw 5447476b08 Add architect output document template 2026-07-09 11:20:22 +08:00
openclaw e58d99ba57 Add architect agent governance policy 2026-07-09 11:20:18 +08:00
openclaw 51d6f7e0c2 Extend architect agent governance responsibilities 2026-07-09 11:20:13 +08:00
openclaw 40459af09e Add OpenClaw command for change governance UI 2026-07-09 11:11:17 +08:00
openclaw c7dc61597a Add change governance dashboard tests 2026-07-09 11:11:14 +08:00
openclaw 43d55ac3b1 Add change governance dashboard API 2026-07-09 11:11:11 +08:00
openclaw 736e981005 Add change governance Web UI dashboard model 2026-07-09 11:11:06 +08:00
openclaw b84d6be3ca Add OpenClaw command for change governance agents 2026-07-09 11:09:56 +08:00
openclaw eff4940e9c Add change governance policy for acceptance agents 2026-07-09 11:09:52 +08:00
openclaw 0d6f2ce68d Add independent architecture optimization agent 2026-07-09 11:09:49 +08:00
openclaw a654978813 Add independent user and architecture acceptance agents 2026-07-09 11:09:45 +08:00
openclaw a399e30a8e Add change acceptance governance gate 2026-07-09 11:09:40 +08:00
openclaw e0644c0297 Add OpenClaw validation command for V1R4C1 fixes 2026-07-09 11:06:46 +08:00
openclaw 37eed9df7d Add V1R4C1 risk fix tests 2026-07-09 11:06:42 +08:00
openclaw 22582b834f Move model list from hardcode to configurable registry 2026-07-09 11:06:38 +08:00
openclaw 8a0c5f0556 Add Git repository path resolver and validator 2026-07-09 11:06:33 +08:00
openclaw 62b10d89c2 Create V1R4C1 review risk fixes branch 2026-07-09 11:06:26 +08:00
openclaw 741206597b Merge branch 'patch/agent-observability-webui' 2026-07-09 11:03:37 +08:00
openclaw 36755f5f8b fix: update BARE_REPO path from aios-v23.git to aios.git 2026-07-09 11:03:29 +08:00
openclaw fe36cc9241 feat(version): define V1R4C1 release version
- Group-owner-agent-context-isolation
- Agent Observability complete loop
- Agent Event Bus unified platform
- Agent Command Plane and Audit Plane
- OpenAPI workspace permission fix
2026-07-09 10:59:35 +08:00
gito c1e05f7c4a feat: merge group-owner-agent-context-isolation patch
feat: deploy group-owner-agent-context-isolation
2026-07-09 10:52:27 +08:00
openclaw eb4a3adb3f feat: deploy group-owner-agent-context-isolation patch
- Add agent context isolation API, RBAC, resolver, audit, dashboard modules
- Deploy context isolation policy config
- Fix OpenAPI workspace permissions (chown aiman:gitops)
- Update ChatGPT actions API with new internals
- Add CMD files for group/owner isolation and permission fix
- Archive completed command files
2026-07-09 10:45:21 +08:00
openclaw f1ebd2cf99 Add OpenClaw hook wiring command for agent observability 2026-07-09 10:16:30 +08:00
openclaw cb3b5f1e01 Add closed loop agent observability tests 2026-07-09 10:16:27 +08:00
openclaw 6fc538bf50 Add Agent Event Bus observability bridge 2026-07-09 10:16:23 +08:00
openclaw 665c33f69a Add runtime hooks for agent observability 2026-07-09 10:16:17 +08:00
openclaw 880591fc00 Add OpenClaw command for complete agent observability loop 2026-07-09 10:14:28 +08:00
openclaw 68060fbcba Add agent observability API 2026-07-09 10:14:24 +08:00
openclaw db1ebc29e0 Add persistent agent observability store 2026-07-09 10:14:18 +08:00
openclaw 93b8709c36 Add OpenClaw command for agent observability dashboard 2026-07-09 10:02:45 +08:00
openclaw 7900c4df2e Add agent observability policy 2026-07-09 10:02:40 +08:00
openclaw 8b6fb1c06e Add agent observability Web UI dashboard model 2026-07-09 10:02:35 +08:00
openclaw de8b996dea Add OpenClaw Hermes agent observability model 2026-07-09 10:02:29 +08:00
openclaw eb8d229f28 Create agent observability web UI patch branch 2026-07-09 10:02:20 +08:00
openclaw c93f99da41 feat: add AI evolution dashboard API endpoint
- Serve HTML dashboard at /admin/ai-evolution/
- API endpoint /admin/ai-evolution/api/data returning baseline, versions, commands, agent_runs
- Background agent runner base class for autonomous file operations
- Chat API sync service with real-time message polling and task execution
- Add admin route to aios_chatgpt_actions_api.py, fix missing start_time variable
2026-07-09 00:24:23 +08:00
openclaw c2e782e6fa fix: add admin/ai-evolution route to API, fix missing start_time
- Inserted /admin/ai-evolution endpoint returning baseline, versions, commands, agent_runs
- Added helper functions: baselines(), commands(), agent_runs()
- Added missing start_time module-level variable
- This enables the Web UI dashboard for monitoring AIOS GitOps state
2026-07-09 00:07:16 +08:00
openclaw 7fd4a4aae3 Merge: resolve conflict in aios_chatgpt_actions_api.py, remove patches conflict 2026-07-08 21:07:54 +08:00
openclaw e4bcf47f79 feat(cmd): enable Agent Command Plane, audit plane, and Web UI dashboard
CMD-20260708-fix-git-actions-write-and-enable-audit-plane
CMD-BOOTSTRAP-001-enable-command-plane

- Add admin AI evolution dashboard (Web UI)
- Add Agent Command Plane structure (agents/, cmd/)
- Add audit plane with agent-run records
- Add architecture documentation
- Fix gitops-write-bot token authentication

Baseline: V1R3C1
2026-07-08 21:07:54 +08:00
openclaw 00cc1d9b6d feat(cmd): add final report for autonomous operation enablement
- Add evidence/final-report.md documenting all deliverables
- Web UI dashboard confirmed running at /admin/ai-evolution

Baseline: V1R3C1
2026-07-08 17:24:21 +08:00
openclaw da67ec9714 feat(cmd): enable autonomous operation and notification system
CMD-20260708-enable-aios-autonomous-operation-and-notification

- Add autonomous controller daemon (agents/scripts/autonomous_controller.py)
- Add agent-run audit record (openclaw-20260708-003)
- Register ai-evolution Web UI routes in API service
- Update baseline/promotions/ with V1R4C0-test owner approval
- Update version-history/ with V1R4C0-test promotion record

Components:
  - agents/scripts/autonomous_controller.py - health checks, auto-patching, state mgmt
  - active-runtime/app/aios_chatgpt_actions_api.py - admin/ai-evolution routes
  - audit/agent-runs/ - agent execution audit trail

Baseline: V1R3C1
2026-07-08 17:11:56 +08:00
openclaw 2e93ed904a Add OpenClaw deployment command for unified Agent Event Bus 2026-07-08 16:51:02 +08:00
openclaw b901fa3a5a Add unified Agent Event Bus tests 2026-07-08 16:50:24 +08:00
openclaw b9778f1f69 Add unified Agent Event Bus OpenAPI operation ids 2026-07-08 16:50:07 +08:00
openclaw 41c4c4f003 Add unified Agent Event Bus configuration 2026-07-08 16:49:57 +08:00
openclaw d119c2d87c Add unified Agent Event Bus admin dashboard model 2026-07-08 16:49:31 +08:00
openclaw 0a143c6b98 Add OpenClaw deploy command for unified Agent Event Bus 2026-07-08 16:37:04 +08:00
openclaw 7ef76b739f Add unified Agent Event Bus configuration 2026-07-08 16:37:00 +08:00
openclaw a46878f9b8 Add unified Agent Event Bus admin dashboard 2026-07-08 16:36:56 +08:00
openclaw f73c7946d0 Add unified Agent Event Bus Actions API with compatibility adapters 2026-07-08 16:36:53 +08:00
openclaw ecd7ec40dd Implement unified Agent Event Bus 2026-07-08 16:36:47 +08:00
openclaw 82bf1bd118 Create unified agent event bus implementation branch 2026-07-08 16:36:38 +08:00
337 changed files with 38392 additions and 44 deletions
@@ -0,0 +1,3 @@
d23bb1ec24a7f49359314e04cd2c04c8f38876e45f25a701506499d1b34f270f MANIFEST-AIOS-V2R1-HERMES-LIFECYCLE-20260721.json
5ce2894fdf8a22622954a1a10fb09fbab74af04f0260b249615c70236ff31731 RESULT-AIOS-V2R1-HERMES-LIFECYCLE-20260721.md
ca2ab3c22c2b2b33bbc03e59f1206b999ad1271de3e1938fd3a0b4234c613eef ../../../docs/aios-v2/AIOS-V2R1-HARDENING-GATE-A-REPORT.md
@@ -0,0 +1,44 @@
{
"schema_version": "aios.evidence.manifest.v1",
"generated_at": "2026-07-21T05:50:00Z",
"branch": "patch/chatgpt/AIOS-V2R1-HERMES-LIFECYCLE-20260721",
"validated_oid": "95d2e52cb55c7b913e89bc6bed2bcdacf67512d4",
"evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1-hermes-lifecycle-20260721T054205Z",
"evidence_hashes": {
"steps.tsv": "b2ad20585ba7f576e85657827a0b1db6f252887bbd73bcd718df38623e9d95cf",
"final-live-gate.log": "82040c674759dacf587658c5e936c313a1f50e8e2425be48ad891927befdf4fa",
"16R.log": "641246927a372ebaa9e6dde8b018b88275b2ba15a442473759b23fb1f215d9cf",
"15.log": "b0a474e2a74164f560c4f7572091911f32011c75faccd37a3c7315046275011e",
"13.log": "b9f637cca6e97ed893bf38530e05cd33b78e324c5e8865d4c602dfeac22a7710",
"12R2.log": "0c431f5f5f1c1b17acda160c917b08c7f4036485016c23e1236b9b6f4a78de67"
},
"validation": {
"candidate_tests": {"passed": 49, "failed": 0},
"hermes_lifecycle": {"passed": 17, "failed": 0},
"gate_b": {"passed": 10, "failed": 0},
"offline_acceptance": {"passed": 8, "failed": 0, "expected_warnings": 1},
"openclaw_real_http": "PASS",
"restart_recovery": "PASS",
"rollback_forward": "PASS",
"state_preservation": "PASS",
"final_health_corrected_16R": "PASS"
},
"git_gate": {
"oid_match": true,
"candidate_clean": true,
"active_controller_clean": true,
"locked": 0,
"prunable": 0,
"worktree_total": 88,
"worktree_dirty": 10,
"global_worktree_clean": false
},
"resolved_blockers": [],
"active_blockers": [
"BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING",
"BLK-GLOBAL-WORKTREE-CLEANLINESS"
],
"overall": "PARTIAL / BLOCKED_GLOBAL_WORKTREE_CLEANLINESS",
"release_decision": "STOP_PREPRODUCTION",
"secrets_recorded": false
}
@@ -0,0 +1,16 @@
# AIOS V2R1 Hermes Lifecycle Result
- Branch: `patch/chatgpt/AIOS-V2R1-HERMES-LIFECYCLE-20260721`
- Validated OID: `95d2e52cb55c7b913e89bc6bed2bcdacf67512d4`
- Evidence root: `/var/lib/aios-gitops/acceptance/aios-v2r1-hermes-lifecycle-20260721T054205Z`
- Hermes blocker: `EVIDENCE_COMPLETE / FORMAL_CLOSURE_BLOCKED`
- Overall: `PARTIAL / BLOCKED_GLOBAL_WORKTREE_CLEANLINESS`
- Release decision: `STOP_PREPRODUCTION`
Hermes lifecycle `17/17`, candidate `49/49`, Gate B `10/10`, offline acceptance `8/0/1`, OpenClaw real HTTP, restart recovery, rollback/forward, state preservation and corrected final health all passed at the validated OID. The Owner acceptance contract nevertheless forbids formally closing the Hermes blocker until every global gate also passes.
The candidate worktree and active Controller worktree are clean; local, worktree and Gitea OIDs match; locked/prunable are zero. The global authority currently has 10 dirty worktrees out of 88, so the Owner hard gate requiring every worktree to be clean fails.
No RC, Tag Approval Request, Tag, main mutation, force push, production traffic switch or secret disclosure was performed.
See `MANIFEST-AIOS-V2R1-HERMES-LIFECYCLE-20260721.json` and `docs/aios-v2/AIOS-V2R1-HARDENING-GATE-A-REPORT.md`.
@@ -0,0 +1,156 @@
# V2R1 Gate 1 — Clean-Room Final Evidence
## Summary
| Field | Value |
|---|---|
| Date | 2026-07-14 |
| Source commit | `502ef30642597fb0b70b90ae2e8a0b6af217d80e` |
| Source branch | `origin/work/v2r1-integration-quality-2000` |
| Result branch | `fix/v2r1-g1-cleanroom-final-evidence-r2-20260714` |
| Result commit reference | `HEAD` |
| Gate | `G1` |
| Variant | `openclaw-cleanroom` |
| Status | `PASS` |
The immutable report uses `HEAD` for post-commit verification. The resolved commit object ID is recorded by the external acceptance result after this file is committed; a Git commit cannot reliably embed its own object ID because changing the file changes the tree and commit identity.
## Machine-Readable Result
```yaml
gate: G1
variant: openclaw-cleanroom
date: 2026-07-14
status: PASS
source_commit: 502ef30642597fb0b70b90ae2e8a0b6af217d80e
source_branch: origin/work/v2r1-integration-quality-2000
result_branch: fix/v2r1-g1-cleanroom-final-evidence-r2-20260714
result_commit_ref: HEAD
tests_total: 32
tests_passed: 32
tests_failed: 0
cleanup_scan: PASS
secret_scan: PASS
rollback_status: PASS
recovery_verified: true
workspace_clean_at_test_completion: true
merge_performed: false
tag_created: false
baseline_promoted: false
deployment_performed: false
```
## Evidence Provenance
The test, cleanup, secret-scan, rollback, and recovery results below are execution evidence supplied by OpenClaw for the Gate 1 run. This correction commit changes only the evidence report and does not claim that the GitOps API reran the suites.
## HMAC Test-Key Length Verification
| Test file | Variable | Test-only value | Length |
|---|---|---|---:|
| `tests/v2r1/test_runtime_wiring.py:24` | `internal_key` | `b"runtime-internal-context-key-is-32bytes"` | 39 bytes |
| `tests/v2r1/test_runtime_wiring.py:25` | `ingress_key` | `b"runtime-external-ingress-key-is-32bytes"` | 39 bytes |
Both values are test fixtures. The supplied evidence reports that no production secret was introduced.
## Test Results Supplied by OpenClaw
### V2R1 suite — 29 of 29 passed
```shell
PYTHONPATH=active-runtime/app python3 -m pytest \
tests/v2r1/test_hermes_fabric.py \
tests/v2r1/test_message_ledger_append.py \
tests/v2r1/test_router_isolation.py \
tests/v2r1/test_runtime_identity_binding.py \
tests/v2r1/test_runtime_wiring.py \
tests/v2r1/test_tenant_context.py -v
# Reported result: 29 passed, exit code 0
```
### Integration suite — 3 of 3 passed
```shell
PYTHONPATH=active-runtime/app python3 -m pytest tests/integration/test_runtime_ingress_flow.py -v
# Reported result: 3 passed, exit code 0
```
**Reported total: 32 of 32 passed.**
## Cleanup, Security, and Recovery Evidence
| Check | Supplied result |
|---|---|
| Conflict-marker scan | PASS |
| Cleanup scan | PASS |
| Secret scan | PASS |
| Legitimate hardcoded test keys | Two 39-byte fixtures in `test_runtime_wiring.py` |
| Rollback rehearsal | PASS; 13 of 13 core tests reported after rollback to `6077331` |
| Recovery verification | PASS |
## Deployment State
| Operation | Performed |
|---|---|
| Merge | No |
| Tag creation | No |
| Baseline promotion | No |
| Staging deployment | No |
| Production deployment | No |
## Post-Commit Verification
Run from the repository root after the commit is created:
```shell
set -eu
SOURCE=502ef30642597fb0b70b90ae2e8a0b6af217d80e
RESULT_FILE=.agent/results/openclaw/RESULT-V2R1-G1-OPENCLAW-FINAL-EVIDENCE-20260714.md
git rev-parse HEAD
test "$(git diff --name-only "$SOURCE" HEAD | wc -l | tr -d ' ')" = "1"
test "$(git diff --name-only "$SOURCE" HEAD)" = "$RESULT_FILE"
git ls-tree -r HEAD -- "$RESULT_FILE"
python3 - "$RESULT_FILE" <<'PY'
from pathlib import Path
import re
import sys
path = Path(sys.argv[1])
text = path.read_text(encoding="utf-8")
encoded_tokens = [
[84, 66, 68],
[80, 69, 78, 68, 73, 78, 71],
[84, 79, 68, 79],
[70, 73, 88, 77, 69],
[91, 123, 99, 111, 109, 109, 105, 116, 125, 93],
]
tokens = ["".join(chr(value) for value in item) for item in encoded_tokens]
angle_token = re.compile(chr(60) + r"[^>\n]+" + chr(62))
hits = [token for token in tokens if token in text]
if angle_token.search(text):
hits.append("angle-delimited-token")
if hits:
print("TOKEN_SCAN=FAIL", hits)
raise SystemExit(1)
print("TOKEN_SCAN=PASS")
PY
```
## Acceptance Conditions
Gate 1 is eligible to proceed only when the external acceptance record confirms all of the following against the resolved `HEAD` commit:
- the commit exists and has source commit `502ef30642597fb0b70b90ae2e8a0b6af217d80e` as its parent;
- exactly this one report file changed;
- the committed report contains no unresolved legacy token;
- the result tree is not identical to rejected commit `71239b4d187e683e05fd22fc9d5df8ae324e3390`;
- no merge, tag, baseline promotion, or deployment occurred.
@@ -0,0 +1,12 @@
# V2R1C1 Cleanup Closure
- `CLEANUP_REVIEW=PASS`
- `ARCHIVE_VALIDATION=PASS`
- `GLOBAL_WORKTREE_CLEAN=true`
- `global_dirty_count=0`
- `locked=0`
- `prunable=0`
- Portal Archive OID: `99671cc14d2631f989e0763dc882e4001c50a1ea`
- Spark Archive OID: `d97cd3642528bd3373fe4cf5161a5d99c0b9285f`
Closure evidence: `/var/lib/aios-gitops/acceptance/aios-v2r1c1-blocker-clear-20260721T075647Z`.
@@ -0,0 +1,57 @@
{
"schema_version": "aios.v2r1c1.deployment-manifest.v1",
"vrc": "V2R1C1",
"release_branch": "release/AIOS-V2R1C1-20260721",
"old_release_oid": "80c4cbde03a82c30648a09b817bc2f03d5402936",
"validated_release_parent_oid": "0778a31ec073e7c90003ef091f1d093c16bf22e5",
"product_baseline_oid": "fb739f6e8ff9c031d319221ace68dce157694086",
"companion": {
"branch": "patch/openclaw/AIOS-V2R1C1-COMMAND-V2-COMPANION-20260721",
"source_oid": "83f8f878c7da5dd8263f2af2efa80750492e5cb3",
"oid": "12d1c13170e0c5a691d67a9408e739e7a17064a3",
"plugin_version": "2.1.0-rc.4",
"contract_version": "aios.openclaw.command.v2",
"signature_algorithm": "Ed25519",
"verification_key_id": "openclaw-v2r1-candidate-ed25519",
"artifact_sha256": "aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671"
},
"isolated_validation": {
"service": "aios-v2r1c1-openclaw-companion.service",
"account": "aios-v2r1c1-openclaw",
"listen": "127.0.0.1:28789",
"accept_v2": true,
"accept_v1": false,
"public_key_only": true,
"production_traffic_switched": false,
"result": "PASS"
},
"previous_plugin": {
"source_oid": "83f8f878c7da5dd8263f2af2efa80750492e5cb3",
"plugin_version": "2.1.0-rc.3",
"artifact_sha256": "d0fd11be4aec03eee9ca24bf1585d9ef3597254ad4b1db0d0137eab4456b5737"
},
"rollback": {
"production_plugin_unchanged": true,
"failed_isolated_attempts_rolled_back": true,
"previous_plugin_evidence": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z/00-baseline",
"previous_oid": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a",
"isolated_rollback_forward": "PASS",
"production_symlink_modified": false
},
"release_validation": {
"gate_a": "PASS",
"gate_b": "PASS",
"openclaw_v2_live": "PASS",
"openclaw_negative_live": "PASS",
"hermes_live": "PASS",
"restart_recovery": "PASS",
"rollback_forward": "PASS",
"final_health": "PASS",
"secret_violations": 0,
"global_dirty_count": 0,
"sync_status": "fully_synced"
},
"tag_status": "ABSENT",
"preproduction_status": "NOT_STARTED",
"production_status": "NOT_STARTED"
}
@@ -0,0 +1,55 @@
{
"schema_version": "aios.v2r1c1.release-evidence.v2",
"vrc": "V2R1C1",
"semver": "2.1.0-rc.1",
"product_baseline_oid": "fb739f6e8ff9c031d319221ace68dce157694086",
"old_release_oid": "80c4cbde03a82c30648a09b817bc2f03d5402936",
"validated_release_parent_oid": "0778a31ec073e7c90003ef091f1d093c16bf22e5",
"release_branch": "release/AIOS-V2R1C1-20260721",
"release_evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-release-20260721",
"companion_evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z",
"openclaw_companion": {
"branch": "patch/openclaw/AIOS-V2R1C1-COMMAND-V2-COMPANION-20260721",
"source_oid": "83f8f878c7da5dd8263f2af2efa80750492e5cb3",
"companion_oid": "12d1c13170e0c5a691d67a9408e739e7a17064a3",
"plugin_version": "2.1.0-rc.4",
"contract_version": "aios.openclaw.command.v2",
"signature_algorithm": "Ed25519",
"verification_key_id": "openclaw-v2r1-candidate-ed25519",
"artifact_sha256": "aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671"
},
"step13": {
"original_result": "FAIL_HTTP_401",
"original_diagnosis_manifest_sha256": "320017cbfc0f31392b4d0fb5996d0e32709982a967d102592ac45efdbdaad91f",
"remediated_result": "PASS",
"online_negative_evidence_sha256": "57ab8efd19d3bf659a65cbeda830785f6cea9172925a3cac9e027b9b842e644f",
"remediated_evidence_sha256": "5db59096c0586fcc0a51851d9a25d0a0b94f2bade45063fab16cc2a729f23130",
"task_state": "COMPLETED",
"dispatch_state": "COMPLETED",
"task_dispatch_audit_consistency": "PASS"
},
"release_validation": {
"static_revalidation": "PASS",
"static_evidence_sha256": "c33ffaf9a0f509d6e78bdfdc0848940c166b8a800940e259b7196adab04ec604",
"gate_a": "PASS",
"gate_b": "PASS",
"documentation_sync": "PASS",
"secret_scan_violations": 0,
"release_metadata": "PASS",
"hermes_live": "PASS",
"hermes_evidence_sha256": "e6553c99db55c3cdf9e16a4c1e6712064e00c41dc9965d0d179b6b1a2a1ad3ac",
"restart_recovery": "PASS",
"restart_evidence_sha256": "6031c92cd890dffd1de28ba75d81bed908e7da11a24aa129bcfde8efb23f0149",
"rollback_forward": "PASS",
"rollback_evidence_sha256": "f3d49c54e3f722facf68df2da3d82a9c82eb576f2c7c8dacb0f62c424d25737c",
"final_health": "PASS",
"final_health_evidence_sha256": "121cd52c0a4852b818e06be69211ae18b9fcccf976fa064e2a09c54eb8a01300",
"global_dirty_count": 0,
"sync_status": "fully_synced"
},
"validation_status": "PASS",
"tag_status": "ABSENT",
"preproduction_status": "NOT_STARTED",
"production_status": "NOT_STARTED",
"secrets_recorded": false
}
@@ -0,0 +1,52 @@
# V2R1C1 Validation Result
Status: `RELEASE_VALIDATION_PASS / FINAL_METADATA_COMMIT_REVALIDATION_REQUIRED`
Release decision: `STOP_PREPRODUCTION`
## OpenClaw Command v2 Companion
- Source OID: `83f8f878c7da5dd8263f2af2efa80750492e5cb3`
- Companion OID: `12d1c13170e0c5a691d67a9408e739e7a17064a3`
- Plugin version: `2.1.0-rc.4`
- Contract: `aios.openclaw.command.v2`
- Signature: `Ed25519`
- Artifact SHA256: `aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671`
- Companion unit/contract tests: `PASS`
- V1 fallback: `REJECTED`
- Isolated Gateway health: `PASS`
- Online positive/negative contract: `PASS`
- AIOS -> OpenClaw real HTTP: `PASS`
- Task state: `COMPLETED`
- Dispatch state: `COMPLETED`
- Task/Dispatch/Audit consistency: `PASS`
## Evidence linkage
- Original failed Release OID: `80c4cbde03a82c30648a09b817bc2f03d5402936`
- Original HTTP 401 diagnosis: `/var/lib/aios-gitops/acceptance/aios-v2r1c1-release-20260721/pass1-80c4cbde/13-diagnosis`
- Original manifest SHA256: `320017cbfc0f31392b4d0fb5996d0e32709982a967d102592ac45efdbdaad91f`
- Companion evidence root: `/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z`
- Validated Release parent OID: `0778a31ec073e7c90003ef091f1d093c16bf22e5`
- Online negative evidence SHA256: `57ab8efd19d3bf659a65cbeda830785f6cea9172925a3cac9e027b9b842e644f`
- Remediated Step 13 evidence SHA256: `5db59096c0586fcc0a51851d9a25d0a0b94f2bade45063fab16cc2a729f23130`
## Release validation closure
- Gate A: `PASS`
- Gate B: `PASS`
- Documentation sync: `PASS`
- Secret violations: `0`
- Release Metadata: `PASS`
- Hermes live: `PASS`
- Restart recovery: `PASS`
- Previous ↔ V2R1C1 isolated rollback/forward: `PASS`
- Final health: `PASS`
- Global dirty / locked / prunable: `0 / 0 / 0`
- OID sync: `fully_synced`
- Production traffic switched: `false`
- Tag: `ABSENT`
## Remaining mandatory validation
本次最终元数据闭环提交将形成新的 Release OID。必须针对该最终 OID 再次复验 Gate A、Gate B、文档同步、Secret Scan、Release Metadata 和 OpenClaw 第 13 步正向/负向验证。复验通过前不得创建 Tag 或部署预生产/生产。
+1
View File
@@ -10,3 +10,4 @@ node_modules/
runtime/tmp/
runtime/cache/
logs/
config/test_write_perms
+394
View File
@@ -0,0 +1,394 @@
# AIOS V2R1 生产候选报告
- 候选版本:`V2R1C0-candidate`
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
- 报告语言:中文
- 架构负责人:AIOS V2 CTO
- 当前状态:代码与文档闭环已完成;精确 Commit 测试、真实 Runtime 联调和预发布验证待执行
## 1. 执行摘要
AIOS V2R1 Candidate 已完成以下核心建设:
- AIOS 唯一控制面;
- Tenant、Agent、Task、Policy、Approval 和 Audit 权威模型;
- SQLite WAL 候选持久化与重启恢复;
- Execution Orchestrator;
- OpenClaw 签名命令、Adapter、Transport、Health 和 Audit Callback;
- Hermes tenant/cell/profile 分区、Queue、Worker Lease、Heartbeat 和 Result;
- Agent Execution Evidence;
- Web Console;
- GitOps Status;
- systemd、Docker、Compose、Healthcheck 和 Rollback;
- 用户需求、目标架构、迁移策略、模块契约、追踪矩阵、文档治理和 ADR;
- 文档—代码匹配检查器;
- 全量验收入口;
- OpenClaw 预发布部署任务;
- Codex 架构监督和阻塞处理任务。
本报告不把“代码已提交”误报为“生产已验证”。以下状态仍需由无人值守任务执行并形成证据:
- 精确 Candidate Commit 的自动测试;
- 真实 OpenClaw Runtime 联调;
- Hermes 长任务、Worker 崩溃和 Lease Recovery 演练;
- systemd/Docker 预发布部署;
- 重启恢复与回滚演练;
- 本地仓库与 Gitea Candidate OID 一致验证;
- 身份代理、安全、性能和容量验证;
- Owner 对合并、Tag 和生产部署的批准。
## 2. 用户需求完成情况
| 需求 | 代码状态 | 测试状态 | 预发布状态 |
|---|---|---|---|
| AIOS 唯一控制面 | 已实现 | 已定义 | 待验证 |
| 复用 OpenClaw/Hermes/Agent Router | 已实现 | 已定义 | 待真实联调 |
| OpenClaw/Hermes 解耦 | 已实现 | 已定义 | 待旁路冻结验证 |
| 全面 Web 化 | 候选实现完成 | 冒烟测试待执行 | 待反向代理接入 |
| 多租户隔离 | 已实现 | Domain 负向测试已定义 | 待全链路安全验证 |
| 统一任务生命周期 | 已实现 | 已定义 | 待精确 Commit 执行 |
| 策略与审批 | 已实现 | 部分测试待补强 | 待身份代理验证 |
| 幂等与并发安全 | 已实现 | 基础测试已定义 | 待压力和并发验证 |
| 执行证据 | 已实现 | 已定义 | 待外部执行器持续回写 |
| GitOps 唯一流程 | 已实现并修复 Controller 冲突 | Runtime 部分验证 | 待双目标持续镜像证据 |
| 持久化与恢复 | 已实现 | 重启恢复测试已定义 | 待预发布演练 |
| 审计与合规 | 已实现候选 | 部分断言待补强 | 待不可变外部固化 |
| 密钥安全 | 引用模式已实现 | 基础扫描纳入验收 | 待 Secret Provider 联调 |
| 部署与回滚 | 资产完成 | 验收脚本已定义 | 待实际演练 |
| 文档与代码同步 | 已实现门禁 | 待精确 Commit 执行 | 待接入持续验收 |
## 3. 目标架构
```text
浏览器 / Web Console / Public API
|
v
AIOS 唯一控制面
Tenant | Agent | Task | Policy | Approval | Audit
|
v
Execution Orchestrator / Agent Router
/ \
v v
OpenClaw Adapter Hermes Boundary
| |
v v
OpenClaw Runtime Hermes Workers
\ /
v v
结果回写 + 审计 + 执行证据
```
权威边界:
- AIOS:租户、身份、Agent、任务、策略、审批、审计和执行证据;
- Agent Router:调度与路由;
- OpenClaw:签名命令执行、取消、状态、健康和回调;
- Hermes:队列、Worker、Lease、Heartbeat 和结果;
- Web:只调用 AIOS API;
- GitOps:所有代码、文档、部署和修复变更入口。
## 4. 实现清单
### 4.1 Control Plane
```text
active-runtime/app/aios_v2/control_plane.py
active-runtime/app/aios_v2/persistence.py
active-runtime/app/aios_v2/gateway.py
active-runtime/app/aios_v2/app.py
active-runtime/app/aios_v2/orchestrator.py
active-runtime/app/aios_v2/execution_tracking.py
active-runtime/app/aios_v2/gitops_status.py
active-runtime/app/aios_v2/console.py
```
### 4.2 OpenClaw
```text
active-runtime/app/aios_openclaw/contract.py
active-runtime/app/aios_openclaw/adapter.py
active-runtime/app/aios_openclaw/transport.py
active-runtime/app/aios_openclaw/health.py
active-runtime/app/aios_openclaw/callback.py
```
### 4.3 Hermes
```text
active-runtime/app/aios_hermes/contract.py
active-runtime/app/aios_hermes/adapter.py
active-runtime/app/aios_hermes/worker.py
```
### 4.4 测试与治理
```text
tests/aios_v2_candidate/test_candidate_flow.py
tools/aios_v2_docs_code_check.py
deploy/aios-v2/acceptance/run-all.sh
```
### 4.5 部署
```text
deploy/aios-v2/systemd/aios-v2.service
deploy/aios-v2/env/aios-v2.env.example
deploy/aios-v2/docker/Dockerfile
deploy/aios-v2/docker/compose.yaml
deploy/aios-v2/healthcheck/check.sh
deploy/aios-v2/rollback/rollback.sh
```
### 4.6 文档与任务
```text
docs/aios-v2/
tasks/openclaw-production-deploy-task.yaml
tasks/codex-production-supervisor-task.yaml
```
## 5. 多租户与安全设计
### 5.1 权威上下文
```text
tenant_id
principal_id
session_id
trace_id
task_id
policy_version
idempotency_key
```
### 5.2 OpenClaw
- HMAC-SHA256 规范化签名;
- 签名绑定 tenant/task/trace/action/payload/idempotency/time;
- Runtime Result 身份字段必须匹配;
- Secret 只通过受保护文件或引用解析。
### 5.3 Hermes
- tenant/cell/profile 分区;
- Worker Lease 与 Heartbeat;
- revision 乐观并发;
- Lease 过期自动回到 QUEUED;
- Worker 完成前停止 Heartbeat,避免 revision 竞态。
### 5.4 Web/API
- 可信代理注入 `X-AIOS-*`;
- 浏览器不选择租户;
- CSP、no-store、nosniff、DENY frame、no-referrer;
- 应用端口不得直接暴露公网。
### 5.5 GitOps
- Controller detached + clean;
- 禁止 fetch 到已检出本地分支;
- 所有修改进入分支、Commit、测试和统一审查;
- 明文 Secret 门禁;
- 文档变化与代码变化同步门禁。
## 6. 持久化策略
候选版:
- SQLite WAL;
- `synchronous=FULL`;
- `BEGIN IMMEDIATE`;
- 原子 Snapshot;
- schema version;
- 状态目录与代码版本分离;
- Execution Evidence 租户分区原子 JSON。
已知限制:
- 单进程候选优先;
- 尚无生产 RLS、多实例 HA、PITR 和容量证据;
- 后续必须通过新 ADR 迁移到生产数据层。
## 7. 测试与验收
### 7.1 文档—代码匹配
```bash
python3 tools/aios_v2_docs_code_check.py \
--base-ref integration/aios-v2r1-cto-production-candidate-20260720
```
### 7.2 候选测试
```bash
python3 -m unittest discover \
-s tests/aios_v2_candidate \
-p 'test_*.py' \
-v
```
覆盖:
- OpenClaw 完整执行;
- Hermes 完整执行;
- 跨租户拒绝;
- SQLite 重启恢复;
- Execution Evidence。
### 7.3 全量验收
```bash
bash deploy/aios-v2/acceptance/run-all.sh
```
可选在线健康检查:
```bash
AIOS_RUN_LIVE_HEALTHCHECK=1 \
bash deploy/aios-v2/acceptance/run-all.sh
```
### 7.4 健康检查
```bash
bash deploy/aios-v2/healthcheck/check.sh
```
检查:
- systemd;
- HTTP Health;
- 状态目录;
- SQLite quick_check;
- GitOps detached/clean;
- Evidence 文件。
## 8. 部署方式
### 8.1 systemd
```text
/etc/aios-v2/aios-v2.env
/opt/aios/current
/var/lib/aios-v2
```
运行用户:`aios`。
安全选项:NoNewPrivileges、ProtectSystem、ProtectHome、PrivateTmp、Capability 限制。
### 8.2 Docker/Compose
- 非 root UID/GID;
- Read-only root filesystem;
- Drop all capabilities;
- 独立 State Volume;
- HTTP Healthcheck;
- Secret 文件只读挂载。
## 9. 回滚
```bash
bash deploy/aios-v2/rollback/rollback.sh <previous-release>
```
原则:
- 回滚前自动备份状态;
- 默认只回滚代码;
- 状态恢复需显式 `AIOS_V2_RESTORE_STATE=1`;
- 回滚后必须执行健康检查;
- 目标版本失败时自动恢复 previous release。
## 10. 无人值守任务
### OpenClaw
任务:`tasks/openclaw-production-deploy-task.yaml`
职责:
- 预发布部署;
- 健康检查;
- Runtime 联调;
- 小型部署与 Runtime Bug 修复;
- 证据回写。
### Codex
任务:`tasks/codex-production-supervisor-task.yaml`
职责:
- 架构监督;
- 阻塞分析和跨模块修复;
- 文档、测试和安全审查;
- 发布建议。
所有修改和阻塞必须进入 Git 和 Execution Evidence,最终统一审视。
## 11. 当前已知风险
| 风险 | 级别 | 当前处理 |
|---|---|---|
| 精确 Candidate Commit 尚未执行全部测试 | 高 | OpenClaw 执行,Codex 监督 |
| 真实 OpenClaw Runtime 尚未联调 | 高 | 预发布任务必须完成 |
| 候选 SQLite 不支持生产多实例 HA | 高 | Candidate 单实例;后续 ADR 迁移 |
| 身份代理 Header 防伪尚未环境验证 | 高 | 预发布安全检查 |
| Web Console 为候选只读 MVP | 中 | 生产前补充 E2E 与安全验证 |
| 部分策略负向、并发和审计测试待补强 | 中 | Codex 登记 blocker 并补充 |
| 本地/Gitea 自动双写状态仍需持续证明 | 中 | 验收记录 OID |
| 性能、容量和 SLO 尚无实测 | 中 | 预发布基准测试 |
| 外部不可变审计固化尚未实现 | 中 | 生产数据层阶段完成 |
## 12. 发布 Gate
### 允许进入预发布
条件:
- Candidate 分支可见;
- 文档完整;
- 任务合同已生成;
- 部署资产存在;
- 无明文 Secret。
### 允许进入合并审查
条件:
- 全量验收通过;
- OpenClaw/Hermes 联调通过;
- 重启和回滚通过;
- 严重/高风险 blocker 已解决;
- Codex 输出正向监督结论;
- 本地/Gitea OID 一致。
### 允许生产发布
条件:
- Owner 明确批准;
- Merge 与 Tag 通过受控流程;
- 生产配置、Secret 和反向代理准备完成;
- 发布窗口、观察指标、回滚负责人明确。
## 13. 当前结论
**代码和正式中文文档已经达到候选闭环状态,但尚不能宣称生产已验证。**
下一阶段由 OpenClaw 执行预发布部署和低风险 Bug 修复,由 Codex 监督架构、文档、安全、测试和阻塞。所有过程证据统一进入系统,满足 Gate 后再请求 Owner 批准。
## 14. 更新规则
以下变化必须更新本报告:
- Candidate Commit;
- 测试结果;
- 部署和回滚结果;
- Blocker 和风险;
- OpenClaw/Codex 任务状态;
- 文档—代码匹配状态;
- Gitea OID;
- 发布建议和 Owner 决策。
+8
View File
@@ -0,0 +1,8 @@
# Changelog
## V2R1C1 - 2.1.0-rc.1
- 固定发布基线为 `fb739f6e8ff9c031d319221ace68dce157694086`。
- 收口 Hermes 生命周期、Grant 隔离、Dispatch 恢复、运行证据和回滚/前滚验证。
- Portal Adapter 与 Spark Provisioning 仅保留在 Archive,不属于产品发布图。
- Future Incubator 保持隔离,不属于 V2R1C1 发布输入。
+1 -1
View File
@@ -1 +1 @@
V1R1C4
V2R1C1
+56
View File
@@ -0,0 +1,56 @@
"""API adapter for agent context routing and visibility."""
from typing import Any
try:
from fastapi import APIRouter
except Exception:
APIRouter = None
from .agent_context_dashboard import AgentContextDashboard
from .agent_context_rbac import AgentContextRBAC
from .agent_context_resolver import AgentContextResolver
resolver = AgentContextResolver()
rbac = AgentContextRBAC()
dashboard = AgentContextDashboard()
def resolve_agent_context(payload: dict[str, Any]) -> dict[str, Any]:
route = rbac.route(payload["user_id"], payload.get("group_id"), payload.get("role", "user"))
context = resolver.resolve(
user_id=payload["user_id"],
group_id=payload.get("group_id", ""),
session=payload.get("session"),
user_profile=payload.get("user_profile"),
group_policy=payload.get("group_policy"),
agent_default=payload.get("agent_default"),
owner_policy=payload.get("owner_policy"),
owner=payload.get("role") == "owner",
)
return {"routing": route.to_dict(), "context": context.to_dict()}
def get_context_dashboard() -> dict[str, Any]:
return dashboard.overview()
def build_router():
if APIRouter is None:
return None
router = APIRouter(prefix="/admin/ai-evolution/context", tags=["context-isolation"])
@router.post("/resolve", operation_id="resolveAgentContext")
def resolve(payload: dict[str, Any]):
return resolve_agent_context(payload)
@router.get("/overview", operation_id="getAgentContextDashboard")
def overview():
return get_context_dashboard()
return router
router = build_router()
__all__ = ["router", "resolve_agent_context", "get_context_dashboard"]
+23
View File
@@ -0,0 +1,23 @@
"""Audit context resolution decisions."""
from datetime import datetime, timezone
from typing import Any
def utc_now():
return datetime.now(timezone.utc).isoformat()
class AgentContextAudit:
def record(self, context: dict[str, Any]) -> dict[str, Any]:
return {
"event": "context_loaded",
"agent_id": context.get("agent_id"),
"user_id": context.get("user_id"),
"group_id": context.get("group_id"),
"layers": [x.get("name") for x in context.get("layers", [])],
"timestamp": utc_now(),
}
__all__ = ["AgentContextAudit"]
@@ -0,0 +1,47 @@
"""Web UI model for context isolation visibility."""
from __future__ import annotations
from typing import Any
class AgentContextDashboard:
def overview(self, contexts: list[dict[str, Any]] | None = None) -> dict[str, Any]:
contexts = contexts or []
return {
"module": "agent-context-isolation",
"route": "/admin/ai-evolution/context",
"title": "Agent Context Isolation",
"panels": [
{
"name": "agent-routing",
"title": "Agent 路由",
"data": contexts,
},
{
"name": "context-layers",
"title": "Context 来源层级",
"layers": [
"safety_policy",
"owner_policy",
"group_policy",
"role_policy",
"user_profile",
"session_context",
"agent_default",
],
},
{
"name": "audit",
"title": "Context 审计",
"events": [
"context_loaded",
"profile_updated",
"agent_selected",
],
},
],
}
__all__ = ["AgentContextDashboard"]
+55
View File
@@ -0,0 +1,55 @@
"""RBAC binding for group main agents and owner agents."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any
@dataclass(frozen=True)
class AgentRoutingDecision:
user_id: str
group_id: str
role: str
agent_id: str
scope: str
allowed: bool
reason: str
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class AgentContextRBAC:
"""Routes users to group agents and owners to dedicated owner agents."""
VALID_ROLES = {"user", "group_admin", "owner"}
def route(self, user_id: str, group_id: str | None, role: str) -> AgentRoutingDecision:
if role not in self.VALID_ROLES:
return AgentRoutingDecision(user_id, group_id or "", role, "", "none", False, "unknown_role")
if role == "owner":
return AgentRoutingDecision(
user_id=user_id,
group_id=group_id or "global",
role=role,
agent_id=f"owner-agent:{user_id}",
scope="global",
allowed=True,
reason="owner_dedicated_agent",
)
if not group_id:
return AgentRoutingDecision(user_id, "", role, "", "none", False, "group_required")
scope = "group" if role == "group_admin" else "self"
return AgentRoutingDecision(
user_id=user_id,
group_id=group_id,
role=role,
agent_id=f"group-main-agent:{group_id}",
scope=scope,
allowed=True,
reason="group_agent_routing",
)
__all__ = ["AgentRoutingDecision", "AgentContextRBAC"]
@@ -0,0 +1,82 @@
"""Multi-user agent context resolver.
Architecture:
- shared agent kernel
- group main agent
- owner dedicated agent
- user profile overlay
- session overlay
Personal preferences never enter shared USER.md.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class ContextLayer:
name: str
priority: int
data: dict[str, Any]
@dataclass
class EffectiveContext:
agent_id: str
user_id: str
group_id: str
layers: list[ContextLayer]
resolved: dict[str, Any]
resolved_at: str = field(default_factory=utc_now)
def to_dict(self):
return {
"agent_id": self.agent_id,
"user_id": self.user_id,
"group_id": self.group_id,
"layers": [asdict(x) for x in self.layers],
"resolved": self.resolved,
"resolved_at": self.resolved_at,
}
class AgentContextResolver:
"""Resolves context without shared user-state pollution."""
def resolve(
self,
user_id: str,
group_id: str,
session: dict[str, Any] | None = None,
user_profile: dict[str, Any] | None = None,
group_policy: dict[str, Any] | None = None,
agent_default: dict[str, Any] | None = None,
owner_policy: dict[str, Any] | None = None,
owner: bool = False,
) -> EffectiveContext:
layers = [
ContextLayer("agent_default", 10, agent_default or {}),
ContextLayer("group_policy", 30, group_policy or {}),
ContextLayer("user_profile", 50, user_profile or {}),
ContextLayer("session", 70, session or {}),
]
agent_id = f"group-main-agent:{group_id}"
if owner:
agent_id = f"owner-agent:{user_id}"
layers.append(ContextLayer("owner_policy", 90, owner_policy or {}))
resolved: dict[str, Any] = {}
# Higher priority overlays lower priority except safety policies handled upstream.
for layer in sorted(layers, key=lambda x: x.priority):
resolved.update(layer.data)
return EffectiveContext(agent_id, user_id, group_id, layers, resolved)
__all__ = ["ContextLayer", "EffectiveContext", "AgentContextResolver"]
+77
View File
@@ -0,0 +1,77 @@
"""Actions API for unified Agent Event Bus."""
from __future__ import annotations
from typing import Any
try:
from fastapi import APIRouter
except Exception:
APIRouter = None
from .agent_event_bus import AgentEventBus
bus = AgentEventBus()
def create_agent_event(payload: dict[str, Any]) -> dict[str, Any]:
return {"status": "ok", "event": bus.publish(**payload)}
def list_agent_events(kind: str | None = None, status: str = "pending") -> dict[str, Any]:
return {"status": "ok", "events": bus.list_events(kind, status)}
def get_agent_event(event_id: str) -> dict[str, Any]:
return {"status": "ok", "event": bus.get(event_id)}
def transition_agent_event(event_id: str, status: str, actor: str = "system") -> dict[str, Any]:
return {"status": "ok", "event": bus.transition(event_id, status, actor)}
def agent_event_stats() -> dict[str, Any]:
return {"status": "ok", "stats": bus.stats()}
# Compatibility adapters
def list_gpt_inbox_messages():
return list_agent_events(kind="gpt_inbox")
def ack_gpt_inbox_message(event_id: str):
return transition_agent_event(event_id, "completed", "custom-gpt")
def build_router():
if APIRouter is None:
return None
router = APIRouter(prefix="/agent-events", tags=["agent-events"])
@router.post("", operation_id="createAgentEvent")
def create(payload: dict[str, Any]):
return create_agent_event(payload)
@router.get("", operation_id="listAgentEvents")
def listing(kind: str | None = None, status: str = "pending"):
return list_agent_events(kind, status)
@router.get("/{event_id}", operation_id="getAgentEvent")
def get(event_id: str):
return get_agent_event(event_id)
@router.post("/{event_id}/transition", operation_id="transitionAgentEvent")
def transition(event_id: str, payload: dict[str, Any]):
return transition_agent_event(event_id, payload["status"], payload.get("actor", "system"))
@router.get("/stats/overview", operation_id="getAgentEventStats")
def stats():
return agent_event_stats()
return router
router = build_router()
__all__ = ["router", "create_agent_event", "list_agent_events", "get_agent_event", "transition_agent_event"]
+125
View File
@@ -0,0 +1,125 @@
"""Unified Agent Event Bus.
Unifies commands, GPT messages, replies, approvals, audit and reports into one
state machine. Compatibility layers can filter events by kind instead of keeping
separate queues.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from hashlib import sha256
from pathlib import Path
import json
from typing import Any
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass
class AgentEvent:
id: str
kind: str
source: str
target: str
summary: str
payload: dict[str, Any]
status: str = "pending"
correlation_id: str | None = None
created_at: str = field(default_factory=utc_now)
updated_at: str = field(default_factory=utc_now)
actor: str | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class AgentEventBus:
STATES = ("pending", "running", "completed", "failed")
def __init__(self, root: str = "agents/events"):
self.root = Path(root)
for state in self.STATES:
(self.root / state).mkdir(parents=True, exist_ok=True)
(self.root / "audit").mkdir(parents=True, exist_ok=True)
def publish(
self,
kind: str,
source: str,
target: str,
summary: str,
payload: dict[str, Any],
correlation_id: str | None = None,
) -> dict[str, Any]:
event_id = "evt_" + sha256(
json.dumps(
{"kind": kind, "source": source, "summary": summary, "payload": payload},
sort_keys=True,
ensure_ascii=False,
).encode("utf-8")
).hexdigest()[:16]
event = AgentEvent(event_id, kind, source, target, summary, payload, correlation_id=correlation_id)
self._write("pending", event.to_dict())
self._audit("created", event.to_dict())
return event.to_dict()
def list_events(self, kind: str | None = None, status: str = "pending", limit: int = 100) -> list[dict[str, Any]]:
result = []
for path in sorted((self.root / status).glob("*.json")):
event = self._read(path)
if kind and event.get("kind") != kind:
continue
result.append(event)
return result[:limit]
def get(self, event_id: str) -> dict[str, Any] | None:
for state in self.STATES:
path = self.root / state / f"{event_id}.json"
if path.exists():
return self._read(path)
return None
def transition(self, event_id: str, status: str, actor: str | None = None) -> dict[str, Any]:
event = self.get(event_id)
if not event:
return {"status": "not_found", "id": event_id}
if status not in self.STATES:
return {"status": "invalid_state"}
old = event.get("status", "pending")
old_path = self.root / old / f"{event_id}.json"
event["status"] = status
event["actor"] = actor
event["updated_at"] = utc_now()
self._write(status, event)
if old_path.exists() and old != status:
old_path.unlink()
self._audit("transition", event, {"from": old, "to": status})
return event
def stats(self) -> dict[str, Any]:
return {
"events": {state: len(list((self.root / state).glob("*.json"))) for state in self.STATES},
"audit_events": len(list((self.root / "audit").glob("*.json"))),
"status": "healthy",
}
def _write(self, state: str, data: dict[str, Any]):
(self.root / state / f"{data['id']}.json").write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
def _read(self, path: Path):
return json.loads(path.read_text(encoding="utf-8"))
def _audit(self, action: str, event: dict[str, Any], extra: dict[str, Any] | None = None):
data = {"action": action, "event": event, "extra": extra or {}, "time": utc_now()}
(self.root / "audit" / f"{event['id']}-{action}.json").write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
__all__ = ["AgentEvent", "AgentEventBus"]
@@ -0,0 +1,50 @@
"""Admin dashboard data model for the unified Agent Event Bus."""
from __future__ import annotations
from collections import Counter
from typing import Any
from .agent_event_bus import AgentEventBus
class AgentEventDashboard:
def __init__(self, bus: AgentEventBus | None = None):
self.bus = bus or AgentEventBus()
def overview(self) -> dict[str, Any]:
stats = self.bus.stats()
pending = self.bus.list_events(status="pending")
running = self.bus.list_events(status="running")
completed = self.bus.list_events(status="completed")
failed = self.bus.list_events(status="failed")
all_visible = pending + running + completed + failed
by_kind = Counter(event.get("kind", "unknown") for event in all_visible)
by_target = Counter(event.get("target", "unknown") for event in all_visible)
return {
"module": "agent-event-bus",
"title": "Unified Agent Event Bus",
"route": "/admin/ai-evolution/events",
"metrics": {
**stats,
"by_kind": dict(by_kind),
"by_target": dict(by_target),
"gpt_inbox_pending": by_kind.get("gpt_inbox", 0),
"openclaw_pending": len([e for e in pending if e.get("target") == "openclaw"]),
},
"panels": [
"event timeline",
"pending commands",
"GPT inbox",
"agent replies",
"failure reports",
"audit trail",
],
"audit": {
"enabled": True,
"location": "agents/events/audit",
},
}
__all__ = ["AgentEventDashboard"]
@@ -0,0 +1,37 @@
"""Bridge Agent Event Bus events into Agent Observability metrics."""
from .agent_observability_hooks import AgentObservabilityHooks
class AgentEventObservabilityBridge:
def __init__(self, hooks: AgentObservabilityHooks | None = None):
self.hooks = hooks or AgentObservabilityHooks()
def consume(self, event: dict):
kind = event.get("kind")
payload = event.get("payload", {})
if kind == "agent_started":
return self.hooks.start(
user_id=payload.get("user_id", "unknown"),
visible_scope=payload.get("scope", "self"),
entry_agent=payload.get("agent", "unknown"),
execution_id=event.get("id"),
)
if kind == "agent_delegated":
return self.hooks.delegate(event["execution_id"], payload.get("agent", "unknown"))
if kind == "token_consumed":
return self.hooks.tokens(
event["execution_id"],
payload.get("input_tokens", 0),
payload.get("output_tokens", 0),
)
if kind in {"agent_completed", "agent_failed"}:
return self.hooks.finish(
event["execution_id"],
payload.get("closed_by"),
"failed" if kind == "agent_failed" else "completed",
)
return {"status": "ignored", "kind": kind}
__all__ = ["AgentEventObservabilityBridge"]
+80
View File
@@ -0,0 +1,80 @@
"""Agent orchestration observability model.
Tracks OpenClaw, Hermes and sub-agent execution chains. Token usage is optional
and depends on model adapter instrumentation.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any
def utc_now():
return datetime.now(timezone.utc).isoformat()
@dataclass
class AgentExecutionRecord:
execution_id: str
user_id: str
visible_scope: str
entry_agent: str
path: list[str]
used_hermes: bool
closed_by: str
duration_ms: int = 0
input_tokens: int | None = None
output_tokens: int | None = None
total_tokens: int | None = None
status: str = "completed"
started_at: str = field(default_factory=utc_now)
ended_at: str = field(default_factory=utc_now)
def to_dict(self):
return asdict(self)
class AgentObservability:
def __init__(self):
self.records: list[AgentExecutionRecord] = []
def record(self, record: AgentExecutionRecord):
self.records.append(record)
return record.to_dict()
def summary(self, user_id: str | None = None) -> dict[str, Any]:
records = [r for r in self.records if not user_id or r.user_id == user_id]
hermes = [r for r in records if r.used_hermes]
direct = [r for r in records if not r.used_hermes]
return {
"total_executions": len(records),
"openclaw_independent_loops": len(direct),
"hermes_orchestrated_loops": len(hermes),
"average_duration_ms": (
sum(r.duration_ms for r in records) / len(records) if records else 0
),
"token_usage": {
"input": sum(r.input_tokens or 0 for r in records),
"output": sum(r.output_tokens or 0 for r in records),
"total": sum(r.total_tokens or 0 for r in records),
},
}
def hermes_tree(self, user_id: str | None = None):
return [
{
"execution_id": r.execution_id,
"entry": r.entry_agent,
"used_hermes": r.used_hermes,
"children": r.path,
"duration_ms": r.duration_ms,
"tokens": r.total_tokens,
}
for r in self.records
if (not user_id or r.user_id == user_id)
]
__all__ = ["AgentExecutionRecord", "AgentObservability"]
@@ -0,0 +1,52 @@
"""API for agent orchestration observability."""
from __future__ import annotations
from typing import Any
try:
from fastapi import APIRouter
except Exception:
APIRouter = None
from .agent_observability import AgentExecutionRecord
from .agent_observability_dashboard import AgentObservabilityDashboard
from .agent_observability_store import AgentObservabilityStore
store = AgentObservabilityStore()
dashboard = AgentObservabilityDashboard(store.load())
def record_agent_execution(payload: dict[str, Any]):
record = AgentExecutionRecord(**payload)
return {"status": "ok", "record": store.append(record)}
def get_agent_observability(user_id: str | None = None, group_id: str | None = None, role: str = "user"):
records = store.query(user_id, group_id, role)
return {
"status": "ok",
"records": records,
"summary": store.load().summary(user_id if role != "owner" else None),
}
def build_router():
if APIRouter is None:
return None
router = APIRouter(prefix="/admin/ai-evolution/agents", tags=["agent-observability"])
@router.post("/execution", operation_id="recordAgentExecution")
def record(payload: dict[str, Any]):
return record_agent_execution(payload)
@router.get("/overview", operation_id="getAgentObservabilityOverview")
def overview(user_id: str | None = None, group_id: str | None = None, role: str = "user"):
return get_agent_observability(user_id, group_id, role)
return router
router = build_router()
__all__ = ["router", "record_agent_execution", "get_agent_observability"]
@@ -0,0 +1,44 @@
"""Web UI dashboard model for agent execution visibility."""
from .agent_observability import AgentObservability
class AgentObservabilityDashboard:
def __init__(self, observability: AgentObservability | None = None):
self.observability = observability or AgentObservability()
def overview(self, user_id: str | None = None):
return {
"route": "/admin/ai-evolution/agents",
"title": "Agent Orchestration Observability",
"summary": self.observability.summary(user_id),
"views": [
{
"name": "scheduler_flow",
"title": "OpenClaw/Hermes 调度流程",
"data": self.observability.hermes_tree(user_id),
},
{
"name": "agent_metrics",
"title": "智能体指标",
"fields": [
"duration_ms",
"input_tokens",
"output_tokens",
"total_tokens",
"status",
],
},
{
"name": "loop_distribution",
"title": "闭环分布",
"fields": [
"openclaw_independent_loops",
"hermes_orchestrated_loops",
],
},
],
}
__all__ = ["AgentObservabilityDashboard"]
@@ -0,0 +1,113 @@
"""Runtime hooks for Agent Observability.
OpenClaw/Hermes/sub-agents can call these hooks at start/end/delegation points.
The hooks store sanitized metadata only: no prompts, no secrets, no private message
content.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from .agent_observability import AgentExecutionRecord
from .agent_observability_store import AgentObservabilityStore
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def parse_time(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
@dataclass
class AgentRunContext:
execution_id: str
user_id: str
visible_scope: str
entry_agent: str
path: list[str] = field(default_factory=list)
used_hermes: bool = False
started_at: str = field(default_factory=utc_now)
input_tokens: int = 0
output_tokens: int = 0
status: str = "running"
def delegate(self, agent_name: str) -> None:
if agent_name not in self.path:
self.path.append(agent_name)
if agent_name.lower() == "hermes":
self.used_hermes = True
def add_tokens(self, input_tokens: int = 0, output_tokens: int = 0) -> None:
self.input_tokens += max(input_tokens, 0)
self.output_tokens += max(output_tokens, 0)
class AgentObservabilityHooks:
def __init__(self, store: AgentObservabilityStore | None = None):
self.store = store or AgentObservabilityStore()
self.active: dict[str, AgentRunContext] = {}
def start(
self,
user_id: str,
visible_scope: str,
entry_agent: str,
execution_id: str | None = None,
) -> dict[str, Any]:
run = AgentRunContext(
execution_id=execution_id or f"exec_{uuid4().hex[:12]}",
user_id=user_id,
visible_scope=visible_scope,
entry_agent=entry_agent,
path=[entry_agent],
used_hermes=entry_agent.lower() == "hermes",
)
self.active[run.execution_id] = run
return {"status": "started", "execution_id": run.execution_id, "run": run.__dict__}
def delegate(self, execution_id: str, agent_name: str) -> dict[str, Any]:
run = self.active[execution_id]
run.delegate(agent_name)
return {"status": "delegated", "execution_id": execution_id, "path": run.path}
def tokens(self, execution_id: str, input_tokens: int = 0, output_tokens: int = 0) -> dict[str, Any]:
run = self.active[execution_id]
run.add_tokens(input_tokens, output_tokens)
return {
"status": "tokens_recorded",
"execution_id": execution_id,
"input_tokens": run.input_tokens,
"output_tokens": run.output_tokens,
"total_tokens": run.input_tokens + run.output_tokens,
}
def finish(self, execution_id: str, closed_by: str | None = None, status: str = "completed") -> dict[str, Any]:
run = self.active.pop(execution_id)
ended_at = utc_now()
duration_ms = int((parse_time(ended_at) - parse_time(run.started_at)).total_seconds() * 1000)
record = AgentExecutionRecord(
execution_id=run.execution_id,
user_id=run.user_id,
visible_scope=run.visible_scope,
entry_agent=run.entry_agent,
path=run.path,
used_hermes=run.used_hermes,
closed_by=closed_by or run.path[-1],
duration_ms=duration_ms,
input_tokens=run.input_tokens,
output_tokens=run.output_tokens,
total_tokens=run.input_tokens + run.output_tokens,
status=status,
started_at=run.started_at,
ended_at=ended_at,
)
return {"status": "stored", "record": self.store.append(record)}
__all__ = ["AgentRunContext", "AgentObservabilityHooks"]
@@ -0,0 +1,45 @@
"""Persistent store for agent observability records.
Stores sanitized execution metrics for OpenClaw/Hermes/sub-agents. No prompts,
secrets, or private message contents are persisted here; only metrics and routing
metadata are stored for Web UI statistics and audit.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from .agent_observability import AgentExecutionRecord, AgentObservability
class AgentObservabilityStore:
def __init__(self, root: str = "runtime/agent-observability"):
self.root = Path(root)
self.records_dir = self.root / "records"
self.records_dir.mkdir(parents=True, exist_ok=True)
def append(self, record: AgentExecutionRecord) -> dict[str, Any]:
path = self.records_dir / f"{record.execution_id}.json"
data = record.to_dict()
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
return data
def load(self) -> AgentObservability:
observability = AgentObservability()
for path in sorted(self.records_dir.glob("*.json")):
raw = json.loads(path.read_text(encoding="utf-8"))
observability.records.append(AgentExecutionRecord(**raw))
return observability
def query(self, user_id: str | None = None, group_id: str | None = None, role: str = "user") -> list[dict[str, Any]]:
records = [record.to_dict() for record in self.load().records]
if role == "owner":
return records
if role == "group_admin" and group_id:
return [r for r in records if r.get("visible_scope") == group_id or r.get("user_id") == user_id]
return [r for r in records if r.get("user_id") == user_id]
__all__ = ["AgentObservabilityStore"]
+1 -1
View File
@@ -15,7 +15,7 @@ TOKEN_FILE = Path(os.getenv('AIOS_GITOPS_TOKEN_FILE', str(SECRETS / 'gitops.toke
HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios-v23.git')))
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
monitor = ActionMonitor()
BASE_MODELS = [
@@ -0,0 +1,27 @@
"""AIOS-managed Hermes runtime boundary."""
from .adapter import (
HermesAdapter,
HermesConflict,
HermesNotFound,
HermesRuntimeError,
InMemoryHermesStore,
SQLiteHermesStore,
)
from .contract import HermesHealth, HermesJob, HermesJobState, HermesResult
from .worker import HermesWorker, WorkerRunResult
__all__ = [
"HermesAdapter",
"HermesConflict",
"HermesHealth",
"HermesJob",
"HermesJobState",
"HermesNotFound",
"HermesResult",
"HermesRuntimeError",
"HermesWorker",
"InMemoryHermesStore",
"SQLiteHermesStore",
"WorkerRunResult",
]
+593
View File
@@ -0,0 +1,593 @@
"""Tenant-partitioned Hermes queue and worker lease boundary."""
from __future__ import annotations
import hashlib
import json
import sqlite3
import threading
from dataclasses import asdict, replace
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Iterable, Mapping
from .contract import (
ALLOWED_TRANSITIONS,
TERMINAL_STATES,
HermesHealth,
HermesJob,
HermesJobState,
HermesResult,
utc_now,
)
class HermesRuntimeError(RuntimeError):
"""Base Hermes runtime-boundary failure."""
class HermesNotFound(HermesRuntimeError):
"""A tenant-scoped job was not found."""
class HermesConflict(HermesRuntimeError):
"""A state, lease, idempotency, or revision conflict occurred."""
class InMemoryHermesStore:
"""Thread-safe reference store; production backends implement this contract."""
def __init__(self) -> None:
self.lock = threading.RLock()
self.jobs: dict[str, HermesJob] = {}
self.results: dict[str, HermesResult] = {}
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
self.worker_heartbeats: dict[str, tuple[str, str | None]] = {}
def persist_locked(self) -> None:
"""Persist a mutation while ``lock`` is held; memory stores are a no-op."""
class SQLiteHermesStore(InMemoryHermesStore):
"""Durable snapshot for the existing Hermes queue state machine.
The adapter remains the sole owner of transitions and lease rules. This
store only makes its jobs, results, idempotency keys and worker heartbeats
survive a process restart.
"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
super().__init__()
with self._connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS hermes_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
payload TEXT NOT NULL
)
"""
)
self._load()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(str(self.path), timeout=30)
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=FULL")
return connection
@staticmethod
def _record(value: HermesJob | HermesResult) -> dict[str, object]:
record = asdict(value)
record["state"] = value.state.value
return record
def _load(self) -> None:
with self.lock, self._connect() as connection:
row = connection.execute(
"SELECT payload FROM hermes_state WHERE singleton = 1"
).fetchone()
if row is None:
return
payload = json.loads(str(row[0]))
self.jobs = {
str(item["job_id"]): HermesJob(**item)
for item in payload.get("jobs", [])
}
self.results = {
str(item["job_id"]): HermesResult(**item)
for item in payload.get("results", [])
}
self.idempotency = {
(str(item["tenant_id"]), str(item["idempotency_key"])): (
str(item["job_id"]),
str(item["digest"]),
)
for item in payload.get("idempotency", [])
}
self.worker_heartbeats = {
str(item["worker_id"]): (
str(item["heartbeat_at"]),
str(item["job_id"]) if item.get("job_id") is not None else None,
)
for item in payload.get("worker_heartbeats", [])
}
def persist_locked(self) -> None:
payload = {
"version": 1,
"jobs": [self._record(item) for item in self.jobs.values()],
"results": [self._record(item) for item in self.results.values()],
"idempotency": [
{
"tenant_id": tenant_id,
"idempotency_key": idempotency_key,
"job_id": job_id,
"digest": digest,
}
for (tenant_id, idempotency_key), (job_id, digest)
in self.idempotency.items()
],
"worker_heartbeats": [
{
"worker_id": worker_id,
"heartbeat_at": heartbeat_at,
"job_id": job_id,
}
for worker_id, (heartbeat_at, job_id)
in self.worker_heartbeats.items()
],
}
encoded = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"""
INSERT INTO hermes_state(singleton, payload) VALUES(1, ?)
ON CONFLICT(singleton) DO UPDATE SET payload=excluded.payload
""",
(encoded,),
)
connection.commit()
class HermesAdapter:
"""Queue and reconcile Hermes jobs without owning AIOS policy."""
def __init__(
self,
store: InMemoryHermesStore | None = None,
*,
runtime_id: str = "hermes-runtime",
runtime_version: str = "v1",
default_lease_seconds: int = 60,
) -> None:
if default_lease_seconds < 5:
raise ValueError("default_lease_seconds must be >= 5")
self.store = store or InMemoryHermesStore()
self.runtime_id = runtime_id
self.runtime_version = runtime_version
self.default_lease_seconds = int(default_lease_seconds)
@staticmethod
def _digest(job: HermesJob) -> str:
raw = json.dumps(
{
"tenant_id": job.tenant_id,
"cell_id": job.cell_id,
"profile_id": job.profile_id,
"task_id": job.task_id,
"trace_id": job.trace_id,
"action": job.action,
"payload": dict(job.payload),
"priority": job.priority,
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(raw).hexdigest()
@staticmethod
def _parse(value: str | None) -> datetime | None:
if value is None:
return None
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
raise ValueError("timestamp must include timezone")
return parsed.astimezone(timezone.utc)
@staticmethod
def _format(value: datetime) -> str:
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
@staticmethod
def _assert_tenant(job: HermesJob, tenant_id: str) -> None:
if job.tenant_id != tenant_id:
raise HermesNotFound("job not found")
def enqueue(self, job: HermesJob) -> HermesJob:
if job.state != HermesJobState.QUEUED or job.revision != 1:
raise HermesConflict("new job must be QUEUED at revision 1")
digest = self._digest(job)
idem_ref = (job.tenant_id, job.idempotency_key)
with self.store.lock:
existing = self.store.jobs.get(job.job_id)
if existing is not None:
if self._digest(existing) != digest:
raise HermesConflict("job_id reused with different content")
return existing
prior = self.store.idempotency.get(idem_ref)
if prior is not None:
prior_id, prior_digest = prior
if prior_digest != digest:
raise HermesConflict("idempotency key reused with different content")
return self.store.jobs[prior_id]
self.store.jobs[job.job_id] = job
self.store.idempotency[idem_ref] = (job.job_id, digest)
self.store.persist_locked()
return job
def get(self, job_id: str, *, tenant_id: str) -> HermesJob:
with self.store.lock:
job = self.store.jobs.get(job_id)
if job is None:
raise HermesNotFound("job not found")
self._assert_tenant(job, tenant_id)
return job
def list_jobs(
self,
*,
tenant_id: str,
cell_id: str | None = None,
profile_id: str | None = None,
states: Iterable[HermesJobState | str] | None = None,
) -> list[HermesJob]:
state_filter = {HermesJobState(item) for item in states} if states else None
with self.store.lock:
if self._requeue_expired_locked():
self.store.persist_locked()
jobs = [
job
for job in self.store.jobs.values()
if job.tenant_id == tenant_id
and (cell_id is None or job.cell_id == cell_id)
and (profile_id is None or job.profile_id == profile_id)
and (state_filter is None or job.state in state_filter)
]
return sorted(jobs, key=lambda item: (item.priority, item.created_at, item.job_id))
def claim(
self,
*,
tenant_id: str,
cell_id: str,
profile_id: str,
worker_id: str,
lease_seconds: int | None = None,
job_id: str | None = None,
) -> HermesJob | None:
worker_id = str(worker_id or "").strip()
if not worker_id:
raise ValueError("worker_id is required")
duration = int(lease_seconds or self.default_lease_seconds)
if duration < 5:
raise ValueError("lease_seconds must be >= 5")
with self.store.lock:
self._requeue_expired_locked()
candidates = [
job
for job in self.store.jobs.values()
if job.tenant_id == tenant_id
and job.cell_id == cell_id
and job.profile_id == profile_id
and (job_id is None or job.job_id == job_id)
and job.state == HermesJobState.QUEUED
]
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
if not candidates:
self.store.persist_locked()
return None
current = sorted(
candidates,
key=lambda item: (item.priority, item.created_at, item.job_id),
)[0]
claimed = self._transition_locked(
current,
HermesJobState.CLAIMED,
assigned_worker_id=worker_id,
lease_expires_at=self._format(
datetime.now(timezone.utc) + timedelta(seconds=duration)
),
)
self.store.worker_heartbeats[worker_id] = (utc_now(), claimed.job_id)
self.store.persist_locked()
return claimed
def start(
self,
job_id: str,
*,
tenant_id: str,
worker_id: str,
expected_revision: int | None = None,
) -> HermesJob:
with self.store.lock:
self._requeue_expired_locked()
current = self._owned_job_locked(job_id, tenant_id, worker_id)
self._assert_revision(current, expected_revision)
updated = self._transition_locked(current, HermesJobState.RUNNING)
self.store.persist_locked()
return updated
def heartbeat(
self,
job_id: str,
*,
tenant_id: str,
worker_id: str,
lease_seconds: int | None = None,
) -> HermesJob:
duration = int(lease_seconds or self.default_lease_seconds)
if duration < 5:
raise ValueError("lease_seconds must be >= 5")
with self.store.lock:
self._requeue_expired_locked()
current = self._owned_job_locked(job_id, tenant_id, worker_id)
if current.state not in {HermesJobState.CLAIMED, HermesJobState.RUNNING}:
raise HermesConflict("job does not accept heartbeat")
updated = replace(
current,
revision=current.revision + 1,
lease_expires_at=self._format(
datetime.now(timezone.utc) + timedelta(seconds=duration)
),
updated_at=utc_now(),
)
self.store.jobs[job_id] = updated
self.store.worker_heartbeats[worker_id] = (utc_now(), job_id)
self.store.persist_locked()
return updated
def complete(
self,
job_id: str,
*,
tenant_id: str,
worker_id: str,
output: Mapping[str, object],
expected_revision: int | None = None,
) -> HermesResult:
return self._finish(
job_id,
tenant_id=tenant_id,
worker_id=worker_id,
state=HermesJobState.COMPLETED,
output=output,
expected_revision=expected_revision,
)
def fail(
self,
job_id: str,
*,
tenant_id: str,
worker_id: str,
error: Mapping[str, object],
expected_revision: int | None = None,
) -> HermesResult:
return self._finish(
job_id,
tenant_id=tenant_id,
worker_id=worker_id,
state=HermesJobState.FAILED,
error=error,
expected_revision=expected_revision,
)
def cancel(
self,
job_id: str,
*,
tenant_id: str,
expected_revision: int | None = None,
) -> HermesResult:
with self.store.lock:
current = self.store.jobs.get(job_id)
if current is None:
raise HermesNotFound("job not found")
self._assert_tenant(current, tenant_id)
self._assert_revision(current, expected_revision)
if current.state in TERMINAL_STATES:
result = self.store.results.get(job_id)
if result is None:
raise HermesConflict("terminal result missing")
if result.state != HermesJobState.CANCELLED:
raise HermesConflict("job already has a different terminal result")
return result
updated = self._transition_locked(current, HermesJobState.CANCELLED)
result = HermesResult(
job_id=updated.job_id,
tenant_id=updated.tenant_id,
task_id=updated.task_id,
trace_id=updated.trace_id,
state=HermesJobState.CANCELLED,
worker_id=updated.assigned_worker_id,
error={"code": "cancelled", "message": "cancelled by AIOS"},
finished_at=utc_now(),
)
self.store.results[job_id] = result
self.store.persist_locked()
return result
def result(self, job_id: str, *, tenant_id: str) -> HermesResult:
with self.store.lock:
job = self.store.jobs.get(job_id)
if job is None:
raise HermesNotFound("job not found")
self._assert_tenant(job, tenant_id)
result = self.store.results.get(job_id)
if result is None:
raise HermesConflict("job has no terminal result")
return result
def requeue_expired(self) -> tuple[str, ...]:
"""Persistently release expired leases without changing policy state."""
with self.store.lock:
changed = self._requeue_expired_locked()
if changed:
self.store.persist_locked()
return tuple(sorted(changed))
def health(self) -> HermesHealth:
with self.store.lock:
if self._requeue_expired_locked():
self.store.persist_locked()
active_jobs = sum(
1 for job in self.store.jobs.values()
if job.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
)
queued_jobs = sum(
1 for job in self.store.jobs.values()
if job.state == HermesJobState.QUEUED
)
partitions = {
(job.tenant_id, job.cell_id, job.profile_id)
for job in self.store.jobs.values()
}
active_workers = sum(
1 for heartbeat, _ in self.store.worker_heartbeats.values()
if self._parse(heartbeat)
and self._parse(heartbeat) >= datetime.now(timezone.utc) - timedelta(seconds=120)
)
return HermesHealth(
runtime_id=self.runtime_id,
status="healthy",
version=self.runtime_version,
heartbeat_at=utc_now(),
active_workers=active_workers,
active_jobs=active_jobs,
queued_jobs=queued_jobs,
tenant_partitions=len(partitions),
profile_isolation_enforced=True,
details={"store": type(self.store).__name__},
)
def _finish(
self,
job_id: str,
*,
tenant_id: str,
worker_id: str,
state: HermesJobState,
output: Mapping[str, object] | None = None,
error: Mapping[str, object] | None = None,
expected_revision: int | None,
) -> HermesResult:
with self.store.lock:
self._requeue_expired_locked()
current = self._owned_job_locked(job_id, tenant_id, worker_id)
if current.state in TERMINAL_STATES:
existing = self.store.results.get(job_id)
if existing is None:
raise HermesConflict("terminal result missing")
if (
existing.state == state
and existing.worker_id == worker_id
and existing.output == output
and existing.error == error
):
return existing
raise HermesConflict("job already has a different terminal result")
self._assert_revision(current, expected_revision)
updated = self._transition_locked(current, state)
result = HermesResult(
job_id=updated.job_id,
tenant_id=updated.tenant_id,
task_id=updated.task_id,
trace_id=updated.trace_id,
state=state,
worker_id=worker_id,
output=output,
error=error,
started_at=current.updated_at,
finished_at=utc_now(),
)
self.store.results[job_id] = result
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
self.store.persist_locked()
return result
def _owned_job_locked(self, job_id: str, tenant_id: str, worker_id: str) -> HermesJob:
current = self.store.jobs.get(job_id)
if current is None:
raise HermesNotFound("job not found")
self._assert_tenant(current, tenant_id)
if current.assigned_worker_id != worker_id:
raise HermesConflict("worker does not own job lease")
return current
@staticmethod
def _assert_revision(job: HermesJob, expected: int | None) -> None:
if expected is not None and job.revision != expected:
raise HermesConflict("job revision changed")
def _transition_locked(
self,
current: HermesJob,
target: HermesJobState,
*,
assigned_worker_id: str | None = None,
lease_expires_at: str | None = None,
) -> HermesJob:
if target not in ALLOWED_TRANSITIONS[current.state]:
raise HermesConflict(
f"invalid transition {current.state.value} -> {target.value}"
)
updated = replace(
current,
state=target,
revision=current.revision + 1,
assigned_worker_id=(
assigned_worker_id
if assigned_worker_id is not None
else current.assigned_worker_id
),
lease_expires_at=(
lease_expires_at
if lease_expires_at is not None
else (None if target in TERMINAL_STATES else current.lease_expires_at)
),
updated_at=utc_now(),
)
self.store.jobs[current.job_id] = updated
return updated
def _requeue_expired_locked(self) -> set[str]:
now = datetime.now(timezone.utc)
changed: set[str] = set()
for current in list(self.store.jobs.values()):
expiry = self._parse(current.lease_expires_at)
if (
current.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
and expiry is not None
and expiry <= now
):
self.store.jobs[current.job_id] = replace(
current,
state=HermesJobState.QUEUED,
revision=current.revision + 1,
assigned_worker_id=None,
lease_expires_at=None,
updated_at=utc_now(),
)
changed.add(current.job_id)
return changed
+153
View File
@@ -0,0 +1,153 @@
"""Tenant-scoped Hermes runtime contracts."""
from __future__ import annotations
import json
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Mapping
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _required(value: str, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str))
class HermesJobState(str, Enum):
QUEUED = "QUEUED"
CLAIMED = "CLAIMED"
RUNNING = "RUNNING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
ALLOWED_TRANSITIONS: Mapping[HermesJobState, frozenset[HermesJobState]] = {
HermesJobState.QUEUED: frozenset({HermesJobState.CLAIMED, HermesJobState.CANCELLED}),
HermesJobState.CLAIMED: frozenset({HermesJobState.RUNNING, HermesJobState.CANCELLED}),
HermesJobState.RUNNING: frozenset(
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
),
HermesJobState.COMPLETED: frozenset(),
HermesJobState.FAILED: frozenset(),
HermesJobState.CANCELLED: frozenset(),
}
TERMINAL_STATES = frozenset(
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
)
@dataclass(frozen=True)
class HermesJob:
job_id: str
tenant_id: str
cell_id: str
profile_id: str
task_id: str
trace_id: str
action: str
payload: Mapping[str, Any] = field(default_factory=dict)
idempotency_key: str = ""
priority: int = 100
state: HermesJobState = HermesJobState.QUEUED
revision: int = 1
assigned_worker_id: str | None = None
lease_expires_at: str | None = None
created_at: str = field(default_factory=utc_now)
updated_at: str = field(default_factory=utc_now)
def __post_init__(self) -> None:
for name in (
"job_id", "tenant_id", "cell_id", "profile_id", "task_id",
"trace_id", "action", "idempotency_key", "created_at", "updated_at",
):
object.__setattr__(self, name, _required(getattr(self, name), name))
object.__setattr__(self, "payload", _safe_mapping(self.payload))
object.__setattr__(self, "state", HermesJobState(self.state))
if not 0 <= int(self.priority) <= 1000:
raise ValueError("priority must be between 0 and 1000")
if self.revision < 1:
raise ValueError("revision must be >= 1")
@classmethod
def create(
cls,
*,
tenant_id: str,
cell_id: str,
profile_id: str,
task_id: str,
trace_id: str,
action: str,
payload: Mapping[str, Any] | None,
idempotency_key: str,
priority: int = 100,
) -> "HermesJob":
return cls(
job_id=f"hermes_{uuid.uuid4().hex}",
tenant_id=tenant_id,
cell_id=cell_id,
profile_id=profile_id,
task_id=task_id,
trace_id=trace_id,
action=action,
payload=_safe_mapping(payload),
idempotency_key=idempotency_key,
priority=priority,
)
@dataclass(frozen=True)
class HermesResult:
job_id: str
tenant_id: str
task_id: str
trace_id: str
state: HermesJobState
worker_id: str | None = None
output: Mapping[str, Any] | None = None
error: Mapping[str, Any] | None = None
started_at: str | None = None
finished_at: str | None = None
def __post_init__(self) -> None:
for name in ("job_id", "tenant_id", "task_id", "trace_id"):
object.__setattr__(self, name, _required(getattr(self, name), name))
object.__setattr__(self, "state", HermesJobState(self.state))
if self.output is not None:
object.__setattr__(self, "output", _safe_mapping(self.output))
if self.error is not None:
object.__setattr__(self, "error", _safe_mapping(self.error))
@dataclass(frozen=True)
class HermesHealth:
runtime_id: str
status: str
version: str
heartbeat_at: str
active_workers: int = 0
active_jobs: int = 0
queued_jobs: int = 0
tenant_partitions: int = 0
profile_isolation_enforced: bool = True
details: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
for name in ("runtime_id", "status", "version", "heartbeat_at"):
object.__setattr__(self, name, _required(getattr(self, name), name))
for name in ("active_workers", "active_jobs", "queued_jobs", "tenant_partitions"):
if int(getattr(self, name)) < 0:
raise ValueError(f"{name} must be >= 0")
object.__setattr__(self, "details", _safe_mapping(self.details))
+155
View File
@@ -0,0 +1,155 @@
"""Hermes worker loop with lease renewal and bounded recovery."""
from __future__ import annotations
import threading
from dataclasses import dataclass
from typing import Any, Callable, Mapping
from .adapter import HermesAdapter, HermesConflict
from .contract import HermesJob, HermesJobState
JobHandler = Callable[[HermesJob], Mapping[str, Any]]
@dataclass(frozen=True)
class WorkerRunResult:
claimed: bool
job_id: str | None = None
state: str | None = None
error: str | None = None
class HermesWorker:
"""Poll one tenant/cell/profile partition and execute jobs safely."""
def __init__(
self,
adapter: HermesAdapter,
handler: JobHandler,
*,
worker_id: str,
tenant_id: str,
cell_id: str,
profile_id: str,
poll_seconds: float = 1.0,
heartbeat_seconds: float = 10.0,
) -> None:
if poll_seconds <= 0 or heartbeat_seconds <= 0:
raise ValueError("poll_seconds and heartbeat_seconds must be positive")
for name, value in {
"worker_id": worker_id,
"tenant_id": tenant_id,
"cell_id": cell_id,
"profile_id": profile_id,
}.items():
if not str(value or "").strip():
raise ValueError(f"{name} is required")
self.adapter = adapter
self.handler = handler
self.worker_id = worker_id
self.tenant_id = tenant_id
self.cell_id = cell_id
self.profile_id = profile_id
self.poll_seconds = float(poll_seconds)
self.heartbeat_seconds = float(heartbeat_seconds)
self._stop = threading.Event()
def stop(self) -> None:
self._stop.set()
def run_once(self) -> WorkerRunResult:
job = self.adapter.claim(
tenant_id=self.tenant_id,
cell_id=self.cell_id,
profile_id=self.profile_id,
worker_id=self.worker_id,
)
if job is None:
return WorkerRunResult(claimed=False)
running = self.adapter.start(
job.job_id,
tenant_id=self.tenant_id,
worker_id=self.worker_id,
expected_revision=job.revision,
)
heartbeat_stop = threading.Event()
heartbeat_thread = threading.Thread(
target=self._heartbeat_loop,
args=(running.job_id, heartbeat_stop),
name=f"hermes-heartbeat-{self.worker_id}",
daemon=True,
)
heartbeat_thread.start()
output: Mapping[str, Any] | None = None
execution_error: Exception | None = None
try:
output = dict(self.handler(running))
except Exception as exc: # runtime boundary: normalize below
execution_error = exc
finally:
# Stop the lease heartbeat before reading the final revision. This
# prevents a revision increment racing with complete/fail.
heartbeat_stop.set()
heartbeat_thread.join(timeout=max(self.heartbeat_seconds * 2, 1.0))
try:
current = self.adapter.get(running.job_id, tenant_id=self.tenant_id)
if execution_error is None:
result = self.adapter.complete(
running.job_id,
tenant_id=self.tenant_id,
worker_id=self.worker_id,
output=dict(output or {}),
expected_revision=current.revision,
)
return WorkerRunResult(
claimed=True,
job_id=result.job_id,
state=result.state.value,
)
result = self.adapter.fail(
running.job_id,
tenant_id=self.tenant_id,
worker_id=self.worker_id,
error={
"code": "worker_error",
"message": str(execution_error),
},
expected_revision=current.revision,
)
return WorkerRunResult(
claimed=True,
job_id=result.job_id,
state=result.state.value,
error=str(execution_error),
)
except HermesConflict as exc:
return WorkerRunResult(
claimed=True,
job_id=running.job_id,
state=HermesJobState.FAILED.value,
error=f"reconciliation_conflict: {exc}",
)
def run_forever(self) -> None:
while not self._stop.is_set():
result = self.run_once()
if not result.claimed:
self._stop.wait(self.poll_seconds)
def _heartbeat_loop(self, job_id: str, stop_event: threading.Event) -> None:
while not stop_event.wait(self.heartbeat_seconds):
try:
self.adapter.heartbeat(
job_id,
tenant_id=self.tenant_id,
worker_id=self.worker_id,
)
except Exception:
stop_event.set()
return
@@ -0,0 +1,45 @@
"""AIOS-managed OpenClaw Ed25519 execution adapter."""
from .adapter import (
AdapterError,
CommandConflict,
CommandNotFound,
CommandRejected,
InMemoryCallbackSink,
OpenClawAdapter,
)
from .contract import (
CONTRACT_VERSION,
SIGNATURE_ALGORITHM,
CommandState,
Ed25519CommandSigner,
Ed25519CommandVerifier,
OpenClawCommand,
OpenClawResult,
RuntimeHealth,
sign_command,
verify_command_signature,
)
from .transport import HTTPRuntimeTransport, InProcessRuntimeTransport, RuntimeTransport
__all__ = [
"AdapterError",
"CONTRACT_VERSION",
"CommandConflict",
"CommandNotFound",
"CommandRejected",
"CommandState",
"Ed25519CommandSigner",
"Ed25519CommandVerifier",
"HTTPRuntimeTransport",
"InMemoryCallbackSink",
"InProcessRuntimeTransport",
"OpenClawAdapter",
"OpenClawCommand",
"OpenClawResult",
"RuntimeHealth",
"RuntimeTransport",
"SIGNATURE_ALGORITHM",
"sign_command",
"verify_command_signature",
]
+228
View File
@@ -0,0 +1,228 @@
"""Signed, tenant-isolated execution adapter for OpenClaw runtimes."""
from __future__ import annotations
import threading
from dataclasses import replace
from typing import Callable, Mapping, Protocol
from .contract import (
CommandState,
OpenClawCommand,
OpenClawResult,
RuntimeHealth,
utc_now,
verify_command_signature,
)
class AdapterError(RuntimeError):
"""Base adapter failure."""
class CommandRejected(AdapterError):
"""A command failed signature or contract validation."""
class CommandNotFound(AdapterError):
"""A tenant-scoped command is not visible."""
class CommandConflict(AdapterError):
"""A command or idempotency key conflicts with an existing execution."""
class RuntimeTransport(Protocol):
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult: ...
def cancel(self, command_id: str) -> bool: ...
def health(self) -> RuntimeHealth: ...
class CallbackSink(Protocol):
def on_started(self, result: OpenClawResult) -> None: ...
def on_completed(self, result: OpenClawResult) -> None: ...
def on_failed(self, result: OpenClawResult) -> None: ...
class InMemoryCallbackSink:
def __init__(self) -> None:
self._lock = threading.RLock()
self.started: list[OpenClawResult] = []
self.completed: list[OpenClawResult] = []
self.failed: list[OpenClawResult] = []
def on_started(self, result: OpenClawResult) -> None:
with self._lock:
self.started.append(result)
def on_completed(self, result: OpenClawResult) -> None:
with self._lock:
self.completed.append(result)
def on_failed(self, result: OpenClawResult) -> None:
with self._lock:
self.failed.append(result)
SigningMaterialResolver = Callable[[str, str], bytes | str]
class OpenClawAdapter:
"""Verify, de-duplicate, execute, cancel and reconcile commands."""
def __init__(
self,
transport: RuntimeTransport,
signing_material_resolver: SigningMaterialResolver,
*,
callback_sink: CallbackSink | None = None,
default_timeout_seconds: float = 300.0,
) -> None:
if default_timeout_seconds <= 0:
raise ValueError("default_timeout_seconds must be positive")
self.transport = transport
self.signing_material_resolver = signing_material_resolver
self.callback_sink = callback_sink or InMemoryCallbackSink()
self.default_timeout_seconds = float(default_timeout_seconds)
self._lock = threading.RLock()
self._commands: dict[str, OpenClawCommand] = {}
self._results: dict[str, OpenClawResult] = {}
self._idempotency: dict[tuple[str, str], tuple[str, str]] = {}
def _verify(self, command: OpenClawCommand) -> None:
try:
key_material = self.signing_material_resolver(command.tenant_id, command.key_id)
except Exception as exc:
raise CommandRejected("signing material unavailable") from exc
if not verify_command_signature(command, key_material):
raise CommandRejected("invalid or expired command signature")
@staticmethod
def _validate_result(command: OpenClawCommand, result: OpenClawResult) -> None:
if (
result.command_id != command.command_id
or result.tenant_id != command.tenant_id
or result.task_id != command.task_id
or result.trace_id != command.trace_id
):
raise AdapterError("runtime returned mismatched command identity")
if result.state not in {
CommandState.COMPLETED,
CommandState.FAILED,
CommandState.CANCELLED,
CommandState.TIMED_OUT,
}:
raise AdapterError("runtime returned a non-terminal result")
def execute(
self,
command: OpenClawCommand,
*,
timeout_seconds: float | None = None,
) -> OpenClawResult:
timeout = self.default_timeout_seconds if timeout_seconds is None else float(timeout_seconds)
if timeout <= 0:
raise ValueError("timeout_seconds must be positive")
self._verify(command)
digest = command.signature
idem_ref = (command.tenant_id, command.idempotency_key)
with self._lock:
prior_command = self._commands.get(command.command_id)
if prior_command is not None:
if prior_command.signature != command.signature:
raise CommandConflict("command_id reused with different content")
return self._results[command.command_id]
prior_idem = self._idempotency.get(idem_ref)
if prior_idem is not None:
prior_id, prior_digest = prior_idem
if prior_digest != digest:
raise CommandConflict("idempotency key reused with different content")
return self._results[prior_id]
running = OpenClawResult(
command_id=command.command_id,
tenant_id=command.tenant_id,
task_id=command.task_id,
trace_id=command.trace_id,
state=CommandState.RUNNING,
started_at=utc_now(),
)
self._commands[command.command_id] = command
self._results[command.command_id] = running
self._idempotency[idem_ref] = (command.command_id, digest)
self.callback_sink.on_started(running)
try:
result = self.transport.execute(command, timeout)
self._validate_result(command, result)
except TimeoutError as exc:
result = replace(
running,
state=CommandState.TIMED_OUT,
error={"code": "runtime_timeout", "message": str(exc) or "runtime timeout"},
finished_at=utc_now(),
)
except Exception as exc:
result = replace(
running,
state=CommandState.FAILED,
error={"code": "runtime_error", "message": str(exc)},
finished_at=utc_now(),
)
if result.started_at is None:
result = replace(result, started_at=running.started_at)
if result.finished_at is None:
result = replace(result, finished_at=utc_now())
with self._lock:
self._results[command.command_id] = result
if result.state == CommandState.COMPLETED:
self.callback_sink.on_completed(result)
else:
self.callback_sink.on_failed(result)
return result
def status(self, command_id: str, *, tenant_id: str) -> OpenClawResult:
with self._lock:
command = self._commands.get(command_id)
result = self._results.get(command_id)
if command is None or result is None or command.tenant_id != tenant_id:
raise CommandNotFound("command not found")
return result
def cancel(self, command_id: str, *, tenant_id: str) -> OpenClawResult:
with self._lock:
command = self._commands.get(command_id)
current = self._results.get(command_id)
if command is None or current is None or command.tenant_id != tenant_id:
raise CommandNotFound("command not found")
if current.state in {
CommandState.COMPLETED,
CommandState.FAILED,
CommandState.CANCELLED,
CommandState.TIMED_OUT,
CommandState.REJECTED,
}:
return current
if not self.transport.cancel(command_id):
raise AdapterError("runtime rejected cancellation")
cancelled = replace(
current,
state=CommandState.CANCELLED,
error={"code": "cancelled", "message": "cancelled by AIOS"},
finished_at=utc_now(),
)
with self._lock:
self._results[command_id] = cancelled
self.callback_sink.on_failed(cancelled)
return cancelled
def health(self) -> RuntimeHealth:
return self.transport.health()
def snapshot(self, *, tenant_id: str) -> Mapping[str, OpenClawResult]:
with self._lock:
return {
command_id: result
for command_id, result in self._results.items()
if self._commands[command_id].tenant_id == tenant_id
}
@@ -0,0 +1,97 @@
"""Audit-oriented lifecycle callbacks for OpenClaw execution."""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass, field
from typing import Any, Mapping, Protocol
from .contract import OpenClawResult, utc_now
class EventPublisher(Protocol):
def publish(self, event: Mapping[str, Any]) -> None: ...
@dataclass(frozen=True)
class ExecutionEvent:
event_type: str
tenant_id: str
task_id: str
trace_id: str
command_id: str
state: str
created_at: str = field(default_factory=utc_now)
details: Mapping[str, Any] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
return {
"event_type": self.event_type,
"tenant_id": self.tenant_id,
"task_id": self.task_id,
"trace_id": self.trace_id,
"command_id": self.command_id,
"state": self.state,
"created_at": self.created_at,
"details": json.loads(
json.dumps(dict(self.details), ensure_ascii=False, default=str)
),
}
class InMemoryEventPublisher:
"""Thread-safe publisher used by tests and first integration slice."""
def __init__(self) -> None:
self._lock = threading.RLock()
self.events: list[dict[str, Any]] = []
def publish(self, event: Mapping[str, Any]) -> None:
with self._lock:
self.events.append(
json.loads(json.dumps(dict(event), ensure_ascii=False, default=str))
)
class AuditCallbackSink:
"""Convert runtime callbacks into tenant-scoped audit events."""
def __init__(self, publisher: EventPublisher) -> None:
self.publisher = publisher
@staticmethod
def _event(
event_type: str,
result: OpenClawResult,
) -> ExecutionEvent:
details: dict[str, Any] = {
"runtime_id": result.runtime_id,
"attempts": result.attempts,
}
if result.error:
details["error"] = dict(result.error)
if result.output:
details["output_present"] = True
return ExecutionEvent(
event_type=event_type,
tenant_id=result.tenant_id,
task_id=result.task_id,
trace_id=result.trace_id,
command_id=result.command_id,
state=result.state.value,
details=details,
)
def on_started(self, result: OpenClawResult) -> None:
self.publisher.publish(self._event("OPENCLAW_STARTED", result).as_dict())
def on_completed(self, result: OpenClawResult) -> None:
self.publisher.publish(self._event("OPENCLAW_COMPLETED", result).as_dict())
def on_failed(self, result: OpenClawResult) -> None:
event_type = {
"CANCELLED": "OPENCLAW_CANCELLED",
"TIMED_OUT": "OPENCLAW_TIMED_OUT",
"REJECTED": "OPENCLAW_REJECTED",
}.get(result.state.value, "OPENCLAW_FAILED")
self.publisher.publish(self._event(event_type, result).as_dict())
@@ -0,0 +1,383 @@
"""AIOS 到 OpenClaw 的 Ed25519 执行合同。"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import uuid
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Mapping, Protocol
CONTRACT_VERSION = "aios.openclaw.command.v2"
SIGNATURE_ALGORITHM = "Ed25519"
DEFAULT_CLOCK_SKEW_SECONDS = 300
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _parse_timestamp(value: str) -> datetime:
normalized = str(value or "").strip()
if not normalized:
raise ValueError("timestamp is required")
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
raise ValueError("timestamp must include a timezone")
return parsed.astimezone(timezone.utc)
def _required(value: str, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
return json.loads(
json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str)
)
def _load_crypto() -> tuple[Any, Any, Any]:
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
except ImportError as exc: # pragma: no cover
raise RuntimeError("cryptography with Ed25519 support is required") from exc
return serialization, Ed25519PrivateKey, Ed25519PublicKey
class CommandSigner(Protocol):
key_id: str
fingerprint: str
def sign(self, payload: bytes) -> bytes: ...
class CommandVerifier(Protocol):
key_id: str
fingerprint: str
def verify(self, payload: bytes, signature: bytes) -> bool: ...
class Ed25519CommandSigner:
def __init__(self, private_key: Any, *, key_id: str) -> None:
serialization, private_cls, _ = _load_crypto()
if not isinstance(private_key, private_cls):
raise ValueError("private key is not Ed25519")
self.private_key = private_key
self.key_id = _required(key_id, "key_id")
raw = private_key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
self.fingerprint = hashlib.sha256(raw).hexdigest()
@classmethod
def from_bytes(cls, value: bytes | str, *, key_id: str) -> "Ed25519CommandSigner":
serialization, private_cls, _ = _load_crypto()
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
if raw.startswith(b"-----BEGIN"):
key = serialization.load_pem_private_key(raw, password=None)
elif len(raw) == 32:
key = private_cls.from_private_bytes(raw)
else:
raise ValueError("Ed25519 private key must be PEM or a raw 32-byte seed")
return cls(key, key_id=key_id)
def sign(self, payload: bytes) -> bytes:
return self.private_key.sign(bytes(payload))
def verifier(self) -> "Ed25519CommandVerifier":
return Ed25519CommandVerifier(self.private_key.public_key(), key_id=self.key_id)
class Ed25519CommandVerifier:
def __init__(self, public_key: Any, *, key_id: str) -> None:
serialization, _, public_cls = _load_crypto()
if not isinstance(public_key, public_cls):
raise ValueError("public key is not Ed25519")
self.public_key = public_key
self.key_id = _required(key_id, "key_id")
raw = public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
self.fingerprint = hashlib.sha256(raw).hexdigest()
@classmethod
def from_bytes(cls, value: bytes | str, *, key_id: str) -> "Ed25519CommandVerifier":
serialization, private_cls, public_cls = _load_crypto()
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
if raw.startswith(b"-----BEGIN"):
try:
key = serialization.load_pem_public_key(raw)
except ValueError:
private = serialization.load_pem_private_key(raw, password=None)
if not isinstance(private, private_cls):
raise ValueError("key is not Ed25519")
key = private.public_key()
elif len(raw) == 32:
key = public_cls.from_public_bytes(raw)
else:
raise ValueError("Ed25519 public key must be PEM or raw 32-byte key")
return cls(key, key_id=key_id)
def verify(self, payload: bytes, signature: bytes) -> bool:
try:
self.public_key.verify(bytes(signature), bytes(payload))
return True
except Exception:
return False
class CommandState(str, Enum):
ACCEPTED = "ACCEPTED"
RUNNING = "RUNNING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
TIMED_OUT = "TIMED_OUT"
REJECTED = "REJECTED"
@dataclass(frozen=True)
class OpenClawCommand:
command_id: str
tenant_id: str
task_id: str
trace_id: str
action: str
payload: Mapping[str, Any] = field(default_factory=dict)
idempotency_key: str = ""
created_at: str = field(default_factory=utc_now)
expires_at: str | None = None
contract_version: str = CONTRACT_VERSION
signature_algorithm: str = SIGNATURE_ALGORITHM
key_id: str = "default"
public_key_fingerprint: str = ""
signature: str = ""
def __post_init__(self) -> None:
for field_name in (
"command_id",
"tenant_id",
"task_id",
"trace_id",
"action",
"idempotency_key",
"created_at",
"contract_version",
"signature_algorithm",
"key_id",
):
object.__setattr__(
self, field_name, _required(getattr(self, field_name), field_name)
)
if self.contract_version != CONTRACT_VERSION:
raise ValueError(f"unsupported contract_version: {self.contract_version}")
if self.signature_algorithm != SIGNATURE_ALGORITHM:
raise ValueError(
f"unsupported signature_algorithm: {self.signature_algorithm}"
)
object.__setattr__(self, "payload", _safe_mapping(self.payload))
object.__setattr__(
self, "public_key_fingerprint", str(self.public_key_fingerprint or "").strip()
)
object.__setattr__(self, "signature", str(self.signature or "").strip())
_parse_timestamp(self.created_at)
if self.expires_at is not None:
object.__setattr__(self, "expires_at", _required(self.expires_at, "expires_at"))
if _parse_timestamp(self.expires_at) <= _parse_timestamp(self.created_at):
raise ValueError("expires_at must be later than created_at")
@classmethod
def create(
cls,
*,
tenant_id: str,
task_id: str,
trace_id: str,
action: str,
payload: Mapping[str, Any] | None,
idempotency_key: str,
expires_at: str | None = None,
key_id: str = "default",
command_id: str | None = None,
) -> "OpenClawCommand":
return cls(
command_id=command_id or f"cmd_{uuid.uuid4().hex}",
tenant_id=tenant_id,
task_id=task_id,
trace_id=trace_id,
action=action,
payload=_safe_mapping(payload),
idempotency_key=idempotency_key,
expires_at=expires_at,
key_id=key_id,
)
def unsigned_dict(self) -> dict[str, Any]:
return {
"command_id": self.command_id,
"tenant_id": self.tenant_id,
"task_id": self.task_id,
"trace_id": self.trace_id,
"action": self.action,
"payload": _safe_mapping(self.payload),
"idempotency_key": self.idempotency_key,
"created_at": self.created_at,
"expires_at": self.expires_at,
"contract_version": self.contract_version,
"signature_algorithm": self.signature_algorithm,
"key_id": self.key_id,
"public_key_fingerprint": self.public_key_fingerprint,
}
@dataclass(frozen=True)
class OpenClawResult:
command_id: str
tenant_id: str
task_id: str
trace_id: str
state: CommandState
output: Mapping[str, Any] | None = None
error: Mapping[str, Any] | None = None
started_at: str | None = None
finished_at: str | None = None
runtime_id: str | None = None
attempts: int = 1
def __post_init__(self) -> None:
for field_name in ("command_id", "tenant_id", "task_id", "trace_id"):
object.__setattr__(
self, field_name, _required(getattr(self, field_name), field_name)
)
object.__setattr__(self, "state", CommandState(self.state))
if self.output is not None:
object.__setattr__(self, "output", _safe_mapping(self.output))
if self.error is not None:
object.__setattr__(self, "error", _safe_mapping(self.error))
if self.started_at is not None:
_parse_timestamp(self.started_at)
if self.finished_at is not None:
_parse_timestamp(self.finished_at)
if self.attempts < 1:
raise ValueError("attempts must be >= 1")
@dataclass(frozen=True)
class RuntimeHealth:
runtime: str
runtime_id: str
status: str
version: str
heartbeat_at: str
active_tasks: int = 0
workspace_isolated: bool = False
credential_ref_configured: bool = False
details: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
for field_name in (
"runtime",
"runtime_id",
"status",
"version",
"heartbeat_at",
):
object.__setattr__(
self, field_name, _required(getattr(self, field_name), field_name)
)
_parse_timestamp(self.heartbeat_at)
if self.active_tasks < 0:
raise ValueError("active_tasks must be >= 0")
object.__setattr__(self, "details", _safe_mapping(self.details))
def canonical_command_bytes(command: OpenClawCommand) -> bytes:
return json.dumps(
command.unsigned_dict(),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def _as_signer(command: OpenClawCommand, value: Any) -> CommandSigner:
if hasattr(value, "sign") and hasattr(value, "fingerprint") and hasattr(value, "key_id"):
return value
return Ed25519CommandSigner.from_bytes(value, key_id=command.key_id)
def _as_verifier(command: OpenClawCommand, value: Any) -> CommandVerifier:
if isinstance(value, Ed25519CommandSigner):
return value.verifier()
if hasattr(value, "verify") and hasattr(value, "fingerprint") and hasattr(value, "key_id"):
return value
return Ed25519CommandVerifier.from_bytes(value, key_id=command.key_id)
def sign_command(command: OpenClawCommand, signing_key: Any) -> OpenClawCommand:
signer = _as_signer(command, signing_key)
if signer.key_id != command.key_id:
raise ValueError("command key_id does not match signer")
prepared = replace(
command,
signature_algorithm=SIGNATURE_ALGORITHM,
public_key_fingerprint=signer.fingerprint,
signature="",
)
signature = base64.b64encode(
signer.sign(canonical_command_bytes(prepared))
).decode("ascii")
return replace(prepared, signature=signature)
def verify_command_signature(
command: OpenClawCommand,
verification_key: Any,
*,
now: datetime | None = None,
clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS,
) -> bool:
if (
not command.signature
or command.contract_version != CONTRACT_VERSION
or command.signature_algorithm != SIGNATURE_ALGORITHM
):
return False
try:
verifier = _as_verifier(command, verification_key)
signature = base64.b64decode(command.signature, validate=True)
except Exception:
return False
if verifier.key_id != command.key_id:
return False
if not command.public_key_fingerprint or not hmac.compare_digest(
command.public_key_fingerprint, verifier.fingerprint
):
return False
if not verifier.verify(canonical_command_bytes(command), signature):
return False
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
created = _parse_timestamp(command.created_at)
if created.timestamp() - current.timestamp() > clock_skew_seconds:
return False
if command.expires_at is not None:
expires = _parse_timestamp(command.expires_at)
if current.timestamp() - expires.timestamp() > clock_skew_seconds:
return False
return True
+103
View File
@@ -0,0 +1,103 @@
"""Health policy and self-healing decisions for OpenClaw runtimes."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Mapping
from .contract import RuntimeHealth
def _parse(value: str) -> datetime:
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
raise ValueError("heartbeat timestamp must include timezone")
return parsed.astimezone(timezone.utc)
@dataclass(frozen=True)
class HealthPolicy:
max_heartbeat_age_seconds: int = 90
require_workspace_isolation: bool = True
require_credential_ref: bool = True
accepted_statuses: frozenset[str] = field(
default_factory=lambda: frozenset({"healthy", "ready"})
)
def __post_init__(self) -> None:
if self.max_heartbeat_age_seconds < 5:
raise ValueError("max_heartbeat_age_seconds must be >= 5")
if not self.accepted_statuses:
raise ValueError("accepted_statuses must not be empty")
@dataclass(frozen=True)
class HealthAssessment:
healthy: bool
reasons: tuple[str, ...]
recoverable: bool
recommended_action: str
details: Mapping[str, object] = field(default_factory=dict)
class RuntimeHealthMonitor:
"""Evaluate runtime health and recommend bounded recovery actions."""
def __init__(self, policy: HealthPolicy | None = None) -> None:
self.policy = policy or HealthPolicy()
def assess(
self,
health: RuntimeHealth,
*,
now: datetime | None = None,
) -> HealthAssessment:
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
heartbeat = _parse(health.heartbeat_at)
age = max(0.0, current.timestamp() - heartbeat.timestamp())
reasons: list[str] = []
if health.status.lower() not in self.policy.accepted_statuses:
reasons.append("runtime_status_unhealthy")
if age > self.policy.max_heartbeat_age_seconds:
reasons.append("heartbeat_stale")
if self.policy.require_workspace_isolation and not health.workspace_isolated:
reasons.append("workspace_not_isolated")
if self.policy.require_credential_ref and not health.credential_ref_configured:
reasons.append("credential_reference_missing")
if not reasons:
return HealthAssessment(
healthy=True,
reasons=(),
recoverable=True,
recommended_action="none",
details={"heartbeat_age_seconds": age},
)
non_recoverable = {
"workspace_not_isolated",
"credential_reference_missing",
}.intersection(reasons)
if non_recoverable:
action = "quarantine_runtime"
recoverable = False
elif "heartbeat_stale" in reasons:
action = "restart_runtime"
recoverable = True
else:
action = "drain_and_restart"
recoverable = True
return HealthAssessment(
healthy=False,
reasons=tuple(reasons),
recoverable=recoverable,
recommended_action=action,
details={
"heartbeat_age_seconds": age,
"runtime_id": health.runtime_id,
"active_tasks": health.active_tasks,
},
)
@@ -0,0 +1,214 @@
"""Runtime transport implementations for the OpenClaw adapter."""
from __future__ import annotations
import json
import threading
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Callable, Mapping, Protocol
from .contract import (
CommandState,
OpenClawCommand,
OpenClawResult,
RuntimeHealth,
utc_now,
)
class RuntimeTransport(Protocol):
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult: ...
def cancel(self, command_id: str) -> bool: ...
def health(self) -> RuntimeHealth: ...
class TransportError(RuntimeError):
"""Runtime transport or protocol failure."""
RuntimeHandler = Callable[[OpenClawCommand], Mapping[str, Any] | OpenClawResult]
class InProcessRuntimeTransport:
"""Deterministic runtime used by tests and controlled local integrations."""
def __init__(
self,
handler: RuntimeHandler,
*,
runtime_id: str = "openclaw-in-process",
version: str = "v1",
workspace_isolated: bool = True,
credential_ref_configured: bool = True,
) -> None:
self.handler = handler
self.runtime_id = runtime_id
self.version = version
self.workspace_isolated = workspace_isolated
self.credential_ref_configured = credential_ref_configured
self._lock = threading.RLock()
self._running: set[str] = set()
self._cancelled: set[str] = set()
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
with self._lock:
if command.command_id in self._cancelled:
return OpenClawResult(
command_id=command.command_id,
tenant_id=command.tenant_id,
task_id=command.task_id,
trace_id=command.trace_id,
state=CommandState.CANCELLED,
error={"code": "cancelled", "message": "cancelled before execution"},
finished_at=utc_now(),
runtime_id=self.runtime_id,
)
self._running.add(command.command_id)
try:
raw = self.handler(command)
if isinstance(raw, OpenClawResult):
return raw
if not isinstance(raw, Mapping):
raise TransportError("runtime handler must return a mapping or OpenClawResult")
return OpenClawResult(
command_id=command.command_id,
tenant_id=command.tenant_id,
task_id=command.task_id,
trace_id=command.trace_id,
state=CommandState(str(raw.get("state", "COMPLETED"))),
output=raw.get("output"),
error=raw.get("error"),
started_at=str(raw.get("started_at") or utc_now()),
finished_at=str(raw.get("finished_at") or utc_now()),
runtime_id=str(raw.get("runtime_id") or self.runtime_id),
attempts=int(raw.get("attempts", 1)),
)
finally:
with self._lock:
self._running.discard(command.command_id)
def cancel(self, command_id: str) -> bool:
with self._lock:
self._cancelled.add(command_id)
return command_id in self._running
def health(self) -> RuntimeHealth:
with self._lock:
active = len(self._running)
return RuntimeHealth(
runtime="openclaw",
runtime_id=self.runtime_id,
status="healthy",
version=self.version,
heartbeat_at=utc_now(),
active_tasks=active,
workspace_isolated=self.workspace_isolated,
credential_ref_configured=self.credential_ref_configured,
details={"transport": "in_process"},
)
class HTTPRuntimeTransport:
"""Private HTTP transport for an OpenClaw runtime service."""
def __init__(
self,
base_url: str,
*,
runtime_id: str = "openclaw-http",
credential_ref: str | None = None,
max_response_bytes: int = 2_097_152,
) -> None:
normalized = str(base_url).rstrip("/")
if not normalized.startswith(("http://", "https://")):
raise ValueError("base_url must use http or https")
self.base_url = normalized
self.runtime_id = runtime_id
self.credential_ref = credential_ref
self.max_response_bytes = int(max_response_bytes)
def _request(
self,
method: str,
path: str,
*,
payload: Mapping[str, Any] | None,
timeout_seconds: float,
) -> Mapping[str, Any]:
body = None
headers = {"Accept": "application/json", "User-Agent": "AIOS-OpenClaw/1"}
if payload is not None:
body = json.dumps(dict(payload), separators=(",", ":")).encode("utf-8")
headers["Content-Type"] = "application/json"
if self.credential_ref:
headers["X-AIOS-Credential-Ref"] = self.credential_ref
request = urllib.request.Request(
f"{self.base_url}{path}",
data=body,
method=method,
headers=headers,
)
try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
raw = response.read(self.max_response_bytes + 1)
except urllib.error.HTTPError as exc:
raise TransportError(f"runtime returned HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise TransportError(f"runtime unavailable: {exc.reason}") from exc
if len(raw) > self.max_response_bytes:
raise TransportError("runtime response exceeded size limit")
decoded = json.loads(raw.decode("utf-8"))
if not isinstance(decoded, Mapping):
raise TransportError("runtime response must be a JSON object")
return decoded
@staticmethod
def _command_payload(command: OpenClawCommand) -> dict[str, Any]:
return {**command.unsigned_dict(), "signature": command.signature}
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
value = self._request(
"POST",
"/v1/execute",
payload=self._command_payload(command),
timeout_seconds=timeout_seconds,
)
return OpenClawResult(
command_id=str(value["command_id"]),
tenant_id=str(value["tenant_id"]),
task_id=str(value["task_id"]),
trace_id=str(value["trace_id"]),
state=CommandState(str(value["state"])),
output=value.get("output"),
error=value.get("error"),
started_at=value.get("started_at"),
finished_at=value.get("finished_at"),
runtime_id=str(value.get("runtime_id") or self.runtime_id),
attempts=int(value.get("attempts", 1)),
)
def cancel(self, command_id: str) -> bool:
value = self._request(
"POST",
f"/v1/commands/{urllib.parse.quote(command_id, safe='')}/cancel",
payload={},
timeout_seconds=10.0,
)
return bool(value.get("accepted"))
def health(self) -> RuntimeHealth:
value = self._request("GET", "/v1/health", payload=None, timeout_seconds=5.0)
return RuntimeHealth(
runtime=str(value.get("runtime") or "openclaw"),
runtime_id=str(value.get("runtime_id") or self.runtime_id),
status=str(value.get("status") or "unknown"),
version=str(value.get("version") or "unknown"),
heartbeat_at=str(value.get("heartbeat_at") or utc_now()),
active_tasks=int(value.get("active_tasks", 0)),
workspace_isolated=bool(value.get("workspace_isolated", False)),
credential_ref_configured=bool(value.get("credential_ref_configured", False)),
details=value.get("details") if isinstance(value.get("details"), Mapping) else {},
)
+110
View File
@@ -0,0 +1,110 @@
"""AIOS V2 控制面、执行编排与可信运行来源证明。"""
from .control_plane import (
AgentRecord,
AuditEvent,
ControlPlaneError,
ControlPlaneService,
IdempotencyConflict,
InMemoryControlPlaneStore,
InvalidTransition,
NotFound,
PolicyDenied,
RequestContext,
RevisionConflict,
TaskRecord,
TaskState,
TenantIsolationError,
TenantRecord,
Unauthorized,
)
from .execution_tracking import ExecutionEvidence, ExecutionTracker
from .orchestrator import DispatchRecord, ExecutionOrchestrator, OrchestrationError
from .provenance import (
Ed25519PrivateKeySigner,
Ed25519PublicKeyVerifier,
ExecutionPath,
GitOperation,
PRODUCTION_POLICY_VERSION,
ProvenanceAuthorization,
ProvenanceControlledWriter,
ProvenanceDenied,
ProvenanceError,
ProvenanceExpired,
ProvenanceIssuer,
ProvenanceNotFound,
ProvenanceReplay,
ProvenanceRevoked,
ProvenanceSignatureError,
ProvenanceStatus,
ProvenanceVerifier,
RunProvenance,
SQLiteProvenanceStore,
digest_payload,
)
from .provenance_git import (
GitCommandResult,
GitExecutionContext,
ProvenanceGitError,
ProvenanceGitWriter,
)
from .provenance_operations import (
OperationContext,
OperationReceipt,
ProvenanceOperationGate,
build_provenance_writer_from_env,
context_from_environment,
)
__all__ = [
"AgentRecord",
"AuditEvent",
"ControlPlaneError",
"ControlPlaneService",
"DispatchRecord",
"Ed25519PrivateKeySigner",
"Ed25519PublicKeyVerifier",
"ExecutionEvidence",
"ExecutionOrchestrator",
"ExecutionPath",
"ExecutionTracker",
"GitCommandResult",
"GitExecutionContext",
"GitOperation",
"IdempotencyConflict",
"InMemoryControlPlaneStore",
"InvalidTransition",
"NotFound",
"OperationContext",
"OperationReceipt",
"OrchestrationError",
"PRODUCTION_POLICY_VERSION",
"PolicyDenied",
"ProvenanceAuthorization",
"ProvenanceControlledWriter",
"ProvenanceDenied",
"ProvenanceError",
"ProvenanceExpired",
"ProvenanceGitError",
"ProvenanceGitWriter",
"ProvenanceIssuer",
"ProvenanceNotFound",
"ProvenanceOperationGate",
"ProvenanceReplay",
"ProvenanceRevoked",
"ProvenanceSignatureError",
"ProvenanceStatus",
"ProvenanceVerifier",
"RequestContext",
"RevisionConflict",
"RunProvenance",
"SQLiteProvenanceStore",
"TaskRecord",
"TaskState",
"TenantIsolationError",
"TenantRecord",
"Unauthorized",
"build_provenance_writer_from_env",
"context_from_environment",
"digest_payload",
]
+807
View File
@@ -0,0 +1,807 @@
"""AIOS V2 production-candidate application entrypoint."""
from __future__ import annotations
import dataclasses
import json
import os
import stat
import urllib.parse
from enum import Enum
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Mapping
from aios_hermes import HermesAdapter, HermesJobState, HermesResult, SQLiteHermesStore
from aios_openclaw import (
Ed25519CommandSigner,
HTTPRuntimeTransport,
OpenClawAdapter,
)
from aios_openclaw.callback import AuditCallbackSink, EventPublisher
from .console import CONSOLE_HTML
from .control_plane import AuditEvent, RequestContext, Unauthorized
from .dispatch_store import SQLiteDispatchRepository
from .execution_tracking import ExecutionEvidence, ExecutionTracker
from .gateway import GatewayApplication, error_response
from .gitops_status import collect_gitops_status
from .orchestrator import ExecutionOrchestrator
from .persistence import DurableControlPlaneService, SQLiteSnapshotRepository
from .provenance_operations import build_provenance_writer_from_env
def _json_value(value: Any) -> Any:
if dataclasses.is_dataclass(value):
return {
item.name: _json_value(getattr(value, item.name))
for item in dataclasses.fields(value)
}
if isinstance(value, Enum):
return value.value
if isinstance(value, Mapping):
return {str(key): _json_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set, frozenset)):
return [_json_value(item) for item in value]
return value
def _enabled(value: str | None) -> bool:
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
def _header(headers: Mapping[str, str], name: str) -> str | None:
target = name.lower()
for key, value in headers.items():
if str(key).lower() == target:
normalized = str(value or "").strip()
return normalized or None
return None
def _provenance_context(
headers: Mapping[str, str],
payload: Mapping[str, Any],
) -> tuple[str | None, str | None]:
header_provenance = _header(headers, "X-AIOS-Provenance-ID")
header_run = _header(headers, "X-AIOS-Run-ID")
body_provenance = str(payload.get("provenance_id") or "").strip() or None
body_run = str(payload.get("run_id") or "").strip() or None
if header_provenance and body_provenance and header_provenance != body_provenance:
raise ValueError("provenance_id header/body mismatch")
if header_run and body_run and header_run != body_run:
raise ValueError("run_id header/body mismatch")
return header_provenance or body_provenance, header_run or body_run
def _read_protected_file(path: Path, *, private: bool) -> bytes:
flags = os.O_RDONLY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(str(path), flags)
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode):
raise RuntimeError("key reference is not a regular file")
denied = 0o077 if private else 0o022
if metadata.st_mode & denied:
raise RuntimeError(
"private key file permissions must be 0600 or stricter"
if private
else "public key file must not be group/world writable"
)
chunks: list[bytes] = []
while True:
chunk = os.read(descriptor, 65536)
if not chunk:
break
chunks.append(chunk)
value = b"".join(chunks)
if not value:
raise RuntimeError("key file is empty")
return value
finally:
os.close(descriptor)
class FileCommandSignerResolver:
"""Resolve tenant/key-id scoped OpenClaw Ed25519 signing material."""
def __init__(self, path: str | Path, *, configured_key_id: str) -> None:
self.path = Path(path).resolve()
self.configured_key_id = str(configured_key_id or "").strip()
if not self.configured_key_id:
raise ValueError("configured_key_id is required")
def __call__(self, tenant_id: str, key_id: str) -> Ed25519CommandSigner:
if not str(tenant_id or "").strip():
raise RuntimeError("tenant_id is required for command signing")
if key_id != self.configured_key_id:
raise RuntimeError("OpenClaw signing key_id mismatch")
return Ed25519CommandSigner.from_bytes(
_read_protected_file(self.path, private=True),
key_id=key_id,
)
class ControlPlaneEventPublisher(EventPublisher):
"""Append Runtime callback events to the tenant audit stream.
Callback audit remains informational. Authoritative task reconciliation is
performed only by the orchestrator after dispatch/result validation.
"""
def __init__(self, service: DurableControlPlaneService) -> None:
self.service = service
def publish(self, event: Mapping[str, Any]) -> None:
tenant_id = str(event.get("tenant_id") or "").strip()
trace_id = str(event.get("trace_id") or "").strip()
command_id = str(event.get("command_id") or "").strip()
event_type = str(event.get("event_type") or "OPENCLAW_EVENT").strip()
if not tenant_id or not trace_id or not command_id:
raise ValueError("runtime audit event is missing tenant identity")
state_value = str(event.get("state") or "unknown").lower()
outcome = "allowed" if state_value == "completed" else state_value
runtime = str(event.get("runtime") or "openclaw").strip().lower()
principal_id = str(
event.get("principal_id") or f"{runtime}-runtime"
).strip()
audit = AuditEvent(
event_id=f"runtime_{command_id}_{len(self.service.store.audit_events) + 1}",
tenant_id=tenant_id,
principal_id=principal_id,
trace_id=trace_id,
action=event_type,
resource_type=f"{runtime}_execution",
resource_id=command_id,
outcome=outcome,
policy_version="runtime-v2",
details=dict(event.get("details") or {}),
)
with self.service.store.lock:
self.service.store.audit_events.append(audit)
self.service.repository.save(self.service._snapshot())
class CandidateApplication:
def __init__(
self,
gateway: GatewayApplication,
orchestrator: ExecutionOrchestrator,
tracker: ExecutionTracker,
hermes: HermesAdapter,
openclaw: OpenClawAdapter | None,
*,
hermes_lifecycle_api_enabled: bool = False,
) -> None:
self.gateway = gateway
self.orchestrator = orchestrator
self.tracker = tracker
self.hermes = hermes
self.openclaw = openclaw
self.hermes_lifecycle_api_enabled = bool(hermes_lifecycle_api_enabled)
self.runtime_events = ControlPlaneEventPublisher(orchestrator.control_plane)
def context(self, headers: Mapping[str, str]) -> RequestContext:
return self.gateway.context_from_headers(headers)
@staticmethod
def _require_runtime_role(context: RequestContext) -> None:
if not ({"runtime", "platform_admin"} & set(context.roles)):
raise Unauthorized("runtime reconciliation role is required")
@staticmethod
def _require_control_plane_cancel(context: RequestContext) -> None:
required = {"runtime", "platform_admin"}
if not required.issubset(set(context.roles)):
raise Unauthorized(
"Hermes cancellation requires trusted AIOS control-plane authority"
)
@staticmethod
def _required_identity(payload: Mapping[str, Any], name: str) -> str:
value = str(payload.get(name) or "").strip()
if not value:
raise ValueError(f"{name} is required")
return value
def _record_hermes_lifecycle(
self,
context: RequestContext,
dispatch: Any,
job: Any,
*,
action: str,
worker_id: str | None,
provenance_id: str,
run_id: str,
) -> None:
executor = worker_id or "aios-control-plane"
evidence = ExecutionEvidence(
execution_id=job.job_id,
tenant_id=job.tenant_id,
task_id=job.task_id,
agent_id=dispatch.agent_id,
executor=executor,
branch="runtime/hermes",
status=job.state.value.lower(),
progress_completed=0,
progress_total=3,
current_step=action,
provenance_id=provenance_id,
run_id=run_id,
)
current_evidence = self.tracker.claim(
evidence,
principal_id=context.principal_id,
provenance_id=provenance_id,
run_id=run_id,
)
if job.state in {
HermesJobState.COMPLETED,
HermesJobState.FAILED,
HermesJobState.CANCELLED,
}:
if current_evidence.completed_at is None:
self.tracker.complete(
tenant_id=job.tenant_id,
execution_id=job.job_id,
status=job.state.value.lower(),
test_status="runtime_terminal",
test_summary={"lifecycle_action": action},
result_summary={
"state": job.state.value,
"worker_id": worker_id,
"dispatch_id": dispatch.dispatch_id,
},
principal_id=context.principal_id,
provenance_id=provenance_id,
run_id=run_id,
)
else:
self.tracker.heartbeat(
tenant_id=job.tenant_id,
execution_id=job.job_id,
status=job.state.value.lower(),
current_step=action,
progress_completed=(1 if job.state == HermesJobState.RUNNING else 0),
progress_total=3,
principal_id=context.principal_id,
provenance_id=provenance_id,
run_id=run_id,
)
self.runtime_events.publish(
{
"runtime": "hermes",
"tenant_id": job.tenant_id,
"principal_id": context.principal_id,
"trace_id": job.trace_id,
"command_id": job.job_id,
"event_type": f"HERMES_{action.upper()}",
"state": job.state.value,
"details": {
"dispatch_id": dispatch.dispatch_id,
"task_id": job.task_id,
"worker_id": worker_id,
"cell_id": job.cell_id,
"profile_id": job.profile_id,
"revision": job.revision,
"provenance_id": provenance_id,
"run_id": run_id,
},
}
)
def handle_json(
self,
method: str,
raw_path: str,
headers: Mapping[str, str],
body: Any,
) -> tuple[int, Mapping[str, Any]]:
parsed = urllib.parse.urlparse(raw_path)
path = parsed.path.rstrip("/") or "/"
method = method.upper()
if method == "GET" and path == "/api/v2/gitops":
context = self.context(headers)
return HTTPStatus.OK, {
"tenant_id": context.tenant_id,
"gitops": collect_gitops_status().as_dict(),
}
if method == "GET" and path == "/api/v2/runtime":
context = self.context(headers)
runtimes: list[dict[str, Any]] = []
if self.openclaw is None:
runtimes.append(
{
"name": "openclaw",
"status": "disabled",
"details": {"reason": "runtime_not_configured"},
}
)
else:
try:
health = self.openclaw.health()
runtimes.append(
{
"name": "openclaw",
"status": health.status,
"details": _json_value(health),
}
)
except Exception as exc:
runtimes.append(
{
"name": "openclaw",
"status": "unavailable",
"details": {"message": str(exc)},
}
)
hermes_health = self.hermes.health()
runtimes.append(
{
"name": "hermes",
"status": hermes_health.status,
"details": _json_value(hermes_health),
}
)
return HTTPStatus.OK, {
"tenant_id": context.tenant_id,
"runtimes": runtimes,
}
if method == "GET" and path == "/api/v2/executions":
context = self.context(headers)
return HTTPStatus.OK, {
"executions": _json_value(
self.tracker.list(tenant_id=context.tenant_id)
)
}
dispatch_match = __import__("re").fullmatch(
r"/api/v2/tasks/([^/]+)/dispatch", path
)
if method == "POST" and dispatch_match:
context = self.context(headers)
task_id = urllib.parse.unquote(dispatch_match.group(1))
payload = body if isinstance(body, Mapping) else {}
timeout = payload.get("timeout_seconds")
provenance_id, run_id = _provenance_context(headers, payload)
record = self.orchestrator.dispatch(
context,
task_id=task_id,
timeout_seconds=float(timeout) if timeout is not None else None,
provenance_id=provenance_id,
run_id=run_id,
)
return HTTPStatus.ACCEPTED, {"dispatch": _json_value(record)}
lifecycle_match = __import__("re").fullmatch(
r"/api/v2/internal/runtime/hermes/lifecycle/"
r"(claim|start|heartbeat|complete|fail|cancel|status)",
path,
)
if method == "POST" and lifecycle_match:
if not self.hermes_lifecycle_api_enabled:
return HTTPStatus.NOT_FOUND, {
"error": "not_found",
"message": "route not found",
}
context = self.context(headers)
self._require_runtime_role(context)
if not isinstance(body, Mapping):
raise ValueError("JSON body must be an object")
payload = body
action = lifecycle_match.group(1)
identity = {
name: self._required_identity(payload, name)
for name in (
"dispatch_id",
"job_id",
"tenant_id",
"task_id",
"trace_id",
"principal_id",
"provenance_id",
"run_id",
"cell_id",
"profile_id",
)
}
header_provenance, header_run = _provenance_context(headers, payload)
if header_provenance != identity["provenance_id"]:
raise ValueError("provenance_id context mismatch")
if header_run != identity["run_id"]:
raise ValueError("run_id context mismatch")
requeued_job_ids = self.hermes.requeue_expired()
dispatch, job = self.orchestrator.hermes_lifecycle_context(
context,
dispatch_id=identity["dispatch_id"],
job_id=identity["job_id"],
tenant_id=identity["tenant_id"],
task_id=identity["task_id"],
trace_id=identity["trace_id"],
principal_id=identity["principal_id"],
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
if job.cell_id != identity["cell_id"]:
raise ValueError("cell_id mismatch")
if job.profile_id != identity["profile_id"]:
raise ValueError("profile_id mismatch")
if action == "status":
if job.job_id in requeued_job_ids:
self._record_hermes_lifecycle(
context,
dispatch,
job,
action="lease_expired_requeue",
worker_id=None,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
task = self.orchestrator.control_plane.get_task(
context, dispatch.task_id
)
response: dict[str, Any] = {
"job": _json_value(job),
"dispatch": _json_value(dispatch),
"task": _json_value(task),
}
if job.state in {
HermesJobState.COMPLETED,
HermesJobState.FAILED,
HermesJobState.CANCELLED,
}:
response["result"] = _json_value(
self.hermes.result(job.job_id, tenant_id=job.tenant_id)
)
return HTTPStatus.OK, response
expected_revision = payload.get("expected_revision")
expected = (
int(expected_revision) if expected_revision is not None else None
)
if action == "cancel":
self._require_control_plane_cancel(context)
if payload.get("worker_id") is not None:
raise Unauthorized("Hermes workers may observe but not initiate cancel")
if payload.get("cancellation_source") != "aios-control-plane":
raise ValueError(
"cancellation_source must be aios-control-plane"
)
result = self.hermes.cancel(
job.job_id,
tenant_id=job.tenant_id,
expected_revision=expected,
)
else:
worker_id = self._required_identity(payload, "worker_id")
if action == "claim":
claimed = self.hermes.claim(
tenant_id=job.tenant_id,
cell_id=job.cell_id,
profile_id=job.profile_id,
worker_id=worker_id,
lease_seconds=(
int(payload["lease_seconds"])
if payload.get("lease_seconds") is not None
else None
),
job_id=job.job_id,
)
if claimed is None:
raise ValueError("Hermes job is not claimable")
bound = self.orchestrator.bind_hermes_worker(
context,
claimed,
dispatch_id=dispatch.dispatch_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
worker_id=worker_id,
)
self._record_hermes_lifecycle(
context,
bound,
claimed,
action="claim",
worker_id=worker_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
return HTTPStatus.OK, {
"job": _json_value(claimed),
"dispatch": _json_value(bound),
}
if action == "start":
started = self.hermes.start(
job.job_id,
tenant_id=job.tenant_id,
worker_id=worker_id,
expected_revision=expected,
)
self._record_hermes_lifecycle(
context,
dispatch,
started,
action="start",
worker_id=worker_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
return HTTPStatus.OK, {"job": _json_value(started)}
if action == "heartbeat":
heartbeat = self.hermes.heartbeat(
job.job_id,
tenant_id=job.tenant_id,
worker_id=worker_id,
lease_seconds=(
int(payload["lease_seconds"])
if payload.get("lease_seconds") is not None
else None
),
)
self._record_hermes_lifecycle(
context,
dispatch,
heartbeat,
action="heartbeat",
worker_id=worker_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
return HTTPStatus.OK, {"job": _json_value(heartbeat)}
if action == "complete":
output = payload.get("output")
if not isinstance(output, Mapping):
raise ValueError("output must be an object")
result = self.hermes.complete(
job.job_id,
tenant_id=job.tenant_id,
worker_id=worker_id,
output=output,
expected_revision=expected,
)
elif action == "fail":
error = payload.get("error")
if not isinstance(error, Mapping):
raise ValueError("error must be an object")
result = self.hermes.fail(
job.job_id,
tenant_id=job.tenant_id,
worker_id=worker_id,
error=error,
expected_revision=expected,
)
else:
raise ValueError("unsupported Hermes lifecycle action")
task = self.orchestrator.reconcile_hermes(
context,
result,
dispatch_id=dispatch.dispatch_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
updated_dispatch = self.orchestrator.dispatch_repository.get(
dispatch.dispatch_id, tenant_id=dispatch.tenant_id
)
terminal_job = self.hermes.get(job.job_id, tenant_id=job.tenant_id)
self._record_hermes_lifecycle(
context,
updated_dispatch,
terminal_job,
action=action,
worker_id=result.worker_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
return HTTPStatus.OK, {
"result": _json_value(result),
"task": _json_value(task),
"dispatch": _json_value(updated_dispatch),
}
if method == "POST" and path == "/api/v2/internal/runtime/hermes/reconcile":
context = self.context(headers)
self._require_runtime_role(context)
if not isinstance(body, Mapping):
raise ValueError("JSON body must be an object")
provenance_id, run_id = _provenance_context(headers, body)
if not provenance_id or not run_id:
raise ValueError("provenance_id and run_id are required")
dispatch_id = str(body.get("dispatch_id") or "").strip()
if not dispatch_id:
raise ValueError("dispatch_id is required")
result = HermesResult(
job_id=str(body["job_id"]),
tenant_id=str(body["tenant_id"]),
task_id=str(body["task_id"]),
trace_id=str(body["trace_id"]),
state=HermesJobState(str(body["state"])),
worker_id=(
str(body["worker_id"])
if body.get("worker_id") is not None
else None
),
output=body.get("output"),
error=body.get("error"),
started_at=body.get("started_at"),
finished_at=body.get("finished_at"),
)
task = self.orchestrator.reconcile_hermes(
context,
result,
dispatch_id=dispatch_id,
provenance_id=provenance_id,
run_id=run_id,
)
return HTTPStatus.OK, {"task": _json_value(task)}
if method == "POST" and path == "/api/v2/hermes/results/reconcile":
return HTTPStatus.GONE, {
"error": "RuntimeReconcileEndpointMoved",
"message": "use /api/v2/internal/runtime/hermes/reconcile",
}
return self.gateway.handle(method, raw_path, headers, body)
class CandidateRequestHandler(BaseHTTPRequestHandler):
server_version = "AIOSV2Candidate/2.0"
max_body_bytes = 1_048_576
@property
def application(self) -> CandidateApplication:
return self.server.application # type: ignore[attr-defined]
def _read_body(self) -> Any:
length = int(self.headers.get("Content-Length", "0") or "0")
if length < 0 or length > self.max_body_bytes:
raise ValueError("request body is too large")
if length == 0:
return {}
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
if content_type != "application/json":
raise ValueError("Content-Type must be application/json")
return json.loads(self.rfile.read(length).decode("utf-8"))
def _security_headers(self) -> None:
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'",
)
def _send_json(self, status_code: int, payload: Mapping[str, Any]) -> None:
encoded = json.dumps(
_json_value(payload), ensure_ascii=False, separators=(",", ":")
).encode("utf-8")
self.send_response(int(status_code))
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self._security_headers()
self.end_headers()
self.wfile.write(encoded)
def _send_console(self) -> None:
encoded = CONSOLE_HTML.encode("utf-8")
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self._security_headers()
self.end_headers()
self.wfile.write(encoded)
def _dispatch(self, method: str) -> None:
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
if method == "GET" and path in {"/", "/console"}:
self._send_console()
return
try:
body = self._read_body() if method == "POST" else {}
status_code, payload = self.application.handle_json(
method,
self.path,
{key: value for key, value in self.headers.items()},
body,
)
except Exception as exc:
status_code, payload = error_response(exc)
self._send_json(status_code, payload)
def do_GET(self) -> None: # noqa: N802
self._dispatch("GET")
def do_POST(self) -> None: # noqa: N802
self._dispatch("POST")
class CandidateHTTPServer(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(self, address: tuple[str, int], application: CandidateApplication) -> None:
self.application = application
super().__init__(address, CandidateRequestHandler)
def build_application() -> CandidateApplication:
state_dir = Path(os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
state_dir.mkdir(parents=True, exist_ok=True)
repository = SQLiteSnapshotRepository(state_dir / "control-plane.sqlite3")
dispatch_repository = SQLiteDispatchRepository(state_dir / "dispatches.sqlite3")
service = DurableControlPlaneService(repository)
require_provenance = _enabled(os.getenv("AIOS_REQUIRE_RUN_PROVENANCE", "0"))
provenance_writer = build_provenance_writer_from_env(
state_dir=state_dir, required=require_provenance
)
tracker = ExecutionTracker(
state_dir / "executions",
provenance_writer=provenance_writer,
require_provenance=require_provenance,
)
hermes = HermesAdapter(store=SQLiteHermesStore(state_dir / "hermes.sqlite3"))
openclaw_url = os.getenv("AIOS_OPENCLAW_URL", "").strip()
signing_file = os.getenv("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", "").strip()
signing_key_id = os.getenv(
"AIOS_OPENCLAW_SIGNING_KEY_ID", "openclaw-v2r1-candidate-ed25519"
).strip()
openclaw = None
resolver = None
if openclaw_url and signing_file:
resolver = FileCommandSignerResolver(
signing_file,
configured_key_id=signing_key_id,
)
transport = HTTPRuntimeTransport(
openclaw_url,
credential_ref=os.getenv("AIOS_OPENCLAW_CREDENTIAL_REF") or None,
)
openclaw = OpenClawAdapter(
transport,
resolver,
callback_sink=AuditCallbackSink(ControlPlaneEventPublisher(service)),
)
gateway = GatewayApplication(service)
orchestrator = ExecutionOrchestrator(
service,
openclaw=openclaw,
hermes=hermes,
openclaw_signing_material_resolver=resolver,
openclaw_key_id=signing_key_id,
provenance_writer=provenance_writer,
require_provenance=require_provenance,
dispatch_repository=dispatch_repository,
)
return CandidateApplication(
gateway,
orchestrator,
tracker,
hermes,
openclaw,
hermes_lifecycle_api_enabled=_enabled(
os.getenv("AIOS_HERMES_LIFECYCLE_API_ENABLED", "0")
),
)
def main() -> None:
host = os.getenv("AIOS_V2_HOST", "127.0.0.1")
port = int(os.getenv("AIOS_V2_PORT", "18920"))
server = CandidateHTTPServer((host, port), build_application())
print(f"AIOS V2 candidate listening on {host}:{port}")
server.serve_forever()
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
"""Dependency-free, role-aware AIOS unified portal.
The browser receives only server-authorized capabilities and tenant-scoped data.
It never selects a tenant, signs Provenance, mutates Git tags, or calls Runtime
services directly.
"""
CONSOLE_HTML = r'''<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>AIOS Unified Portal</title>
<style>
:root{font-family:Inter,"PingFang SC","Microsoft YaHei",ui-sans-serif,system-ui,sans-serif;color:#172033;background:#eef2f7;--nav:#0f172a;--card:#fff;--line:#dfe6ef;--muted:#667085;--blue:#2563eb;--green:#047857;--red:#b42318;--amber:#b54708}
*{box-sizing:border-box}body{margin:0;min-height:100vh}button,input,textarea,select{font:inherit}
.shell{display:grid;grid-template-columns:250px minmax(0,1fr);min-height:100vh}.sidebar{background:var(--nav);color:#e2e8f0;padding:22px 16px;position:sticky;top:0;height:100vh;overflow:auto}.brand{font-weight:800;font-size:20px;letter-spacing:.2px}.version{font-size:12px;color:#94a3b8;margin:5px 0 22px}.identity{padding:12px;background:#182338;border:1px solid #2b3950;border-radius:12px;margin-bottom:18px}.identity strong{display:block;color:#fff}.identity span{font-size:12px;color:#aebbd0;display:block;margin-top:3px;overflow-wrap:anywhere}.nav button{width:100%;display:flex;gap:10px;align-items:center;border:0;background:transparent;color:#cbd5e1;padding:11px 12px;border-radius:9px;text-align:left;cursor:pointer;margin:2px 0}.nav button:hover,.nav button.active{background:#24324b;color:#fff}.nav .dot{width:8px;height:8px;border-radius:50%;background:#60a5fa}.main{min-width:0}.topbar{height:70px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 26px;position:sticky;top:0;z-index:4}.topbar h1{font-size:19px;margin:0}.top-actions{display:flex;align-items:center;gap:10px}.timestamp{font-size:12px;color:var(--muted)}.content{padding:24px;max-width:1680px;margin:auto}.notice{background:#eff6ff;border:1px solid #bfdbfe;border-radius:12px;padding:12px 14px;color:#1e3a8a;font-size:13px;margin-bottom:18px}.error{background:#fef3f2;border:1px solid #fecdca;color:var(--red);border-radius:10px;padding:10px 12px;white-space:pre-wrap;margin-bottom:14px;display:none}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px;margin-bottom:18px}.card,.panel{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:0 1px 3px rgba(16,24,40,.05)}.card{padding:16px}.card small{color:var(--muted);font-weight:600}.card b{display:block;font-size:28px;margin-top:7px}.panel{padding:17px;margin-bottom:16px;overflow:auto}.panel h2{font-size:16px;margin:0}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:13px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(410px,1fr));gap:16px}.wide{grid-column:1/-1}.muted{color:var(--muted)}.good{color:var(--green);font-weight:700}.bad{color:var(--red);font-weight:700}.warn{color:var(--amber);font-weight:700}.pill{display:inline-block;border:1px solid #cbd5e1;border-radius:999px;padding:3px 8px;font-size:11px;margin:2px;background:#f8fafc}.pill.good{background:#ecfdf3;border-color:#a6f4c5}.pill.bad{background:#fef3f2;border-color:#fecdca}.toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.btn{border:0;border-radius:9px;padding:9px 12px;background:var(--blue);color:#fff;cursor:pointer;font-weight:650}.btn.secondary{background:#fff;color:#344054;border:1px solid #d0d5dd}.btn.danger{background:#d92d20}.btn:disabled{opacity:.45;cursor:not-allowed}.icon-btn{padding:7px 10px}.view{display:none}.view.active{display:block}table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;padding:9px 8px;border-bottom:1px solid #edf0f4;vertical-align:top}th{color:#667085;font-weight:700;white-space:nowrap}td{overflow-wrap:anywhere}.empty{padding:24px;text-align:center;color:var(--muted)}.timeline{border-left:2px solid #dbe4f0;margin-left:8px;padding-left:20px}.event{position:relative;padding:0 0 17px}.event:before{content:"";position:absolute;left:-27px;top:4px;width:11px;height:11px;border-radius:50%;background:#3b82f6;border:3px solid #fff;box-shadow:0 0 0 1px #93c5fd}.event strong{font-size:13px}.event p{margin:4px 0;color:var(--muted);font-size:12px}.chain{display:flex;align-items:stretch;gap:7px;overflow:auto;padding:5px 0}.chain-item{min-width:135px;border:1px solid var(--line);border-radius:10px;padding:10px;background:#f8fafc}.chain-arrow{display:flex;align-items:center;color:#94a3b8}.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:12px}.field label{font-size:12px;color:#475467;font-weight:650;display:block;margin-bottom:5px}.field input,.field textarea,.field select{width:100%;border:1px solid #d0d5dd;border-radius:9px;padding:9px;background:#fff}.field textarea{min-height:90px;resize:vertical}.dialog{border:0;border-radius:14px;padding:0;max-width:650px;width:calc(100% - 32px);box-shadow:0 20px 60px rgba(15,23,42,.28)}.dialog::backdrop{background:rgba(15,23,42,.6)}.dialog-head{padding:16px 18px;border-bottom:1px solid var(--line);font-weight:750}.dialog-body{padding:18px}.dialog-actions{padding:14px 18px;border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:8px}.kv{display:grid;grid-template-columns:150px minmax(0,1fr);gap:6px 12px;font-size:12px}.kv div:nth-child(odd){color:var(--muted)}pre{white-space:pre-wrap;word-break:break-word;font-size:11px;background:#0f172a;color:#dbeafe;border-radius:10px;padding:12px;max-height:300px;overflow:auto}
@media(max-width:900px){.shell{grid-template-columns:1fr}.sidebar{height:auto;position:static}.nav{display:flex;overflow:auto}.nav button{min-width:max-content}.topbar{position:static}.grid{grid-template-columns:1fr}.content{padding:16px}}
</style>
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="brand">AIOS Unified Portal</div><div class="version">V2R1C1 · Role-aware Control Plane</div>
<div class="identity"><strong id="principal">加载身份…</strong><span id="tenant"></span><span id="roles"></span></div>
<nav id="navigation" class="nav"></nav>
</aside>
<section class="main">
<header class="topbar"><h1 id="pageTitle">系统总览</h1><div class="top-actions"><span id="updated" class="timestamp">尚未加载</span><button class="btn secondary icon-btn" id="refresh">刷新</button></div></header>
<main class="content">
<div class="notice">身份、租户和角色由可信反向代理注入。门户只调用 AIOS Control Plane;Provenance 签发、Tag、生产部署与 Runtime 权威状态仍由受控流程执行。</div>
<div id="error" class="error"></div>
<section id="overview" class="view active"></section>
<section id="tasks" class="view"></section>
<section id="authorization" class="view"></section>
<section id="runtime" class="view"></section>
<section id="evidence" class="view"></section>
<section id="audit" class="view"></section>
<section id="release" class="view"></section>
</main>
</section>
</div>
<dialog id="taskDialog" class="dialog">
<div class="dialog-head">创建任务</div><form id="taskForm"><div class="dialog-body form-grid">
<div class="field"><label>Agent ID</label><input name="agent_id" required></div>
<div class="field"><label>Action</label><input name="action" required></div>
<div class="field"><label>Idempotency Key</label><input name="idempotency_key" required></div>
<div class="field wide"><label>Payload JSON</label><textarea name="payload">{}</textarea></div>
</div><div class="dialog-actions"><button type="button" class="btn secondary" data-close="taskDialog">取消</button><button class="btn">创建</button></div></form>
</dialog>
<dialog id="confirmDialog" class="dialog"><div class="dialog-head" id="confirmTitle">确认操作</div><form id="confirmForm"><div class="dialog-body"><p id="confirmText"></p><div class="field"><label>原因</label><textarea id="confirmReason" required></textarea></div></div><div class="dialog-actions"><button type="button" class="btn secondary" data-close="confirmDialog">取消</button><button class="btn danger">确认</button></div></form></dialog>
<script>
const $=s=>document.querySelector(s), esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const state={identity:null,current:'overview',cache:{},confirm:null};
const labels={overview:'系统总览',tasks:'任务中心',authorization:'授权中心',runtime:'运行与运维',evidence:'执行证据',audit:'审计中心',release:'GitOps 与发布'};
function has(c){return !!state.identity?.capabilities?.includes(c)}
async function api(path,options={}){const r=await fetch(path,{credentials:'same-origin',headers:{'Accept':'application/json','Content-Type':'application/json',...(options.headers||{})},...options});const d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(`${r.status} ${d.message||d.error||path}`);return d}
function err(e){const el=$('#error');el.textContent=e?.message||String(e);el.style.display='block'}function clearErr(){$('#error').style.display='none';$('#error').textContent=''}
function pill(v){const s=String(v??'unknown'),c=/complete|pass|healthy|active|synced|approved|used/i.test(s)?'good':/fail|deny|reject|revoke|expire|cancel|unavailable/i.test(s)?'bad':'';return `<span class="pill ${c}">${esc(s)}</span>`}
function table(cols,rows){if(!rows?.length)return '<div class="empty">暂无数据</div>';return `<table><thead><tr>${cols.map(x=>`<th>${esc(x[0])}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${cols.map(x=>`<td>${x[1](r)}</td>`).join('')}</tr>`).join('')}</tbody></table>`}
function card(label,value,detail=''){return `<div class="card"><small>${esc(label)}</small><b>${esc(value)}</b>${detail?`<span class="muted">${esc(detail)}</span>`:''}</div>`}
function json(v){return `<pre>${esc(JSON.stringify(v,null,2))}</pre>`}
function show(view){state.current=view;document.querySelectorAll('.view').forEach(x=>x.classList.toggle('active',x.id===view));document.querySelectorAll('.nav button').forEach(x=>x.classList.toggle('active',x.dataset.view===view));$('#pageTitle').textContent=labels[view]||view;load(view)}
function navigation(){const nav=$('#navigation');nav.innerHTML=(state.identity.navigation||[]).map(x=>`<button data-view="${esc(x.id)}"><span class="dot"></span>${esc(x.label)}</button>`).join('');nav.querySelectorAll('button').forEach(b=>b.onclick=()=>show(b.dataset.view));}
async function identity(){state.identity=await api('/api/v2/me/capabilities');$('#principal').textContent=state.identity.principal_id;$('#tenant').textContent=`租户:${state.identity.tenant_id}`;$('#roles').textContent=`角色:${state.identity.roles.join(', ')||'无'}`;navigation();if(!state.identity.navigation.some(x=>x.id===state.current))state.current=state.identity.navigation[0]?.id||'overview';show(state.current)}
async function load(view){clearErr();try{if(view==='overview')await overview();if(view==='tasks')await tasks();if(view==='authorization')await authorizations();if(view==='runtime')await runtime();if(view==='evidence')await evidence();if(view==='audit')await audit();if(view==='release')await release();$('#updated').textContent=`更新于 ${new Date().toLocaleString()}`}catch(e){err(e)}}
async function overview(){const d=await api('/api/v2/portal/overview');state.cache.overview=d;const s=d.summary||{},rt=d.sections?.runtime?.runtimes||[],rel=d.sections?.release||{};$('#overview').innerHTML=`<div class="cards">${card('任务',s.tasks||0)}${card('活动 Dispatch',s.active_dispatches||0,`总数 ${s.dispatches||0}`)}${card('执行证据',s.evidence||0)}${card('审计事件',s.audit_events||0)}${card('当前版本',rel.version||'—',rel.status||'')}${card('授权 Grant',d.sections?.authorization?.active_grants||0,`总数 ${d.sections?.authorization?.grants||0}`)}</div><div class="grid"><div class="panel"><div class="panel-head"><h2>Runtime 健康</h2></div>${table([['Runtime',x=>esc(x.name)],['状态',x=>pill(x.status)],['版本/详情',x=>esc(x.details?.version||x.details?.runtime_id||'—')]],rt)}</div><div class="panel"><div class="panel-head"><h2>任务状态</h2></div>${table([['状态',x=>pill(x[0])],['数量',x=>esc(x[1])]],Object.entries(s.task_states||{}))}</div><div class="panel wide"><div class="panel-head"><h2>最近任务</h2></div>${table([['任务',x=>esc(x.task_id)],['Agent',x=>esc(x.agent_id)],['动作',x=>esc(x.action)],['状态',x=>pill(x.state)],['更新时间',x=>esc(x.updated_at)]],d.recent?.tasks||[])}</div></div>`}
async function tasks(){const d=await api('/api/v2/portal/tasks');state.cache.tasks=d.items||[];const create=has('task.create')?'<button class="btn" id="newTask">创建任务</button>':'';$('#tasks').innerHTML=`<div class="panel"><div class="panel-head"><h2>任务全链路</h2><div class="toolbar">${create}</div></div>${(d.items||[]).map(taskBlock).join('')||'<div class="empty">暂无任务</div>'}</div>`;$('#newTask')?.addEventListener('click',()=>$('#taskDialog').showModal());document.querySelectorAll('[data-task-action]').forEach(b=>b.onclick=()=>taskAction(b.dataset.taskId,b.dataset.taskAction,b.dataset.revision))}
function taskBlock(x){const t=x.task||{},d=x.dispatch,e=x.evidence||[],a=x.audit||[],actions=(x.available_actions||[]).map(v=>`<button class="btn ${v==='cancel'?'danger':'secondary'}" data-task-action="${v}" data-task-id="${esc(t.task_id)}" data-revision="${esc(t.revision)}">${{approve:'审批',cancel:'取消',dispatch:'调度'}[v]||v}</button>`).join('');return `<div class="panel" style="background:#fbfcfe"><div class="panel-head"><h2>${esc(t.task_id)} · ${esc(t.action)}</h2><div class="toolbar">${pill(t.state)}${actions}</div></div><div class="chain"><div class="chain-item"><b>Task</b><div>${esc(t.agent_id)}</div><small>${esc(t.created_by)}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Approval</b><div>${esc(t.approved_by||'待审批')}</div><small>revision ${esc(t.revision)}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Provenance</b><div>${esc(d?.provenance_id||'未调度')}</div><small>${esc(d?.authorization_id||'')}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Dispatch</b><div>${esc(d?.dispatch_id||'—')}</div><small>${pill(d?.state||'not_created')}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Runtime</b><div>${esc(d?.runtime||'—')}</div><small>${esc(d?.runtime_execution_id||'')}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Evidence</b><div>${e.length} 条</div><small>Audit ${a.length} 条</small></div></div></div>`}
async function taskAction(id,action,revision){clearErr();try{let body={expected_revision:Number(revision)};if(action==='dispatch'){body={};const p=prompt('Provenance ID(生产调度必填)','');const r=prompt('Run ID(生产调度必填)','');if(p)body.provenance_id=p;if(r)body.run_id=r}await api(`/api/v2/portal/tasks/${encodeURIComponent(id)}/${action}`,{method:'POST',body:JSON.stringify(body)});await tasks()}catch(e){err(e)}}
async function authorizations(){const d=await api('/api/v2/portal/authorizations');state.cache.authorization=d;const workflows=(d.workflows||[]),grants=(d.grants||[]);$('#authorization').innerHTML=`<div class="cards">${card('Workflow',d.summary?.workflows||0)}${card('Grant',d.summary?.grants||0)}${card('有效 Grant',d.summary?.active_grants||0)}${card('已撤销',d.summary?.revoked_grants||0)}</div><div class="panel"><div class="panel-head"><h2>Workflow Provenance</h2></div>${table([['ID',x=>esc(x.provenance_id)],['Run / Task',x=>`${esc(x.run_id)}<br>${esc(x.task_id)}`],['Principal / Agent',x=>`${esc(x.principal_id)}<br>${esc(x.agent_id)}`],['状态',x=>pill(x.status)],['有效期',x=>esc(x.expires_at)],['使用',x=>esc(x.usage_count)],['操作',x=>has('provenance.revoke')&&x.status!=='revoked'?`<button class="btn danger" data-revoke-workflow="${esc(x.provenance_id)}">撤销</button>`:'—']],workflows)}</div><div class="panel"><div class="panel-head"><h2>Child Grants</h2></div>${table([['Grant',x=>esc(x.grant_id)],['类型 / Scope',x=>`${esc(x.grant_type)}<br>${esc(x.execution_path)}`],['Git 操作',x=>esc((x.allowed_git_operations||[]).join(', ')||'—')],['状态',x=>pill(x.status)],['有效期',x=>esc(x.expires_at)],['使用',x=>esc(x.usage_count)],['操作',x=>has('provenance.revoke')&&x.status!=='revoked'?`<button class="btn danger" data-revoke-grant="${esc(x.grant_id)}">撤销</button>`:'—']],grants)}</div>`;document.querySelectorAll('[data-revoke-workflow]').forEach(b=>b.onclick=()=>confirmOp('撤销 Workflow',`撤销 ${b.dataset.revokeWorkflow} 将使所有子 Grant 失效。`,reason=>api(`/api/v2/portal/provenance/${encodeURIComponent(b.dataset.revokeWorkflow)}/revoke`,{method:'POST',body:JSON.stringify({reason})}).then(authorizations)));document.querySelectorAll('[data-revoke-grant]').forEach(b=>b.onclick=()=>confirmOp('撤销 Grant',`撤销 ${b.dataset.revokeGrant}。`,reason=>api(`/api/v2/portal/grants/${encodeURIComponent(b.dataset.revokeGrant)}/revoke`,{method:'POST',body:JSON.stringify({reason})}).then(authorizations)))}
async function runtime(){const d=await api('/api/v2/portal/runtime');const runtimes=d.runtimes||[],dispatches=d.dispatches||[],jobs=d.hermes_jobs||[];$('#runtime').innerHTML=`<div class="grid"><div class="panel"><div class="panel-head"><h2>Runtime</h2></div>${table([['名称',x=>esc(x.name)],['状态',x=>pill(x.status)],['详情',x=>esc(JSON.stringify(x.details||{}))]],runtimes)}</div><div class="panel"><div class="panel-head"><h2>Hermes Queue / Lease</h2></div>${table([['Job',x=>esc(x.job_id)],['Cell / Profile',x=>`${esc(x.cell_id)} / ${esc(x.profile_id)}`],['Worker',x=>esc(x.assigned_worker_id||'—')],['状态',x=>pill(x.state)],['Lease',x=>esc(x.lease_expires_at||'—')]],jobs)}</div><div class="panel wide"><div class="panel-head"><h2>Dispatch</h2></div>${table([['Dispatch',x=>esc(x.dispatch_id)],['Task',x=>esc(x.task_id)],['Runtime',x=>esc(x.runtime)],['执行 ID',x=>esc(x.runtime_execution_id)],['Principal',x=>esc(x.principal_id)],['状态',x=>pill(x.state)]],dispatches)}</div></div>`}
async function evidence(){const d=await api('/api/v2/portal/evidence');$('#evidence').innerHTML=`<div class="panel"><div class="panel-head"><h2>Execution Evidence</h2></div>${table([['Execution',x=>esc(x.execution_id)],['Task / Agent',x=>`${esc(x.task_id)}<br>${esc(x.agent_id)}`],['Executor',x=>esc(x.executor)],['状态',x=>pill(x.status)],['进度',x=>`${esc(x.progress_completed)}/${esc(x.progress_total)}`],['Test',x=>pill(x.test_status)],['Commit',x=>esc(x.last_commit||'—')],['授权',x=>esc(x.last_authorization_id||'—')]],d.executions||[])}</div>`}
async function audit(){const d=await api('/api/v2/portal/audit');const events=(d.events||[]).slice().reverse();$('#audit').innerHTML=`<div class="panel"><div class="panel-head"><h2>统一审计时间线</h2></div><div class="timeline">${events.map(x=>`<div class="event"><strong>${esc(x.action)} · ${pill(x.outcome)}</strong><p>${esc(x.created_at)} · ${esc(x.principal_id)} · ${esc(x.resource_type)}/${esc(x.resource_id)}</p><div class="muted">Trace ${esc(x.trace_id)} · Policy ${esc(x.policy_version)}</div></div>`).join('')||'<div class="empty">暂无审计事件</div>'}</div></div>`}
async function release(){const d=await api('/api/v2/portal/release');const g=d.gitops||{};$('#release').innerHTML=`<div class="cards">${card('版本',d.version||'—')}${card('Baseline',d.baseline||'—')}${card('发布状态',d.status||'—')}${card('Git 同步',g.sync_status||'unknown')}</div><div class="grid"><div class="panel"><div class="panel-head"><h2>当前 Release</h2></div><div class="kv"><div>Release OID</div><div>${esc(d.release_oid)}</div><div>Branch</div><div>${esc(g.workspace_branch||g.branch||'—')}</div><div>Workspace</div><div>${pill(g.workspace_clean===true?'clean':g.workspace_clean===false?'dirty':'unknown')}</div><div>Local Sync</div><div>${pill(g.local_sync||'unknown')}</div><div>Gitea Sync</div><div>${pill(g.gitea_sync||'unknown')}</div></div></div><div class="panel"><div class="panel-head"><h2>治理操作</h2></div><p class="muted">门户不直接移动 Tag、合并主线或部署生产。以下仅为可发起的 GitOps 请求目录。</p>${(d.operations||[]).map(x=>pill(x)).join('')||'<span class="muted">当前角色只读</span>'}</div><div class="panel wide"><div class="panel-head"><h2>原始 GitOps 状态</h2></div>${json(g)}</div></div>`}
function confirmOp(title,text,fn){state.confirm=fn;$('#confirmTitle').textContent=title;$('#confirmText').textContent=text;$('#confirmReason').value='';$('#confirmDialog').showModal()}
$('#taskForm').onsubmit=async e=>{e.preventDefault();clearErr();try{const fd=new FormData(e.target),payload=JSON.parse(fd.get('payload')||'{}');await api('/api/v2/portal/tasks',{method:'POST',body:JSON.stringify({agent_id:fd.get('agent_id'),action:fd.get('action'),idempotency_key:fd.get('idempotency_key'),payload})});$('#taskDialog').close();e.target.reset();await tasks()}catch(e){err(e)}};
$('#confirmForm').onsubmit=async e=>{e.preventDefault();const fn=state.confirm;$('#confirmDialog').close();state.confirm=null;try{await fn?.($('#confirmReason').value)}catch(e){err(e)}};
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>document.getElementById(b.dataset.close).close());$('#refresh').onclick=()=>load(state.current);
identity().catch(err);setInterval(()=>load(state.current),30000);
</script>
</body></html>'''
+627
View File
@@ -0,0 +1,627 @@
"""Tenant-isolated AIOS V2 control-plane domain service.
This module deliberately contains no OpenClaw or Hermes implementation details.
It owns authoritative tenant, agent, task-lifecycle, idempotency and audit state.
Runtime adapters consume approved tasks through a separate boundary.
"""
from __future__ import annotations
import copy
import hashlib
import json
import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Callable, Iterable, Mapping
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _new_id(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex}"
def _canonical_digest(value: Mapping[str, Any]) -> str:
encoded = json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
class ControlPlaneError(RuntimeError):
"""Base error returned by the control-plane domain."""
class Unauthorized(ControlPlaneError):
"""The caller is not authenticated or lacks the required role."""
class TenantIsolationError(ControlPlaneError):
"""A request attempted to cross a tenant boundary."""
class NotFound(ControlPlaneError):
"""A tenant-scoped resource does not exist or is not visible."""
class InvalidTransition(ControlPlaneError):
"""A task state transition violates the lifecycle contract."""
class IdempotencyConflict(ControlPlaneError):
"""An idempotency key was reused for a different command."""
class RevisionConflict(ControlPlaneError):
"""Optimistic task revision does not match current state."""
class PolicyDenied(ControlPlaneError):
"""The policy gate denied a control-plane operation."""
@dataclass(frozen=True)
class RequestContext:
"""Authenticated request identity propagated across all AIOS layers."""
tenant_id: str
principal_id: str
trace_id: str
roles: frozenset[str] = field(default_factory=frozenset)
session_id: str | None = None
policy_version: str = "v1"
def __post_init__(self) -> None:
for name in ("tenant_id", "principal_id", "trace_id"):
if not str(getattr(self, name, "")).strip():
raise ValueError(f"{name} is required")
@classmethod
def create(
cls,
*,
tenant_id: str,
principal_id: str,
roles: Iterable[str] = (),
trace_id: str | None = None,
session_id: str | None = None,
policy_version: str = "v1",
) -> "RequestContext":
return cls(
tenant_id=tenant_id.strip(),
principal_id=principal_id.strip(),
trace_id=trace_id or _new_id("trace"),
roles=frozenset(str(role).strip() for role in roles if str(role).strip()),
session_id=session_id,
policy_version=policy_version,
)
@dataclass(frozen=True)
class TenantRecord:
tenant_id: str
display_name: str
status: str = "active"
metadata: Mapping[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=_utc_now)
updated_at: str = field(default_factory=_utc_now)
@dataclass(frozen=True)
class AgentRecord:
agent_id: str
tenant_id: str
display_name: str
runtime: str
capabilities: tuple[str, ...] = field(default_factory=tuple)
enabled: bool = True
metadata: Mapping[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=_utc_now)
updated_at: str = field(default_factory=_utc_now)
class TaskState(str, Enum):
CREATED = "CREATED"
APPROVED = "APPROVED"
RUNNING = "RUNNING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
TERMINAL_TASK_STATES = frozenset(
{TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED}
)
ALLOWED_TASK_TRANSITIONS: Mapping[TaskState, frozenset[TaskState]] = {
TaskState.CREATED: frozenset({TaskState.APPROVED, TaskState.CANCELLED}),
TaskState.APPROVED: frozenset({TaskState.RUNNING, TaskState.CANCELLED}),
TaskState.RUNNING: frozenset(
{TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED}
),
TaskState.COMPLETED: frozenset(),
TaskState.FAILED: frozenset(),
TaskState.CANCELLED: frozenset(),
}
@dataclass(frozen=True)
class TaskRecord:
task_id: str
tenant_id: str
agent_id: str
action: str
payload: Mapping[str, Any]
state: TaskState = TaskState.CREATED
revision: int = 1
idempotency_key: str | None = None
request_digest: str = ""
result: Mapping[str, Any] | None = None
error: Mapping[str, Any] | None = None
created_by: str = ""
approved_by: str | None = None
created_at: str = field(default_factory=_utc_now)
updated_at: str = field(default_factory=_utc_now)
@dataclass(frozen=True)
class AuditEvent:
event_id: str
tenant_id: str
principal_id: str
trace_id: str
action: str
resource_type: str
resource_id: str
outcome: str
policy_version: str
details: Mapping[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=_utc_now)
@dataclass(frozen=True)
class PolicyDecision:
allowed: bool
reason: str
policy_version: str
PolicyEvaluator = Callable[[RequestContext, str, Mapping[str, Any]], PolicyDecision]
class InMemoryControlPlaneStore:
"""Thread-safe reference store used by tests and the first gateway slice.
Production backends must preserve the same tenant-scoped interface and add
database row-level security, migrations, backup and HA evidence.
"""
def __init__(self) -> None:
self.lock = threading.RLock()
self.tenants: dict[str, TenantRecord] = {}
self.agents: dict[str, AgentRecord] = {}
self.tasks: dict[str, TaskRecord] = {}
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
self.audit_events: list[AuditEvent] = []
class ControlPlaneService:
"""Authoritative tenant, agent and task lifecycle service."""
def __init__(
self,
store: InMemoryControlPlaneStore | None = None,
*,
policy_evaluator: PolicyEvaluator | None = None,
) -> None:
self.store = store or InMemoryControlPlaneStore()
self.policy_evaluator = policy_evaluator or self._default_policy
@staticmethod
def _default_policy(
context: RequestContext,
action: str,
resource: Mapping[str, Any],
) -> PolicyDecision:
del resource
role_requirements = {
"tenant.register": {"platform_admin"},
"tenant.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
"agent.register": {"tenant_admin", "operator", "platform_admin"},
"agent.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
"task.create": {"tenant_admin", "operator", "platform_admin"},
"task.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
"task.approve": {"tenant_admin", "approver", "platform_admin"},
"task.start": {"runtime", "operator", "platform_admin"},
"task.complete": {"runtime", "operator", "platform_admin"},
"task.fail": {"runtime", "operator", "platform_admin"},
"task.cancel": {"tenant_admin", "operator", "approver", "platform_admin"},
"audit.read": {"tenant_admin", "auditor", "platform_admin"},
}
required = role_requirements.get(action, {"platform_admin"})
allowed = bool(context.roles.intersection(required))
return PolicyDecision(
allowed=allowed,
reason="role_allowed" if allowed else "required_role_missing",
policy_version=context.policy_version,
)
def _require_context(self, context: RequestContext) -> None:
if not context.principal_id or not context.tenant_id or not context.trace_id:
raise Unauthorized("authenticated tenant context is required")
def _authorize(
self,
context: RequestContext,
action: str,
resource: Mapping[str, Any],
) -> PolicyDecision:
self._require_context(context)
decision = self.policy_evaluator(context, action, resource)
if not decision.allowed:
self._audit(
context,
action=action,
resource_type=str(resource.get("resource_type", "policy")),
resource_id=str(resource.get("resource_id", "unknown")),
outcome="denied",
details={"reason": decision.reason},
)
raise PolicyDenied(f"{action} denied: {decision.reason}")
return decision
@staticmethod
def _assert_same_tenant(context: RequestContext, tenant_id: str) -> None:
if context.tenant_id != tenant_id and "platform_admin" not in context.roles:
raise TenantIsolationError("cross-tenant access denied")
def _audit(
self,
context: RequestContext,
*,
action: str,
resource_type: str,
resource_id: str,
outcome: str,
details: Mapping[str, Any] | None = None,
) -> AuditEvent:
event = AuditEvent(
event_id=_new_id("audit"),
tenant_id=context.tenant_id,
principal_id=context.principal_id,
trace_id=context.trace_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
outcome=outcome,
policy_version=context.policy_version,
details=copy.deepcopy(dict(details or {})),
)
self.store.audit_events.append(event)
return event
def register_tenant(
self,
context: RequestContext,
*,
tenant_id: str,
display_name: str,
metadata: Mapping[str, Any] | None = None,
) -> TenantRecord:
tenant_id = tenant_id.strip()
if not tenant_id or not display_name.strip():
raise ValueError("tenant_id and display_name are required")
self._authorize(
context,
"tenant.register",
{"resource_type": "tenant", "resource_id": tenant_id},
)
self._assert_same_tenant(context, tenant_id)
with self.store.lock:
if tenant_id in self.store.tenants:
raise ControlPlaneError(f"tenant already exists: {tenant_id}")
tenant = TenantRecord(
tenant_id=tenant_id,
display_name=display_name.strip(),
metadata=copy.deepcopy(dict(metadata or {})),
)
self.store.tenants[tenant_id] = tenant
self._audit(
context,
action="tenant.register",
resource_type="tenant",
resource_id=tenant_id,
outcome="allowed",
)
return tenant
def get_tenant(self, context: RequestContext, tenant_id: str | None = None) -> TenantRecord:
tenant_id = tenant_id or context.tenant_id
self._authorize(
context,
"tenant.read",
{"resource_type": "tenant", "resource_id": tenant_id},
)
self._assert_same_tenant(context, tenant_id)
with self.store.lock:
tenant = self.store.tenants.get(tenant_id)
if tenant is None:
raise NotFound("tenant not found")
return tenant
def register_agent(
self,
context: RequestContext,
*,
agent_id: str,
display_name: str,
runtime: str,
capabilities: Iterable[str] = (),
metadata: Mapping[str, Any] | None = None,
) -> AgentRecord:
self._authorize(
context,
"agent.register",
{"resource_type": "agent", "resource_id": agent_id},
)
tenant = self.get_tenant(context)
if tenant.status != "active":
raise ControlPlaneError("tenant is not active")
normalized_capabilities = tuple(
sorted({str(item).strip() for item in capabilities if str(item).strip()})
)
if not agent_id.strip() or not display_name.strip() or not runtime.strip():
raise ValueError("agent_id, display_name and runtime are required")
with self.store.lock:
existing = self.store.agents.get(agent_id)
if existing is not None and existing.tenant_id != context.tenant_id:
raise TenantIsolationError("agent id belongs to another tenant")
if existing is not None:
raise ControlPlaneError(f"agent already exists: {agent_id}")
agent = AgentRecord(
agent_id=agent_id.strip(),
tenant_id=context.tenant_id,
display_name=display_name.strip(),
runtime=runtime.strip(),
capabilities=normalized_capabilities,
metadata=copy.deepcopy(dict(metadata or {})),
)
self.store.agents[agent.agent_id] = agent
self._audit(
context,
action="agent.register",
resource_type="agent",
resource_id=agent.agent_id,
outcome="allowed",
details={"runtime": agent.runtime},
)
return agent
def get_agent(self, context: RequestContext, agent_id: str) -> AgentRecord:
self._authorize(
context,
"agent.read",
{"resource_type": "agent", "resource_id": agent_id},
)
with self.store.lock:
agent = self.store.agents.get(agent_id)
if agent is None:
raise NotFound("agent not found")
self._assert_same_tenant(context, agent.tenant_id)
return agent
def list_agents(self, context: RequestContext) -> list[AgentRecord]:
self._authorize(
context,
"agent.read",
{"resource_type": "agent_collection", "resource_id": context.tenant_id},
)
with self.store.lock:
return [
agent
for agent in self.store.agents.values()
if agent.tenant_id == context.tenant_id
]
def create_task(
self,
context: RequestContext,
*,
agent_id: str,
action: str,
payload: Mapping[str, Any] | None = None,
idempotency_key: str | None = None,
) -> TaskRecord:
self._authorize(
context,
"task.create",
{"resource_type": "task", "resource_id": idempotency_key or "new"},
)
agent = self.get_agent(context, agent_id)
if not agent.enabled:
raise ControlPlaneError("agent is disabled")
if action not in agent.capabilities and "*" not in agent.capabilities:
raise PolicyDenied(f"agent does not advertise capability: {action}")
normalized_payload = copy.deepcopy(dict(payload or {}))
request_digest = _canonical_digest(
{"agent_id": agent_id, "action": action, "payload": normalized_payload}
)
normalized_key = idempotency_key.strip() if idempotency_key else None
with self.store.lock:
if normalized_key:
idempotency_ref = (context.tenant_id, normalized_key)
existing = self.store.idempotency.get(idempotency_ref)
if existing is not None:
existing_task_id, existing_digest = existing
if existing_digest != request_digest:
raise IdempotencyConflict(
"idempotency key was reused with a different request"
)
return self.store.tasks[existing_task_id]
task = TaskRecord(
task_id=_new_id("task"),
tenant_id=context.tenant_id,
agent_id=agent_id,
action=action,
payload=normalized_payload,
idempotency_key=normalized_key,
request_digest=request_digest,
created_by=context.principal_id,
)
self.store.tasks[task.task_id] = task
if normalized_key:
self.store.idempotency[(context.tenant_id, normalized_key)] = (
task.task_id,
request_digest,
)
self._audit(
context,
action="task.create",
resource_type="task",
resource_id=task.task_id,
outcome="allowed",
details={"agent_id": agent_id, "action": action},
)
return task
def get_task(self, context: RequestContext, task_id: str) -> TaskRecord:
self._authorize(
context,
"task.read",
{"resource_type": "task", "resource_id": task_id},
)
with self.store.lock:
task = self.store.tasks.get(task_id)
if task is None:
raise NotFound("task not found")
self._assert_same_tenant(context, task.tenant_id)
return task
def list_tasks(
self,
context: RequestContext,
*,
states: Iterable[TaskState | str] | None = None,
) -> list[TaskRecord]:
self._authorize(
context,
"task.read",
{"resource_type": "task_collection", "resource_id": context.tenant_id},
)
state_filter = (
{TaskState(item) for item in states}
if states is not None
else None
)
with self.store.lock:
return [
task
for task in self.store.tasks.values()
if task.tenant_id == context.tenant_id
and (state_filter is None or task.state in state_filter)
]
def transition_task(
self,
context: RequestContext,
*,
task_id: str,
target_state: TaskState | str,
expected_revision: int | None = None,
result: Mapping[str, Any] | None = None,
error: Mapping[str, Any] | None = None,
) -> TaskRecord:
target = TaskState(target_state)
action_name = {
TaskState.APPROVED: "task.approve",
TaskState.RUNNING: "task.start",
TaskState.COMPLETED: "task.complete",
TaskState.FAILED: "task.fail",
TaskState.CANCELLED: "task.cancel",
}.get(target)
if action_name is None:
raise InvalidTransition(f"cannot transition to {target.value}")
self._authorize(
context,
action_name,
{"resource_type": "task", "resource_id": task_id},
)
with self.store.lock:
current = self.store.tasks.get(task_id)
if current is None:
raise NotFound("task not found")
self._assert_same_tenant(context, current.tenant_id)
if expected_revision is not None and current.revision != expected_revision:
raise RevisionConflict(
f"expected revision {expected_revision}, current {current.revision}"
)
if target not in ALLOWED_TASK_TRANSITIONS[current.state]:
raise InvalidTransition(
f"invalid transition {current.state.value} -> {target.value}"
)
if target == TaskState.COMPLETED and result is None:
raise ValueError("completed task requires result")
if target == TaskState.FAILED and error is None:
raise ValueError("failed task requires error")
updated = TaskRecord(
task_id=current.task_id,
tenant_id=current.tenant_id,
agent_id=current.agent_id,
action=current.action,
payload=current.payload,
state=target,
revision=current.revision + 1,
idempotency_key=current.idempotency_key,
request_digest=current.request_digest,
result=copy.deepcopy(dict(result)) if result is not None else current.result,
error=copy.deepcopy(dict(error)) if error is not None else current.error,
created_by=current.created_by,
approved_by=(
context.principal_id
if target == TaskState.APPROVED
else current.approved_by
),
created_at=current.created_at,
updated_at=_utc_now(),
)
self.store.tasks[task_id] = updated
self._audit(
context,
action=action_name,
resource_type="task",
resource_id=task_id,
outcome="allowed",
details={
"from": current.state.value,
"to": target.value,
"revision": updated.revision,
},
)
return updated
def list_audit_events(
self,
context: RequestContext,
*,
resource_id: str | None = None,
) -> list[AuditEvent]:
self._authorize(
context,
"audit.read",
{"resource_type": "audit", "resource_id": resource_id or context.tenant_id},
)
with self.store.lock:
return [
event
for event in self.store.audit_events
if event.tenant_id == context.tenant_id
and (resource_id is None or event.resource_id == resource_id)
]
@@ -0,0 +1,379 @@
"""AIOS V2 持久化 Dispatch 记录。
Dispatch 是 AIOS Task 与 Runtime command/job 之间的唯一可信绑定。所有结果回写
必须匹配 tenant/task/trace/runtime_execution/provenance/run/principal。
"""
from __future__ import annotations
import json
import sqlite3
import threading
import uuid
from dataclasses import asdict, dataclass, replace
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Mapping, Protocol
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _required(value: str, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
class DispatchState(str, Enum):
PREPARED = "PREPARED"
RUNNING = "RUNNING"
QUEUED = "QUEUED"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
TERMINAL_DISPATCH_STATES = frozenset(
{DispatchState.COMPLETED, DispatchState.FAILED, DispatchState.CANCELLED}
)
@dataclass(frozen=True)
class DispatchRecord:
dispatch_id: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
runtime: str
runtime_execution_id: str
trace_id: str
provenance_id: str
run_id: str
authorization_id: str
state: DispatchState = DispatchState.PREPARED
expected_worker_id: str | None = None
result_digest: str | None = None
created_at: str = ""
updated_at: str = ""
completed_at: str | None = None
def __post_init__(self) -> None:
for name in (
"dispatch_id",
"tenant_id",
"task_id",
"agent_id",
"principal_id",
"runtime",
"runtime_execution_id",
"trace_id",
"provenance_id",
"run_id",
"authorization_id",
):
object.__setattr__(self, name, _required(getattr(self, name), name))
object.__setattr__(self, "state", DispatchState(self.state))
now = utc_now()
object.__setattr__(self, "created_at", self.created_at or now)
object.__setattr__(self, "updated_at", self.updated_at or now)
if self.expected_worker_id is not None:
object.__setattr__(
self,
"expected_worker_id",
_required(self.expected_worker_id, "expected_worker_id"),
)
@classmethod
def create(
cls,
*,
tenant_id: str,
task_id: str,
agent_id: str,
principal_id: str,
runtime: str,
runtime_execution_id: str,
trace_id: str,
provenance_id: str,
run_id: str,
authorization_id: str,
) -> "DispatchRecord":
return cls(
dispatch_id=f"dispatch_{uuid.uuid4().hex}",
tenant_id=tenant_id,
task_id=task_id,
agent_id=agent_id,
principal_id=principal_id,
runtime=runtime,
runtime_execution_id=runtime_execution_id,
trace_id=trace_id,
provenance_id=provenance_id,
run_id=run_id,
authorization_id=authorization_id,
)
def as_dict(self) -> dict[str, Any]:
value = asdict(self)
value["state"] = self.state.value
return value
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "DispatchRecord":
return cls(**dict(value))
class DispatchConflict(RuntimeError):
pass
class DispatchNotFound(RuntimeError):
pass
class DispatchRepository(Protocol):
def create(self, record: DispatchRecord) -> DispatchRecord: ...
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord: ...
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None: ...
def get_by_runtime_execution(
self, runtime_execution_id: str, *, tenant_id: str
) -> DispatchRecord | None: ...
def update(
self,
dispatch_id: str,
*,
tenant_id: str,
state: DispatchState | str | None = None,
expected_worker_id: str | None = None,
result_digest: str | None = None,
) -> DispatchRecord: ...
def list(self, *, tenant_id: str) -> list[DispatchRecord]: ...
class InMemoryDispatchRepository:
def __init__(self) -> None:
self._lock = threading.RLock()
self._records: dict[str, DispatchRecord] = {}
self._by_task: dict[tuple[str, str], str] = {}
self._by_runtime: dict[tuple[str, str], str] = {}
def create(self, record: DispatchRecord) -> DispatchRecord:
with self._lock:
task_ref = (record.tenant_id, record.task_id)
runtime_ref = (record.tenant_id, record.runtime_execution_id)
if task_ref in self._by_task or runtime_ref in self._by_runtime:
existing = self.get_by_task(record.task_id, tenant_id=record.tenant_id)
if existing == record:
return record
raise DispatchConflict("task or runtime execution already has a dispatch")
self._records[record.dispatch_id] = record
self._by_task[task_ref] = record.dispatch_id
self._by_runtime[runtime_ref] = record.dispatch_id
return record
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord:
with self._lock:
record = self._records.get(dispatch_id)
if record is None or record.tenant_id != tenant_id:
raise DispatchNotFound("dispatch not found")
return record
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None:
with self._lock:
dispatch_id = self._by_task.get((tenant_id, task_id))
return self._records.get(dispatch_id) if dispatch_id else None
def get_by_runtime_execution(
self, runtime_execution_id: str, *, tenant_id: str
) -> DispatchRecord | None:
with self._lock:
dispatch_id = self._by_runtime.get((tenant_id, runtime_execution_id))
return self._records.get(dispatch_id) if dispatch_id else None
def update(
self,
dispatch_id: str,
*,
tenant_id: str,
state: DispatchState | str | None = None,
expected_worker_id: str | None = None,
result_digest: str | None = None,
) -> DispatchRecord:
with self._lock:
current = self.get(dispatch_id, tenant_id=tenant_id)
target = DispatchState(state) if state is not None else current.state
if current.state in TERMINAL_DISPATCH_STATES and target != current.state:
raise DispatchConflict("terminal dispatch cannot transition")
updated = replace(
current,
state=target,
expected_worker_id=(
expected_worker_id
if expected_worker_id is not None
else current.expected_worker_id
),
result_digest=result_digest or current.result_digest,
updated_at=utc_now(),
completed_at=(utc_now() if target in TERMINAL_DISPATCH_STATES else None),
)
self._records[dispatch_id] = updated
return updated
def list(self, *, tenant_id: str) -> list[DispatchRecord]:
with self._lock:
return sorted(
[item for item in self._records.values() if item.tenant_id == tenant_id],
key=lambda item: (item.created_at, item.dispatch_id),
)
class SQLiteDispatchRepository:
SCHEMA_VERSION = 1
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
with self._connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS dispatch_records (
dispatch_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
task_id TEXT NOT NULL,
runtime_execution_id TEXT NOT NULL,
payload_json TEXT NOT NULL,
state TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(tenant_id, task_id),
UNIQUE(tenant_id, runtime_execution_id)
);
CREATE INDEX IF NOT EXISTS idx_dispatch_tenant_state
ON dispatch_records(tenant_id, state, updated_at);
"""
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(str(self.path), timeout=30.0)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=FULL")
return connection
@staticmethod
def _encode(record: DispatchRecord) -> str:
return json.dumps(
record.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
@staticmethod
def _decode(row: sqlite3.Row) -> DispatchRecord:
value = json.loads(row["payload_json"])
if not isinstance(value, Mapping):
raise RuntimeError("invalid dispatch record")
return DispatchRecord.from_dict(value)
def create(self, record: DispatchRecord) -> DispatchRecord:
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
try:
connection.execute(
"INSERT INTO dispatch_records VALUES (?, ?, ?, ?, ?, ?, ?)",
(
record.dispatch_id,
record.tenant_id,
record.task_id,
record.runtime_execution_id,
self._encode(record),
record.state.value,
record.updated_at,
),
)
connection.commit()
return record
except sqlite3.IntegrityError as exc:
connection.rollback()
existing = self.get_by_task(record.task_id, tenant_id=record.tenant_id)
if existing is not None and existing == record:
return existing
raise DispatchConflict("task or runtime execution already has a dispatch") from exc
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT payload_json FROM dispatch_records WHERE dispatch_id = ? AND tenant_id = ?",
(_required(dispatch_id, "dispatch_id"), _required(tenant_id, "tenant_id")),
).fetchone()
if row is None:
raise DispatchNotFound("dispatch not found")
return self._decode(row)
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT payload_json FROM dispatch_records WHERE task_id = ? AND tenant_id = ?",
(_required(task_id, "task_id"), _required(tenant_id, "tenant_id")),
).fetchone()
return self._decode(row) if row is not None else None
def get_by_runtime_execution(
self, runtime_execution_id: str, *, tenant_id: str
) -> DispatchRecord | None:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT payload_json FROM dispatch_records WHERE runtime_execution_id = ? AND tenant_id = ?",
(
_required(runtime_execution_id, "runtime_execution_id"),
_required(tenant_id, "tenant_id"),
),
).fetchone()
return self._decode(row) if row is not None else None
def update(
self,
dispatch_id: str,
*,
tenant_id: str,
state: DispatchState | str | None = None,
expected_worker_id: str | None = None,
result_digest: str | None = None,
) -> DispatchRecord:
current = self.get(dispatch_id, tenant_id=tenant_id)
target = DispatchState(state) if state is not None else current.state
if current.state in TERMINAL_DISPATCH_STATES and target != current.state:
raise DispatchConflict("terminal dispatch cannot transition")
updated = replace(
current,
state=target,
expected_worker_id=(
expected_worker_id if expected_worker_id is not None else current.expected_worker_id
),
result_digest=result_digest or current.result_digest,
updated_at=utc_now(),
completed_at=(utc_now() if target in TERMINAL_DISPATCH_STATES else None),
)
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"UPDATE dispatch_records SET payload_json = ?, state = ?, updated_at = ? WHERE dispatch_id = ? AND tenant_id = ?",
(
self._encode(updated),
updated.state.value,
updated.updated_at,
updated.dispatch_id,
updated.tenant_id,
),
)
connection.commit()
return updated
def list(self, *, tenant_id: str) -> list[DispatchRecord]:
with self._lock, self._connect() as connection:
rows = connection.execute(
"SELECT payload_json FROM dispatch_records WHERE tenant_id = ? ORDER BY updated_at, dispatch_id",
(_required(tenant_id, "tenant_id"),),
).fetchall()
return [self._decode(row) for row in rows]
@@ -0,0 +1,543 @@
"""Portable multi-provider execution plans and aggregate release gates.
A plan is a DAG of executor-neutral stages. Only root stages are declared at
plan creation. A dependent stage is declared atomically after every dependency
passes its Truth + notification gate, so its start deadline cannot expire while
it is legitimately waiting for upstream work.
"""
from __future__ import annotations
import json
import sqlite3
import threading
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Mapping, Sequence
from .executor_protocol import ExecutorProviderRegistry, normalize_executor
from .external_task_control import ExternalTaskCoordinator
from .external_task_query import list_records
from .task_execution_registry import (
ExecutionTaskManifest,
ExecutionTruthRecord,
ExecutionTruthState,
required,
utc_now,
)
PLAN_SCHEMA_VERSION = "aios.execution-plan.v2"
WAITING_DEPENDENCY = "WAITING_DEPENDENCY"
@dataclass(frozen=True)
class ExecutionStageManifest:
stage_id: str
task_id: str
executor_type: str
role: str
steps: Sequence[str]
depends_on: Sequence[str] = ()
required_evidence: Sequence[str] = ()
notification_events: Sequence[str] = ("STARTED", "SUCCEEDED", "FAILED")
owner_notification_ref: str = "AIOS_OWNER_WECHAT_TARGET_REF"
start_deadline_seconds: int = 300
heartbeat_timeout_seconds: int = 180
required: bool = True
metadata: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
for name in ("stage_id", "task_id", "role", "owner_notification_ref"):
object.__setattr__(self, name, required(getattr(self, name), name))
object.__setattr__(self, "executor_type", normalize_executor(self.executor_type))
steps = tuple(required(item, "step") for item in self.steps)
if not steps:
raise ValueError("execution stage requires at least one step")
object.__setattr__(self, "steps", steps)
object.__setattr__(
self,
"depends_on",
tuple(sorted({required(item, "depends_on") for item in self.depends_on})),
)
object.__setattr__(
self,
"required_evidence",
tuple(sorted({required(item, "required_evidence") for item in self.required_evidence})),
)
object.__setattr__(
self,
"notification_events",
tuple(sorted({required(item, "notification_event") for item in self.notification_events})),
)
if self.start_deadline_seconds < 1 or self.heartbeat_timeout_seconds < 1:
raise ValueError("stage deadlines must be positive")
object.__setattr__(
self,
"metadata",
json.loads(json.dumps(dict(self.metadata), ensure_ascii=False, default=str)),
)
def as_dict(self) -> dict[str, Any]:
value = asdict(self)
value["steps"] = list(self.steps)
value["depends_on"] = list(self.depends_on)
value["required_evidence"] = list(self.required_evidence)
value["notification_events"] = list(self.notification_events)
return value
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutionStageManifest":
return cls(**dict(value))
@dataclass(frozen=True)
class ExecutionPlanManifest:
plan_id: str
source_branch: str
source_oid: str
target_version: str
stages: Sequence[ExecutionStageManifest]
release_gate_policy: str = "all_required_stages"
schema_version: str = PLAN_SCHEMA_VERSION
metadata: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
for name in ("plan_id", "source_branch", "source_oid", "target_version"):
object.__setattr__(self, name, required(getattr(self, name), name))
if self.schema_version != PLAN_SCHEMA_VERSION:
raise ValueError("unsupported execution plan schema")
if self.release_gate_policy != "all_required_stages":
raise ValueError("unsupported release gate policy")
stages = tuple(self.stages)
if not stages:
raise ValueError("execution plan requires stages")
stage_ids = [item.stage_id for item in stages]
task_ids = [item.task_id for item in stages]
if len(stage_ids) != len(set(stage_ids)):
raise ValueError("execution plan stage_id values must be unique")
if len(task_ids) != len(set(task_ids)):
raise ValueError("execution plan task_id values must be unique")
known = set(stage_ids)
for stage in stages:
unknown = sorted(set(stage.depends_on) - known)
if unknown:
raise ValueError(f"stage {stage.stage_id} has unknown dependencies: {unknown}")
if stage.stage_id in stage.depends_on:
raise ValueError("stage cannot depend on itself")
self._assert_acyclic(stages)
object.__setattr__(self, "stages", stages)
object.__setattr__(
self,
"metadata",
json.loads(json.dumps(dict(self.metadata), ensure_ascii=False, default=str)),
)
@staticmethod
def _assert_acyclic(stages: Sequence[ExecutionStageManifest]) -> None:
dependencies = {item.stage_id: set(item.depends_on) for item in stages}
resolved: set[str] = set()
while len(resolved) < len(stages):
ready = {
stage_id
for stage_id, deps in dependencies.items()
if stage_id not in resolved and deps.issubset(resolved)
}
if not ready:
raise ValueError("execution plan contains a dependency cycle")
resolved.update(ready)
def as_dict(self) -> dict[str, Any]:
return {
"schema_version": self.schema_version,
"plan_id": self.plan_id,
"source_branch": self.source_branch,
"source_oid": self.source_oid,
"target_version": self.target_version,
"release_gate_policy": self.release_gate_policy,
"stages": [item.as_dict() for item in self.stages],
"metadata": dict(self.metadata),
}
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutionPlanManifest":
raw = dict(value)
raw["stages"] = tuple(
ExecutionStageManifest.from_dict(item) for item in raw.get("stages") or ()
)
return cls(**raw)
def stage(self, stage_id: str) -> ExecutionStageManifest:
normalized = required(stage_id, "stage_id")
for stage in self.stages:
if stage.stage_id == normalized:
return stage
raise KeyError(f"execution stage not found: {normalized}")
class SQLiteExecutionPlanStore:
"""Durable plan declarations; stage state remains in Truth Registry."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
with self._connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS execution_plans (
plan_id TEXT PRIMARY KEY,
manifest_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(str(self.path), timeout=30.0)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=FULL")
return connection
@staticmethod
def _encode(plan: ExecutionPlanManifest) -> str:
return json.dumps(
plan.as_dict(),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
def save(
self,
plan: ExecutionPlanManifest,
*,
allow_source_oid_supersession: bool = False,
) -> None:
payload = self._encode(plan)
now = utc_now()
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
"SELECT manifest_json FROM execution_plans WHERE plan_id = ?",
(plan.plan_id,),
).fetchone()
if existing is not None:
if existing["manifest_json"] != payload:
previous = json.loads(existing["manifest_json"])
replacement = plan.as_dict()
previous_oid = previous.pop("source_oid", None)
replacement_oid = replacement.pop("source_oid", None)
if (
not allow_source_oid_supersession
or previous != replacement
or previous_oid == replacement_oid
):
connection.rollback()
raise RuntimeError(
"plan_id already exists with another manifest"
)
connection.execute(
"UPDATE execution_plans SET manifest_json = ?, updated_at = ? WHERE plan_id = ?",
(payload, now, plan.plan_id),
)
connection.commit()
return
connection.rollback()
return
connection.execute(
"INSERT INTO execution_plans VALUES (?, ?, ?, ?)",
(plan.plan_id, payload, now, now),
)
connection.commit()
def get(self, plan_id: str) -> ExecutionPlanManifest:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT manifest_json FROM execution_plans WHERE plan_id = ?",
(required(plan_id, "plan_id"),),
).fetchone()
if row is None:
raise KeyError("execution plan not found")
value = json.loads(row["manifest_json"])
if not isinstance(value, Mapping):
raise ValueError("stored execution plan is invalid")
return ExecutionPlanManifest.from_dict(value)
def list(self) -> list[ExecutionPlanManifest]:
with self._lock, self._connect() as connection:
rows = connection.execute(
"SELECT manifest_json FROM execution_plans ORDER BY created_at, plan_id"
).fetchall()
return [
ExecutionPlanManifest.from_dict(json.loads(row["manifest_json"]))
for row in rows
]
class ExecutionPlanCoordinator:
"""Declares dependency-ready stages and evaluates aggregate release gates."""
def __init__(
self,
*,
task_coordinator: ExternalTaskCoordinator,
plan_store: SQLiteExecutionPlanStore,
providers: ExecutorProviderRegistry,
) -> None:
self.task_coordinator = task_coordinator
self.plan_store = plan_store
self.providers = providers
@classmethod
def from_file(
cls,
path: str | Path,
*,
providers: ExecutorProviderRegistry,
) -> "ExecutionPlanCoordinator":
return cls(
task_coordinator=ExternalTaskCoordinator.from_file(path),
plan_store=SQLiteExecutionPlanStore(path),
providers=providers,
)
@staticmethod
def _task_manifest(
plan: ExecutionPlanManifest,
stage: ExecutionStageManifest,
) -> ExecutionTaskManifest:
return ExecutionTaskManifest(
task_id=stage.task_id,
executor=stage.executor_type,
source_branch=plan.source_branch,
source_oid=plan.source_oid,
target_version=plan.target_version,
steps=stage.steps,
start_deadline_seconds=stage.start_deadline_seconds,
heartbeat_timeout_seconds=stage.heartbeat_timeout_seconds,
required_evidence=stage.required_evidence,
notification_events=stage.notification_events,
owner_notification_ref=stage.owner_notification_ref,
manifest_version="aios.external-task.v2",
)
def _record(self, task_id: str) -> ExecutionTruthRecord | None:
try:
return self.task_coordinator.registry.get(task_id)
except KeyError:
return None
def _declare_stage(
self,
plan: ExecutionPlanManifest,
stage: ExecutionStageManifest,
) -> ExecutionTruthRecord:
self.providers.validate_manifest(self._task_manifest(plan, stage))
return self.task_coordinator.declare(self._task_manifest(plan, stage))
def declare(self, plan: ExecutionPlanManifest) -> Mapping[str, Any]:
for stage in plan.stages:
self.providers.get(stage.executor_type)
allow_source_oid_supersession = False
try:
previous = self.plan_store.get(plan.plan_id)
except KeyError:
previous = None
if previous is not None and previous.source_oid != plan.source_oid:
previous_value = previous.as_dict()
replacement_value = plan.as_dict()
previous_value.pop("source_oid")
replacement_value.pop("source_oid")
root_records = [
self._record(stage.task_id)
for stage in previous.stages
if not stage.depends_on
]
allow_source_oid_supersession = bool(
previous_value == replacement_value
and root_records
and all(
record is not None
and record.state == ExecutionTruthState.NOT_STARTED
for record in root_records
)
)
self.plan_store.save(
plan,
allow_source_oid_supersession=allow_source_oid_supersession,
)
for stage in plan.stages:
if not stage.depends_on:
self._declare_stage(plan, stage)
return self.snapshot(plan.plan_id)
def declare_file(self, path: str | Path) -> Mapping[str, Any]:
value = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(value, Mapping):
raise ValueError("execution plan file must contain a JSON object")
return self.declare(ExecutionPlanManifest.from_dict(value))
def dependency_gate(self, plan_id: str, stage_id: str) -> Mapping[str, Any]:
plan = self.plan_store.get(plan_id)
stage = plan.stage(stage_id)
dependencies: list[Mapping[str, Any]] = []
passed = True
for dependency_id in stage.depends_on:
dependency = plan.stage(dependency_id)
record = self._record(dependency.task_id)
if record is None:
gate: Mapping[str, Any] = {
"task_id": dependency.task_id,
"state": WAITING_DEPENDENCY,
"passed": False,
"release_blocking": True,
}
else:
gate = self.task_coordinator.gate(dependency.task_id)
dependencies.append(
{
"stage_id": dependency.stage_id,
"task_id": dependency.task_id,
"gate": gate,
}
)
if not gate["passed"]:
passed = False
return {
"plan_id": plan.plan_id,
"stage_id": stage.stage_id,
"passed": passed,
"release_blocking": not passed,
"dependencies": dependencies,
}
def activate_ready(self, plan_id: str) -> list[Mapping[str, Any]]:
plan = self.plan_store.get(plan_id)
activated: list[Mapping[str, Any]] = []
for stage in plan.stages:
if self._record(stage.task_id) is not None:
continue
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
if not dependency["passed"]:
continue
record = self._declare_stage(plan, stage)
activated.append(
{
"stage_id": stage.stage_id,
"task_id": record.task_id,
"executor_type": stage.executor_type,
"state": record.state.value,
}
)
return activated
def discover_ready(
self,
*,
plan_id: str,
executor_type: str,
) -> list[Mapping[str, Any]]:
plan = self.plan_store.get(plan_id)
self.activate_ready(plan.plan_id)
normalized = normalize_executor(executor_type)
records = {
item.task_id: item
for item in list_records(
self.task_coordinator.registry.path,
executor=normalized,
states=(
ExecutionTruthState.DECLARED.value,
ExecutionTruthState.REGISTERED.value,
),
)
}
ready: list[Mapping[str, Any]] = []
for stage in plan.stages:
if stage.executor_type != normalized or stage.task_id not in records:
continue
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
if dependency["passed"]:
ready.append(
{
"plan_id": plan.plan_id,
"stage": stage.as_dict(),
"record": records[stage.task_id].as_dict(),
}
)
return ready
def snapshot(self, plan_id: str) -> Mapping[str, Any]:
plan = self.plan_store.get(plan_id)
stages: list[Mapping[str, Any]] = []
for stage in plan.stages:
record = self._record(stage.task_id)
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
if record is None:
truth: Mapping[str, Any] = {
"task_id": stage.task_id,
"state": WAITING_DEPENDENCY,
"passed": False,
"release_blocking": True,
"executor_run_id": None,
"executor_id": None,
"current_step": 0,
"total_steps": len(stage.steps),
"evidence": {},
"error": {},
"notifications": [],
}
else:
truth = self.task_coordinator.status(stage.task_id)
stages.append(
{
"stage": stage.as_dict(),
"dependency_gate": dependency,
"truth": truth,
}
)
return {
"manifest": plan.as_dict(),
"stages": stages,
}
def gate(self, plan_id: str) -> Mapping[str, Any]:
plan = self.plan_store.get(plan_id)
self.activate_ready(plan.plan_id)
stages: list[Mapping[str, Any]] = []
required_passed = True
for stage in plan.stages:
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
record = self._record(stage.task_id)
if record is None:
truth_gate: Mapping[str, Any] = {
"task_id": stage.task_id,
"state": WAITING_DEPENDENCY,
"passed": False,
"release_blocking": True,
}
else:
truth_gate = self.task_coordinator.gate(stage.task_id)
stage_passed = bool(truth_gate["passed"] and dependency["passed"])
if stage.required and not stage_passed:
required_passed = False
stages.append(
{
"stage_id": stage.stage_id,
"task_id": stage.task_id,
"executor_type": stage.executor_type,
"role": stage.role,
"required": stage.required,
"passed": stage_passed,
"truth_gate": truth_gate,
"dependency_gate": dependency,
}
)
return {
"plan_id": plan.plan_id,
"target_version": plan.target_version,
"policy": plan.release_gate_policy,
"passed": required_passed,
"release_blocking": not required_passed,
"stages": stages,
}
@@ -0,0 +1,323 @@
"""AIOS V2 Agent 执行证据。
Evidence 按租户隔离并使用原子 JSON 写入。生产模式下,claim、heartbeat
和 complete 在落盘前必须通过最小权限 Evidence Grant 授权。
"""
from __future__ import annotations
import json
import os
import threading
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping, Protocol
from .provenance import ProvenanceDenied, digest_payload
class EvidenceAuthorizationWriter(Protocol):
def authorize_evidence_write(self, **values: Any) -> Any: ...
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _required(value: str, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
def _safe(value: Mapping[str, Any] | None) -> dict[str, Any]:
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, default=str))
@dataclass(frozen=True)
class ExecutionEvidence:
execution_id: str
tenant_id: str
task_id: str
agent_id: str
executor: str
branch: str
status: str = "claimed"
progress_completed: int = 0
progress_total: int = 1
current_step: str = "claim"
last_commit: str | None = None
test_status: str = "not_run"
test_summary: Mapping[str, Any] = field(default_factory=dict)
result_summary: Mapping[str, Any] = field(default_factory=dict)
claimed_at: str = field(default_factory=utc_now)
heartbeat_at: str = field(default_factory=utc_now)
completed_at: str | None = None
provenance_id: str | None = None
run_id: str | None = None
last_authorization_id: str | None = None
def __post_init__(self) -> None:
for name in (
"execution_id",
"tenant_id",
"task_id",
"agent_id",
"executor",
"branch",
"status",
"current_step",
"claimed_at",
"heartbeat_at",
):
object.__setattr__(self, name, _required(getattr(self, name), name))
if self.progress_completed < 0 or self.progress_total < 1:
raise ValueError("invalid progress values")
if self.progress_completed > self.progress_total:
raise ValueError("progress_completed cannot exceed progress_total")
object.__setattr__(self, "test_summary", _safe(self.test_summary))
object.__setattr__(self, "result_summary", _safe(self.result_summary))
for optional in ("provenance_id", "run_id", "last_authorization_id"):
value = getattr(self, optional)
object.__setattr__(self, optional, str(value).strip() if value else None)
class ExecutionTracker:
"""每个租户、每次执行持久保存一份证据记录。"""
def __init__(
self,
root: str | Path,
*,
provenance_writer: EvidenceAuthorizationWriter | None = None,
require_provenance: bool = False,
) -> None:
self.root = Path(root).resolve()
self.root.mkdir(parents=True, exist_ok=True)
self.provenance_writer = provenance_writer
self.require_provenance = bool(require_provenance)
if self.require_provenance and self.provenance_writer is None:
raise ValueError("production evidence tracking requires a provenance writer")
self._lock = threading.RLock()
@staticmethod
def _component(value: str) -> str:
normalized = _required(value, "path component")
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
raise ValueError("invalid evidence path component")
return normalized
def _path(self, tenant_id: str, execution_id: str) -> Path:
tenant = self._component(tenant_id)
execution = self._component(execution_id)
return self.root / tenant / f"{execution}.json"
def _authorize(
self,
evidence: ExecutionEvidence,
*,
operation: str,
principal_id: str | None,
provenance_id: str | None,
run_id: str | None,
changes: Mapping[str, Any],
) -> str | None:
if not self.require_provenance:
return None
if self.provenance_writer is None:
raise RuntimeError("Run Provenance writer is not configured")
effective_provenance = provenance_id or evidence.provenance_id
effective_run = run_id or evidence.run_id
if not effective_provenance or not effective_run or not principal_id:
raise RuntimeError(
"production evidence write requires provenance_id, run_id and principal_id"
)
request_digest = digest_payload(
{
"operation": operation,
"execution_id": evidence.execution_id,
"tenant_id": evidence.tenant_id,
"task_id": evidence.task_id,
"agent_id": evidence.agent_id,
"executor": evidence.executor,
"branch": evidence.branch,
"changes": dict(changes),
}
)
try:
authorization = self.provenance_writer.authorize_evidence_write(
provenance_id=effective_provenance,
run_id=effective_run,
tenant_id=evidence.tenant_id,
task_id=evidence.task_id,
agent_id=evidence.agent_id,
principal_id=principal_id,
request_digest=request_digest,
)
except ProvenanceDenied as exc:
raise RuntimeError(f"Run Provenance denied evidence write: {exc}") from exc
return authorization.authorization_id
def claim(
self,
evidence: ExecutionEvidence,
*,
principal_id: str | None = None,
provenance_id: str | None = None,
run_id: str | None = None,
) -> ExecutionEvidence:
path = self._path(evidence.tenant_id, evidence.execution_id)
with self._lock:
if path.exists():
current = self._read(path)
if current.task_id != evidence.task_id or current.agent_id != evidence.agent_id:
raise RuntimeError("execution_id already belongs to another task")
if self.require_provenance and (
current.provenance_id != (provenance_id or evidence.provenance_id)
or current.run_id != (run_id or evidence.run_id)
):
raise RuntimeError(
"execution_id already belongs to another provenance context"
)
return current
effective_provenance = provenance_id or evidence.provenance_id
effective_run = run_id or evidence.run_id
authorization_id = self._authorize(
evidence,
operation="claim",
principal_id=principal_id,
provenance_id=effective_provenance,
run_id=effective_run,
changes={"status": evidence.status, "current_step": evidence.current_step},
)
persisted = replace(
evidence,
provenance_id=effective_provenance,
run_id=effective_run,
last_authorization_id=authorization_id,
)
self._write(path, persisted)
return persisted
def heartbeat(
self,
*,
tenant_id: str,
execution_id: str,
status: str | None = None,
current_step: str | None = None,
progress_completed: int | None = None,
progress_total: int | None = None,
last_commit: str | None = None,
principal_id: str | None = None,
provenance_id: str | None = None,
run_id: str | None = None,
) -> ExecutionEvidence:
path = self._path(tenant_id, execution_id)
with self._lock:
current = self._read(path)
changes = {
"status": status or current.status,
"current_step": current_step or current.current_step,
"progress_completed": (
progress_completed
if progress_completed is not None
else current.progress_completed
),
"progress_total": (
progress_total if progress_total is not None else current.progress_total
),
"last_commit": last_commit or current.last_commit,
}
authorization_id = self._authorize(
current,
operation="heartbeat",
principal_id=principal_id,
provenance_id=provenance_id,
run_id=run_id,
changes=changes,
)
updated = replace(
current,
**changes,
last_authorization_id=authorization_id or current.last_authorization_id,
heartbeat_at=utc_now(),
)
self._write(path, updated)
return updated
def complete(
self,
*,
tenant_id: str,
execution_id: str,
status: str,
test_status: str,
test_summary: Mapping[str, Any] | None = None,
result_summary: Mapping[str, Any] | None = None,
last_commit: str | None = None,
principal_id: str | None = None,
provenance_id: str | None = None,
run_id: str | None = None,
) -> ExecutionEvidence:
path = self._path(tenant_id, execution_id)
with self._lock:
current = self._read(path)
changes = {
"status": _required(status, "status"),
"current_step": "complete",
"progress_completed": current.progress_total,
"last_commit": last_commit or current.last_commit,
"test_status": _required(test_status, "test_status"),
"test_summary": _safe(test_summary),
"result_summary": _safe(result_summary),
}
authorization_id = self._authorize(
current,
operation="complete",
principal_id=principal_id,
provenance_id=provenance_id,
run_id=run_id,
changes=changes,
)
updated = replace(
current,
**changes,
last_authorization_id=authorization_id or current.last_authorization_id,
heartbeat_at=utc_now(),
completed_at=utc_now(),
)
self._write(path, updated)
return updated
def get(self, *, tenant_id: str, execution_id: str) -> ExecutionEvidence:
return self._read(self._path(tenant_id, execution_id))
def list(self, *, tenant_id: str) -> list[ExecutionEvidence]:
tenant_dir = self.root / self._component(tenant_id)
if not tenant_dir.exists():
return []
records = [self._read(path) for path in tenant_dir.glob("*.json")]
return sorted(records, key=lambda item: (item.heartbeat_at, item.execution_id), reverse=True)
@staticmethod
def _read(path: Path) -> ExecutionEvidence:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise KeyError("execution evidence not found") from exc
if not isinstance(value, Mapping):
raise RuntimeError("invalid execution evidence file")
return ExecutionEvidence(**dict(value))
@staticmethod
def _write(path: Path, evidence: ExecutionEvidence) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(
json.dumps(asdict(evidence), ensure_ascii=False, sort_keys=True, indent=2) + "\n",
encoding="utf-8",
)
os.chmod(temporary, 0o640)
os.replace(temporary, path)
@@ -0,0 +1,204 @@
"""Persistent and portable AIOS executor provider catalog.
The catalog contains descriptors only: provider names, protocol/adapter versions,
trust roles, capabilities and configuration schema identifiers. Credentials,
endpoints and runtime secrets are intentionally excluded and must be resolved in
the destination environment.
"""
from __future__ import annotations
import hashlib
import json
import os
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping, Sequence
from .executor_protocol import (
EXECUTOR_PROTOCOL_VERSION,
DescriptorOnlyProvider,
ExecutorProvider,
ExecutorProviderDescriptor,
ExecutorProviderRegistry,
default_provider_registry,
)
CATALOG_SCHEMA_VERSION = "aios.executor.catalog.v1"
MIGRATION_SCHEMA_VERSION = "aios.executor.migration.v1"
def canonical_json_bytes(value: Mapping[str, Any]) -> bytes:
return json.dumps(
dict(value),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
def sha256_payload(value: Mapping[str, Any]) -> str:
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
@dataclass(frozen=True)
class ExecutorCatalogSnapshot:
catalog: Mapping[str, Any]
catalog_sha256: str
path: str
def as_dict(self) -> dict[str, Any]:
return {
"catalog": dict(self.catalog),
"catalog_sha256": self.catalog_sha256,
"path": self.path,
}
class FileExecutorCatalogStore:
"""Atomic file store for provider descriptors and migration packages."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
@staticmethod
def _validate_catalog(value: Mapping[str, Any]) -> ExecutorProviderRegistry:
if value.get("schema_version") != CATALOG_SCHEMA_VERSION:
raise ValueError("unsupported executor catalog schema")
if value.get("protocol_version") != EXECUTOR_PROTOCOL_VERSION:
raise ValueError("executor catalog protocol mismatch")
providers = value.get("providers")
if not isinstance(providers, Sequence) or isinstance(providers, (str, bytes)):
raise ValueError("executor catalog providers must be an array")
return ExecutorProviderRegistry.import_catalog(value)
def initialize_defaults(self) -> ExecutorCatalogSnapshot:
with self._lock:
if self.path.exists():
return self.load()
return self.save(default_provider_registry())
def load(self) -> ExecutorCatalogSnapshot:
with self._lock:
value = json.loads(self.path.read_text(encoding="utf-8"))
if not isinstance(value, Mapping):
raise ValueError("executor catalog must contain a JSON object")
registry = self._validate_catalog(value)
normalized = registry.export_catalog()
return ExecutorCatalogSnapshot(
catalog=normalized,
catalog_sha256=sha256_payload(normalized),
path=str(self.path),
)
def registry(
self,
*,
provider_factories: Mapping[str, ExecutorProvider] | None = None,
) -> ExecutorProviderRegistry:
snapshot = self.load()
return ExecutorProviderRegistry.import_catalog(
snapshot.catalog,
provider_factories=provider_factories,
)
def save(self, registry: ExecutorProviderRegistry) -> ExecutorCatalogSnapshot:
catalog = registry.export_catalog()
self._validate_catalog(catalog)
encoded = json.dumps(
catalog,
ensure_ascii=False,
sort_keys=True,
indent=2,
) + "\n"
with self._lock:
temporary = self.path.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(encoded, encoding="utf-8")
os.chmod(temporary, 0o640)
os.replace(temporary, self.path)
return ExecutorCatalogSnapshot(
catalog=catalog,
catalog_sha256=sha256_payload(catalog),
path=str(self.path),
)
def upsert_descriptor(
self,
descriptor: ExecutorProviderDescriptor,
) -> ExecutorCatalogSnapshot:
with self._lock:
if self.path.exists():
registry = self.registry()
else:
registry = default_provider_registry()
try:
registry.register(DescriptorOnlyProvider(descriptor), replace=True)
except KeyError:
registry.register(DescriptorOnlyProvider(descriptor))
return self.save(registry)
def export_migration_package(
self,
*,
source_environment: str,
required_executor_types: Sequence[str] = (),
) -> Mapping[str, Any]:
snapshot = self.load()
registry = ExecutorProviderRegistry.import_catalog(snapshot.catalog)
required = tuple(sorted({str(item).strip().lower() for item in required_executor_types if str(item).strip()}))
if required:
available = {item.executor_type for item in registry.list_descriptors()}
missing = sorted(set(required) - available)
if missing:
raise ValueError(f"migration package is missing providers: {missing}")
package = {
"schema_version": MIGRATION_SCHEMA_VERSION,
"source_environment": str(source_environment or "").strip(),
"catalog": snapshot.catalog,
"catalog_sha256": snapshot.catalog_sha256,
"required_executor_types": list(required),
"secret_values_included": False,
}
if not package["source_environment"]:
raise ValueError("source_environment is required")
return {
**package,
"package_sha256": sha256_payload(package),
}
@classmethod
def import_migration_package(
cls,
value: Mapping[str, Any],
*,
destination_path: str | Path,
destination_environment: str,
provider_factories: Mapping[str, ExecutorProvider] | None = None,
) -> ExecutorCatalogSnapshot:
if value.get("schema_version") != MIGRATION_SCHEMA_VERSION:
raise ValueError("unsupported executor migration schema")
if value.get("secret_values_included") is not False:
raise ValueError("executor migration package must not contain secrets")
package_without_hash = {key: item for key, item in value.items() if key != "package_sha256"}
if sha256_payload(package_without_hash) != value.get("package_sha256"):
raise ValueError("executor migration package hash mismatch")
catalog = value.get("catalog")
if not isinstance(catalog, Mapping):
raise ValueError("executor migration package has no catalog")
if sha256_payload(catalog) != value.get("catalog_sha256"):
raise ValueError("executor catalog hash mismatch")
registry = ExecutorProviderRegistry.import_catalog(
catalog,
provider_factories=provider_factories,
)
required = tuple(value.get("required_executor_types") or ())
available = {item.executor_type for item in registry.list_descriptors()}
missing = sorted(set(required) - available)
if missing:
raise ValueError(f"destination provider factories are missing: {missing}")
if not str(destination_environment or "").strip():
raise ValueError("destination_environment is required")
return cls(destination_path).save(registry)
@@ -0,0 +1,441 @@
"""Versioned, extensible executor protocol for AIOS external tasks.
The core lifecycle is executor-neutral. OpenClaw, Hermes, Codex and the scoped
ChatGPT supervisor are built-in
providers, not hard-coded protocol branches. Future providers register a
versioned descriptor and use the same Truth Registry, evidence, notification
and release-gate mechanisms without modifying the lifecycle state machine.
"""
from __future__ import annotations
import json
import re
import threading
from dataclasses import asdict, dataclass, field
from typing import Any, Mapping, Protocol, Sequence
from .external_task_control import ExternalTaskCoordinator
from .task_execution_registry import ExecutionTaskManifest, ExecutionTruthRecord
EXECUTOR_PROTOCOL_VERSION = "aios.executor.v1"
_PROVIDER_NAME = re.compile(r"^[a-z][a-z0-9._-]{1,63}$")
_VERSION = re.compile(r"^[0-9]+(?:\.[0-9]+){0,2}(?:[-+][A-Za-z0-9._-]+)?$")
DISCOVERABLE_STATES: Sequence[str] = ("DECLARED", "REGISTERED")
def normalize_executor(value: str) -> str:
normalized = str(value or "").strip().lower()
if not _PROVIDER_NAME.fullmatch(normalized):
raise ValueError("executor type must be a lowercase provider identifier")
return normalized
def _major(version: str) -> int:
normalized = str(version or "").strip()
if not _VERSION.fullmatch(normalized):
raise ValueError(f"invalid version: {version}")
return int(normalized.split(".", 1)[0])
@dataclass(frozen=True)
class ExecutorProviderDescriptor:
"""Portable provider metadata stored without credentials or runtime secrets."""
executor_type: str
adapter_version: str
protocol_version: str = EXECUTOR_PROTOCOL_VERSION
trust_roles: Sequence[str] = ()
capabilities: Sequence[str] = ()
configuration_schema: str = "aios.executor.config.v1"
portable: bool = True
metadata: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
object.__setattr__(self, "executor_type", normalize_executor(self.executor_type))
adapter_version = str(self.adapter_version or "").strip()
if not _VERSION.fullmatch(adapter_version):
raise ValueError("adapter_version must be version-like")
object.__setattr__(self, "adapter_version", adapter_version)
protocol_version = str(self.protocol_version or "").strip()
if not protocol_version.startswith("aios.executor.v"):
raise ValueError("unsupported executor protocol namespace")
int(protocol_version.rsplit("v", 1)[1])
object.__setattr__(self, "protocol_version", protocol_version)
roles = tuple(sorted({str(item).strip() for item in self.trust_roles if str(item).strip()}))
if not roles:
raise ValueError("provider must declare at least one trust role")
capabilities = tuple(
sorted({str(item).strip() for item in self.capabilities if str(item).strip()})
)
object.__setattr__(self, "trust_roles", roles)
object.__setattr__(self, "capabilities", capabilities)
object.__setattr__(
self,
"configuration_schema",
str(self.configuration_schema or "").strip(),
)
object.__setattr__(
self,
"metadata",
json.loads(json.dumps(dict(self.metadata), ensure_ascii=False, default=str)),
)
def as_dict(self) -> dict[str, Any]:
value = asdict(self)
value["trust_roles"] = list(self.trust_roles)
value["capabilities"] = list(self.capabilities)
return value
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutorProviderDescriptor":
return cls(**dict(value))
def protocol_compatible_with(self, other: "ExecutorProviderDescriptor") -> bool:
return (
self.executor_type == other.executor_type
and self.protocol_version == other.protocol_version
and _major(self.adapter_version) == _major(other.adapter_version)
)
class ExternalExecutorAdapter(Protocol):
"""Runtime-specific implementation behind the common lifecycle."""
descriptor: ExecutorProviderDescriptor
def discover(self) -> Sequence[ExecutionTruthRecord]: ...
def execute(self, manifest: ExecutionTaskManifest) -> Mapping[str, Any]: ...
def notify_owner(self, payload: Mapping[str, Any]) -> str: ...
class ExecutorProvider(Protocol):
descriptor: ExecutorProviderDescriptor
def validate_manifest(self, manifest: ExecutionTaskManifest) -> None: ...
def build_adapter(self, configuration: Mapping[str, Any]) -> ExternalExecutorAdapter: ...
class DescriptorOnlyProvider:
"""Default provider used when lifecycle integration is supplied externally."""
def __init__(self, descriptor: ExecutorProviderDescriptor) -> None:
self.descriptor = descriptor
def validate_manifest(self, manifest: ExecutionTaskManifest) -> None:
if normalize_executor(manifest.executor) != self.descriptor.executor_type:
raise ValueError("manifest executor does not match provider")
def build_adapter(self, configuration: Mapping[str, Any]) -> ExternalExecutorAdapter:
raise RuntimeError(
f"provider {self.descriptor.executor_type} requires an external adapter factory"
)
class ExecutorProviderRegistry:
"""Thread-safe registry supporting runtime registration and catalog migration."""
def __init__(self, providers: Sequence[ExecutorProvider] = ()) -> None:
self._lock = threading.RLock()
self._providers: dict[str, ExecutorProvider] = {}
for provider in providers:
self.register(provider)
def register(self, provider: ExecutorProvider, *, replace: bool = False) -> None:
descriptor = provider.descriptor
key = normalize_executor(descriptor.executor_type)
with self._lock:
current = self._providers.get(key)
if current is not None and not replace:
raise ValueError(f"executor provider already registered: {key}")
if current is not None and not descriptor.protocol_compatible_with(
current.descriptor
):
raise ValueError(
f"replacement provider is incompatible with registered provider: {key}"
)
self._providers[key] = provider
def unregister(self, executor_type: str) -> ExecutorProvider:
key = normalize_executor(executor_type)
with self._lock:
try:
return self._providers.pop(key)
except KeyError as exc:
raise KeyError(f"executor provider is not registered: {key}") from exc
def get(self, executor_type: str) -> ExecutorProvider:
key = normalize_executor(executor_type)
with self._lock:
provider = self._providers.get(key)
if provider is None:
raise KeyError(f"executor provider is not registered: {key}")
return provider
def descriptor(self, executor_type: str) -> ExecutorProviderDescriptor:
return self.get(executor_type).descriptor
def validate_manifest(self, manifest: ExecutionTaskManifest) -> None:
self.get(manifest.executor).validate_manifest(manifest)
def trusted(
self,
*,
executor_type: str,
roles: Sequence[str],
platform_admin: bool = False,
) -> bool:
if platform_admin or "platform_admin" in set(roles):
return True
descriptor = self.descriptor(executor_type)
return bool(set(descriptor.trust_roles).intersection(set(roles)))
def list_descriptors(self) -> tuple[ExecutorProviderDescriptor, ...]:
with self._lock:
return tuple(
self._providers[key].descriptor for key in sorted(self._providers)
)
def export_catalog(self) -> Mapping[str, Any]:
return {
"schema_version": "aios.executor.catalog.v1",
"protocol_version": EXECUTOR_PROTOCOL_VERSION,
"providers": [item.as_dict() for item in self.list_descriptors()],
}
@classmethod
def import_catalog(
cls,
value: Mapping[str, Any],
*,
provider_factories: Mapping[str, ExecutorProvider] | None = None,
) -> "ExecutorProviderRegistry":
if value.get("schema_version") != "aios.executor.catalog.v1":
raise ValueError("unsupported executor catalog schema")
if value.get("protocol_version") != EXECUTOR_PROTOCOL_VERSION:
raise ValueError("executor catalog protocol mismatch")
factories = dict(provider_factories or {})
providers: list[ExecutorProvider] = []
for raw in value.get("providers") or ():
descriptor = ExecutorProviderDescriptor.from_dict(raw)
provider = factories.get(descriptor.executor_type)
if provider is not None:
if not provider.descriptor.protocol_compatible_with(descriptor):
raise ValueError(
f"provider factory does not match imported descriptor: {descriptor.executor_type}"
)
providers.append(provider)
else:
providers.append(DescriptorOnlyProvider(descriptor))
return cls(providers)
BUILTIN_PROVIDER_DESCRIPTORS: Sequence[ExecutorProviderDescriptor] = (
ExecutorProviderDescriptor(
executor_type="openclaw",
adapter_version="2.1.0",
trust_roles=("runtime", "openclaw_executor"),
capabilities=(
"deploy",
"http_runtime",
"owner_notification",
"healthcheck",
"rollback",
),
configuration_schema="aios.executor.openclaw.config.v1",
metadata={"official": True},
),
ExecutorProviderDescriptor(
executor_type="hermes",
adapter_version="2.1.0",
trust_roles=("runtime", "hermes_executor"),
capabilities=(
"queue",
"lease",
"worker",
"runtime_validation",
"restart_recovery",
),
configuration_schema="aios.executor.hermes.config.v1",
metadata={"official": True},
),
ExecutorProviderDescriptor(
executor_type="codex",
adapter_version="2.1.0",
trust_roles=("codex", "codex_executor"),
capabilities=("code", "test", "docs", "gitops_patch", "security_review"),
configuration_schema="aios.executor.codex.config.v1",
metadata={"official": True},
),
ExecutorProviderDescriptor(
executor_type="chatgpt-supervisor",
adapter_version="2.1.0",
trust_roles=("chatgpt_supervisor",),
capabilities=(
"acceptance",
"deploy",
"evidence",
"healthcheck",
"rollback",
"wechat_simulation",
),
configuration_schema="aios.executor.chatgpt-supervisor.config.v1",
metadata={"official": True, "production_mutation_allowed": False},
),
)
def default_provider_registry() -> ExecutorProviderRegistry:
return ExecutorProviderRegistry(
[DescriptorOnlyProvider(item) for item in BUILTIN_PROVIDER_DESCRIPTORS]
)
def trusted_for_executor(
*,
executor: str,
roles: Sequence[str],
platform_admin: bool = False,
registry: ExecutorProviderRegistry | None = None,
) -> bool:
return (registry or default_provider_registry()).trusted(
executor_type=executor,
roles=roles,
platform_admin=platform_admin,
)
@dataclass(frozen=True)
class ExecutorIdentity:
executor_type: str
executor_id: str
executor_run_id: str
protocol_version: str = EXECUTOR_PROTOCOL_VERSION
def __post_init__(self) -> None:
object.__setattr__(self, "executor_type", normalize_executor(self.executor_type))
for name in ("executor_id", "executor_run_id"):
value = str(getattr(self, name) or "").strip()
if not value:
raise ValueError(f"{name} is required")
object.__setattr__(self, name, value)
if self.protocol_version != EXECUTOR_PROTOCOL_VERSION:
raise ValueError("executor identity protocol mismatch")
class ExecutorLifecycleSession:
"""Executor-neutral lifecycle facade used by every registered provider."""
def __init__(
self,
coordinator: ExternalTaskCoordinator,
identity: ExecutorIdentity,
*,
registry: ExecutorProviderRegistry | None = None,
) -> None:
self.coordinator = coordinator
self.identity = identity
self.registry = registry or default_provider_registry()
self.registry.get(identity.executor_type)
def _assert_manifest(self, task_id: str) -> ExecutionTaskManifest:
record = self.coordinator.registry.get(task_id)
manifest = record.manifest
self.registry.validate_manifest(manifest)
if normalize_executor(manifest.executor) != self.identity.executor_type:
raise PermissionError(
f"task executor is {manifest.executor}, not {self.identity.executor_type}"
)
return manifest
def register(self, task_id: str) -> ExecutionTruthRecord:
self._assert_manifest(task_id)
return self.coordinator.register(
task_id,
executor_run_id=self.identity.executor_run_id,
)
def retry_not_started(self, task_id: str) -> ExecutionTruthRecord:
self._assert_manifest(task_id)
return self.coordinator.retry_not_started(
task_id,
executor_run_id=self.identity.executor_run_id,
)
def claim(self, task_id: str) -> ExecutionTruthRecord:
self._assert_manifest(task_id)
return self.coordinator.claim(
task_id,
executor_run_id=self.identity.executor_run_id,
executor_id=self.identity.executor_id,
)
def start(self, task_id: str) -> ExecutionTruthRecord:
self._assert_manifest(task_id)
return self.coordinator.start(
task_id,
executor_run_id=self.identity.executor_run_id,
executor_id=self.identity.executor_id,
)
def heartbeat(self, task_id: str, *, current_step: int) -> ExecutionTruthRecord:
self._assert_manifest(task_id)
return self.coordinator.heartbeat(
task_id,
executor_run_id=self.identity.executor_run_id,
executor_id=self.identity.executor_id,
current_step=current_step,
)
def succeed(
self,
task_id: str,
*,
evidence: Mapping[str, str],
) -> ExecutionTruthRecord:
self._assert_manifest(task_id)
return self.coordinator.succeed(
task_id,
executor_run_id=self.identity.executor_run_id,
executor_id=self.identity.executor_id,
evidence=evidence,
)
def fail(
self,
task_id: str,
*,
error: Mapping[str, Any],
evidence: Mapping[str, str] | None = None,
) -> ExecutionTruthRecord:
self._assert_manifest(task_id)
return self.coordinator.fail(
task_id,
executor_run_id=self.identity.executor_run_id,
executor_id=self.identity.executor_id,
error=error,
evidence=evidence,
)
class OpenClawExecutorSession(ExecutorLifecycleSession):
def __init__(self, coordinator: ExternalTaskCoordinator, *, executor_id: str, executor_run_id: str) -> None:
super().__init__(
coordinator,
ExecutorIdentity("openclaw", executor_id, executor_run_id),
)
class HermesExecutorSession(ExecutorLifecycleSession):
def __init__(self, coordinator: ExternalTaskCoordinator, *, executor_id: str, executor_run_id: str) -> None:
super().__init__(
coordinator,
ExecutorIdentity("hermes", executor_id, executor_run_id),
)
class CodexExecutorSession(ExecutorLifecycleSession):
def __init__(self, coordinator: ExternalTaskCoordinator, *, executor_id: str, executor_run_id: str) -> None:
super().__init__(
coordinator,
ExecutorIdentity("codex", executor_id, executor_run_id),
)
@@ -0,0 +1,313 @@
"""Provider-neutral worker runtime for AIOS external executors.
Providers implement only step execution and notification delivery. Discovery,
dependency gates, lifecycle truth, heartbeat, evidence validation and durable
notification acknowledgement are identical for every current and future
executor Provider.
"""
from __future__ import annotations
import hashlib
import json
import uuid
from dataclasses import dataclass, field
from typing import Any, Mapping, Protocol, Sequence
from .execution_plan import ExecutionPlanCoordinator, ExecutionStageManifest
from .executor_protocol import (
ExecutorIdentity,
ExecutorLifecycleSession,
ExecutorProviderRegistry,
normalize_executor,
)
from .external_task_control import ExternalTaskCoordinator, NotificationRecord
from .task_execution_registry import ExecutionTruthState
class StepRunner(Protocol):
def run_step(
self,
*,
plan_id: str,
stage: ExecutionStageManifest,
step: str,
step_number: int,
executor_id: str,
executor_run_id: str,
) -> Mapping[str, str]: ...
class NotificationSender(Protocol):
def send(self, notification: NotificationRecord) -> str: ...
@dataclass(frozen=True)
class StageRunResult:
plan_id: str
stage_id: str
task_id: str
executor_type: str
executor_id: str
executor_run_id: str
state: str
evidence: Mapping[str, str] = field(default_factory=dict)
error: Mapping[str, Any] = field(default_factory=dict)
delivered_notifications: Sequence[str] = ()
def as_dict(self) -> dict[str, Any]:
return {
"plan_id": self.plan_id,
"stage_id": self.stage_id,
"task_id": self.task_id,
"executor_type": self.executor_type,
"executor_id": self.executor_id,
"executor_run_id": self.executor_run_id,
"state": self.state,
"evidence": dict(self.evidence),
"error": dict(self.error),
"delivered_notifications": list(self.delivered_notifications),
}
class UnifiedExecutorWorker:
"""Dependency-aware worker shared by every registered Provider."""
def __init__(
self,
*,
plan_coordinator: ExecutionPlanCoordinator,
task_coordinator: ExternalTaskCoordinator,
providers: ExecutorProviderRegistry,
executor_type: str,
executor_id: str,
step_runner: StepRunner,
notification_sender: NotificationSender,
) -> None:
self.plan_coordinator = plan_coordinator
self.task_coordinator = task_coordinator
self.providers = providers
self.executor_type = normalize_executor(executor_type)
self.executor_id = str(executor_id or "").strip()
if not self.executor_id:
raise ValueError("executor_id is required")
self.providers.get(self.executor_type)
self.step_runner = step_runner
self.notification_sender = notification_sender
def _session(self, executor_run_id: str) -> ExecutorLifecycleSession:
return ExecutorLifecycleSession(
self.task_coordinator,
ExecutorIdentity(
executor_type=self.executor_type,
executor_id=self.executor_id,
executor_run_id=executor_run_id,
),
registry=self.providers,
)
def _notification_candidates(self, task_id: str) -> tuple[NotificationRecord, ...]:
"""Return idempotent delivery candidates, including prior failures.
The outbox has a unique ``(task_id, event_type)`` key. Retrying therefore
reuses the same notification ID and cannot create duplicate STARTED or
SUCCEEDED events. A later successful acknowledgement replaces FAILED
with DELIVERED and records the receipt hash.
"""
candidates: dict[str, NotificationRecord] = {}
for status in ("PENDING", "FAILED"):
for item in self.task_coordinator.notifications.list(
task_id=task_id,
status=status,
):
candidates[item.notification_id] = item
return tuple(
candidates[key]
for key in sorted(
candidates,
key=lambda item: (
candidates[item].created_at,
candidates[item].notification_id,
),
)
)
def _deliver_notifications(self, task_id: str) -> tuple[str, ...]:
delivered: list[str] = []
for item in self._notification_candidates(task_id):
try:
receipt = self.notification_sender.send(item)
acknowledged = self.task_coordinator.notifications.acknowledge(
item.notification_id,
receipt_sha256=receipt,
)
delivered.append(acknowledged.event_type)
except Exception as exc:
self.task_coordinator.notifications.fail(
item.notification_id,
error=f"{type(exc).__name__}: {exc}",
)
return tuple(sorted(set(delivered)))
def retry_failed_notifications(self, *, task_id: str) -> tuple[str, ...]:
"""Retry failed owner notifications without changing task truth."""
return self._deliver_notifications(task_id)
def run_ready(
self,
*,
plan_id: str,
max_stages: int = 1,
) -> list[StageRunResult]:
if max_stages < 1:
raise ValueError("max_stages must be positive")
ready = self.plan_coordinator.discover_ready(
plan_id=plan_id,
executor_type=self.executor_type,
)[:max_stages]
return [
self.run_stage(
plan_id=plan_id,
stage_id=item["stage"]["stage_id"],
)
for item in ready
]
def run_stage(
self,
*,
plan_id: str,
stage_id: str,
executor_run_id: str | None = None,
) -> StageRunResult:
plan = self.plan_coordinator.plan_store.get(plan_id)
stage = plan.stage(stage_id)
if stage.executor_type != self.executor_type:
raise PermissionError(
f"stage executor is {stage.executor_type}, not {self.executor_type}"
)
dependency = self.plan_coordinator.dependency_gate(plan_id, stage_id)
if not dependency["passed"]:
raise RuntimeError("stage dependencies are not satisfied")
run_id = executor_run_id or f"{self.executor_type}_run_{uuid.uuid4().hex}"
session = self._session(run_id)
record = self.task_coordinator.registry.get(stage.task_id)
evidence: dict[str, str] = dict(record.evidence)
delivered: list[str] = []
try:
if record.state == ExecutionTruthState.DECLARED:
record = session.register(stage.task_id)
if record.state == ExecutionTruthState.REGISTERED:
record = session.claim(stage.task_id)
if record.state == ExecutionTruthState.CLAIMED:
record = session.start(stage.task_id)
delivered.extend(self._deliver_notifications(stage.task_id))
if record.state != ExecutionTruthState.RUNNING:
raise RuntimeError(
f"stage cannot execute from state {record.state.value}"
)
# Re-running the current step is intentional: Provider steps must be
# idempotent and a crash between side effect and heartbeat cannot be
# safely interpreted as completed work.
start_step = max(1, record.current_step)
for number, step in enumerate(stage.steps, start=1):
if number < start_step:
continue
produced = self.step_runner.run_step(
plan_id=plan_id,
stage=stage,
step=step,
step_number=number,
executor_id=self.executor_id,
executor_run_id=run_id,
)
if not isinstance(produced, Mapping):
raise TypeError("step runner must return an evidence mapping")
evidence.update(
{
str(key).strip(): str(value).strip().lower()
for key, value in produced.items()
}
)
record = session.heartbeat(stage.task_id, current_step=number)
record = session.succeed(stage.task_id, evidence=evidence)
delivered.extend(self._deliver_notifications(stage.task_id))
return StageRunResult(
plan_id=plan_id,
stage_id=stage.stage_id,
task_id=stage.task_id,
executor_type=self.executor_type,
executor_id=self.executor_id,
executor_run_id=run_id,
state=record.state.value,
evidence=dict(record.evidence),
delivered_notifications=tuple(sorted(set(delivered))),
)
except Exception as exc:
current = self.task_coordinator.registry.get(stage.task_id)
if current.state in {
ExecutionTruthState.REGISTERED,
ExecutionTruthState.CLAIMED,
ExecutionTruthState.RUNNING,
}:
current = session.fail(
stage.task_id,
error={
"code": "executor_stage_failed",
"error_type": type(exc).__name__,
"message": str(exc),
},
evidence=evidence or None,
)
delivered.extend(self._deliver_notifications(stage.task_id))
return StageRunResult(
plan_id=plan_id,
stage_id=stage.stage_id,
task_id=stage.task_id,
executor_type=self.executor_type,
executor_id=self.executor_id,
executor_run_id=run_id,
state=current.state.value,
evidence=dict(current.evidence),
error=dict(current.error),
delivered_notifications=tuple(sorted(set(delivered))),
)
class JsonEvidenceStepRunner:
"""Adapter for handlers returning evidence-name to SHA256 mappings."""
def __init__(self, handler) -> None:
self.handler = handler
def run_step(self, **values: Any) -> Mapping[str, str]:
result = self.handler(**values)
if not isinstance(result, Mapping):
raise TypeError("step handler must return a mapping")
return {str(key): str(value) for key, value in result.items()}
class JsonReceiptNotificationSender:
"""Adapter for senders returning a SHA256 or receipt JSON object."""
def __init__(self, sender) -> None:
self.sender = sender
def send(self, notification: NotificationRecord) -> str:
result = self.sender(notification.as_dict())
if isinstance(result, str):
return result
if not isinstance(result, Mapping):
raise TypeError(
"notification sender must return SHA256 or a receipt object"
)
encoded = json.dumps(
dict(result),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
@@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""CLI for durable external-task truth and notification receipts.
The CLI is suitable for OpenClaw workers, systemd jobs and release gates. It
never infers execution from the presence of a manifest: each lifecycle action
must be recorded explicitly in the shared SQLite registry.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any, Mapping, Sequence
from .external_task_control import ExternalTaskCoordinator
from .external_task_query import list_records
def _json_mapping(value: str | None, path: str | None) -> Mapping[str, Any]:
if value and path:
raise ValueError("inline JSON and JSON file are mutually exclusive")
if path:
parsed = json.loads(Path(path).read_text(encoding="utf-8"))
else:
parsed = json.loads(value or "{}")
if not isinstance(parsed, Mapping):
raise ValueError("JSON value must be an object")
return dict(parsed)
def _coordinator(args: argparse.Namespace) -> ExternalTaskCoordinator:
return ExternalTaskCoordinator.from_file(args.store_file)
def _print(value: Any) -> None:
print(json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2, default=str))
def cmd_declare(args: argparse.Namespace) -> int:
record = _coordinator(args).declare_file(args.manifest)
_print(record.as_dict())
return 0
def cmd_discover(args: argparse.Namespace) -> int:
records = list_records(
args.store_file,
executor=args.executor,
states=tuple(args.state or ()),
)
_print({"count": len(records), "items": [item.as_dict() for item in records]})
return 0
def cmd_register(args: argparse.Namespace) -> int:
_print(_coordinator(args).register(args.task_id, executor_run_id=args.executor_run_id).as_dict())
return 0
def cmd_claim(args: argparse.Namespace) -> int:
_print(
_coordinator(args).claim(
args.task_id,
executor_run_id=args.executor_run_id,
executor_id=args.executor_id,
).as_dict()
)
return 0
def cmd_start(args: argparse.Namespace) -> int:
_print(
_coordinator(args).start(
args.task_id,
executor_run_id=args.executor_run_id,
executor_id=args.executor_id,
).as_dict()
)
return 0
def cmd_heartbeat(args: argparse.Namespace) -> int:
_print(
_coordinator(args).heartbeat(
args.task_id,
executor_run_id=args.executor_run_id,
executor_id=args.executor_id,
current_step=args.current_step,
).as_dict()
)
return 0
def cmd_succeed(args: argparse.Namespace) -> int:
evidence = _json_mapping(args.evidence_json, args.evidence_file)
_print(
_coordinator(args).succeed(
args.task_id,
executor_run_id=args.executor_run_id,
executor_id=args.executor_id,
evidence={str(key): str(value) for key, value in evidence.items()},
).as_dict()
)
return 0
def cmd_fail(args: argparse.Namespace) -> int:
error = _json_mapping(args.error_json, args.error_file)
evidence = None
if args.evidence_json or args.evidence_file:
evidence_value = _json_mapping(args.evidence_json, args.evidence_file)
evidence = {str(key): str(value) for key, value in evidence_value.items()}
_print(
_coordinator(args).fail(
args.task_id,
executor_run_id=args.executor_run_id,
executor_id=args.executor_id,
error=error,
evidence=evidence,
).as_dict()
)
return 0
def cmd_status(args: argparse.Namespace) -> int:
_print(_coordinator(args).status(args.task_id))
return 0
def cmd_gate(args: argparse.Namespace) -> int:
value = _coordinator(args).gate(args.task_id)
_print(value)
return 0 if value["passed"] else 3
def cmd_notifications(args: argparse.Namespace) -> int:
items = _coordinator(args).notifications.list(task_id=args.task_id, status=args.status)
_print({"count": len(items), "items": [item.as_dict() for item in items]})
return 0
def cmd_ack(args: argparse.Namespace) -> int:
_print(
_coordinator(args).notifications.acknowledge(
args.notification_id,
receipt_sha256=args.receipt_sha256,
).as_dict()
)
return 0
def common(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--store-file",
default=os.getenv(
"AIOS_EXTERNAL_TASK_STORE_FILE",
"/var/lib/aios-v2/external-task-truth.sqlite3",
),
)
def lifecycle(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--task-id", required=True)
parser.add_argument("--executor-run-id", required=True)
parser.add_argument("--executor-id", required=True)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="AIOS external task truth CLI")
commands = parser.add_subparsers(dest="command", required=True)
declare = commands.add_parser("declare")
common(declare)
declare.add_argument("--manifest", required=True)
declare.set_defaults(handler=cmd_declare)
discover = commands.add_parser("discover")
common(discover)
discover.add_argument("--executor", default="openclaw")
discover.add_argument("--state", action="append")
discover.set_defaults(handler=cmd_discover)
register = commands.add_parser("register")
common(register)
register.add_argument("--task-id", required=True)
register.add_argument("--executor-run-id", required=True)
register.set_defaults(handler=cmd_register)
claim = commands.add_parser("claim")
common(claim)
lifecycle(claim)
claim.set_defaults(handler=cmd_claim)
start = commands.add_parser("start")
common(start)
lifecycle(start)
start.set_defaults(handler=cmd_start)
heartbeat = commands.add_parser("heartbeat")
common(heartbeat)
lifecycle(heartbeat)
heartbeat.add_argument("--current-step", type=int, required=True)
heartbeat.set_defaults(handler=cmd_heartbeat)
succeed = commands.add_parser("succeed")
common(succeed)
lifecycle(succeed)
evidence = succeed.add_mutually_exclusive_group(required=True)
evidence.add_argument("--evidence-json")
evidence.add_argument("--evidence-file")
succeed.set_defaults(handler=cmd_succeed)
fail = commands.add_parser("fail")
common(fail)
lifecycle(fail)
error = fail.add_mutually_exclusive_group(required=True)
error.add_argument("--error-json")
error.add_argument("--error-file")
fail_evidence = fail.add_mutually_exclusive_group()
fail_evidence.add_argument("--evidence-json")
fail_evidence.add_argument("--evidence-file")
fail.set_defaults(handler=cmd_fail)
status = commands.add_parser("status")
common(status)
status.add_argument("--task-id", required=True)
status.set_defaults(handler=cmd_status)
gate = commands.add_parser("gate")
common(gate)
gate.add_argument("--task-id", required=True)
gate.set_defaults(handler=cmd_gate)
notifications = commands.add_parser("notifications")
common(notifications)
notifications.add_argument("--task-id")
notifications.add_argument("--status")
notifications.set_defaults(handler=cmd_notifications)
ack = commands.add_parser("ack-notification")
common(ack)
ack.add_argument("--notification-id", required=True)
ack.add_argument("--receipt-sha256", required=True)
ack.set_defaults(handler=cmd_ack)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
return int(args.handler(args))
except Exception as exc:
print(
json.dumps(
{"status": "error", "error": type(exc).__name__, "message": str(exc)},
ensure_ascii=False,
),
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,420 @@
"""External executor coordination, notification outbox and release truth gate.
OpenClaw/Codex task declarations are not execution evidence. This module wraps
``SQLiteExecutionTruthRegistry`` with a durable notification outbox. A successful
release gate requires both terminal task evidence and acknowledged STARTED /
SUCCEEDED owner notifications when those events are required by the manifest.
"""
from __future__ import annotations
import hashlib
import json
import re
import sqlite3
import threading
import uuid
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Mapping, Sequence
from .task_execution_registry import (
ExecutionTaskManifest,
ExecutionTruthRecord,
ExecutionTruthState,
SQLiteExecutionTruthRegistry,
utc_now,
)
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
def sha256_value(value: str, label: str) -> str:
normalized = str(value or "").strip().lower()
if not _SHA256.fullmatch(normalized):
raise ValueError(f"{label} must be a lowercase SHA256 hex digest")
return normalized
@dataclass(frozen=True)
class NotificationRecord:
notification_id: str
task_id: str
event_type: str
target_ref: str
payload: Mapping[str, Any]
status: str
created_at: str
delivered_at: str | None = None
receipt_sha256: str | None = None
error: str | None = None
def as_dict(self) -> dict[str, Any]:
return asdict(self)
class SQLiteNotificationOutbox:
"""Durable owner-notification delivery and acknowledgement records."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
with self._connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS external_task_notifications (
notification_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
event_type TEXT NOT NULL,
target_ref TEXT NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
delivered_at TEXT,
receipt_sha256 TEXT,
error TEXT,
UNIQUE(task_id, event_type)
);
CREATE INDEX IF NOT EXISTS idx_external_notification_status
ON external_task_notifications(status, created_at);
"""
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(str(self.path), timeout=30.0)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=FULL")
return connection
@staticmethod
def _decode(row: sqlite3.Row) -> NotificationRecord:
return NotificationRecord(
notification_id=row["notification_id"],
task_id=row["task_id"],
event_type=row["event_type"],
target_ref=row["target_ref"],
payload=json.loads(row["payload_json"]),
status=row["status"],
created_at=row["created_at"],
delivered_at=row["delivered_at"],
receipt_sha256=row["receipt_sha256"],
error=row["error"],
)
def enqueue(
self,
*,
task_id: str,
event_type: str,
target_ref: str,
payload: Mapping[str, Any],
) -> NotificationRecord:
now = utc_now()
record = NotificationRecord(
notification_id=f"notify_{uuid.uuid4().hex}",
task_id=str(task_id).strip(),
event_type=str(event_type).strip().upper(),
target_ref=str(target_ref).strip(),
payload=json.loads(json.dumps(dict(payload), ensure_ascii=False, default=str)),
status="PENDING",
created_at=now,
)
if not record.task_id or not record.event_type or not record.target_ref:
raise ValueError("task_id, event_type and target_ref are required")
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
"SELECT * FROM external_task_notifications WHERE task_id = ? AND event_type = ?",
(record.task_id, record.event_type),
).fetchone()
if existing is not None:
connection.rollback()
return self._decode(existing)
connection.execute(
"""
INSERT INTO external_task_notifications
(notification_id, task_id, event_type, target_ref, payload_json,
status, created_at, delivered_at, receipt_sha256, error)
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
""",
(
record.notification_id,
record.task_id,
record.event_type,
record.target_ref,
json.dumps(record.payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
record.status,
record.created_at,
),
)
connection.commit()
return record
def get(self, notification_id: str) -> NotificationRecord:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT * FROM external_task_notifications WHERE notification_id = ?",
(str(notification_id).strip(),),
).fetchone()
if row is None:
raise KeyError("notification not found")
return self._decode(row)
def acknowledge(self, notification_id: str, *, receipt_sha256: str) -> NotificationRecord:
digest = sha256_value(receipt_sha256, "receipt_sha256")
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT * FROM external_task_notifications WHERE notification_id = ?",
(str(notification_id).strip(),),
).fetchone()
if row is None:
connection.rollback()
raise KeyError("notification not found")
current = self._decode(row)
if current.status == "DELIVERED":
if current.receipt_sha256 != digest:
connection.rollback()
raise RuntimeError("notification already acknowledged with another receipt")
connection.rollback()
return current
delivered_at = utc_now()
connection.execute(
"""
UPDATE external_task_notifications
SET status = 'DELIVERED', delivered_at = ?, receipt_sha256 = ?, error = NULL
WHERE notification_id = ?
""",
(delivered_at, digest, current.notification_id),
)
connection.commit()
return self.get(current.notification_id)
def fail(self, notification_id: str, *, error: str) -> NotificationRecord:
message = str(error or "").strip()
if not message:
raise ValueError("notification error is required")
with self._lock, self._connect() as connection:
connection.execute(
"UPDATE external_task_notifications SET status = 'FAILED', error = ? WHERE notification_id = ?",
(message, str(notification_id).strip()),
)
connection.commit()
return self.get(notification_id)
def list(
self,
*,
task_id: str | None = None,
status: str | None = None,
) -> list[NotificationRecord]:
conditions: list[str] = []
values: list[str] = []
if task_id:
conditions.append("task_id = ?")
values.append(str(task_id).strip())
if status:
conditions.append("status = ?")
values.append(str(status).strip().upper())
where = f" WHERE {' AND '.join(conditions)}" if conditions else ""
with self._lock, self._connect() as connection:
rows = connection.execute(
f"SELECT * FROM external_task_notifications{where} ORDER BY created_at, notification_id",
tuple(values),
).fetchall()
return [self._decode(row) for row in rows]
class ExternalTaskCoordinator:
"""Single safe lifecycle used by external executors and release gates."""
def __init__(
self,
registry: SQLiteExecutionTruthRegistry,
notifications: SQLiteNotificationOutbox,
) -> None:
self.registry = registry
self.notifications = notifications
@classmethod
def from_file(cls, path: str | Path) -> "ExternalTaskCoordinator":
return cls(
SQLiteExecutionTruthRegistry(path),
SQLiteNotificationOutbox(path),
)
def declare(self, manifest: ExecutionTaskManifest) -> ExecutionTruthRecord:
return self.registry.declare(manifest)
def declare_file(self, path: str | Path) -> ExecutionTruthRecord:
value = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(value, Mapping):
raise ValueError("external task manifest must be a JSON object")
return self.declare(ExecutionTaskManifest.from_dict(value))
def register(self, task_id: str, *, executor_run_id: str) -> ExecutionTruthRecord:
return self.registry.register(task_id, executor_run_id=executor_run_id)
def retry_not_started(
self,
task_id: str,
*,
executor_run_id: str,
) -> ExecutionTruthRecord:
return self.registry.retry_not_started(
task_id,
executor_run_id=executor_run_id,
)
def claim(
self,
task_id: str,
*,
executor_run_id: str,
executor_id: str,
) -> ExecutionTruthRecord:
return self.registry.claim(
task_id,
executor_run_id=executor_run_id,
executor_id=executor_id,
)
def _notify(self, record: ExecutionTruthRecord, event_type: str) -> NotificationRecord | None:
event = event_type.upper()
if event not in set(record.manifest.notification_events):
return None
return self.notifications.enqueue(
task_id=record.task_id,
event_type=event,
target_ref=record.manifest.owner_notification_ref,
payload={
"task_id": record.task_id,
"target_version": record.manifest.target_version,
"executor": record.manifest.executor,
"executor_id": record.executor_id,
"executor_run_id": record.executor_run_id,
"state": record.state.value,
"current_step": record.current_step,
"total_steps": len(record.manifest.steps),
"timestamp": utc_now(),
},
)
def start(
self,
task_id: str,
*,
executor_run_id: str,
executor_id: str,
) -> ExecutionTruthRecord:
record = self.registry.start(
task_id,
executor_run_id=executor_run_id,
executor_id=executor_id,
)
self._notify(record, "STARTED")
return record
def heartbeat(
self,
task_id: str,
*,
executor_run_id: str,
executor_id: str,
current_step: int,
) -> ExecutionTruthRecord:
return self.registry.heartbeat(
task_id,
executor_run_id=executor_run_id,
executor_id=executor_id,
current_step=current_step,
)
def succeed(
self,
task_id: str,
*,
executor_run_id: str,
executor_id: str,
evidence: Mapping[str, str],
) -> ExecutionTruthRecord:
normalized = {
str(key).strip(): sha256_value(value, f"evidence[{key}]")
for key, value in evidence.items()
}
record = self.registry.succeed(
task_id,
executor_run_id=executor_run_id,
executor_id=executor_id,
evidence=normalized,
)
self._notify(record, "SUCCEEDED")
return record
def fail(
self,
task_id: str,
*,
executor_run_id: str,
executor_id: str,
error: Mapping[str, Any],
evidence: Mapping[str, str] | None = None,
) -> ExecutionTruthRecord:
normalized = None
if evidence is not None:
normalized = {
str(key).strip(): sha256_value(value, f"evidence[{key}]")
for key, value in evidence.items()
}
record = self.registry.fail(
task_id,
executor_run_id=executor_run_id,
executor_id=executor_id,
error=error,
evidence=normalized,
)
self._notify(record, "FAILED")
return record
def gate(self, task_id: str) -> Mapping[str, Any]:
before = self.registry.get(task_id)
truth = self.registry.gate(task_id)
after = self.registry.get(task_id)
if after.state != before.state and after.state in {
ExecutionTruthState.NOT_STARTED,
ExecutionTruthState.STALLED,
}:
self._notify(after, after.state.value)
notifications = self.notifications.list(task_id=task_id)
delivered = {item.event_type for item in notifications if item.status == "DELIVERED"}
required_success_notifications = {
item
for item in ("STARTED", "SUCCEEDED")
if item in set(after.manifest.notification_events)
}
notification_complete = required_success_notifications.issubset(delivered)
passed = bool(truth["passed"] and notification_complete)
return {
**dict(truth),
"passed": passed,
"release_blocking": not passed,
"notification_complete": notification_complete,
"required_success_notifications": sorted(required_success_notifications),
"delivered_notifications": sorted(delivered),
"notifications": [item.as_dict() for item in notifications],
}
def status(self, task_id: str) -> Mapping[str, Any]:
gate = self.gate(task_id)
record = self.registry.get(task_id)
return {
"record": record.as_dict(),
"gate": gate,
"events": self.registry.events(task_id),
}
@staticmethod
def content_receipt(value: Mapping[str, Any]) -> str:
encoded = json.dumps(dict(value), ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
@@ -0,0 +1,51 @@
"""Read models for the durable external-task truth registry."""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from typing import Sequence
from .task_execution_registry import ExecutionTruthRecord, ExecutionTruthState
def _decode(value: str) -> ExecutionTruthRecord:
payload = json.loads(value)
payload["state"] = ExecutionTruthState(payload["state"])
payload["evidence"] = dict(payload.get("evidence") or {})
payload["error"] = dict(payload.get("error") or {})
return ExecutionTruthRecord(**payload)
def list_records(
path: str | Path,
*,
executor: str | None = None,
states: Sequence[str] = (),
) -> list[ExecutionTruthRecord]:
database = Path(path).resolve()
if not database.exists():
return []
conditions: list[str] = []
values: list[str] = []
normalized_states = tuple(sorted({ExecutionTruthState(item).value for item in states}))
if normalized_states:
conditions.append(f"state IN ({','.join('?' for _ in normalized_states)})")
values.extend(normalized_states)
where = f" WHERE {' AND '.join(conditions)}" if conditions else ""
connection = sqlite3.connect(f"file:{database}?mode=ro", uri=True, timeout=10.0)
connection.row_factory = sqlite3.Row
try:
rows = connection.execute(
f"SELECT record_json FROM execution_truth{where} ORDER BY updated_at, task_id",
tuple(values),
).fetchall()
except sqlite3.OperationalError:
return []
finally:
connection.close()
records = [_decode(row["record_json"]) for row in rows]
if executor:
normalized = str(executor).strip().lower()
records = [item for item in records if item.manifest.executor.lower() == normalized]
return records
+353
View File
@@ -0,0 +1,353 @@
"""HTTP/JSON gateway for the AIOS V2 control plane.
The public gateway owns user-facing control operations only. Runtime lifecycle
transitions are deliberately absent: external callers cannot directly move a
task to RUNNING, COMPLETED or FAILED. Those transitions are available only
through the provenance-protected orchestrator and internal reconciliation API.
"""
from __future__ import annotations
import dataclasses
import json
import os
import re
import urllib.parse
from enum import Enum
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Mapping
from aios_hermes import HermesConflict, HermesNotFound
from .control_plane import (
ControlPlaneError,
ControlPlaneService,
IdempotencyConflict,
InvalidTransition,
NotFound,
PolicyDenied,
RequestContext,
RevisionConflict,
TaskState,
TenantIsolationError,
Unauthorized,
)
from .dispatch_store import DispatchConflict, DispatchNotFound
from .orchestrator import OrchestrationError
from .provenance import ProvenanceDenied, ProvenanceReplay
TENANT_HEADER = "X-AIOS-Tenant-ID"
PRINCIPAL_HEADER = "X-AIOS-Principal-ID"
ROLES_HEADER = "X-AIOS-Roles"
TRACE_HEADER = "X-AIOS-Trace-ID"
SESSION_HEADER = "X-AIOS-Session-ID"
POLICY_HEADER = "X-AIOS-Policy-Version"
AUTHENTICATED_HEADER = "X-AIOS-Authenticated"
_RESOURCE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$")
def _json_value(value: Any) -> Any:
if dataclasses.is_dataclass(value):
return {
field.name: _json_value(getattr(value, field.name))
for field in dataclasses.fields(value)
}
if isinstance(value, Enum):
return value.value
if isinstance(value, Mapping):
return {str(key): _json_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set, frozenset)):
return [_json_value(item) for item in value]
return value
def _require_mapping(value: Any, *, label: str = "JSON body") -> Mapping[str, Any]:
if not isinstance(value, Mapping):
raise ValueError(f"{label} must be an object")
return value
def _require_resource_id(value: Any, *, label: str) -> str:
normalized = str(value or "").strip()
if not _RESOURCE_ID_RE.fullmatch(normalized):
raise ValueError(f"invalid {label}")
return normalized
class GatewayApplication:
"""Pure user-facing request router that can be tested without a socket."""
def __init__(self, service: ControlPlaneService | None = None) -> None:
self.service = service or ControlPlaneService()
@staticmethod
def context_from_headers(headers: Mapping[str, str]) -> RequestContext:
normalized_headers = {
str(key).strip().lower(): value for key, value in headers.items()
}
def header(name: str, default: str = "") -> str:
return str(normalized_headers.get(name.lower(), default))
authenticated = header(AUTHENTICATED_HEADER).strip().lower()
if authenticated not in {"1", "true", "yes"}:
raise Unauthorized("trusted authentication assertion is required")
roles = [
role.strip()
for role in header(ROLES_HEADER).split(",")
if role.strip()
]
return RequestContext.create(
tenant_id=header(TENANT_HEADER),
principal_id=header(PRINCIPAL_HEADER),
trace_id=header(TRACE_HEADER).strip() or None,
session_id=header(SESSION_HEADER).strip() or None,
policy_version=header(POLICY_HEADER, "v1").strip() or "v1",
roles=roles,
)
def handle(
self,
method: str,
raw_path: str,
headers: Mapping[str, str],
body: Any = None,
) -> tuple[int, Mapping[str, Any]]:
parsed = urllib.parse.urlparse(raw_path)
path = parsed.path.rstrip("/") or "/"
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=False)
method = method.upper()
if method == "GET" and path in {"/healthz", "/api/v2/health"}:
return HTTPStatus.OK, {
"service": "aios-v2-control-plane",
"status": "ok",
"store": type(self.service.store).__name__,
}
context = self.context_from_headers(headers)
payload = _require_mapping(body or {})
if method == "POST" and path == "/api/v2/tenants":
tenant = self.service.register_tenant(
context,
tenant_id=_require_resource_id(payload.get("tenant_id"), label="tenant_id"),
display_name=str(payload.get("display_name", "")),
metadata=_require_mapping(payload.get("metadata", {}), label="metadata"),
)
return HTTPStatus.CREATED, {"tenant": _json_value(tenant)}
if method == "GET" and path == "/api/v2/tenants/current":
return HTTPStatus.OK, {"tenant": _json_value(self.service.get_tenant(context))}
if method == "POST" and path == "/api/v2/agents":
agent = self.service.register_agent(
context,
agent_id=_require_resource_id(payload.get("agent_id"), label="agent_id"),
display_name=str(payload.get("display_name", "")),
runtime=str(payload.get("runtime", "")),
capabilities=payload.get("capabilities", ()),
metadata=_require_mapping(payload.get("metadata", {}), label="metadata"),
)
return HTTPStatus.CREATED, {"agent": _json_value(agent)}
if method == "GET" and path == "/api/v2/agents":
return HTTPStatus.OK, {"agents": _json_value(self.service.list_agents(context))}
agent_match = re.fullmatch(r"/api/v2/agents/([^/]+)", path)
if method == "GET" and agent_match:
agent_id = _require_resource_id(
urllib.parse.unquote(agent_match.group(1)), label="agent_id"
)
return HTTPStatus.OK, {
"agent": _json_value(self.service.get_agent(context, agent_id))
}
if method == "POST" and path == "/api/v2/tasks":
task = self.service.create_task(
context,
agent_id=_require_resource_id(payload.get("agent_id"), label="agent_id"),
action=str(payload.get("action", "")).strip(),
payload=_require_mapping(payload.get("payload", {}), label="payload"),
idempotency_key=(
str(payload["idempotency_key"]).strip()
if payload.get("idempotency_key") is not None
else None
),
)
return HTTPStatus.CREATED, {"task": _json_value(task)}
if method == "GET" and path == "/api/v2/tasks":
tasks = self.service.list_tasks(context, states=query.get("state"))
return HTTPStatus.OK, {"tasks": _json_value(tasks)}
task_match = re.fullmatch(r"/api/v2/tasks/([^/]+)", path)
if method == "GET" and task_match:
task_id = _require_resource_id(
urllib.parse.unquote(task_match.group(1)), label="task_id"
)
return HTTPStatus.OK, {
"task": _json_value(self.service.get_task(context, task_id))
}
bypass_match = re.fullmatch(
r"/api/v2/tasks/([^/]+)/(start|complete|fail)", path
)
if method == "POST" and bypass_match:
return HTTPStatus.FORBIDDEN, {
"error": "RuntimeLifecycleBypassDenied",
"message": (
"RUNNING, COMPLETED and FAILED transitions are internal; "
"use the provenance-protected dispatch/reconcile flow"
),
}
transition_match = re.fullmatch(
r"/api/v2/tasks/([^/]+)/(approve|cancel)", path
)
if method == "POST" and transition_match:
task_id = _require_resource_id(
urllib.parse.unquote(transition_match.group(1)), label="task_id"
)
target = (
TaskState.APPROVED
if transition_match.group(2) == "approve"
else TaskState.CANCELLED
)
expected_revision = payload.get("expected_revision")
task = self.service.transition_task(
context,
task_id=task_id,
target_state=target,
expected_revision=(
int(expected_revision) if expected_revision is not None else None
),
)
return HTTPStatus.OK, {"task": _json_value(task)}
if method == "GET" and path == "/api/v2/audit":
resource_id = query.get("resource_id", [None])[0]
events = self.service.list_audit_events(
context,
resource_id=(
_require_resource_id(resource_id, label="resource_id")
if resource_id
else None
),
)
return HTTPStatus.OK, {"events": _json_value(events)}
return HTTPStatus.NOT_FOUND, {
"error": "not_found",
"message": "route not found",
}
_ERROR_STATUS: tuple[tuple[type[BaseException], HTTPStatus], ...] = (
(Unauthorized, HTTPStatus.UNAUTHORIZED),
(PolicyDenied, HTTPStatus.FORBIDDEN),
(ProvenanceDenied, HTTPStatus.FORBIDDEN),
(TenantIsolationError, HTTPStatus.NOT_FOUND),
(NotFound, HTTPStatus.NOT_FOUND),
(DispatchNotFound, HTTPStatus.NOT_FOUND),
(HermesNotFound, HTTPStatus.NOT_FOUND),
(ProvenanceReplay, HTTPStatus.CONFLICT),
(IdempotencyConflict, HTTPStatus.CONFLICT),
(RevisionConflict, HTTPStatus.CONFLICT),
(InvalidTransition, HTTPStatus.CONFLICT),
(DispatchConflict, HTTPStatus.CONFLICT),
(HermesConflict, HTTPStatus.CONFLICT),
(OrchestrationError, HTTPStatus.CONFLICT),
(ValueError, HTTPStatus.BAD_REQUEST),
(ControlPlaneError, HTTPStatus.BAD_REQUEST),
)
def error_response(exc: BaseException) -> tuple[int, Mapping[str, Any]]:
for error_type, status in _ERROR_STATUS:
if isinstance(exc, error_type):
return status, {"error": error_type.__name__, "message": str(exc)}
return HTTPStatus.INTERNAL_SERVER_ERROR, {
"error": "InternalServerError",
"message": "internal server error",
}
class GatewayRequestHandler(BaseHTTPRequestHandler):
server_version = "AIOSV2Gateway/1.0"
max_body_bytes = 1_048_576
@property
def application(self) -> GatewayApplication:
return self.server.application # type: ignore[attr-defined]
def _read_body(self) -> Any:
length = int(self.headers.get("Content-Length", "0") or "0")
if length < 0 or length > self.max_body_bytes:
raise ValueError("request body is too large")
if length == 0:
return {}
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
if content_type != "application/json":
raise ValueError("Content-Type must be application/json")
return json.loads(self.rfile.read(length).decode("utf-8"))
def _send_json(self, status: int, payload: Mapping[str, Any]) -> None:
encoded = json.dumps(
_json_value(payload), ensure_ascii=False, separators=(",", ":")
).encode("utf-8")
self.send_response(int(status))
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.end_headers()
self.wfile.write(encoded)
def _dispatch(self, method: str) -> None:
try:
body = self._read_body() if method in {"POST", "PUT", "PATCH"} else {}
status, payload = self.application.handle(
method,
self.path,
{key: value for key, value in self.headers.items()},
body,
)
except Exception as exc:
status, payload = error_response(exc)
self._send_json(status, payload)
def do_GET(self) -> None: # noqa: N802
self._dispatch("GET")
def do_POST(self) -> None: # noqa: N802
self._dispatch("POST")
def log_message(self, format: str, *args: Any) -> None:
super().log_message(format, *args)
class GatewayHTTPServer(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(
self,
server_address: tuple[str, int],
application: GatewayApplication,
) -> None:
self.application = application
super().__init__(server_address, GatewayRequestHandler)
def main() -> None:
host = os.getenv("AIOS_V2_GATEWAY_HOST", "127.0.0.1")
port = int(os.getenv("AIOS_V2_GATEWAY_PORT", "18920"))
server = GatewayHTTPServer((host, port), GatewayApplication())
print(f"AIOS V2 control-plane gateway listening on {host}:{port}")
server.serve_forever()
if __name__ == "__main__":
main()
+123
View File
@@ -0,0 +1,123 @@
"""Sanitized GitOps runtime status for the AIOS Web control plane."""
from __future__ import annotations
import os
import subprocess
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Mapping
@dataclass(frozen=True)
class GitOpsStatus:
available: bool
service_status: str
workspace_mode: str
workspace_branch: str | None
workspace_ref: str | None
workspace_dirty: bool | None
candidate_branch: str
release_channel: str
release_version: str
sync_status: str
details: Mapping[str, Any]
def as_dict(self) -> dict[str, Any]:
return asdict(self)
def _run_git(worktree: Path, *args: str) -> tuple[int, str, str]:
process = subprocess.run(
["git", "-C", str(worktree), *args],
text=True,
capture_output=True,
timeout=5,
)
return process.returncode, process.stdout.strip(), process.stderr.strip()
def collect_gitops_status() -> GitOpsStatus:
"""Return only non-sensitive Git/workspace metadata.
Remote URLs, environment values, credentials, filesystem contents and
command stderr are deliberately excluded from the Web response.
"""
candidate_branch = os.getenv(
"AIOS_CANDIDATE_BRANCH",
"integration/aios-v2r1-cto-production-candidate-20260720",
)
release_channel = os.getenv("AIOS_RELEASE_CHANNEL", "candidate")
release_version = os.getenv("AIOS_RELEASE_VERSION", "V2R1C0-candidate")
sync_status = os.getenv("AIOS_GITOPS_SYNC_STATUS", "unknown")
worktree = Path(
os.getenv(
"AIOS_GITOPS_CONTROLLER_WORKTREE",
"/data/aios-gitops/worktrees/controller",
)
)
if not worktree.is_dir():
return GitOpsStatus(
available=False,
service_status="unknown",
workspace_mode="unavailable",
workspace_branch=None,
workspace_ref=None,
workspace_dirty=None,
candidate_branch=candidate_branch,
release_channel=release_channel,
release_version=release_version,
sync_status=sync_status,
details={"reason": "controller_worktree_unavailable"},
)
inside_code, inside, _ = _run_git(worktree, "rev-parse", "--is-inside-work-tree")
if inside_code != 0 or inside != "true":
return GitOpsStatus(
available=False,
service_status="unknown",
workspace_mode="invalid",
workspace_branch=None,
workspace_ref=None,
workspace_dirty=None,
candidate_branch=candidate_branch,
release_channel=release_channel,
release_version=release_version,
sync_status=sync_status,
details={"reason": "controller_worktree_invalid"},
)
branch_code, branch, _ = _run_git(worktree, "branch", "--show-current")
ref_code, ref_value, _ = _run_git(worktree, "rev-parse", "HEAD")
dirty_code, dirty_value, _ = _run_git(worktree, "status", "--porcelain=v1")
service_status = "unknown"
try:
process = subprocess.run(
["systemctl", "is-active", "aios-gitops-model-api"],
text=True,
capture_output=True,
timeout=3,
)
service_status = process.stdout.strip() or "inactive"
except (FileNotFoundError, subprocess.TimeoutExpired):
service_status = "unavailable"
branch_value = branch if branch_code == 0 and branch else None
return GitOpsStatus(
available=True,
service_status=service_status,
workspace_mode="branch" if branch_value else "detached",
workspace_branch=branch_value,
workspace_ref=ref_value if ref_code == 0 else None,
workspace_dirty=bool(dirty_value) if dirty_code == 0 else None,
candidate_branch=candidate_branch,
release_channel=release_channel,
release_version=release_version,
sync_status=sync_status,
details={
"controller_clean": dirty_code == 0 and not dirty_value,
"controller_detached": branch_code == 0 and not branch,
},
)
@@ -0,0 +1,238 @@
"""Official AIOS executor providers built on the portable provider protocol.
The official providers contain no lifecycle implementation. They validate their
manifests, resolve a generic ``UnifiedExecutorWorker`` from runtime-supplied
boundaries and expose the same adapter contract. Future providers can use
``ConfiguredExecutorProvider`` directly without changing AIOS core modules.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Mapping, Sequence
from .execution_plan import ExecutionPlanCoordinator, ExecutionStageManifest
from .executor_protocol import (
EXECUTOR_PROTOCOL_VERSION,
ExternalExecutorAdapter,
ExecutorProviderDescriptor,
normalize_executor,
)
from .executor_worker import NotificationSender, StepRunner, UnifiedExecutorWorker
from .external_task_control import ExternalTaskCoordinator
from .task_execution_registry import ExecutionTaskManifest, ExecutionTruthRecord
@dataclass(frozen=True)
class ProviderRuntimeConfiguration:
plan_id: str
executor_id: str
plan_coordinator: ExecutionPlanCoordinator
task_coordinator: ExternalTaskCoordinator
providers: Any
step_runner: StepRunner
notification_sender: NotificationSender
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ProviderRuntimeConfiguration":
required = (
"plan_id",
"executor_id",
"plan_coordinator",
"task_coordinator",
"providers",
"step_runner",
"notification_sender",
)
missing = [name for name in required if value.get(name) is None]
if missing:
raise ValueError(f"provider runtime configuration is missing: {missing}")
return cls(**{name: value[name] for name in required})
class ConfiguredExecutorAdapter:
"""Generic adapter produced by every official or third-party provider."""
def __init__(
self,
descriptor: ExecutorProviderDescriptor,
configuration: ProviderRuntimeConfiguration,
) -> None:
self.descriptor = descriptor
self.configuration = configuration
self.worker = UnifiedExecutorWorker(
plan_coordinator=configuration.plan_coordinator,
task_coordinator=configuration.task_coordinator,
providers=configuration.providers,
executor_type=descriptor.executor_type,
executor_id=configuration.executor_id,
step_runner=configuration.step_runner,
notification_sender=configuration.notification_sender,
)
def discover(self) -> Sequence[ExecutionTruthRecord]:
ready = self.configuration.plan_coordinator.discover_ready(
plan_id=self.configuration.plan_id,
executor_type=self.descriptor.executor_type,
)
return tuple(item["record"] for item in ready)
def execute(self, manifest: ExecutionTaskManifest) -> Mapping[str, Any]:
if normalize_executor(manifest.executor) != self.descriptor.executor_type:
raise PermissionError("manifest executor does not match adapter provider")
plan = self.configuration.plan_coordinator.plan_store.get(
self.configuration.plan_id
)
stage: ExecutionStageManifest | None = None
for item in plan.stages:
if item.task_id == manifest.task_id:
stage = item
break
if stage is None:
raise KeyError("manifest task is not part of configured execution plan")
return self.worker.run_stage(
plan_id=plan.plan_id,
stage_id=stage.stage_id,
).as_dict()
def notify_owner(self, payload: Mapping[str, Any]) -> str:
# External callers normally use the durable NotificationOutbox. This
# method remains part of the portable adapter contract for providers
# that need to send a standalone operational notice.
from .external_task_control import NotificationRecord
from .task_execution_registry import utc_now
notification = NotificationRecord(
notification_id="provider_direct_notice",
task_id=str(payload.get("task_id") or "provider-notice"),
event_type=str(payload.get("event_type") or "INFO"),
target_ref=str(
payload.get("target_ref") or "AIOS_OWNER_WECHAT_TARGET_REF"
),
payload=dict(payload),
status="PENDING",
created_at=utc_now(),
)
return self.configuration.notification_sender.send(notification)
class ConfiguredExecutorProvider:
"""Reusable provider implementation for current and future executors."""
descriptor: ExecutorProviderDescriptor
def __init__(self, descriptor: ExecutorProviderDescriptor) -> None:
self.descriptor = descriptor
def validate_manifest(self, manifest: ExecutionTaskManifest) -> None:
if normalize_executor(manifest.executor) != self.descriptor.executor_type:
raise ValueError("manifest executor does not match provider")
if manifest.manifest_version not in {
"aios.external-task.v1",
"aios.external-task.v2",
}:
raise ValueError("unsupported external task manifest version")
if not manifest.steps:
raise ValueError("executor manifest has no steps")
def build_adapter(self, configuration: Mapping[str, Any]) -> ExternalExecutorAdapter:
return ConfiguredExecutorAdapter(
self.descriptor,
ProviderRuntimeConfiguration.from_mapping(configuration),
)
class OpenClawProvider(ConfiguredExecutorProvider):
def __init__(self) -> None:
super().__init__(
ExecutorProviderDescriptor(
executor_type="openclaw",
adapter_version="2.1.0",
protocol_version=EXECUTOR_PROTOCOL_VERSION,
trust_roles=("runtime", "openclaw_executor"),
capabilities=(
"deploy",
"http_runtime",
"owner_notification",
"healthcheck",
"rollback",
),
configuration_schema="aios.executor.openclaw.config.v1",
metadata={"official": True},
)
)
class HermesProvider(ConfiguredExecutorProvider):
def __init__(self) -> None:
super().__init__(
ExecutorProviderDescriptor(
executor_type="hermes",
adapter_version="2.1.0",
protocol_version=EXECUTOR_PROTOCOL_VERSION,
trust_roles=("runtime", "hermes_executor"),
capabilities=(
"queue",
"lease",
"worker",
"runtime_validation",
"restart_recovery",
),
configuration_schema="aios.executor.hermes.config.v1",
metadata={"official": True},
)
)
class CodexProvider(ConfiguredExecutorProvider):
def __init__(self) -> None:
super().__init__(
ExecutorProviderDescriptor(
executor_type="codex",
adapter_version="2.1.0",
protocol_version=EXECUTOR_PROTOCOL_VERSION,
trust_roles=("codex", "codex_executor"),
capabilities=(
"code",
"test",
"docs",
"gitops_patch",
"security_review",
),
configuration_schema="aios.executor.codex.config.v1",
metadata={"official": True},
)
)
class ChatGPTSupervisorProvider(ConfiguredExecutorProvider):
"""Explicit takeover provider; it does not inherit Codex permissions."""
def __init__(self) -> None:
super().__init__(
ExecutorProviderDescriptor(
executor_type="chatgpt-supervisor",
adapter_version="2.1.0",
protocol_version=EXECUTOR_PROTOCOL_VERSION,
trust_roles=("chatgpt_supervisor",),
capabilities=(
"acceptance",
"deploy",
"evidence",
"healthcheck",
"rollback",
"wechat_simulation",
),
configuration_schema="aios.executor.chatgpt-supervisor.config.v1",
metadata={"official": True, "production_mutation_allowed": False},
)
)
def official_provider_factories() -> Mapping[str, ConfiguredExecutorProvider]:
providers = (
OpenClawProvider(),
HermesProvider(),
CodexProvider(),
ChatGPTSupervisorProvider(),
)
return {item.descriptor.executor_type: item for item in providers}
+599
View File
@@ -0,0 +1,599 @@
"""AIOS V2 execution orchestrator.
The orchestrator is the only component allowed to translate an approved AIOS
Task into an OpenClaw command or Hermes job. Every dispatch is persisted before
Runtime side effects. Runtime results must bind to the exact dispatch,
command/job, tenant, task, trace, principal and provenance context.
"""
from __future__ import annotations
from typing import Any, Mapping, Protocol
from aios_hermes import HermesAdapter, HermesJob, HermesJobState, HermesResult
from aios_openclaw import (
CommandState,
OpenClawAdapter,
OpenClawCommand,
OpenClawResult,
sign_command,
)
from .control_plane import (
AgentRecord,
ControlPlaneService,
RequestContext,
TaskRecord,
TaskState,
)
from .dispatch_store import (
DispatchConflict,
DispatchRecord,
DispatchRepository,
DispatchState,
InMemoryDispatchRepository,
TERMINAL_DISPATCH_STATES,
)
from .provenance import ProvenanceDenied, digest_payload
class RuntimeAuthorizationWriter(Protocol):
def authorize_runtime_execute(self, **values: Any) -> Any: ...
class OrchestrationError(RuntimeError):
"""AIOS failed to dispatch or reconcile a runtime execution."""
class ExecutionOrchestrator:
"""Dispatch approved AIOS tasks and strictly reconcile Runtime results."""
def __init__(
self,
control_plane: ControlPlaneService,
*,
openclaw: OpenClawAdapter | None = None,
hermes: HermesAdapter | None = None,
openclaw_signing_material_resolver=None,
openclaw_key_id: str = "default",
default_cell_id: str = "default",
default_profile_id: str = "default",
provenance_writer: RuntimeAuthorizationWriter | None = None,
require_provenance: bool = False,
dispatch_repository: DispatchRepository | None = None,
) -> None:
self.control_plane = control_plane
self.openclaw = openclaw
self.hermes = hermes
self.openclaw_signing_material_resolver = openclaw_signing_material_resolver
self.openclaw_key_id = str(openclaw_key_id or "").strip() or "default"
self.default_cell_id = default_cell_id
self.default_profile_id = default_profile_id
self.provenance_writer = provenance_writer
self.require_provenance = bool(require_provenance)
self.dispatch_repository = dispatch_repository or InMemoryDispatchRepository()
if self.require_provenance and self.provenance_writer is None:
raise ValueError("production orchestration requires a provenance writer")
@staticmethod
def _record_provenance(value: str | None, *, required: bool, label: str) -> str:
normalized = str(value or "").strip()
if required and not normalized:
raise OrchestrationError(f"production dispatch requires {label}")
return normalized or f"{label}-not-required"
def _authorize_dispatch(
self,
context: RequestContext,
*,
task: TaskRecord,
agent: AgentRecord,
provenance_id: str | None,
run_id: str | None,
timeout_seconds: float | None,
) -> str:
if not self.require_provenance:
return "authorization-not-required"
if self.provenance_writer is None:
raise OrchestrationError("Run Provenance writer is not configured")
if not provenance_id or not run_id:
raise OrchestrationError(
"production dispatch requires provenance_id and run_id"
)
request_digest = digest_payload(
{
"operation": "dispatch",
"tenant_id": task.tenant_id,
"task_id": task.task_id,
"agent_id": agent.agent_id,
"principal_id": context.principal_id,
"runtime": agent.runtime.strip().lower(),
"action": task.action,
"payload": task.payload,
"trace_id": context.trace_id,
"timeout_seconds": timeout_seconds,
"task_revision": task.revision,
}
)
try:
authorization = self.provenance_writer.authorize_runtime_execute(
provenance_id=provenance_id,
run_id=run_id,
tenant_id=task.tenant_id,
task_id=task.task_id,
agent_id=agent.agent_id,
principal_id=context.principal_id,
request_digest=request_digest,
)
return authorization.authorization_id
except ProvenanceDenied as exc:
raise OrchestrationError(f"Run Provenance denied dispatch: {exc}") from exc
def dispatch(
self,
context: RequestContext,
*,
task_id: str,
timeout_seconds: float | None = None,
provenance_id: str | None = None,
run_id: str | None = None,
) -> DispatchRecord:
task = self.control_plane.get_task(context, task_id)
agent = self.control_plane.get_agent(context, task.agent_id)
persisted_provenance = self._record_provenance(
provenance_id, required=self.require_provenance, label="provenance_id"
)
persisted_run = self._record_provenance(
run_id, required=self.require_provenance, label="run_id"
)
existing = self.dispatch_repository.get_by_task(
task.task_id, tenant_id=task.tenant_id
)
if existing is not None:
if (
existing.provenance_id != persisted_provenance
or existing.run_id != persisted_run
or existing.principal_id != context.principal_id
):
raise OrchestrationError(
"existing dispatch belongs to another provenance or principal context"
)
return existing
if task.state != TaskState.APPROVED:
raise OrchestrationError("only APPROVED tasks may be dispatched")
authorization_id = self._authorize_dispatch(
context,
task=task,
agent=agent,
provenance_id=provenance_id,
run_id=run_id,
timeout_seconds=timeout_seconds,
)
runtime = agent.runtime.strip().lower()
prepared: DispatchRecord | None = None
try:
if runtime == "openclaw":
command = self._create_openclaw_command(context, task)
prepared = self.dispatch_repository.create(
DispatchRecord.create(
tenant_id=task.tenant_id,
task_id=task.task_id,
agent_id=agent.agent_id,
principal_id=context.principal_id,
runtime="openclaw",
runtime_execution_id=command.command_id,
trace_id=context.trace_id,
provenance_id=persisted_provenance,
run_id=persisted_run,
authorization_id=authorization_id,
)
)
self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.RUNNING,
expected_revision=task.revision,
)
self.dispatch_repository.update(
prepared.dispatch_id,
tenant_id=prepared.tenant_id,
state=DispatchState.RUNNING,
)
if self.openclaw is None:
raise OrchestrationError("OpenClaw runtime is not configured")
result = self.openclaw.execute(
command, timeout_seconds=timeout_seconds
)
self.reconcile_openclaw(
context,
result,
dispatch_id=prepared.dispatch_id,
provenance_id=persisted_provenance,
run_id=persisted_run,
)
return self.dispatch_repository.get(
prepared.dispatch_id, tenant_id=prepared.tenant_id
)
if runtime == "hermes":
if self.hermes is None:
raise OrchestrationError("Hermes runtime is not configured")
job = self._create_hermes_job(context, agent, task)
prepared = self.dispatch_repository.create(
DispatchRecord.create(
tenant_id=task.tenant_id,
task_id=task.task_id,
agent_id=agent.agent_id,
principal_id=context.principal_id,
runtime="hermes",
runtime_execution_id=job.job_id,
trace_id=context.trace_id,
provenance_id=persisted_provenance,
run_id=persisted_run,
authorization_id=authorization_id,
)
)
self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.RUNNING,
expected_revision=task.revision,
)
queued = self.hermes.enqueue(job)
return self.dispatch_repository.update(
prepared.dispatch_id,
tenant_id=prepared.tenant_id,
state=DispatchState.QUEUED,
result_digest=digest_payload(
{
"queued_job_id": queued.job_id,
"state": queued.state.value,
"revision": queued.revision,
}
),
)
raise OrchestrationError(f"unsupported runtime: {agent.runtime}")
except Exception as exc:
if prepared is not None:
try:
self.dispatch_repository.update(
prepared.dispatch_id,
tenant_id=prepared.tenant_id,
state=DispatchState.FAILED,
result_digest=digest_payload(
{
"code": "dispatch_failed",
"error_type": type(exc).__name__,
"message": str(exc),
}
),
)
except Exception:
pass
current = self.control_plane.get_task(context, task.task_id)
if current.state == TaskState.RUNNING:
self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.FAILED,
expected_revision=current.revision,
error={"code": "dispatch_failed", "message": str(exc)},
)
raise
def _create_openclaw_command(
self,
context: RequestContext,
task: TaskRecord,
) -> OpenClawCommand:
if self.openclaw_signing_material_resolver is None:
raise OrchestrationError("OpenClaw signing material is not configured")
key_material = self.openclaw_signing_material_resolver(
task.tenant_id,
self.openclaw_key_id,
)
command = OpenClawCommand.create(
tenant_id=task.tenant_id,
task_id=task.task_id,
trace_id=context.trace_id,
action=task.action,
payload=task.payload,
idempotency_key=task.idempotency_key or task.task_id,
key_id=self.openclaw_key_id,
)
return sign_command(command, key_material)
def _create_hermes_job(
self,
context: RequestContext,
agent: AgentRecord,
task: TaskRecord,
) -> HermesJob:
metadata = dict(agent.metadata)
return HermesJob.create(
tenant_id=task.tenant_id,
cell_id=str(metadata.get("cell_id") or self.default_cell_id),
profile_id=str(metadata.get("profile_id") or self.default_profile_id),
task_id=task.task_id,
trace_id=context.trace_id,
action=task.action,
payload=task.payload,
idempotency_key=task.idempotency_key or task.task_id,
priority=int(metadata.get("priority", 100)),
)
@staticmethod
def _assert_dispatch_context(
dispatch: DispatchRecord,
*,
runtime: str,
runtime_execution_id: str,
tenant_id: str,
task_id: str,
trace_id: str,
principal_id: str,
provenance_id: str,
run_id: str,
) -> None:
expected = {
"runtime": runtime,
"runtime_execution_id": runtime_execution_id,
"tenant_id": tenant_id,
"task_id": task_id,
"trace_id": trace_id,
"principal_id": principal_id,
"provenance_id": provenance_id,
"run_id": run_id,
}
for field_name, expected_value in expected.items():
if getattr(dispatch, field_name) != expected_value:
raise OrchestrationError(f"dispatch {field_name} mismatch")
def hermes_lifecycle_context(
self,
context: RequestContext,
*,
dispatch_id: str,
job_id: str,
tenant_id: str,
task_id: str,
trace_id: str,
principal_id: str,
provenance_id: str,
run_id: str,
) -> tuple[DispatchRecord, HermesJob]:
"""Resolve a runtime lifecycle request against its durable dispatch."""
dispatch = self.dispatch_repository.get(
dispatch_id, tenant_id=context.tenant_id
)
self._assert_dispatch_context(
dispatch,
runtime="hermes",
runtime_execution_id=job_id,
tenant_id=tenant_id,
task_id=task_id,
trace_id=trace_id,
principal_id=principal_id,
provenance_id=provenance_id,
run_id=run_id,
)
if context.tenant_id != tenant_id:
raise OrchestrationError("runtime context tenant_id mismatch")
if context.principal_id != principal_id:
raise OrchestrationError("runtime context principal_id mismatch")
if context.trace_id != trace_id:
raise OrchestrationError("runtime context trace_id mismatch")
if self.hermes is None:
raise OrchestrationError("Hermes runtime is not configured")
job = self.hermes.get(job_id, tenant_id=tenant_id)
if job.task_id != task_id or job.trace_id != trace_id:
raise OrchestrationError("Hermes job identity mismatch")
return dispatch, job
def bind_hermes_worker(
self,
context: RequestContext,
job: HermesJob,
*,
dispatch_id: str,
provenance_id: str,
run_id: str,
worker_id: str,
) -> DispatchRecord:
dispatch, current = self.hermes_lifecycle_context(
context,
dispatch_id=dispatch_id,
job_id=job.job_id,
tenant_id=job.tenant_id,
task_id=job.task_id,
trace_id=job.trace_id,
principal_id=context.principal_id,
provenance_id=provenance_id,
run_id=run_id,
)
if current.assigned_worker_id != worker_id:
raise OrchestrationError("Hermes worker does not own claimed job")
return self.dispatch_repository.update(
dispatch.dispatch_id,
tenant_id=dispatch.tenant_id,
expected_worker_id=worker_id,
)
def reconcile_openclaw(
self,
context: RequestContext,
result: OpenClawResult,
*,
dispatch_id: str,
provenance_id: str,
run_id: str,
) -> TaskRecord:
dispatch = self.dispatch_repository.get(
dispatch_id, tenant_id=context.tenant_id
)
self._assert_dispatch_context(
dispatch,
runtime="openclaw",
runtime_execution_id=result.command_id,
tenant_id=result.tenant_id,
task_id=result.task_id,
trace_id=result.trace_id,
principal_id=context.principal_id,
provenance_id=provenance_id,
run_id=run_id,
)
result_digest = digest_payload(
{
"command_id": result.command_id,
"state": result.state.value,
"output": result.output,
"error": result.error,
"runtime_id": result.runtime_id,
"attempts": result.attempts,
"finished_at": result.finished_at,
}
)
if dispatch.state in TERMINAL_DISPATCH_STATES:
if dispatch.result_digest != result_digest:
raise DispatchConflict("terminal dispatch received a different result")
return self.control_plane.get_task(context, dispatch.task_id)
task = self.control_plane.get_task(context, dispatch.task_id)
if task.state != TaskState.RUNNING:
raise OrchestrationError("task is not RUNNING for active dispatch")
if result.state == CommandState.COMPLETED:
target = TaskState.COMPLETED
values = {"result": result.output or {}}
dispatch_state = DispatchState.COMPLETED
elif result.state in {
CommandState.FAILED,
CommandState.TIMED_OUT,
CommandState.REJECTED,
}:
target = TaskState.FAILED
values = {
"error": result.error or {"code": result.state.value.lower()}
}
dispatch_state = DispatchState.FAILED
elif result.state == CommandState.CANCELLED:
target = TaskState.CANCELLED
values = {}
dispatch_state = DispatchState.CANCELLED
else:
self.dispatch_repository.update(
dispatch.dispatch_id,
tenant_id=dispatch.tenant_id,
state=DispatchState.RUNNING,
result_digest=result_digest,
)
return task
updated_task = self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=target,
expected_revision=task.revision,
**values,
)
self.dispatch_repository.update(
dispatch.dispatch_id,
tenant_id=dispatch.tenant_id,
state=dispatch_state,
result_digest=result_digest,
)
return updated_task
def reconcile_hermes(
self,
context: RequestContext,
result: HermesResult,
*,
dispatch_id: str,
provenance_id: str,
run_id: str,
) -> TaskRecord:
dispatch = self.dispatch_repository.get(
dispatch_id, tenant_id=context.tenant_id
)
self._assert_dispatch_context(
dispatch,
runtime="hermes",
runtime_execution_id=result.job_id,
tenant_id=result.tenant_id,
task_id=result.task_id,
trace_id=result.trace_id,
principal_id=context.principal_id,
provenance_id=provenance_id,
run_id=run_id,
)
if self.hermes is None:
raise OrchestrationError("Hermes runtime is not configured")
job = self.hermes.get(result.job_id, tenant_id=result.tenant_id)
if result.state in {HermesJobState.COMPLETED, HermesJobState.FAILED}:
if not result.worker_id:
raise OrchestrationError("terminal Hermes result requires worker_id")
if job.assigned_worker_id != result.worker_id:
raise OrchestrationError("Hermes result worker does not own job lease")
if (
dispatch.expected_worker_id is not None
and dispatch.expected_worker_id != result.worker_id
):
raise OrchestrationError("Hermes result worker mismatch")
result_digest = digest_payload(
{
"job_id": result.job_id,
"worker_id": result.worker_id,
"state": result.state.value,
"output": result.output,
"error": result.error,
"finished_at": result.finished_at,
}
)
if dispatch.state in TERMINAL_DISPATCH_STATES:
if dispatch.result_digest != result_digest:
raise DispatchConflict("terminal dispatch received a different result")
return self.control_plane.get_task(context, dispatch.task_id)
task = self.control_plane.get_task(context, dispatch.task_id)
if task.state != TaskState.RUNNING:
raise OrchestrationError("task is not RUNNING for active dispatch")
if result.state == HermesJobState.COMPLETED:
target = TaskState.COMPLETED
values = {"result": result.output or {}}
dispatch_state = DispatchState.COMPLETED
elif result.state == HermesJobState.FAILED:
target = TaskState.FAILED
values = {"error": result.error or {"code": "hermes_failed"}}
dispatch_state = DispatchState.FAILED
elif result.state == HermesJobState.CANCELLED:
target = TaskState.CANCELLED
values = {}
dispatch_state = DispatchState.CANCELLED
else:
raise OrchestrationError("Hermes reconcile only accepts terminal results")
updated_task = self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=target,
expected_revision=task.revision,
**values,
)
self.dispatch_repository.update(
dispatch.dispatch_id,
tenant_id=dispatch.tenant_id,
state=dispatch_state,
expected_worker_id=result.worker_id,
result_digest=result_digest,
)
return updated_task
def dispatch_snapshot(self, *, tenant_id: str) -> Mapping[str, DispatchRecord]:
return {
item.task_id: item
for item in self.dispatch_repository.list(tenant_id=tenant_id)
}
+181
View File
@@ -0,0 +1,181 @@
"""Durable single-process persistence for the AIOS V2 control plane.
The candidate uses an atomic SQLite snapshot so a gateway restart does not lose
tenants, agents, task state, idempotency records, or audit events. The domain
service remains storage-agnostic; a later HA backend can replace this module
without changing API or runtime-adapter contracts.
"""
from __future__ import annotations
import json
import sqlite3
import threading
from dataclasses import asdict
from pathlib import Path
from typing import Any, Callable
from .control_plane import (
AgentRecord,
AuditEvent,
ControlPlaneService,
InMemoryControlPlaneStore,
TaskRecord,
TaskState,
TenantRecord,
)
class SQLiteSnapshotRepository:
"""Store one versioned control-plane snapshot in SQLite."""
SCHEMA_VERSION = 1
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self._initialize()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path, timeout=30.0)
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=FULL")
connection.execute("PRAGMA foreign_keys=ON")
return connection
def _initialize(self) -> None:
with self._connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS control_plane_snapshot (
singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
schema_version INTEGER NOT NULL,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
def load(self) -> dict[str, Any] | None:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT schema_version, payload_json FROM control_plane_snapshot WHERE singleton_id = 1"
).fetchone()
if row is None:
return None
schema_version, payload_json = row
if int(schema_version) != self.SCHEMA_VERSION:
raise RuntimeError(
f"unsupported control-plane snapshot version: {schema_version}"
)
decoded = json.loads(str(payload_json))
if not isinstance(decoded, dict):
raise RuntimeError("invalid control-plane snapshot")
return decoded
def save(self, payload: dict[str, Any]) -> None:
encoded = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"""
INSERT INTO control_plane_snapshot (
singleton_id, schema_version, payload_json, updated_at
) VALUES (1, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(singleton_id) DO UPDATE SET
schema_version = excluded.schema_version,
payload_json = excluded.payload_json,
updated_at = CURRENT_TIMESTAMP
""",
(self.SCHEMA_VERSION, encoded),
)
connection.commit()
class DurableControlPlaneService(ControlPlaneService):
"""Control-plane service that persists after every successful mutation."""
def __init__(
self,
repository: SQLiteSnapshotRepository,
*,
policy_evaluator=None,
) -> None:
self.repository = repository
store = InMemoryControlPlaneStore()
self._restore(store, repository.load())
super().__init__(store, policy_evaluator=policy_evaluator)
@staticmethod
def _restore(
store: InMemoryControlPlaneStore,
snapshot: dict[str, Any] | None,
) -> None:
if not snapshot:
return
for item in snapshot.get("tenants", []):
record = TenantRecord(**item)
store.tenants[record.tenant_id] = record
for item in snapshot.get("agents", []):
normalized = dict(item)
normalized["capabilities"] = tuple(normalized.get("capabilities", ()))
record = AgentRecord(**normalized)
store.agents[record.agent_id] = record
for item in snapshot.get("tasks", []):
normalized = dict(item)
normalized["state"] = TaskState(normalized["state"])
record = TaskRecord(**normalized)
store.tasks[record.task_id] = record
store.audit_events.extend(AuditEvent(**item) for item in snapshot.get("audit", []))
for item in snapshot.get("idempotency", []):
store.idempotency[(item["tenant_id"], item["key"])] = (
item["task_id"],
item["digest"],
)
def _snapshot(self) -> dict[str, Any]:
with self.store.lock:
return {
"schema_version": self.repository.SCHEMA_VERSION,
"tenants": [asdict(item) for item in self.store.tenants.values()],
"agents": [asdict(item) for item in self.store.agents.values()],
"tasks": [
{**asdict(item), "state": item.state.value}
for item in self.store.tasks.values()
],
"audit": [asdict(item) for item in self.store.audit_events],
"idempotency": [
{
"tenant_id": tenant_id,
"key": key,
"task_id": task_id,
"digest": digest,
}
for (tenant_id, key), (task_id, digest)
in self.store.idempotency.items()
],
}
def _mutate(self, operation: Callable[[], Any]) -> Any:
with self.store.lock:
result = operation()
self.repository.save(self._snapshot())
return result
def register_tenant(self, *args, **kwargs):
return self._mutate(lambda: super(DurableControlPlaneService, self).register_tenant(*args, **kwargs))
def register_agent(self, *args, **kwargs):
return self._mutate(lambda: super(DurableControlPlaneService, self).register_agent(*args, **kwargs))
def create_task(self, *args, **kwargs):
return self._mutate(lambda: super(DurableControlPlaneService, self).create_task(*args, **kwargs))
def transition_task(self, *args, **kwargs):
return self._mutate(lambda: super(DurableControlPlaneService, self).transition_task(*args, **kwargs))
+831
View File
@@ -0,0 +1,831 @@
"""AIOS V2 可信 Run Provenance 签发、持久化与受控写入。
生产策略要求 Provenance 同时覆盖七类路径:代码变更、Git 操作、部署、
Runtime 执行、Evidence 写入、回滚和审计。任何缺失、篡改、过期、撤销、
重放或上下文不匹配都默认拒绝。
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import sqlite3
import stat
import threading
import uuid
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Mapping, Protocol, Sequence
PROVENANCE_VERSION = "aios.v2.run-provenance.v1"
PRODUCTION_POLICY_VERSION = "aios.v2.production.v1"
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def parse_timestamp(value: str) -> datetime:
normalized = str(value or "").strip()
if not normalized:
raise ValueError("timestamp is required")
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
raise ValueError("timestamp must include timezone")
return parsed.astimezone(timezone.utc)
def required(value: str, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
def canonical_json_bytes(value: Mapping[str, Any]) -> bytes:
return json.dumps(
dict(value),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
def digest_payload(value: Mapping[str, Any]) -> str:
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
def _validate_digest(value: str) -> str:
normalized = required(value, "request_digest").lower()
if len(normalized) != 64 or any(ch not in "0123456789abcdef" for ch in normalized):
raise ValueError("request_digest must be a SHA-256 hexadecimal digest")
return normalized
class ExecutionPath(str, Enum):
CODE_CHANGE = "code_change"
GIT_OPERATION = "git_operation"
DEPLOYMENT = "deployment"
RUNTIME_EXECUTE = "runtime_execute"
EVIDENCE_WRITE = "evidence_write"
ROLLBACK = "rollback"
AUDIT = "audit"
REQUIRED_PRODUCTION_PATHS = frozenset(item.value for item in ExecutionPath)
class GitOperation(str, Enum):
READ_REF = "read_ref"
FETCH = "fetch"
CREATE_BRANCH = "create_branch"
COMMIT = "commit"
MERGE = "merge"
PUSH = "push"
UPDATE_EVIDENCE = "update_evidence"
class ProvenanceStatus(str, Enum):
ISSUED = "issued"
VERIFIED = "verified"
USED = "used"
EXPIRED = "expired"
REVOKED = "revoked"
class ProvenanceError(RuntimeError):
"""Run Provenance 基础错误。"""
class ProvenanceDenied(ProvenanceError):
"""策略拒绝当前操作。"""
class ProvenanceNotFound(ProvenanceDenied):
"""未找到 Provenance。"""
class ProvenanceSignatureError(ProvenanceDenied):
"""签名无效。"""
class ProvenanceExpired(ProvenanceDenied):
"""Provenance 已过期。"""
class ProvenanceRevoked(ProvenanceDenied):
"""Provenance 已撤销。"""
class ProvenanceReplay(ProvenanceDenied):
"""检测到相同授权摘要重放。"""
class Signer(Protocol):
key_id: str
def sign(self, payload: bytes) -> str: ...
class SignatureVerifier(Protocol):
key_id: str
fingerprint: str
def verify(self, payload: bytes, signature: str) -> bool: ...
def _load_crypto() -> tuple[Any, Any, Any]:
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
except ImportError as exc: # pragma: no cover - depends on deployment image
raise RuntimeError(
"cryptography package with Ed25519 support is required"
) from exc
return serialization, Ed25519PrivateKey, Ed25519PublicKey
def _check_private_key_file(path: Path) -> bytes:
resolved = path.resolve()
metadata = resolved.stat()
if not stat.S_ISREG(metadata.st_mode):
raise RuntimeError("private key reference is not a regular file")
if metadata.st_mode & 0o077:
raise RuntimeError("private key file permissions must be 0600 or stricter")
return resolved.read_bytes()
def _check_public_key_file(path: Path) -> bytes:
resolved = path.resolve()
metadata = resolved.stat()
if not stat.S_ISREG(metadata.st_mode):
raise RuntimeError("public key reference is not a regular file")
if metadata.st_mode & 0o022:
raise RuntimeError("public key file must not be group/world writable")
return resolved.read_bytes()
class Ed25519PrivateKeySigner:
"""Ed25519 Owner/Control Plane 签发器。"""
def __init__(self, private_key: Any, *, key_id: str) -> None:
self.private_key = private_key
self.key_id = required(key_id, "key_id")
@classmethod
def from_file(cls, path: str | Path, *, key_id: str) -> "Ed25519PrivateKeySigner":
serialization, private_cls, _ = _load_crypto()
value = _check_private_key_file(Path(path))
if value.startswith(b"-----BEGIN"):
key = serialization.load_pem_private_key(value, password=None)
elif len(value) == 32:
key = private_cls.from_private_bytes(value)
else:
raise RuntimeError("private key must be PEM or a raw 32-byte Ed25519 seed")
if not isinstance(key, private_cls):
raise RuntimeError("private key is not Ed25519")
return cls(key, key_id=key_id)
def sign(self, payload: bytes) -> str:
return base64.b64encode(self.private_key.sign(bytes(payload))).decode("ascii")
def public_verifier(self) -> "Ed25519PublicKeyVerifier":
return Ed25519PublicKeyVerifier(self.private_key.public_key(), key_id=self.key_id)
class Ed25519PublicKeyVerifier:
"""Ed25519 签名验证器。"""
def __init__(self, public_key: Any, *, key_id: str) -> None:
serialization, _, public_cls = _load_crypto()
if not isinstance(public_key, public_cls):
raise RuntimeError("public key is not Ed25519")
self.public_key = public_key
self.key_id = required(key_id, "key_id")
raw = public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
self.fingerprint = hashlib.sha256(raw).hexdigest()
@classmethod
def from_file(cls, path: str | Path, *, key_id: str) -> "Ed25519PublicKeyVerifier":
serialization, _, public_cls = _load_crypto()
value = _check_public_key_file(Path(path))
if value.startswith(b"-----BEGIN"):
key = serialization.load_pem_public_key(value)
elif len(value) == 32:
key = public_cls.from_public_bytes(value)
else:
raise RuntimeError("public key must be PEM or raw 32-byte Ed25519 key")
if not isinstance(key, public_cls):
raise RuntimeError("public key is not Ed25519")
return cls(key, key_id=key_id)
def verify(self, payload: bytes, signature: str) -> bool:
try:
decoded = base64.b64decode(required(signature, "signature"), validate=True)
self.public_key.verify(decoded, bytes(payload))
return True
except Exception:
return False
@dataclass(frozen=True)
class RunProvenance:
provenance_id: str
run_id: str
issuer: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
allowed_paths: Sequence[str]
allowed_git_operations: Sequence[str]
issued_at: str
expires_at: str
policy_version: str
key_id: str
nonce: str
signature: str = ""
schema_version: str = PROVENANCE_VERSION
def __post_init__(self) -> None:
for name in (
"provenance_id",
"run_id",
"issuer",
"tenant_id",
"task_id",
"agent_id",
"principal_id",
"issued_at",
"expires_at",
"policy_version",
"key_id",
"nonce",
"schema_version",
):
object.__setattr__(self, name, required(getattr(self, name), name))
if self.schema_version != PROVENANCE_VERSION:
raise ValueError(f"unsupported schema_version: {self.schema_version}")
issued = parse_timestamp(self.issued_at)
expires = parse_timestamp(self.expires_at)
if expires <= issued:
raise ValueError("expires_at must be later than issued_at")
paths = tuple(sorted({ExecutionPath(item).value for item in self.allowed_paths}))
operations = tuple(
sorted({GitOperation(item).value for item in self.allowed_git_operations})
)
object.__setattr__(self, "allowed_paths", paths)
object.__setattr__(self, "allowed_git_operations", operations)
object.__setattr__(self, "signature", str(self.signature or "").strip())
def unsigned_dict(self) -> dict[str, Any]:
return {
"schema_version": self.schema_version,
"provenance_id": self.provenance_id,
"run_id": self.run_id,
"issuer": self.issuer,
"tenant_id": self.tenant_id,
"task_id": self.task_id,
"agent_id": self.agent_id,
"principal_id": self.principal_id,
"allowed_paths": list(self.allowed_paths),
"allowed_git_operations": list(self.allowed_git_operations),
"issued_at": self.issued_at,
"expires_at": self.expires_at,
"policy_version": self.policy_version,
"key_id": self.key_id,
"nonce": self.nonce,
}
def canonical_bytes(self) -> bytes:
return canonical_json_bytes(self.unsigned_dict())
def as_dict(self) -> dict[str, Any]:
value = self.unsigned_dict()
value["signature"] = self.signature
return value
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "RunProvenance":
return cls(**dict(value))
class ProvenanceIssuer:
"""只由 Owner 或 AIOS Control Plane 持有的签发服务。"""
def __init__(self, signer: Signer, store: "SQLiteProvenanceStore") -> None:
self.signer = signer
self.store = store
def issue(
self,
*,
run_id: str,
issuer: str,
tenant_id: str,
task_id: str,
agent_id: str,
principal_id: str,
allowed_paths: Sequence[str] = tuple(REQUIRED_PRODUCTION_PATHS),
allowed_git_operations: Sequence[str] = tuple(item.value for item in GitOperation),
ttl_seconds: int = 3600,
policy_version: str = PRODUCTION_POLICY_VERSION,
provenance_id: str | None = None,
nonce: str | None = None,
now: datetime | None = None,
) -> RunProvenance:
if ttl_seconds <= 0:
raise ValueError("ttl_seconds must be positive")
normalized_paths = frozenset(ExecutionPath(item).value for item in allowed_paths)
if policy_version == PRODUCTION_POLICY_VERSION and normalized_paths != REQUIRED_PRODUCTION_PATHS:
missing = sorted(REQUIRED_PRODUCTION_PATHS - normalized_paths)
extra = sorted(normalized_paths - REQUIRED_PRODUCTION_PATHS)
raise ProvenanceDenied(
f"production provenance must cover exactly seven paths; missing={missing}, extra={extra}"
)
normalized_operations = tuple(
sorted({GitOperation(item).value for item in allowed_git_operations})
)
if ExecutionPath.GIT_OPERATION.value in normalized_paths and not normalized_operations:
raise ProvenanceDenied("git_operation path requires explicit allowed Git operations")
issued = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
unsigned = RunProvenance(
provenance_id=provenance_id or f"prov_{uuid.uuid4().hex}",
run_id=run_id,
issuer=issuer,
tenant_id=tenant_id,
task_id=task_id,
agent_id=agent_id,
principal_id=principal_id,
allowed_paths=tuple(normalized_paths),
allowed_git_operations=normalized_operations,
issued_at=issued.isoformat().replace("+00:00", "Z"),
expires_at=(issued + timedelta(seconds=ttl_seconds))
.isoformat()
.replace("+00:00", "Z"),
policy_version=policy_version,
key_id=self.signer.key_id,
nonce=nonce or uuid.uuid4().hex,
)
signed = replace(unsigned, signature=self.signer.sign(unsigned.canonical_bytes()))
self.store.save_issued(signed)
return signed
@dataclass(frozen=True)
class ProvenanceAuthorization:
authorization_id: str
provenance_id: str
run_id: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
execution_path: str
git_operation: str | None
request_digest: str
policy_version: str
key_id: str
public_key_fingerprint: str
authorized_at: str = field(default_factory=utc_now)
def as_dict(self) -> dict[str, Any]:
return asdict(self)
class SQLiteProvenanceStore:
"""持久化 Provenance、状态、授权使用和不可变事件。"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self._connection = sqlite3.connect(
str(self.path), timeout=30.0, check_same_thread=False
)
self._connection.row_factory = sqlite3.Row
with self._lock:
self._connection.execute("PRAGMA journal_mode=WAL")
self._connection.execute("PRAGMA synchronous=FULL")
self._connection.execute("PRAGMA foreign_keys=ON")
self._connection.executescript(
"""
CREATE TABLE IF NOT EXISTS provenance_records (
provenance_id TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
status TEXT NOT NULL,
signature_verified INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS provenance_usage (
authorization_id TEXT PRIMARY KEY,
provenance_id TEXT NOT NULL,
execution_path TEXT NOT NULL,
git_operation TEXT NOT NULL DEFAULT '',
request_digest TEXT NOT NULL,
authorized_at TEXT NOT NULL,
receipt_json TEXT NOT NULL,
UNIQUE(provenance_id, execution_path, git_operation, request_digest),
FOREIGN KEY(provenance_id) REFERENCES provenance_records(provenance_id)
);
CREATE TABLE IF NOT EXISTS provenance_events (
event_id TEXT PRIMARY KEY,
provenance_id TEXT NOT NULL,
event_type TEXT NOT NULL,
details_json TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(provenance_id) REFERENCES provenance_records(provenance_id)
);
CREATE INDEX IF NOT EXISTS idx_provenance_usage_id
ON provenance_usage(provenance_id, authorized_at);
CREATE INDEX IF NOT EXISTS idx_provenance_events_id
ON provenance_events(provenance_id, created_at);
"""
)
self._connection.commit()
def close(self) -> None:
with self._lock:
self._connection.close()
def _event(
self,
provenance_id: str,
event_type: str,
details: Mapping[str, Any] | None = None,
) -> None:
self._connection.execute(
"INSERT INTO provenance_events VALUES (?, ?, ?, ?, ?)",
(
f"pevt_{uuid.uuid4().hex}",
provenance_id,
event_type,
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True),
utc_now(),
),
)
def save_issued(self, provenance: RunProvenance) -> None:
payload = json.dumps(
provenance.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
now = utc_now()
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
prior = self._connection.execute(
"SELECT payload_json FROM provenance_records WHERE provenance_id = ?",
(provenance.provenance_id,),
).fetchone()
if prior is not None:
if prior["payload_json"] != payload:
raise ProvenanceDenied("provenance_id reused with different payload")
self._connection.rollback()
return
self._connection.execute(
"INSERT INTO provenance_records VALUES (?, ?, ?, 0, ?, ?)",
(
provenance.provenance_id,
payload,
ProvenanceStatus.ISSUED.value,
now,
now,
),
)
self._event(provenance.provenance_id, "issued")
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def get(self, provenance_id: str) -> RunProvenance:
with self._lock:
row = self._connection.execute(
"SELECT payload_json FROM provenance_records WHERE provenance_id = ?",
(required(provenance_id, "provenance_id"),),
).fetchone()
if row is None:
raise ProvenanceNotFound("provenance not found")
value = json.loads(row["payload_json"])
if not isinstance(value, Mapping):
raise ProvenanceError("stored provenance payload is invalid")
return RunProvenance.from_dict(value)
def status(self, provenance_id: str) -> ProvenanceStatus:
with self._lock:
row = self._connection.execute(
"SELECT status FROM provenance_records WHERE provenance_id = ?",
(required(provenance_id, "provenance_id"),),
).fetchone()
if row is None:
raise ProvenanceNotFound("provenance not found")
return ProvenanceStatus(row["status"])
def mark_verified(self, provenance_id: str, *, fingerprint: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
current = self.status(provenance_id)
if current in {ProvenanceStatus.REVOKED, ProvenanceStatus.EXPIRED}:
raise ProvenanceDenied(f"cannot verify provenance in state {current.value}")
next_status = (
ProvenanceStatus.USED.value
if current == ProvenanceStatus.USED
else ProvenanceStatus.VERIFIED.value
)
self._connection.execute(
"UPDATE provenance_records SET status = ?, signature_verified = 1, updated_at = ? WHERE provenance_id = ?",
(next_status, utc_now(), provenance_id),
)
self._event(
provenance_id,
"verified",
{"public_key_fingerprint": fingerprint},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def mark_expired(self, provenance_id: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
current = self.status(provenance_id)
if current == ProvenanceStatus.REVOKED:
self._connection.rollback()
return
self._connection.execute(
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
(ProvenanceStatus.EXPIRED.value, utc_now(), provenance_id),
)
self._event(provenance_id, "expired")
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def revoke(self, provenance_id: str, *, reason: str, principal_id: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
self.get(provenance_id)
self._connection.execute(
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
(ProvenanceStatus.REVOKED.value, utc_now(), provenance_id),
)
self._event(
provenance_id,
"revoked",
{
"reason": required(reason, "reason"),
"principal_id": required(principal_id, "principal_id"),
},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def record_authorization(self, authorization: ProvenanceAuthorization) -> None:
receipt = json.dumps(
authorization.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
current = self.status(authorization.provenance_id)
if current in {ProvenanceStatus.REVOKED, ProvenanceStatus.EXPIRED}:
raise ProvenanceDenied(f"provenance state is {current.value}")
try:
self._connection.execute(
"INSERT INTO provenance_usage VALUES (?, ?, ?, ?, ?, ?, ?)",
(
authorization.authorization_id,
authorization.provenance_id,
authorization.execution_path,
authorization.git_operation or "",
authorization.request_digest,
authorization.authorized_at,
receipt,
),
)
except sqlite3.IntegrityError as exc:
raise ProvenanceReplay(
"identical provenance path/operation/request digest already used"
) from exc
self._connection.execute(
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
(
ProvenanceStatus.USED.value,
authorization.authorized_at,
authorization.provenance_id,
),
)
self._event(
authorization.provenance_id,
"used",
{
"authorization_id": authorization.authorization_id,
"execution_path": authorization.execution_path,
"git_operation": authorization.git_operation,
"request_digest": authorization.request_digest,
},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def authorization_count(self, provenance_id: str) -> int:
with self._lock:
row = self._connection.execute(
"SELECT COUNT(*) AS total FROM provenance_usage WHERE provenance_id = ?",
(required(provenance_id, "provenance_id"),),
).fetchone()
return int(row["total"])
def events(self, provenance_id: str) -> list[dict[str, Any]]:
with self._lock:
rows = self._connection.execute(
"SELECT event_type, details_json, created_at FROM provenance_events WHERE provenance_id = ? ORDER BY created_at, event_id",
(required(provenance_id, "provenance_id"),),
).fetchall()
return [
{
"event_type": row["event_type"],
"details": json.loads(row["details_json"]),
"created_at": row["created_at"],
}
for row in rows
]
class ProvenanceVerifier:
"""验证 Owner 签名、上下文、路径、Git 操作、时效和重放。"""
def __init__(
self,
store: SQLiteProvenanceStore,
verifiers: Mapping[str, SignatureVerifier],
) -> None:
if not verifiers:
raise ValueError("at least one public key verifier is required")
self.store = store
self.verifiers = {required(key, "key_id"): value for key, value in verifiers.items()}
def authorize(
self,
*,
provenance_id: str,
run_id: str,
tenant_id: str,
task_id: str,
agent_id: str,
principal_id: str,
execution_path: str,
request_digest: str,
git_operation: str | None = None,
now: datetime | None = None,
) -> ProvenanceAuthorization:
provenance = self.store.get(provenance_id)
status = self.store.status(provenance.provenance_id)
if status == ProvenanceStatus.REVOKED:
raise ProvenanceRevoked("provenance is revoked")
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
if current >= parse_timestamp(provenance.expires_at):
self.store.mark_expired(provenance.provenance_id)
raise ProvenanceExpired("provenance is expired")
verifier = self.verifiers.get(provenance.key_id)
if verifier is None:
raise ProvenanceSignatureError("no trusted public key for provenance key_id")
if not provenance.signature or not verifier.verify(
provenance.canonical_bytes(), provenance.signature
):
raise ProvenanceSignatureError("invalid provenance signature")
expected = {
"run_id": required(run_id, "run_id"),
"tenant_id": required(tenant_id, "tenant_id"),
"task_id": required(task_id, "task_id"),
"agent_id": required(agent_id, "agent_id"),
"principal_id": required(principal_id, "principal_id"),
}
for field_name, expected_value in expected.items():
if getattr(provenance, field_name) != expected_value:
raise ProvenanceDenied(f"provenance {field_name} mismatch")
path = ExecutionPath(execution_path).value
if path not in provenance.allowed_paths:
raise ProvenanceDenied(f"execution path is not allowed: {path}")
operation: str | None = None
if path == ExecutionPath.GIT_OPERATION.value:
if git_operation is None:
raise ProvenanceDenied("git operation is required for git_operation path")
operation = GitOperation(git_operation).value
if operation not in provenance.allowed_git_operations:
raise ProvenanceDenied(f"Git operation is not allowed: {operation}")
elif git_operation is not None:
raise ProvenanceDenied("git_operation is only valid for git_operation path")
digest = _validate_digest(request_digest)
self.store.mark_verified(
provenance.provenance_id, fingerprint=verifier.fingerprint
)
authorization = ProvenanceAuthorization(
authorization_id=f"pauth_{uuid.uuid4().hex}",
provenance_id=provenance.provenance_id,
run_id=provenance.run_id,
tenant_id=provenance.tenant_id,
task_id=provenance.task_id,
agent_id=provenance.agent_id,
principal_id=provenance.principal_id,
execution_path=path,
git_operation=operation,
request_digest=digest,
policy_version=provenance.policy_version,
key_id=provenance.key_id,
public_key_fingerprint=verifier.fingerprint,
)
self.store.record_authorization(authorization)
return authorization
class ProvenanceControlledWriter:
"""七路径统一策略入口并持久写入授权回执。"""
def __init__(
self,
verifier: ProvenanceVerifier,
receipt_root: str | Path,
*,
writer_id: str = "aios-v2-provenance-writer",
) -> None:
self.verifier = verifier
self.receipt_root = Path(receipt_root).resolve()
self.receipt_root.mkdir(parents=True, exist_ok=True)
self.writer_id = required(writer_id, "writer_id")
self._lock = threading.RLock()
@staticmethod
def _component(value: str) -> str:
normalized = required(value, "path component")
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
raise ProvenanceDenied("invalid receipt path component")
return normalized
def authorize(self, **values: Any) -> ProvenanceAuthorization:
authorization = self.verifier.authorize(**values)
tenant = self._component(authorization.tenant_id)
provenance_id = self._component(authorization.provenance_id)
target_dir = self.receipt_root / tenant / provenance_id
target = target_dir / f"{authorization.authorization_id}.json"
payload = authorization.as_dict()
payload["writer_id"] = self.writer_id
with self._lock:
target_dir.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
encoding="utf-8",
)
os.chmod(temporary, 0o640)
os.replace(temporary, target)
return authorization
def authorize_code_change(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.CODE_CHANGE.value, **values)
def authorize_git_operation(
self, *, git_operation: str, **values: Any
) -> ProvenanceAuthorization:
return self.authorize(
execution_path=ExecutionPath.GIT_OPERATION.value,
git_operation=git_operation,
**values,
)
def authorize_deployment(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.DEPLOYMENT.value, **values)
def authorize_runtime_execute(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values)
def authorize_evidence_write(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values)
def authorize_rollback(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.ROLLBACK.value, **values)
def authorize_audit(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
@@ -0,0 +1,381 @@
#!/usr/bin/env python3
"""AIOS V2 Run Provenance 与最小权限 Grant 运维 CLI。
私钥只用于签发;授权命令只加载一个与 ``--path`` 对应的 Grant。所有输出均为
不含 Secret 的 JSON。旧 ``issue`` 仅为候选兼容,生产必须使用
``issue-workflow`` 与 ``issue-grant``。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any, Mapping, Sequence
from .provenance import (
Ed25519PrivateKeySigner,
Ed25519PublicKeyVerifier,
ExecutionPath,
GitOperation,
ProvenanceIssuer,
SQLiteProvenanceStore,
)
from .provenance_grants import (
GrantIssuer,
GrantType,
GrantVerifier,
SQLiteGrantStore,
WorkflowProvenanceIssuer,
)
from .provenance_operations import (
OperationContext,
ProvenanceOperationGate,
context_from_environment,
load_request_file,
)
from .provenance_scoped import ScopedLeastPrivilegeProvenanceWriter
def _json_object(value: str | None, file_path: str | None) -> Mapping[str, Any]:
if value and file_path:
raise ValueError("request-json and request-file are mutually exclusive")
if file_path:
return load_request_file(file_path)
parsed = json.loads(value or "{}")
if not isinstance(parsed, Mapping):
raise ValueError("request JSON must be an object")
return dict(parsed)
def _csv(value: str | None) -> tuple[str, ...]:
return tuple(item.strip() for item in str(value or "").split(",") if item.strip())
def _write_json(path: str | None, payload: Mapping[str, Any]) -> None:
encoded = json.dumps(dict(payload), ensure_ascii=False, sort_keys=True, indent=2) + "\n"
if path:
target = Path(path).resolve()
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(encoded, encoding="utf-8")
os.chmod(temporary, 0o640)
os.replace(temporary, target)
print(encoded, end="")
def _parent_store(args: argparse.Namespace) -> SQLiteProvenanceStore:
return SQLiteProvenanceStore(args.store_file)
def _grant_store(args: argparse.Namespace) -> SQLiteGrantStore:
return SQLiteGrantStore(args.store_file)
def _context(args: argparse.Namespace) -> OperationContext:
if args.context_from_env:
return context_from_environment()
values = {
"provenance_id": args.provenance_id,
"run_id": args.run_id,
"tenant_id": args.tenant_id,
"task_id": args.task_id,
"agent_id": args.agent_id,
"principal_id": args.principal_id,
}
missing = [name for name, value in values.items() if not str(value or "").strip()]
if missing:
raise ValueError(f"missing context arguments: {missing}")
return OperationContext(**{name: str(value).strip() for name, value in values.items()})
def command_issue_legacy(args: argparse.Namespace) -> int:
store = _parent_store(args)
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
provenance = ProvenanceIssuer(signer, store).issue(
run_id=args.run_id,
issuer=args.issuer,
tenant_id=args.tenant_id,
task_id=args.task_id,
agent_id=args.agent_id,
principal_id=args.principal_id,
allowed_paths=_csv(args.allowed_paths) or tuple(item.value for item in ExecutionPath),
allowed_git_operations=_csv(args.allowed_git_operations)
or tuple(item.value for item in GitOperation),
ttl_seconds=args.ttl_seconds,
policy_version=args.policy_version,
)
_write_json(
args.output,
{
"mode": "legacy-full-rights-candidate-only",
"provenance": provenance.as_dict(),
"status": store.status(provenance.provenance_id).value,
"secret_value_stored": False,
},
)
return 0
def command_issue_workflow(args: argparse.Namespace) -> int:
store = _parent_store(args)
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
workflow = WorkflowProvenanceIssuer(signer, store).issue(
run_id=args.run_id,
issuer=args.issuer,
tenant_id=args.tenant_id,
task_id=args.task_id,
agent_id=args.agent_id,
principal_id=args.principal_id,
ttl_seconds=args.ttl_seconds,
)
_write_json(
args.output,
{
"mode": "least-privilege-workflow",
"workflow_provenance": workflow.as_dict(),
"status": store.status(workflow.provenance_id).value,
"secret_value_stored": False,
},
)
return 0
def command_issue_grant(args: argparse.Namespace) -> int:
parent_store = _parent_store(args)
grant_store = _grant_store(args)
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
grant = GrantIssuer(signer, parent_store, grant_store).issue(
parent_provenance_id=args.parent_provenance_id,
grant_type=GrantType(args.grant_type),
ttl_seconds=args.ttl_seconds,
allowed_git_operations=_csv(args.allowed_git_operations),
)
_write_json(
args.output,
{
"grant": grant.as_dict(),
"status": grant_store.status(grant.grant_id).value,
"secret_value_stored": False,
},
)
return 0
def _single_scope_writer(args: argparse.Namespace) -> ScopedLeastPrivilegeProvenanceWriter:
path = ExecutionPath(args.path).value
parent_store = _parent_store(args)
grant_store = _grant_store(args)
verifier = Ed25519PublicKeyVerifier.from_file(args.public_key_file, key_id=args.key_id)
trusted = GrantVerifier(parent_store, grant_store, {args.key_id: verifier})
return ScopedLeastPrivilegeProvenanceWriter(
trusted,
args.receipt_dir,
{path: args.grant_id},
required_paths=(path,),
writer_id=f"aios-v2-cli-{path}",
)
def command_authorize(args: argparse.Namespace) -> int:
writer = _single_scope_writer(args)
context = _context(args)
receipt = ProvenanceOperationGate(writer).authorize(
context,
execution_path=args.path,
operation=args.operation,
request=_json_object(args.request_json, args.request_file),
)
_write_json(args.output, {"authorization": receipt.as_dict()})
return 0
def command_revoke(args: argparse.Namespace) -> int:
store = _parent_store(args)
store.revoke(args.provenance_id, reason=args.reason, principal_id=args.principal_id)
_write_json(
args.output,
{"provenance_id": args.provenance_id, "status": store.status(args.provenance_id).value},
)
return 0
def command_status(args: argparse.Namespace) -> int:
store = _parent_store(args)
provenance = store.get(args.provenance_id)
grant_store = _grant_store(args)
_write_json(
args.output,
{
"provenance": provenance.as_dict(),
"status": store.status(args.provenance_id).value,
"authorization_count": store.authorization_count(args.provenance_id),
"events": store.events(args.provenance_id),
"grants": [item.as_dict() for item in grant_store.list_for_parent(args.provenance_id)],
},
)
return 0
def command_grant_revoke(args: argparse.Namespace) -> int:
store = _grant_store(args)
store.revoke(args.grant_id, reason=args.reason, principal_id=args.principal_id)
_write_json(
args.output,
{"grant_id": args.grant_id, "status": store.status(args.grant_id).value},
)
return 0
def command_grant_status(args: argparse.Namespace) -> int:
store = _grant_store(args)
grant = store.get(args.grant_id)
_write_json(
args.output,
{"grant": grant.as_dict(), "status": store.status(args.grant_id).value},
)
return 0
def add_common_store(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--store-file",
default=os.getenv(
"AIOS_PROVENANCE_STORE_FILE",
"/var/lib/aios-v2/run-provenance.sqlite3",
),
)
parser.add_argument("--output", default=None)
def add_signing_context(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--private-key-file", required=True)
parser.add_argument("--key-id", required=True)
def add_workflow_identity(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--run-id", required=True)
parser.add_argument("--issuer", required=True)
parser.add_argument("--tenant-id", required=True)
parser.add_argument("--task-id", required=True)
parser.add_argument("--agent-id", required=True)
parser.add_argument("--principal-id", required=True)
def add_context(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--context-from-env", action="store_true")
parser.add_argument("--provenance-id")
parser.add_argument("--run-id")
parser.add_argument("--tenant-id")
parser.add_argument("--task-id")
parser.add_argument("--agent-id")
parser.add_argument("--principal-id")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="AIOS V2 Run Provenance CLI")
subcommands = parser.add_subparsers(dest="command", required=True)
issue = subcommands.add_parser("issue", help="候选兼容:签发全权限 Provenance;生产禁止")
add_common_store(issue)
add_signing_context(issue)
add_workflow_identity(issue)
issue.add_argument("--allowed-paths")
issue.add_argument("--allowed-git-operations")
issue.add_argument("--ttl-seconds", type=int, default=3600)
issue.add_argument("--policy-version", default="aios.v2.candidate.legacy")
issue.set_defaults(handler=command_issue_legacy)
workflow = subcommands.add_parser(
"issue-workflow", help="签发不含操作权限的父 Workflow Provenance"
)
add_common_store(workflow)
add_signing_context(workflow)
add_workflow_identity(workflow)
workflow.add_argument("--ttl-seconds", type=int, default=14400)
workflow.set_defaults(handler=command_issue_workflow)
grant = subcommands.add_parser("issue-grant", help="签发单一路径最小权限 Grant")
add_common_store(grant)
add_signing_context(grant)
grant.add_argument("--parent-provenance-id", required=True)
grant.add_argument("--grant-type", choices=[item.value for item in GrantType], required=True)
grant.add_argument("--allowed-git-operations")
grant.add_argument("--ttl-seconds", type=int, default=900)
grant.set_defaults(handler=command_issue_grant)
authorize = subcommands.add_parser("authorize", help="使用单一子 Grant 授权一次运维操作")
add_common_store(authorize)
add_context(authorize)
authorize.add_argument("--public-key-file", required=True)
authorize.add_argument("--key-id", required=True)
authorize.add_argument("--grant-id", required=True)
authorize.add_argument(
"--receipt-dir",
default=os.getenv(
"AIOS_PROVENANCE_RECEIPT_DIR",
"/var/lib/aios-v2/provenance-receipts",
),
)
authorize.add_argument(
"--path",
required=True,
choices=[
ExecutionPath.CODE_CHANGE.value,
ExecutionPath.DEPLOYMENT.value,
ExecutionPath.RUNTIME_EXECUTE.value,
ExecutionPath.EVIDENCE_WRITE.value,
ExecutionPath.ROLLBACK.value,
ExecutionPath.AUDIT.value,
],
)
authorize.add_argument("--operation", required=True)
request_group = authorize.add_mutually_exclusive_group()
request_group.add_argument("--request-json")
request_group.add_argument("--request-file")
authorize.set_defaults(handler=command_authorize)
revoke = subcommands.add_parser("revoke", help="撤销父 Workflow Provenance")
add_common_store(revoke)
revoke.add_argument("--provenance-id", required=True)
revoke.add_argument("--reason", required=True)
revoke.add_argument("--principal-id", required=True)
revoke.set_defaults(handler=command_revoke)
status = subcommands.add_parser("status", help="查询父 Workflow 与子 Grant")
add_common_store(status)
status.add_argument("--provenance-id", required=True)
status.set_defaults(handler=command_status)
grant_revoke = subcommands.add_parser("grant-revoke", help="单独撤销子 Grant")
add_common_store(grant_revoke)
grant_revoke.add_argument("--grant-id", required=True)
grant_revoke.add_argument("--reason", required=True)
grant_revoke.add_argument("--principal-id", required=True)
grant_revoke.set_defaults(handler=command_grant_revoke)
grant_status = subcommands.add_parser("grant-status", help="查询子 Grant 状态")
add_common_store(grant_status)
grant_status.add_argument("--grant-id", required=True)
grant_status.set_defaults(handler=command_grant_status)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
return int(args.handler(args))
except Exception as exc:
print(
json.dumps(
{"status": "denied", "error": type(exc).__name__, "message": str(exc)},
ensure_ascii=False,
),
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,416 @@
"""受最小权限 Run Provenance Grant 约束的 Git 写入器。
所有 Git 操作都使用参数数组执行。对于 Commit 和 Evidence Commit,先计算
工作树内容摘要并完成 Provenance 授权,之后才允许执行 ``git add``,避免未授权
请求改变 Git Index。
"""
from __future__ import annotations
import hashlib
import os
import re
import subprocess
import uuid
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Mapping, Protocol, Sequence
from .provenance import GitOperation, ProvenanceDenied, digest_payload, utc_now
_BRANCH_PATTERN = re.compile(r"^(?![-./])(?!.*\.\.)(?!.*//)[A-Za-z0-9._/-]{1,240}$")
_SECRET_URL = re.compile(r"(?i)(https?://)([^/@\s]+)@")
_TOKENISH = re.compile(r"(?i)(token|password|secret|authorization)([=:]\s*)([^\s]+)")
class GitAuthorizationWriter(Protocol):
def authorize_code_change(self, **values: Any) -> Any: ...
def authorize_git_operation(self, *, git_operation: str, **values: Any) -> Any: ...
class ProvenanceGitError(RuntimeError):
"""Git 操作被拒绝或执行失败。"""
@dataclass(frozen=True)
class GitExecutionContext:
provenance_id: str
run_id: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
@dataclass(frozen=True)
class GitCommandResult:
operation_id: str
operation: str
authorization_ids: Sequence[str]
command: Sequence[str]
exit_code: int
stdout: str
stderr: str
head_before: str | None
head_after: str | None
started_at: str
completed_at: str
def as_dict(self) -> dict[str, Any]:
return asdict(self)
class ProvenanceGitWriter:
"""执行经过单路径 Grant 授权的窄范围 Git 操作。"""
def __init__(
self,
repo: str | Path,
writer: GitAuthorizationWriter,
*,
timeout_seconds: float = 120.0,
output_limit: int = 16000,
) -> None:
self.repo = Path(repo).resolve()
self.writer = writer
self.timeout_seconds = float(timeout_seconds)
self.output_limit = int(output_limit)
if self.timeout_seconds <= 0 or self.output_limit < 1024:
raise ValueError("invalid Git writer limits")
self._run_raw(["rev-parse", "--is-inside-work-tree"], check=True)
@staticmethod
def _validate_branch(branch: str) -> str:
normalized = str(branch or "").strip()
if not _BRANCH_PATTERN.fullmatch(normalized) or normalized.endswith((".", "/")):
raise ProvenanceGitError("invalid Git branch name")
if normalized in {"main", "master"}:
raise ProvenanceGitError("direct main/master operation is prohibited")
return normalized
def _validate_path(self, value: str | Path) -> str:
raw = Path(value)
candidate = (self.repo / raw).resolve() if not raw.is_absolute() else raw.resolve()
try:
relative = candidate.relative_to(self.repo)
except ValueError as exc:
raise ProvenanceGitError("Git path escapes repository") from exc
if str(relative) in {"", "."}:
raise ProvenanceGitError("repository root is not a valid file path")
return relative.as_posix()
@staticmethod
def _redact(value: str, limit: int) -> str:
text = _SECRET_URL.sub(r"\1***@", str(value or ""))
text = _TOKENISH.sub(r"\1\2***", text)
if len(text) > limit:
return text[:limit] + "\n...[truncated]"
return text
def _head(self) -> str | None:
process = self._run_raw(["rev-parse", "HEAD"], check=False)
return process.stdout.strip() if process.returncode == 0 else None
def _run_raw(
self,
args: Sequence[str],
*,
check: bool,
env: Mapping[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
command = ["git", "-C", str(self.repo), *[str(item) for item in args]]
safe_env = {
**os.environ,
"GIT_TERMINAL_PROMPT": "0",
"GIT_CONFIG_NOSYSTEM": os.environ.get("GIT_CONFIG_NOSYSTEM", "1"),
}
if env:
safe_env.update({str(key): str(value) for key, value in env.items()})
process = subprocess.run(
command,
text=True,
capture_output=True,
timeout=self.timeout_seconds,
env=safe_env,
)
if check and process.returncode != 0:
raise ProvenanceGitError(
self._redact(process.stderr or process.stdout, self.output_limit)
or f"Git command failed with exit code {process.returncode}"
)
return process
def _path_digest(self, relative: str) -> str:
target = self.repo / relative
if target.exists():
if not target.is_file():
raise ProvenanceGitError("Git writer accepts file paths only")
hasher = hashlib.sha256()
with target.open("rb") as handle:
while True:
block = handle.read(65536)
if not block:
break
hasher.update(block)
return hasher.hexdigest()
tracked = self._run_raw(["ls-files", "--error-unmatch", "--", relative], check=False)
if tracked.returncode != 0:
raise ProvenanceGitError(f"path does not exist and is not tracked: {relative}")
return "deleted"
def _content_manifest(self, paths: Sequence[str]) -> Mapping[str, str]:
return {path: self._path_digest(path) for path in sorted(set(paths))}
def _authorize(
self,
context: GitExecutionContext,
*,
operation: GitOperation,
request: Mapping[str, Any],
code_change: bool,
) -> list[str]:
request_digest = digest_payload(
{
"operation": operation.value,
"repo": self.repo.name,
"request": dict(request),
"head": self._head(),
}
)
ids: list[str] = []
try:
if code_change:
code_authorization = self.writer.authorize_code_change(
provenance_id=context.provenance_id,
run_id=context.run_id,
tenant_id=context.tenant_id,
task_id=context.task_id,
agent_id=context.agent_id,
principal_id=context.principal_id,
request_digest=digest_payload(
{
"git_operation": operation.value,
"request_digest": request_digest,
}
),
)
ids.append(code_authorization.authorization_id)
git_authorization = self.writer.authorize_git_operation(
provenance_id=context.provenance_id,
run_id=context.run_id,
tenant_id=context.tenant_id,
task_id=context.task_id,
agent_id=context.agent_id,
principal_id=context.principal_id,
git_operation=operation.value,
request_digest=request_digest,
)
ids.append(git_authorization.authorization_id)
return ids
except ProvenanceDenied as exc:
raise ProvenanceGitError(f"Run Provenance denied Git operation: {exc}") from exc
def _run_authorized(
self,
*,
operation: GitOperation,
args: Sequence[str],
authorization_ids: Sequence[str],
) -> GitCommandResult:
started = utc_now()
before = self._head()
process = self._run_raw(args, check=False)
after = self._head()
result = GitCommandResult(
operation_id=f"gitop_{uuid.uuid4().hex}",
operation=operation.value,
authorization_ids=tuple(authorization_ids),
command=tuple(["git", "-C", "<repo>", *[str(item) for item in args]]),
exit_code=int(process.returncode),
stdout=self._redact(process.stdout, self.output_limit),
stderr=self._redact(process.stderr, self.output_limit),
head_before=before,
head_after=after,
started_at=started,
completed_at=utc_now(),
)
if process.returncode != 0:
raise ProvenanceGitError(
result.stderr or result.stdout or f"Git {operation.value} failed"
)
return result
def _execute(
self,
context: GitExecutionContext,
*,
operation: GitOperation,
args: Sequence[str],
request: Mapping[str, Any],
code_change: bool,
) -> GitCommandResult:
authorization_ids = self._authorize(
context,
operation=operation,
request=request,
code_change=code_change,
)
return self._run_authorized(
operation=operation,
args=args,
authorization_ids=authorization_ids,
)
def read_ref(self, context: GitExecutionContext, ref: str) -> GitCommandResult:
normalized = str(ref or "").strip()
if not normalized or any(ch.isspace() for ch in normalized):
raise ProvenanceGitError("invalid Git ref")
return self._execute(
context,
operation=GitOperation.READ_REF,
args=["rev-parse", "--verify", normalized],
request={"ref": normalized},
code_change=False,
)
def fetch(
self,
context: GitExecutionContext,
*,
remote: str,
refspecs: Sequence[str],
) -> GitCommandResult:
normalized_remote = str(remote or "").strip()
normalized_refspecs = tuple(str(item).strip() for item in refspecs if str(item).strip())
if not normalized_remote or not normalized_refspecs:
raise ProvenanceGitError("remote and refspecs are required")
if any(item.startswith("+") and ":refs/heads/" in item for item in normalized_refspecs):
raise ProvenanceGitError(
"fetching directly into local branch refs is prohibited; use remote-tracking refs"
)
return self._execute(
context,
operation=GitOperation.FETCH,
args=["fetch", "--prune", normalized_remote, *normalized_refspecs],
request={"remote": normalized_remote, "refspecs": normalized_refspecs},
code_change=False,
)
def create_branch(
self,
context: GitExecutionContext,
*,
branch: str,
start_point: str,
) -> GitCommandResult:
normalized = self._validate_branch(branch)
start = str(start_point or "").strip()
if not start:
raise ProvenanceGitError("start_point is required")
return self._execute(
context,
operation=GitOperation.CREATE_BRANCH,
args=["branch", normalized, start],
request={"branch": normalized, "start_point": start},
code_change=False,
)
def commit(
self,
context: GitExecutionContext,
*,
paths: Sequence[str | Path],
message: str,
) -> GitCommandResult:
normalized_paths = tuple(self._validate_path(item) for item in paths)
normalized_message = str(message or "").strip()
if not normalized_paths or not normalized_message:
raise ProvenanceGitError("commit paths and message are required")
if len(normalized_message) > 500:
raise ProvenanceGitError("commit message is too long")
request = {
"paths": normalized_paths,
"message": normalized_message,
"content_manifest": self._content_manifest(normalized_paths),
}
authorization_ids = self._authorize(
context,
operation=GitOperation.COMMIT,
request=request,
code_change=True,
)
self._run_raw(["add", "--", *normalized_paths], check=True)
return self._run_authorized(
operation=GitOperation.COMMIT,
args=["commit", "--no-gpg-sign", "-m", normalized_message, "--", *normalized_paths],
authorization_ids=authorization_ids,
)
def merge(
self,
context: GitExecutionContext,
*,
source_ref: str,
message: str,
) -> GitCommandResult:
source = str(source_ref or "").strip()
normalized_message = str(message or "").strip()
if not source or not normalized_message:
raise ProvenanceGitError("source_ref and message are required")
return self._execute(
context,
operation=GitOperation.MERGE,
args=["merge", "--no-ff", "--no-edit", "-m", normalized_message, source],
request={"source_ref": source, "message": normalized_message},
code_change=True,
)
def push(
self,
context: GitExecutionContext,
*,
remote: str,
branch: str,
) -> GitCommandResult:
normalized_remote = str(remote or "").strip()
normalized_branch = self._validate_branch(branch)
if not normalized_remote:
raise ProvenanceGitError("remote is required")
return self._execute(
context,
operation=GitOperation.PUSH,
args=["push", normalized_remote, f"refs/heads/{normalized_branch}:refs/heads/{normalized_branch}"],
request={"remote": normalized_remote, "branch": normalized_branch},
code_change=False,
)
def update_evidence(
self,
context: GitExecutionContext,
*,
paths: Sequence[str | Path],
message: str,
) -> GitCommandResult:
normalized_paths = tuple(self._validate_path(item) for item in paths)
normalized_message = str(message or "").strip()
if not normalized_paths or not normalized_message:
raise ProvenanceGitError("evidence paths and message are required")
if any(not path.startswith(".agent/") for path in normalized_paths):
raise ProvenanceGitError("UPDATE_EVIDENCE may only commit .agent/ paths")
request = {
"paths": normalized_paths,
"message": normalized_message,
"content_manifest": self._content_manifest(normalized_paths),
}
authorization_ids = self._authorize(
context,
operation=GitOperation.UPDATE_EVIDENCE,
request=request,
code_change=False,
)
self._run_raw(["add", "--", *normalized_paths], check=True)
return self._run_authorized(
operation=GitOperation.UPDATE_EVIDENCE,
args=["commit", "--no-gpg-sign", "-m", normalized_message, "--", *normalized_paths],
authorization_ids=authorization_ids,
)
@@ -0,0 +1,744 @@
"""AIOS V2 最小权限 Run Provenance 子 Grant。
父 Workflow Provenance 只绑定 run/tenant/task/agent/principal 身份,不直接授予
操作权限。每个子 Grant 只允许一个 ExecutionPath;Git Grant 还必须限定具体
GitOperation。子 Grant 具备独立 TTL、nonce、状态、撤销和重放防护。
"""
from __future__ import annotations
import json
import os
import sqlite3
import threading
import uuid
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Mapping, Sequence
from .provenance import (
ExecutionPath,
GitOperation,
ProvenanceDenied,
ProvenanceExpired,
ProvenanceIssuer,
ProvenanceNotFound,
ProvenanceReplay,
ProvenanceRevoked,
ProvenanceSignatureError,
ProvenanceStatus,
ProvenanceVerifier,
RunProvenance,
SQLiteProvenanceStore,
Signer,
SignatureVerifier,
_validate_digest,
canonical_json_bytes,
parse_timestamp,
required,
utc_now,
)
WORKFLOW_POLICY_VERSION = "aios.v2.workflow.v1"
GRANT_SCHEMA_VERSION = "aios.v2.provenance-grant.v1"
class GrantType(str, Enum):
CODE_CHANGE = "code_change"
GIT = "git"
DEPLOYMENT = "deployment"
RUNTIME = "runtime"
EVIDENCE = "evidence"
ROLLBACK = "rollback"
AUDIT = "audit"
GRANT_PATHS: Mapping[GrantType, ExecutionPath] = {
GrantType.CODE_CHANGE: ExecutionPath.CODE_CHANGE,
GrantType.GIT: ExecutionPath.GIT_OPERATION,
GrantType.DEPLOYMENT: ExecutionPath.DEPLOYMENT,
GrantType.RUNTIME: ExecutionPath.RUNTIME_EXECUTE,
GrantType.EVIDENCE: ExecutionPath.EVIDENCE_WRITE,
GrantType.ROLLBACK: ExecutionPath.ROLLBACK,
GrantType.AUDIT: ExecutionPath.AUDIT,
}
class GrantStatus(str, Enum):
ISSUED = "issued"
VERIFIED = "verified"
USED = "used"
EXPIRED = "expired"
REVOKED = "revoked"
@dataclass(frozen=True)
class ProvenanceGrant:
grant_id: str
parent_provenance_id: str
grant_type: GrantType
execution_path: ExecutionPath
run_id: str
issuer: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
allowed_git_operations: Sequence[str]
issued_at: str
expires_at: str
policy_version: str
key_id: str
nonce: str
signature: str = ""
schema_version: str = GRANT_SCHEMA_VERSION
def __post_init__(self) -> None:
for name in (
"grant_id",
"parent_provenance_id",
"run_id",
"issuer",
"tenant_id",
"task_id",
"agent_id",
"principal_id",
"issued_at",
"expires_at",
"policy_version",
"key_id",
"nonce",
"schema_version",
):
object.__setattr__(self, name, required(getattr(self, name), name))
if self.schema_version != GRANT_SCHEMA_VERSION:
raise ValueError(f"unsupported grant schema: {self.schema_version}")
grant_type = GrantType(self.grant_type)
path = ExecutionPath(self.execution_path)
if GRANT_PATHS[grant_type] != path:
raise ValueError("grant_type does not match execution_path")
object.__setattr__(self, "grant_type", grant_type)
object.__setattr__(self, "execution_path", path)
operations = tuple(
sorted({GitOperation(item).value for item in self.allowed_git_operations})
)
if grant_type == GrantType.GIT and not operations:
raise ValueError("Git Grant requires explicit Git operations")
if grant_type != GrantType.GIT and operations:
raise ValueError("only Git Grant may contain Git operations")
object.__setattr__(self, "allowed_git_operations", operations)
issued = parse_timestamp(self.issued_at)
expires = parse_timestamp(self.expires_at)
if expires <= issued:
raise ValueError("grant expires_at must be later than issued_at")
object.__setattr__(self, "signature", str(self.signature or "").strip())
def unsigned_dict(self) -> dict[str, Any]:
return {
"schema_version": self.schema_version,
"grant_id": self.grant_id,
"parent_provenance_id": self.parent_provenance_id,
"grant_type": self.grant_type.value,
"execution_path": self.execution_path.value,
"run_id": self.run_id,
"issuer": self.issuer,
"tenant_id": self.tenant_id,
"task_id": self.task_id,
"agent_id": self.agent_id,
"principal_id": self.principal_id,
"allowed_git_operations": list(self.allowed_git_operations),
"issued_at": self.issued_at,
"expires_at": self.expires_at,
"policy_version": self.policy_version,
"key_id": self.key_id,
"nonce": self.nonce,
}
def canonical_bytes(self) -> bytes:
return canonical_json_bytes(self.unsigned_dict())
def as_dict(self) -> dict[str, Any]:
value = self.unsigned_dict()
value["signature"] = self.signature
return value
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "ProvenanceGrant":
return cls(**dict(value))
@dataclass(frozen=True)
class GrantAuthorization:
authorization_id: str
grant_id: str
parent_provenance_id: str
run_id: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
execution_path: str
git_operation: str | None
request_digest: str
policy_version: str
key_id: str
public_key_fingerprint: str
authorized_at: str = field(default_factory=utc_now)
@property
def provenance_id(self) -> str:
return self.parent_provenance_id
def as_dict(self) -> dict[str, Any]:
return asdict(self)
class WorkflowProvenanceIssuer:
"""签发不携带操作权限的父 Workflow Provenance。"""
def __init__(self, signer: Signer, store: SQLiteProvenanceStore) -> None:
self._issuer = ProvenanceIssuer(signer, store)
def issue(
self,
*,
run_id: str,
issuer: str,
tenant_id: str,
task_id: str,
agent_id: str,
principal_id: str,
ttl_seconds: int = 14400,
provenance_id: str | None = None,
nonce: str | None = None,
now: datetime | None = None,
) -> RunProvenance:
return self._issuer.issue(
run_id=run_id,
issuer=issuer,
tenant_id=tenant_id,
task_id=task_id,
agent_id=agent_id,
principal_id=principal_id,
allowed_paths=(),
allowed_git_operations=(),
ttl_seconds=ttl_seconds,
policy_version=WORKFLOW_POLICY_VERSION,
provenance_id=provenance_id,
nonce=nonce,
now=now,
)
class SQLiteGrantStore:
"""在 Provenance SQLite 数据库中持久化子 Grant 与使用记录。"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self._connection = sqlite3.connect(
str(self.path), timeout=30.0, check_same_thread=False
)
self._connection.row_factory = sqlite3.Row
with self._lock:
self._connection.execute("PRAGMA journal_mode=WAL")
self._connection.execute("PRAGMA synchronous=FULL")
self._connection.execute("PRAGMA foreign_keys=ON")
self._connection.executescript(
"""
CREATE TABLE IF NOT EXISTS provenance_grants (
grant_id TEXT PRIMARY KEY,
parent_provenance_id TEXT NOT NULL,
grant_type TEXT NOT NULL,
execution_path TEXT NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL,
signature_verified INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_grants_parent
ON provenance_grants(parent_provenance_id, grant_type, status);
CREATE TABLE IF NOT EXISTS provenance_grant_usage (
authorization_id TEXT PRIMARY KEY,
grant_id TEXT NOT NULL,
execution_path TEXT NOT NULL,
git_operation TEXT NOT NULL DEFAULT '',
request_digest TEXT NOT NULL,
authorized_at TEXT NOT NULL,
receipt_json TEXT NOT NULL,
UNIQUE(grant_id, execution_path, git_operation, request_digest),
FOREIGN KEY(grant_id) REFERENCES provenance_grants(grant_id)
);
CREATE TABLE IF NOT EXISTS provenance_grant_events (
event_id TEXT PRIMARY KEY,
grant_id TEXT NOT NULL,
event_type TEXT NOT NULL,
details_json TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(grant_id) REFERENCES provenance_grants(grant_id)
);
"""
)
self._connection.commit()
def close(self) -> None:
with self._lock:
self._connection.close()
def _event(
self,
grant_id: str,
event_type: str,
details: Mapping[str, Any] | None = None,
) -> None:
self._connection.execute(
"INSERT INTO provenance_grant_events VALUES (?, ?, ?, ?, ?)",
(
f"gevt_{uuid.uuid4().hex}",
grant_id,
event_type,
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True),
utc_now(),
),
)
def save_issued(self, grant: ProvenanceGrant) -> None:
payload = json.dumps(
grant.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
now = utc_now()
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
self._connection.execute(
"INSERT INTO provenance_grants VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)",
(
grant.grant_id,
grant.parent_provenance_id,
grant.grant_type.value,
grant.execution_path.value,
payload,
GrantStatus.ISSUED.value,
now,
now,
),
)
self._event(grant.grant_id, "issued")
self._connection.commit()
except sqlite3.IntegrityError as exc:
self._connection.rollback()
raise ProvenanceDenied("grant_id already exists") from exc
except Exception:
self._connection.rollback()
raise
def get(self, grant_id: str) -> ProvenanceGrant:
with self._lock:
row = self._connection.execute(
"SELECT payload_json FROM provenance_grants WHERE grant_id = ?",
(required(grant_id, "grant_id"),),
).fetchone()
if row is None:
raise ProvenanceNotFound("provenance grant not found")
value = json.loads(row["payload_json"])
if not isinstance(value, Mapping):
raise ProvenanceDenied("stored grant is invalid")
return ProvenanceGrant.from_dict(value)
def status(self, grant_id: str) -> GrantStatus:
with self._lock:
row = self._connection.execute(
"SELECT status FROM provenance_grants WHERE grant_id = ?",
(required(grant_id, "grant_id"),),
).fetchone()
if row is None:
raise ProvenanceNotFound("provenance grant not found")
return GrantStatus(row["status"])
def mark_verified(self, grant_id: str, *, fingerprint: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
current = self.status(grant_id)
if current in {GrantStatus.REVOKED, GrantStatus.EXPIRED}:
raise ProvenanceDenied(f"cannot verify grant in state {current.value}")
next_status = (
GrantStatus.USED.value
if current == GrantStatus.USED
else GrantStatus.VERIFIED.value
)
self._connection.execute(
"UPDATE provenance_grants SET status = ?, signature_verified = 1, updated_at = ? WHERE grant_id = ?",
(next_status, utc_now(), grant_id),
)
self._event(
grant_id,
"verified",
{"public_key_fingerprint": fingerprint},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def mark_expired(self, grant_id: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
if self.status(grant_id) == GrantStatus.REVOKED:
self._connection.rollback()
return
self._connection.execute(
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
(GrantStatus.EXPIRED.value, utc_now(), grant_id),
)
self._event(grant_id, "expired")
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def revoke(self, grant_id: str, *, reason: str, principal_id: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
self.get(grant_id)
self._connection.execute(
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
(GrantStatus.REVOKED.value, utc_now(), grant_id),
)
self._event(
grant_id,
"revoked",
{
"reason": required(reason, "reason"),
"principal_id": required(principal_id, "principal_id"),
},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def record_authorization(self, authorization: GrantAuthorization) -> None:
receipt = json.dumps(
authorization.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
current = self.status(authorization.grant_id)
if current in {GrantStatus.REVOKED, GrantStatus.EXPIRED}:
raise ProvenanceDenied(f"grant state is {current.value}")
try:
self._connection.execute(
"INSERT INTO provenance_grant_usage VALUES (?, ?, ?, ?, ?, ?, ?)",
(
authorization.authorization_id,
authorization.grant_id,
authorization.execution_path,
authorization.git_operation or "",
authorization.request_digest,
authorization.authorized_at,
receipt,
),
)
except sqlite3.IntegrityError as exc:
raise ProvenanceReplay(
"identical grant path/operation/request digest already used"
) from exc
self._connection.execute(
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
(GrantStatus.USED.value, authorization.authorized_at, authorization.grant_id),
)
self._event(
authorization.grant_id,
"used",
{
"authorization_id": authorization.authorization_id,
"execution_path": authorization.execution_path,
"git_operation": authorization.git_operation,
"request_digest": authorization.request_digest,
},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def list_for_parent(self, parent_provenance_id: str) -> list[ProvenanceGrant]:
with self._lock:
rows = self._connection.execute(
"SELECT payload_json FROM provenance_grants WHERE parent_provenance_id = ? ORDER BY grant_type, grant_id",
(required(parent_provenance_id, "parent_provenance_id"),),
).fetchall()
return [ProvenanceGrant.from_dict(json.loads(row["payload_json"])) for row in rows]
class GrantIssuer:
"""从父 Workflow Provenance 派生单一路径最小权限 Grant。"""
def __init__(
self,
signer: Signer,
parent_store: SQLiteProvenanceStore,
grant_store: SQLiteGrantStore,
) -> None:
self.signer = signer
self.parent_store = parent_store
self.grant_store = grant_store
def issue(
self,
*,
parent_provenance_id: str,
grant_type: GrantType | str,
ttl_seconds: int = 900,
allowed_git_operations: Sequence[str] = (),
grant_id: str | None = None,
nonce: str | None = None,
now: datetime | None = None,
) -> ProvenanceGrant:
if ttl_seconds <= 0:
raise ValueError("grant ttl_seconds must be positive")
parent = self.parent_store.get(parent_provenance_id)
if parent.policy_version != WORKFLOW_POLICY_VERSION:
raise ProvenanceDenied("child grants require a workflow provenance parent")
if parent.allowed_paths or parent.allowed_git_operations:
raise ProvenanceDenied("workflow provenance parent must not contain operation rights")
parent_status = self.parent_store.status(parent.provenance_id)
if parent_status == ProvenanceStatus.REVOKED:
raise ProvenanceRevoked("workflow provenance is revoked")
issued = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
parent_expiry = parse_timestamp(parent.expires_at)
expires = min(issued + timedelta(seconds=ttl_seconds), parent_expiry)
if expires <= issued:
raise ProvenanceExpired("workflow provenance has expired")
normalized_type = GrantType(grant_type)
operations = tuple(allowed_git_operations)
unsigned = ProvenanceGrant(
grant_id=grant_id or f"grant_{uuid.uuid4().hex}",
parent_provenance_id=parent.provenance_id,
grant_type=normalized_type,
execution_path=GRANT_PATHS[normalized_type],
run_id=parent.run_id,
issuer=parent.issuer,
tenant_id=parent.tenant_id,
task_id=parent.task_id,
agent_id=parent.agent_id,
principal_id=parent.principal_id,
allowed_git_operations=operations,
issued_at=issued.isoformat().replace("+00:00", "Z"),
expires_at=expires.isoformat().replace("+00:00", "Z"),
policy_version=f"{WORKFLOW_POLICY_VERSION}.{normalized_type.value}",
key_id=self.signer.key_id,
nonce=nonce or uuid.uuid4().hex,
)
signed = replace(unsigned, signature=self.signer.sign(unsigned.canonical_bytes()))
self.grant_store.save_issued(signed)
return signed
class GrantVerifier:
"""验证父 Workflow 与子 Grant,确保单一路径最小权限。"""
def __init__(
self,
parent_store: SQLiteProvenanceStore,
grant_store: SQLiteGrantStore,
verifiers: Mapping[str, SignatureVerifier],
) -> None:
if not verifiers:
raise ValueError("at least one trusted verifier is required")
self.parent_store = parent_store
self.grant_store = grant_store
self.verifiers = {required(key, "key_id"): value for key, value in verifiers.items()}
def _verify_parent(self, parent: RunProvenance, *, now: datetime) -> SignatureVerifier:
status = self.parent_store.status(parent.provenance_id)
if status == ProvenanceStatus.REVOKED:
raise ProvenanceRevoked("workflow provenance is revoked")
if now >= parse_timestamp(parent.expires_at):
self.parent_store.mark_expired(parent.provenance_id)
raise ProvenanceExpired("workflow provenance is expired")
verifier = self.verifiers.get(parent.key_id)
if verifier is None or not parent.signature or not verifier.verify(
parent.canonical_bytes(), parent.signature
):
raise ProvenanceSignatureError("invalid workflow provenance signature")
if parent.policy_version != WORKFLOW_POLICY_VERSION:
raise ProvenanceDenied("parent is not a workflow provenance")
if parent.allowed_paths or parent.allowed_git_operations:
raise ProvenanceDenied("workflow provenance must not contain operation rights")
return verifier
def authorize(
self,
*,
grant_id: str,
parent_provenance_id: str,
run_id: str,
tenant_id: str,
task_id: str,
agent_id: str,
principal_id: str,
execution_path: str,
request_digest: str,
git_operation: str | None = None,
now: datetime | None = None,
) -> GrantAuthorization:
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
parent = self.parent_store.get(parent_provenance_id)
self._verify_parent(parent, now=current)
grant = self.grant_store.get(grant_id)
grant_status = self.grant_store.status(grant.grant_id)
if grant_status == GrantStatus.REVOKED:
raise ProvenanceRevoked("provenance grant is revoked")
if current >= parse_timestamp(grant.expires_at):
self.grant_store.mark_expired(grant.grant_id)
raise ProvenanceExpired("provenance grant is expired")
verifier = self.verifiers.get(grant.key_id)
if verifier is None or not grant.signature or not verifier.verify(
grant.canonical_bytes(), grant.signature
):
raise ProvenanceSignatureError("invalid provenance grant signature")
expected = {
"parent_provenance_id": parent.provenance_id,
"run_id": required(run_id, "run_id"),
"tenant_id": required(tenant_id, "tenant_id"),
"task_id": required(task_id, "task_id"),
"agent_id": required(agent_id, "agent_id"),
"principal_id": required(principal_id, "principal_id"),
}
for field_name, expected_value in expected.items():
if getattr(grant, field_name) != expected_value:
raise ProvenanceDenied(f"grant {field_name} mismatch")
if field_name != "parent_provenance_id" and getattr(parent, field_name) != expected_value:
raise ProvenanceDenied(f"workflow {field_name} mismatch")
path = ExecutionPath(execution_path)
if grant.execution_path != path:
raise ProvenanceDenied(
f"grant permits {grant.execution_path.value}, not {path.value}"
)
operation: str | None = None
if path == ExecutionPath.GIT_OPERATION:
if git_operation is None:
raise ProvenanceDenied("Git operation is required")
operation = GitOperation(git_operation).value
if operation not in grant.allowed_git_operations:
raise ProvenanceDenied(f"Git operation is not allowed: {operation}")
elif git_operation is not None:
raise ProvenanceDenied("git_operation is only valid for a Git Grant")
digest = _validate_digest(request_digest)
self.grant_store.mark_verified(grant.grant_id, fingerprint=verifier.fingerprint)
authorization = GrantAuthorization(
authorization_id=f"gauth_{uuid.uuid4().hex}",
grant_id=grant.grant_id,
parent_provenance_id=parent.provenance_id,
run_id=grant.run_id,
tenant_id=grant.tenant_id,
task_id=grant.task_id,
agent_id=grant.agent_id,
principal_id=grant.principal_id,
execution_path=path.value,
git_operation=operation,
request_digest=digest,
policy_version=grant.policy_version,
key_id=grant.key_id,
public_key_fingerprint=verifier.fingerprint,
)
self.grant_store.record_authorization(authorization)
return authorization
class LeastPrivilegeProvenanceWriter:
"""按路径选择独立子 Grant 的生产 Writer。
``provenance_id`` 参数始终指父 Workflow Provenance;Grant ID 来自受保护
配置映射,调用方无法通过请求体临时扩大权限。
"""
def __init__(
self,
verifier: GrantVerifier,
receipt_root: str | Path,
grant_ids: Mapping[str, str],
*,
writer_id: str = "aios-v2-least-privilege-writer",
) -> None:
self.verifier = verifier
self.receipt_root = Path(receipt_root).resolve()
self.receipt_root.mkdir(parents=True, exist_ok=True)
self.grant_ids = {
ExecutionPath(path).value: required(grant_id, f"grant_id[{path}]")
for path, grant_id in grant_ids.items()
}
missing = sorted(path.value for path in ExecutionPath if path.value not in self.grant_ids)
if missing:
raise ValueError(f"missing least-privilege Grant mappings: {missing}")
self.writer_id = required(writer_id, "writer_id")
self._lock = threading.RLock()
@staticmethod
def _component(value: str) -> str:
normalized = required(value, "path component")
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
raise ProvenanceDenied("invalid receipt path component")
return normalized
def authorize(self, **values: Any) -> GrantAuthorization:
path = ExecutionPath(values["execution_path"]).value
grant_id = self.grant_ids[path]
parent_provenance_id = values.pop("provenance_id")
authorization = self.verifier.authorize(
grant_id=grant_id,
parent_provenance_id=parent_provenance_id,
**values,
)
tenant = self._component(authorization.tenant_id)
parent = self._component(authorization.parent_provenance_id)
grant = self._component(authorization.grant_id)
target_dir = self.receipt_root / tenant / parent / grant
target = target_dir / f"{authorization.authorization_id}.json"
payload = authorization.as_dict()
payload["writer_id"] = self.writer_id
with self._lock:
target_dir.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
encoding="utf-8",
)
os.chmod(temporary, 0o640)
os.replace(temporary, target)
return authorization
def authorize_code_change(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.CODE_CHANGE.value, **values)
def authorize_git_operation(
self, *, git_operation: str, **values: Any
) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.GIT_OPERATION.value,
git_operation=git_operation,
**values,
)
def authorize_deployment(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.DEPLOYMENT.value, **values)
def authorize_runtime_execute(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values)
def authorize_evidence_write(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values)
def authorize_rollback(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.ROLLBACK.value, **values)
def authorize_audit(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
@@ -0,0 +1,265 @@
"""AIOS V2 运维操作 Provenance Gate 与服务级 Scoped Writer 工厂。"""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Mapping, Protocol, Sequence
from .provenance import (
Ed25519PublicKeyVerifier,
ExecutionPath,
ProvenanceAuthorization,
ProvenanceControlledWriter,
ProvenanceDenied,
ProvenanceVerifier,
SQLiteProvenanceStore,
digest_payload,
)
from .provenance_grants import GrantAuthorization, GrantVerifier, SQLiteGrantStore
from .provenance_scoped import ScopedLeastPrivilegeProvenanceWriter
APPLICATION_PROVENANCE_SCOPE = (
ExecutionPath.RUNTIME_EXECUTE.value,
ExecutionPath.EVIDENCE_WRITE.value,
ExecutionPath.AUDIT.value,
)
class AuthorizationWriter(Protocol):
def authorize_deployment(self, **values: Any) -> Any: ...
def authorize_rollback(self, **values: Any) -> Any: ...
def authorize_audit(self, **values: Any) -> Any: ...
def authorize_code_change(self, **values: Any) -> Any: ...
def authorize_evidence_write(self, **values: Any) -> Any: ...
def authorize_runtime_execute(self, **values: Any) -> Any: ...
@dataclass(frozen=True)
class OperationContext:
provenance_id: str
run_id: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
def as_dict(self) -> dict[str, str]:
return asdict(self)
@dataclass(frozen=True)
class OperationReceipt:
operation: str
request_digest: str
authorization_id: str
provenance_id: str
grant_id: str | None
run_id: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
execution_path: str
authorized_at: str
def as_dict(self) -> dict[str, Any]:
return asdict(self)
class ProvenanceOperationGate:
"""部署、回滚、审计和运行操作的统一授权入口。"""
def __init__(self, writer: AuthorizationWriter) -> None:
self.writer = writer
def authorize(
self,
context: OperationContext,
*,
execution_path: str,
operation: str,
request: Mapping[str, Any],
) -> OperationReceipt:
path = ExecutionPath(execution_path).value
normalized_operation = str(operation or "").strip()
if not normalized_operation:
raise ValueError("operation is required")
digest = digest_payload(
{
"operation": normalized_operation,
"execution_path": path,
"request": dict(request),
}
)
values = {
"provenance_id": context.provenance_id,
"run_id": context.run_id,
"tenant_id": context.tenant_id,
"task_id": context.task_id,
"agent_id": context.agent_id,
"principal_id": context.principal_id,
"request_digest": digest,
}
if path == ExecutionPath.DEPLOYMENT.value:
authorization = self.writer.authorize_deployment(**values)
elif path == ExecutionPath.ROLLBACK.value:
authorization = self.writer.authorize_rollback(**values)
elif path == ExecutionPath.AUDIT.value:
authorization = self.writer.authorize_audit(**values)
elif path == ExecutionPath.CODE_CHANGE.value:
authorization = self.writer.authorize_code_change(**values)
elif path == ExecutionPath.EVIDENCE_WRITE.value:
authorization = self.writer.authorize_evidence_write(**values)
elif path == ExecutionPath.RUNTIME_EXECUTE.value:
authorization = self.writer.authorize_runtime_execute(**values)
else:
raise ProvenanceDenied(
"generic operation gate does not authorize git_operation; use ProvenanceGitWriter"
)
return self._receipt(normalized_operation, digest, authorization)
@staticmethod
def _receipt(
operation: str,
request_digest: str,
authorization: ProvenanceAuthorization | GrantAuthorization | Any,
) -> OperationReceipt:
return OperationReceipt(
operation=operation,
request_digest=request_digest,
authorization_id=authorization.authorization_id,
provenance_id=authorization.provenance_id,
grant_id=getattr(authorization, "grant_id", None),
run_id=authorization.run_id,
tenant_id=authorization.tenant_id,
task_id=authorization.task_id,
agent_id=authorization.agent_id,
principal_id=authorization.principal_id,
execution_path=authorization.execution_path,
authorized_at=authorization.authorized_at,
)
_GRANT_ENV_BY_PATH: Mapping[str, str] = {
ExecutionPath.CODE_CHANGE.value: "AIOS_PROVENANCE_GRANT_CODE_CHANGE_ID",
ExecutionPath.GIT_OPERATION.value: "AIOS_PROVENANCE_GRANT_GIT_ID",
ExecutionPath.DEPLOYMENT.value: "AIOS_PROVENANCE_GRANT_DEPLOYMENT_ID",
ExecutionPath.RUNTIME_EXECUTE.value: "AIOS_PROVENANCE_GRANT_RUNTIME_ID",
ExecutionPath.EVIDENCE_WRITE.value: "AIOS_PROVENANCE_GRANT_EVIDENCE_ID",
ExecutionPath.ROLLBACK.value: "AIOS_PROVENANCE_GRANT_ROLLBACK_ID",
ExecutionPath.AUDIT.value: "AIOS_PROVENANCE_GRANT_AUDIT_ID",
}
def grant_ids_from_environment(
*,
required_paths: Sequence[str] | None = None,
) -> dict[str, str]:
paths = tuple(
sorted(
{
ExecutionPath(path).value
for path in (required_paths or tuple(item.value for item in ExecutionPath))
}
)
)
values = {path: os.getenv(_GRANT_ENV_BY_PATH[path], "").strip() for path in paths}
missing = [_GRANT_ENV_BY_PATH[path] for path, value in values.items() if not value]
if missing:
raise RuntimeError(f"missing scoped Provenance Grant configuration: {missing}")
return values
def build_provenance_writer_from_env(
*,
state_dir: str | Path | None = None,
required: bool | None = None,
required_paths: Sequence[str] | None = None,
writer_id: str = "aios-v2-application-grant-writer",
) -> AuthorizationWriter | None:
"""从环境构建服务级最小权限 Writer。
生产应用默认只加载 Runtime、Evidence、Audit Grant。Git、部署和回滚进程
必须显式传入自己的 ``required_paths``,不能继承应用默认范围。旧全权限 Writer
只允许在显式关闭强制模式的候选/测试环境中使用。
"""
require_value = (
bool(required)
if required is not None
else os.getenv("AIOS_REQUIRE_RUN_PROVENANCE", "0").strip().lower()
in {"1", "true", "yes", "on"}
)
public_key_file = os.getenv("AIOS_PROVENANCE_PUBLIC_KEY_FILE", "").strip()
key_id = os.getenv("AIOS_PROVENANCE_KEY_ID", "").strip()
base = Path(state_dir or os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
store_file = os.getenv(
"AIOS_PROVENANCE_STORE_FILE", str(base / "run-provenance.sqlite3")
).strip()
receipt_root = os.getenv(
"AIOS_PROVENANCE_RECEIPT_DIR", str(base / "provenance-receipts")
).strip()
configured = bool(public_key_file and key_id)
if not configured:
if require_value:
raise RuntimeError(
"Run Provenance is required but AIOS_PROVENANCE_PUBLIC_KEY_FILE or AIOS_PROVENANCE_KEY_ID is missing"
)
return None
verifier = Ed25519PublicKeyVerifier.from_file(public_key_file, key_id=key_id)
parent_store = SQLiteProvenanceStore(store_file)
normalized_paths = tuple(
sorted(
{
ExecutionPath(path).value
for path in (
required_paths
if required_paths is not None
else APPLICATION_PROVENANCE_SCOPE
)
}
)
)
if require_value:
if not normalized_paths:
raise RuntimeError("production Run Provenance requires a non-empty service scope")
grant_ids = grant_ids_from_environment(required_paths=normalized_paths)
grant_store = SQLiteGrantStore(store_file)
trusted_grants = GrantVerifier(parent_store, grant_store, {key_id: verifier})
return ScopedLeastPrivilegeProvenanceWriter(
trusted_grants,
receipt_root,
grant_ids,
required_paths=normalized_paths,
writer_id=writer_id,
)
legacy = ProvenanceVerifier(parent_store, {key_id: verifier})
return ProvenanceControlledWriter(legacy, receipt_root)
def context_from_environment() -> OperationContext:
values = {
"provenance_id": os.getenv("AIOS_PROVENANCE_ID", ""),
"run_id": os.getenv("AIOS_RUN_ID", ""),
"tenant_id": os.getenv("AIOS_TENANT_ID", ""),
"task_id": os.getenv("AIOS_TASK_ID", ""),
"agent_id": os.getenv("AIOS_AGENT_ID", ""),
"principal_id": os.getenv("AIOS_PRINCIPAL_ID", ""),
}
missing = [name for name, value in values.items() if not str(value).strip()]
if missing:
raise RuntimeError(f"missing Provenance context environment variables: {missing}")
return OperationContext(**{name: str(value).strip() for name, value in values.items()})
def load_request_file(path: str | Path) -> Mapping[str, Any]:
value = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(value, Mapping):
raise ValueError("request file must contain a JSON object")
return dict(value)
@@ -0,0 +1,131 @@
"""按服务范围加载的最小权限 Provenance Writer。
该 Writer 只持有调用进程需要的 Grant ID。未配置路径的方法即使被调用也会
默认拒绝,避免应用进程获得 Git、部署或回滚权限。
"""
from __future__ import annotations
import json
import os
import threading
from pathlib import Path
from typing import Any, Mapping, Sequence
from .provenance import ExecutionPath, ProvenanceDenied, required
from .provenance_grants import GrantAuthorization, GrantVerifier
class ScopedLeastPrivilegeProvenanceWriter:
"""只暴露指定 ExecutionPath 的最小权限 Writer。"""
def __init__(
self,
verifier: GrantVerifier,
receipt_root: str | Path,
grant_ids: Mapping[str, str],
*,
required_paths: Sequence[str],
writer_id: str = "aios-v2-scoped-grant-writer",
) -> None:
self.verifier = verifier
self.receipt_root = Path(receipt_root).resolve()
self.receipt_root.mkdir(parents=True, exist_ok=True)
normalized_required = tuple(
sorted({ExecutionPath(path).value for path in required_paths})
)
if not normalized_required:
raise ValueError("required_paths must not be empty")
self.required_paths = frozenset(normalized_required)
self.grant_ids = {
path: required(grant_ids.get(path, ""), f"grant_id[{path}]")
for path in normalized_required
}
unexpected = sorted(set(grant_ids) - self.required_paths)
if unexpected:
raise ValueError(
f"scoped writer received unnecessary Grant mappings: {unexpected}"
)
self.writer_id = required(writer_id, "writer_id")
self._lock = threading.RLock()
@staticmethod
def _component(value: str) -> str:
normalized = required(value, "path component")
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
raise ProvenanceDenied("invalid receipt path component")
return normalized
def _grant_for(self, path: str) -> str:
normalized = ExecutionPath(path).value
grant_id = self.grant_ids.get(normalized)
if grant_id is None:
raise ProvenanceDenied(
f"this process is not allowed to authorize {normalized}"
)
return grant_id
def authorize(self, **values: Any) -> GrantAuthorization:
path = ExecutionPath(values["execution_path"]).value
grant_id = self._grant_for(path)
parent_provenance_id = values.pop("provenance_id")
authorization = self.verifier.authorize(
grant_id=grant_id,
parent_provenance_id=parent_provenance_id,
**values,
)
tenant = self._component(authorization.tenant_id)
parent = self._component(authorization.parent_provenance_id)
grant = self._component(authorization.grant_id)
target_dir = self.receipt_root / tenant / parent / grant
target = target_dir / f"{authorization.authorization_id}.json"
payload = authorization.as_dict()
payload["writer_id"] = self.writer_id
payload["scope"] = sorted(self.required_paths)
with self._lock:
target_dir.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2)
+ "\n",
encoding="utf-8",
)
os.chmod(temporary, 0o640)
os.replace(temporary, target)
return authorization
def authorize_code_change(self, **values: Any) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.CODE_CHANGE.value, **values
)
def authorize_git_operation(
self, *, git_operation: str, **values: Any
) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.GIT_OPERATION.value,
git_operation=git_operation,
**values,
)
def authorize_deployment(self, **values: Any) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.DEPLOYMENT.value, **values
)
def authorize_runtime_execute(self, **values: Any) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values
)
def authorize_evidence_write(self, **values: Any) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values
)
def authorize_rollback(self, **values: Any) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.ROLLBACK.value, **values
)
def authorize_audit(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
@@ -0,0 +1,607 @@
"""Durable execution truth registry for externally executed AIOS tasks.
A task manifest being present in Git is not evidence that an executor discovered
or ran it. This registry separates declaration, registration, claim, start,
heartbeat and terminal evidence. Release gates must require a terminal success
record with immutable evidence hashes.
"""
from __future__ import annotations
import json
import sqlite3
import threading
import uuid
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Mapping, Sequence
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def parse_time(value: str) -> datetime:
normalized = str(value or "").strip()
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
raise ValueError("timestamp requires timezone")
return parsed.astimezone(timezone.utc)
def required(value: str, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
class ExecutionTruthState(str, Enum):
DECLARED = "DECLARED"
REGISTERED = "REGISTERED"
CLAIMED = "CLAIMED"
RUNNING = "RUNNING"
SUCCEEDED = "SUCCEEDED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
NOT_STARTED = "NOT_STARTED"
STALLED = "STALLED"
TERMINAL_STATES = frozenset(
{
ExecutionTruthState.SUCCEEDED,
ExecutionTruthState.FAILED,
ExecutionTruthState.CANCELLED,
ExecutionTruthState.NOT_STARTED,
ExecutionTruthState.STALLED,
}
)
@dataclass(frozen=True)
class ExecutionTaskManifest:
task_id: str
executor: str
source_branch: str
source_oid: str
target_version: str
steps: Sequence[str]
start_deadline_seconds: int = 300
heartbeat_timeout_seconds: int = 180
required_evidence: Sequence[str] = ()
notification_events: Sequence[str] = ("STARTED", "SUCCEEDED", "FAILED")
owner_notification_ref: str = "AIOS_OWNER_WECHAT_TARGET_REF"
manifest_version: str = "aios.external-task.v1"
def __post_init__(self) -> None:
for name in (
"task_id",
"executor",
"source_branch",
"source_oid",
"target_version",
"manifest_version",
"owner_notification_ref",
):
object.__setattr__(self, name, required(getattr(self, name), name))
steps = tuple(required(item, "step") for item in self.steps)
if not steps:
raise ValueError("at least one execution step is required")
if self.start_deadline_seconds < 1 or self.heartbeat_timeout_seconds < 1:
raise ValueError("execution deadlines must be positive")
object.__setattr__(self, "steps", steps)
object.__setattr__(
self,
"required_evidence",
tuple(sorted({required(item, "required_evidence") for item in self.required_evidence})),
)
object.__setattr__(
self,
"notification_events",
tuple(sorted({required(item, "notification_event") for item in self.notification_events})),
)
def as_dict(self) -> dict[str, Any]:
value = asdict(self)
value["steps"] = list(self.steps)
value["required_evidence"] = list(self.required_evidence)
value["notification_events"] = list(self.notification_events)
return value
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutionTaskManifest":
return cls(**dict(value))
@dataclass(frozen=True)
class ExecutionTruthRecord:
task_id: str
manifest_json: str
state: ExecutionTruthState
executor_run_id: str | None = None
executor_id: str | None = None
declared_at: str = field(default_factory=utc_now)
registered_at: str | None = None
claimed_at: str | None = None
started_at: str | None = None
heartbeat_at: str | None = None
completed_at: str | None = None
current_step: int = 0
evidence: Mapping[str, str] = field(default_factory=dict)
error: Mapping[str, Any] = field(default_factory=dict)
revision: int = 1
@property
def manifest(self) -> ExecutionTaskManifest:
return ExecutionTaskManifest.from_dict(json.loads(self.manifest_json))
def as_dict(self) -> dict[str, Any]:
return {
**asdict(self),
"state": self.state.value,
"manifest": self.manifest.as_dict(),
"evidence": dict(self.evidence),
"error": dict(self.error),
}
class ExecutionTruthConflict(RuntimeError):
pass
class SQLiteExecutionTruthRegistry:
"""Persistent registration and run-evidence store."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
with self._connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS execution_truth (
task_id TEXT PRIMARY KEY,
manifest_json TEXT NOT NULL,
state TEXT NOT NULL,
record_json TEXT NOT NULL,
revision INTEGER NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS execution_truth_events (
event_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
event_type TEXT NOT NULL,
details_json TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(task_id) REFERENCES execution_truth(task_id)
);
CREATE INDEX IF NOT EXISTS idx_execution_truth_state
ON execution_truth(state, updated_at);
"""
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(str(self.path), timeout=30.0)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=FULL")
connection.execute("PRAGMA foreign_keys=ON")
return connection
@staticmethod
def _manifest_json(manifest: ExecutionTaskManifest) -> str:
return json.dumps(manifest.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
@staticmethod
def _record_json(record: ExecutionTruthRecord) -> str:
value = record.as_dict()
value.pop("manifest", None)
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
@staticmethod
def _decode(row: sqlite3.Row) -> ExecutionTruthRecord:
value = json.loads(row["record_json"])
value["state"] = ExecutionTruthState(value["state"])
value["evidence"] = dict(value.get("evidence") or {})
value["error"] = dict(value.get("error") or {})
return ExecutionTruthRecord(**value)
def _event(
self,
connection: sqlite3.Connection,
task_id: str,
event_type: str,
details: Mapping[str, Any] | None = None,
) -> None:
connection.execute(
"INSERT INTO execution_truth_events VALUES (?, ?, ?, ?, ?)",
(
f"truth_evt_{uuid.uuid4().hex}",
task_id,
event_type,
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True, default=str),
utc_now(),
),
)
def declare(self, manifest: ExecutionTaskManifest) -> ExecutionTruthRecord:
manifest_json = self._manifest_json(manifest)
record = ExecutionTruthRecord(
task_id=manifest.task_id,
manifest_json=manifest_json,
state=ExecutionTruthState.DECLARED,
)
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
"SELECT record_json FROM execution_truth WHERE task_id = ?",
(manifest.task_id,),
).fetchone()
if existing is not None:
current = self._decode(existing)
if current.manifest_json != manifest_json:
previous_manifest = current.manifest.as_dict()
replacement_manifest = manifest.as_dict()
previous_oid = previous_manifest.pop("source_oid")
replacement_oid = replacement_manifest.pop("source_oid")
if (
current.state != ExecutionTruthState.NOT_STARTED
or previous_manifest != replacement_manifest
):
connection.rollback()
raise ExecutionTruthConflict(
"task_id already declared with another manifest"
)
now = utc_now()
updated = replace(
current,
manifest_json=manifest_json,
revision=current.revision + 1,
)
connection.execute(
"UPDATE execution_truth SET manifest_json = ?, record_json = ?, revision = ?, updated_at = ? WHERE task_id = ?",
(
manifest_json,
self._record_json(updated),
updated.revision,
now,
updated.task_id,
),
)
self._event(
connection,
updated.task_id,
"MANIFEST_SUPERSEDED",
{
"previous_source_oid": previous_oid,
"source_oid": replacement_oid,
"state": updated.state.value,
},
)
connection.commit()
return updated
connection.rollback()
return current
connection.execute(
"INSERT INTO execution_truth VALUES (?, ?, ?, ?, ?, ?)",
(
record.task_id,
manifest_json,
record.state.value,
self._record_json(record),
record.revision,
record.declared_at,
),
)
self._event(connection, record.task_id, "DECLARED", {"source_oid": manifest.source_oid})
connection.commit()
return record
def get(self, task_id: str) -> ExecutionTruthRecord:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT record_json FROM execution_truth WHERE task_id = ?",
(required(task_id, "task_id"),),
).fetchone()
if row is None:
raise KeyError("execution task not declared")
return self._decode(row)
def _transition(
self,
task_id: str,
*,
allowed: Sequence[ExecutionTruthState],
target: ExecutionTruthState,
executor_run_id: str | None = None,
executor_id: str | None = None,
current_step: int | None = None,
evidence: Mapping[str, str] | None = None,
error: Mapping[str, Any] | None = None,
) -> ExecutionTruthRecord:
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT record_json FROM execution_truth WHERE task_id = ?",
(required(task_id, "task_id"),),
).fetchone()
if row is None:
connection.rollback()
raise KeyError("execution task not declared")
current = self._decode(row)
if current.state not in set(allowed):
connection.rollback()
raise ExecutionTruthConflict(
f"cannot transition {current.state.value} to {target.value}"
)
now = utc_now()
updates: dict[str, Any] = {
"state": target,
"revision": current.revision + 1,
}
if executor_run_id is not None:
if current.executor_run_id and current.executor_run_id != executor_run_id:
connection.rollback()
raise ExecutionTruthConflict("executor_run_id mismatch")
updates["executor_run_id"] = required(executor_run_id, "executor_run_id")
if executor_id is not None:
if current.executor_id and current.executor_id != executor_id:
connection.rollback()
raise ExecutionTruthConflict("executor_id mismatch")
updates["executor_id"] = required(executor_id, "executor_id")
if target == ExecutionTruthState.REGISTERED:
updates["registered_at"] = now
if target == ExecutionTruthState.CLAIMED:
updates["claimed_at"] = now
updates["heartbeat_at"] = now
if target == ExecutionTruthState.RUNNING:
updates["started_at"] = current.started_at or now
updates["heartbeat_at"] = now
if current_step is not None:
if current_step < current.current_step or current_step > len(current.manifest.steps):
connection.rollback()
raise ExecutionTruthConflict("invalid task step progression")
updates["current_step"] = current_step
updates["heartbeat_at"] = now
if evidence is not None:
updates["evidence"] = {
**dict(current.evidence),
**{required(key, "evidence key"): required(value, "evidence hash") for key, value in evidence.items()},
}
if error is not None:
updates["error"] = json.loads(json.dumps(dict(error), default=str))
if target in TERMINAL_STATES:
updates["completed_at"] = now
updated = replace(current, **updates)
connection.execute(
"UPDATE execution_truth SET state = ?, record_json = ?, revision = ?, updated_at = ? WHERE task_id = ?",
(
updated.state.value,
self._record_json(updated),
updated.revision,
now,
updated.task_id,
),
)
self._event(
connection,
updated.task_id,
target.value,
{
"executor_run_id": updated.executor_run_id,
"executor_id": updated.executor_id,
"current_step": updated.current_step,
},
)
connection.commit()
return updated
def register(self, task_id: str, *, executor_run_id: str) -> ExecutionTruthRecord:
return self._transition(
task_id,
allowed=(ExecutionTruthState.DECLARED,),
target=ExecutionTruthState.REGISTERED,
executor_run_id=executor_run_id,
)
def retry_not_started(
self,
task_id: str,
*,
executor_run_id: str,
) -> ExecutionTruthRecord:
"""Start a new audited attempt after the declaration deadline expired."""
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT record_json FROM execution_truth WHERE task_id = ?",
(required(task_id, "task_id"),),
).fetchone()
if row is None:
connection.rollback()
raise KeyError("execution task not declared")
current = self._decode(row)
if current.state != ExecutionTruthState.NOT_STARTED:
connection.rollback()
raise ExecutionTruthConflict(
f"cannot retry task from {current.state.value}"
)
now = utc_now()
updated = replace(
current,
state=ExecutionTruthState.REGISTERED,
executor_run_id=required(executor_run_id, "executor_run_id"),
executor_id=None,
registered_at=now,
claimed_at=None,
started_at=None,
heartbeat_at=None,
completed_at=None,
current_step=0,
evidence={},
error={},
revision=current.revision + 1,
)
connection.execute(
"UPDATE execution_truth SET state = ?, record_json = ?, revision = ?, updated_at = ? WHERE task_id = ?",
(
updated.state.value,
self._record_json(updated),
updated.revision,
now,
updated.task_id,
),
)
self._event(
connection,
updated.task_id,
"RETRIED",
{
"previous_state": current.state.value,
"previous_executor_run_id": current.executor_run_id,
"executor_run_id": updated.executor_run_id,
"previous_error": dict(current.error),
},
)
connection.commit()
return updated
def claim(self, task_id: str, *, executor_run_id: str, executor_id: str) -> ExecutionTruthRecord:
return self._transition(
task_id,
allowed=(ExecutionTruthState.REGISTERED,),
target=ExecutionTruthState.CLAIMED,
executor_run_id=executor_run_id,
executor_id=executor_id,
)
def start(self, task_id: str, *, executor_run_id: str, executor_id: str) -> ExecutionTruthRecord:
return self._transition(
task_id,
allowed=(ExecutionTruthState.CLAIMED,),
target=ExecutionTruthState.RUNNING,
executor_run_id=executor_run_id,
executor_id=executor_id,
current_step=1,
)
def heartbeat(
self,
task_id: str,
*,
executor_run_id: str,
executor_id: str,
current_step: int,
) -> ExecutionTruthRecord:
return self._transition(
task_id,
allowed=(ExecutionTruthState.RUNNING,),
target=ExecutionTruthState.RUNNING,
executor_run_id=executor_run_id,
executor_id=executor_id,
current_step=current_step,
)
def succeed(
self,
task_id: str,
*,
executor_run_id: str,
executor_id: str,
evidence: Mapping[str, str],
) -> ExecutionTruthRecord:
current = self.get(task_id)
missing = sorted(set(current.manifest.required_evidence) - set(evidence))
if missing:
raise ExecutionTruthConflict(f"missing required evidence: {missing}")
if current.current_step != len(current.manifest.steps):
raise ExecutionTruthConflict("all declared steps must complete before success")
return self._transition(
task_id,
allowed=(ExecutionTruthState.RUNNING,),
target=ExecutionTruthState.SUCCEEDED,
executor_run_id=executor_run_id,
executor_id=executor_id,
evidence=evidence,
)
def fail(
self,
task_id: str,
*,
executor_run_id: str,
executor_id: str,
error: Mapping[str, Any],
evidence: Mapping[str, str] | None = None,
) -> ExecutionTruthRecord:
return self._transition(
task_id,
allowed=(ExecutionTruthState.REGISTERED, ExecutionTruthState.CLAIMED, ExecutionTruthState.RUNNING),
target=ExecutionTruthState.FAILED,
executor_run_id=executor_run_id,
executor_id=executor_id,
evidence=evidence,
error=error,
)
def evaluate_deadlines(self, task_id: str, *, now: datetime | None = None) -> ExecutionTruthRecord:
current = self.get(task_id)
current_time = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
manifest = current.manifest
if current.state in TERMINAL_STATES:
return current
declared = parse_time(current.declared_at)
if current.state in {ExecutionTruthState.DECLARED, ExecutionTruthState.REGISTERED}:
if current_time >= declared + timedelta(seconds=manifest.start_deadline_seconds):
return self._transition(
task_id,
allowed=(current.state,),
target=ExecutionTruthState.NOT_STARTED,
error={"code": "start_deadline_exceeded"},
)
if current.state in {ExecutionTruthState.CLAIMED, ExecutionTruthState.RUNNING}:
heartbeat = parse_time(current.heartbeat_at or current.claimed_at or current.declared_at)
if current_time >= heartbeat + timedelta(seconds=manifest.heartbeat_timeout_seconds):
return self._transition(
task_id,
allowed=(current.state,),
target=ExecutionTruthState.STALLED,
error={"code": "heartbeat_timeout"},
)
return current
def gate(self, task_id: str) -> Mapping[str, Any]:
record = self.evaluate_deadlines(task_id)
passed = record.state == ExecutionTruthState.SUCCEEDED
return {
"task_id": record.task_id,
"state": record.state.value,
"passed": passed,
"release_blocking": not passed,
"executor_run_id": record.executor_run_id,
"executor_id": record.executor_id,
"current_step": record.current_step,
"total_steps": len(record.manifest.steps),
"evidence": dict(record.evidence),
"error": dict(record.error),
}
def events(self, task_id: str) -> list[Mapping[str, Any]]:
with self._lock, self._connect() as connection:
rows = connection.execute(
"SELECT event_id, event_type, details_json, created_at FROM execution_truth_events WHERE task_id = ? ORDER BY created_at, event_id",
(required(task_id, "task_id"),),
).fetchall()
return [
{
"event_id": row["event_id"],
"event_type": row["event_type"],
"details": json.loads(row["details_json"]),
"created_at": row["created_at"],
}
for row in rows
]
+13
View File
@@ -0,0 +1,13 @@
"""AIOS unified, role-aware web portal."""
from .application import UnifiedPortalApplication, build_application
from .capabilities import ROLE_CAPABILITIES, allows, require, resolve
__all__ = [
"ROLE_CAPABILITIES",
"UnifiedPortalApplication",
"allows",
"build_application",
"require",
"resolve",
]
+512
View File
@@ -0,0 +1,512 @@
"""Unified, role-aware AIOS web application.
The portal wraps the production CandidateApplication instead of replacing its
runtime/control-plane routes. Portal endpoints aggregate existing authoritative
stores and require server-side capabilities before delegating any state change.
"""
from __future__ import annotations
import dataclasses
import json
import os
import re
import sqlite3
import urllib.parse
from enum import Enum
from http import HTTPStatus
from pathlib import Path
from typing import Any, Mapping
from aios_v2.app import (
CandidateApplication,
CandidateHTTPServer,
build_application as build_candidate_application,
)
from aios_v2.control_plane import RequestContext, TaskState
from aios_v2.gitops_status import collect_gitops_status
from aios_v2.provenance import SQLiteProvenanceStore
from aios_v2.provenance_grants import SQLiteGrantStore
from .capabilities import allows, require, resolve
def _json_value(value: Any) -> Any:
if dataclasses.is_dataclass(value):
return {
field.name: _json_value(getattr(value, field.name))
for field in dataclasses.fields(value)
}
if isinstance(value, Enum):
return value.value
if isinstance(value, Mapping):
return {str(key): _json_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set, frozenset)):
return [_json_value(item) for item in value]
return value
def _mapping(value: Any) -> Mapping[str, Any]:
if not isinstance(value, Mapping):
raise ValueError("JSON body must be an object")
return value
def _required(value: Any, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
def _safe_count(items: list[Any]) -> int:
return len(items)
class AuthorizationCatalog:
"""Tenant-filtered read/revoke access to existing Provenance stores."""
def __init__(self, store_file: str | Path) -> None:
self.store_file = Path(store_file).resolve()
def _read_connection(self) -> sqlite3.Connection | None:
if not self.store_file.exists():
return None
connection = sqlite3.connect(
f"file:{self.store_file}?mode=ro",
uri=True,
timeout=10.0,
)
connection.row_factory = sqlite3.Row
return connection
def snapshot(self, *, tenant_id: str) -> Mapping[str, Any]:
connection = self._read_connection()
if connection is None:
return {
"configured": False,
"workflows": [],
"grants": [],
"summary": {"workflows": 0, "grants": 0, "active_grants": 0},
}
try:
workflows: list[dict[str, Any]] = []
grants: list[dict[str, Any]] = []
tables = {
row["name"]
for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
}
if "provenance_records" in tables:
rows = connection.execute(
"""
SELECT r.payload_json, r.status, r.signature_verified,
r.created_at, r.updated_at,
COUNT(u.authorization_id) AS usage_count
FROM provenance_records r
LEFT JOIN provenance_usage u
ON u.provenance_id = r.provenance_id
GROUP BY r.provenance_id
ORDER BY r.updated_at DESC, r.provenance_id
"""
).fetchall()
for row in rows:
payload = json.loads(row["payload_json"])
if payload.get("tenant_id") != tenant_id:
continue
workflows.append(
{
**payload,
"status": row["status"],
"signature_verified": bool(row["signature_verified"]),
"usage_count": int(row["usage_count"]),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
)
if "provenance_grants" in tables:
rows = connection.execute(
"""
SELECT g.payload_json, g.status, g.signature_verified,
g.created_at, g.updated_at,
COUNT(u.authorization_id) AS usage_count
FROM provenance_grants g
LEFT JOIN provenance_grant_usage u
ON u.grant_id = g.grant_id
GROUP BY g.grant_id
ORDER BY g.updated_at DESC, g.grant_id
"""
).fetchall()
for row in rows:
payload = json.loads(row["payload_json"])
if payload.get("tenant_id") != tenant_id:
continue
grants.append(
{
**payload,
"status": row["status"],
"signature_verified": bool(row["signature_verified"]),
"usage_count": int(row["usage_count"]),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
)
active = sum(
1
for item in grants
if item.get("status") not in {"revoked", "expired"}
)
return {
"configured": True,
"workflows": workflows,
"grants": grants,
"summary": {
"workflows": len(workflows),
"grants": len(grants),
"active_grants": active,
"revoked_grants": sum(
1 for item in grants if item.get("status") == "revoked"
),
},
}
finally:
connection.close()
def revoke_workflow(
self,
*,
provenance_id: str,
tenant_id: str,
principal_id: str,
reason: str,
) -> Mapping[str, Any]:
store = SQLiteProvenanceStore(self.store_file)
try:
record = store.get(provenance_id)
if record.tenant_id != tenant_id:
raise KeyError("workflow provenance not found")
store.revoke(
record.provenance_id,
reason=reason,
principal_id=principal_id,
)
return {
"provenance_id": record.provenance_id,
"status": store.status(record.provenance_id).value,
}
finally:
store.close()
def revoke_grant(
self,
*,
grant_id: str,
tenant_id: str,
principal_id: str,
reason: str,
) -> Mapping[str, Any]:
store = SQLiteGrantStore(self.store_file)
try:
grant = store.get(grant_id)
if grant.tenant_id != tenant_id:
raise KeyError("provenance grant not found")
store.revoke(
grant.grant_id,
reason=reason,
principal_id=principal_id,
)
return {
"grant_id": grant.grant_id,
"status": store.status(grant.grant_id).value,
}
finally:
store.close()
class UnifiedPortalApplication:
"""Role-aware portal facade over the existing production application."""
def __init__(
self,
base: CandidateApplication,
*,
provenance_store_file: str | Path,
) -> None:
self.base = base
self.gateway = base.gateway
self.orchestrator = base.orchestrator
self.tracker = base.tracker
self.hermes = base.hermes
self.openclaw = base.openclaw
self.authorization_catalog = AuthorizationCatalog(provenance_store_file)
def context(self, headers: Mapping[str, str]) -> RequestContext:
return self.base.context(headers)
def _runtime(self, headers: Mapping[str, str]) -> Mapping[str, Any]:
status, payload = self.base.handle_json("GET", "/api/v2/runtime", headers, {})
if int(status) != HTTPStatus.OK:
raise RuntimeError("runtime status is unavailable")
return payload
def _gitops(self, context: RequestContext) -> Mapping[str, Any]:
return {
"tenant_id": context.tenant_id,
"gitops": collect_gitops_status().as_dict(),
}
def _tasks_view(self, context: RequestContext) -> list[Mapping[str, Any]]:
service = self.orchestrator.control_plane
tasks = service.list_tasks(context)
dispatches = {
item.task_id: item
for item in self.orchestrator.dispatch_repository.list(
tenant_id=context.tenant_id
)
}
evidence_by_task: dict[str, list[Any]] = {}
for evidence in self.tracker.list(tenant_id=context.tenant_id):
evidence_by_task.setdefault(evidence.task_id, []).append(evidence)
audit_by_task: dict[str, list[Any]] = {}
for event in service.list_audit_events(context):
if event.resource_type == "task":
audit_by_task.setdefault(event.resource_id, []).append(event)
capabilities = set(resolve(context).capabilities)
result: list[Mapping[str, Any]] = []
for task in tasks:
actions: list[str] = []
if task.state == TaskState.CREATED and "task.approve" in capabilities:
actions.append("approve")
if task.state in {TaskState.CREATED, TaskState.APPROVED, TaskState.RUNNING} and "task.cancel" in capabilities:
actions.append("cancel")
if task.state == TaskState.APPROVED and "task.dispatch" in capabilities:
actions.append("dispatch")
result.append(
{
"task": _json_value(task),
"dispatch": _json_value(dispatches.get(task.task_id)),
"evidence": _json_value(evidence_by_task.get(task.task_id, [])),
"audit": _json_value(audit_by_task.get(task.task_id, [])[-12:]),
"available_actions": actions,
}
)
return result
def _overview(
self,
context: RequestContext,
headers: Mapping[str, str],
) -> Mapping[str, Any]:
service = self.orchestrator.control_plane
capabilities = resolve(context)
sections: dict[str, Any] = {}
tasks = service.list_tasks(context) if allows(context, "task.read") else []
dispatches = (
self.orchestrator.dispatch_repository.list(tenant_id=context.tenant_id)
if allows(context, "dispatch.read")
else []
)
evidence = (
self.tracker.list(tenant_id=context.tenant_id)
if allows(context, "evidence.read")
else []
)
audit = (
service.list_audit_events(context)
if allows(context, "audit.read")
else []
)
if allows(context, "runtime.read"):
sections["runtime"] = self._runtime(headers)
if allows(context, "release.read"):
sections["release"] = {
**self._gitops(context),
"version": os.getenv("AIOS_RELEASE_VERSION", "V2R1C1"),
"release_oid": os.getenv("AIOS_RELEASE_OID", "unknown"),
"baseline": os.getenv("AIOS_BASELINE_VERSION", "V2R1C1"),
"status": os.getenv("AIOS_RELEASE_STATUS", "PRODUCTION_DEPLOYED"),
}
if allows(context, "provenance.read"):
sections["authorization"] = self.authorization_catalog.snapshot(
tenant_id=context.tenant_id
)["summary"]
task_states: dict[str, int] = {}
for task in tasks:
task_states[task.state.value] = task_states.get(task.state.value, 0) + 1
return {
"identity": capabilities.as_dict(),
"summary": {
"tasks": _safe_count(tasks),
"task_states": task_states,
"dispatches": _safe_count(dispatches),
"active_dispatches": sum(
1
for item in dispatches
if item.state.value not in {"COMPLETED", "FAILED", "CANCELLED"}
),
"evidence": _safe_count(evidence),
"audit_events": _safe_count(audit),
},
"sections": sections,
"recent": {
"tasks": _json_value(tasks[:8]),
"dispatches": _json_value(dispatches[-8:]),
"audit": _json_value(audit[-12:]),
},
}
def handle_json(
self,
method: str,
raw_path: str,
headers: Mapping[str, str],
body: Any,
) -> tuple[int, Mapping[str, Any]]:
parsed = urllib.parse.urlparse(raw_path)
path = parsed.path.rstrip("/") or "/"
method = method.upper()
if not path.startswith("/api/v2/portal") and path != "/api/v2/me/capabilities":
return self.base.handle_json(method, raw_path, headers, body)
context = self.context(headers)
payload = _mapping(body or {})
if method == "GET" and path == "/api/v2/me/capabilities":
require(context, "portal.access")
return HTTPStatus.OK, resolve(context).as_dict()
if method == "GET" and path == "/api/v2/portal/overview":
require(context, "dashboard.read")
return HTTPStatus.OK, self._overview(context, headers)
if method == "GET" and path == "/api/v2/portal/tasks":
require(context, "task.read")
return HTTPStatus.OK, {"items": self._tasks_view(context)}
if method == "GET" and path == "/api/v2/portal/authorizations":
require(context, "provenance.read")
return HTTPStatus.OK, self.authorization_catalog.snapshot(
tenant_id=context.tenant_id
)
if method == "GET" and path == "/api/v2/portal/runtime":
require(context, "runtime.read")
return HTTPStatus.OK, {
**self._runtime(headers),
"dispatches": _json_value(
self.orchestrator.dispatch_repository.list(
tenant_id=context.tenant_id
)
),
"hermes_jobs": _json_value(
self.hermes.list(tenant_id=context.tenant_id)
),
}
if method == "GET" and path == "/api/v2/portal/evidence":
require(context, "evidence.read")
return HTTPStatus.OK, {
"executions": _json_value(
self.tracker.list(tenant_id=context.tenant_id)
)
}
if method == "GET" and path == "/api/v2/portal/audit":
require(context, "audit.read")
return HTTPStatus.OK, {
"events": _json_value(
self.orchestrator.control_plane.list_audit_events(context)
)
}
if method == "GET" and path == "/api/v2/portal/release":
require(context, "release.read")
return HTTPStatus.OK, {
**self._gitops(context),
"version": os.getenv("AIOS_RELEASE_VERSION", "V2R1C1"),
"release_oid": os.getenv("AIOS_RELEASE_OID", "unknown"),
"baseline": os.getenv("AIOS_BASELINE_VERSION", "V2R1C1"),
"status": os.getenv("AIOS_RELEASE_STATUS", "PRODUCTION_DEPLOYED"),
"operations": (
["review_release", "request_tag", "request_rollback"]
if allows(context, "release.operate")
else []
),
"note": "Release operations remain GitOps approval requests; the portal does not mutate tags or production directly.",
}
if method == "POST" and path == "/api/v2/portal/tasks":
require(context, "task.create")
return self.base.handle_json("POST", "/api/v2/tasks", headers, payload)
task_action = re.fullmatch(
r"/api/v2/portal/tasks/([^/]+)/(approve|cancel|dispatch)", path
)
if method == "POST" and task_action:
action = task_action.group(2)
require(context, f"task.{action}")
task_id = urllib.parse.unquote(task_action.group(1))
target = (
f"/api/v2/tasks/{task_id}/dispatch"
if action == "dispatch"
else f"/api/v2/tasks/{task_id}/{action}"
)
return self.base.handle_json("POST", target, headers, payload)
workflow_revoke = re.fullmatch(
r"/api/v2/portal/provenance/([^/]+)/revoke", path
)
if method == "POST" and workflow_revoke:
require(context, "provenance.revoke")
reason = _required(payload.get("reason"), "reason")
result = self.authorization_catalog.revoke_workflow(
provenance_id=urllib.parse.unquote(workflow_revoke.group(1)),
tenant_id=context.tenant_id,
principal_id=context.principal_id,
reason=reason,
)
return HTTPStatus.OK, {"workflow": result}
grant_revoke = re.fullmatch(r"/api/v2/portal/grants/([^/]+)/revoke", path)
if method == "POST" and grant_revoke:
require(context, "provenance.revoke")
reason = _required(payload.get("reason"), "reason")
result = self.authorization_catalog.revoke_grant(
grant_id=urllib.parse.unquote(grant_revoke.group(1)),
tenant_id=context.tenant_id,
principal_id=context.principal_id,
reason=reason,
)
return HTTPStatus.OK, {"grant": result}
return HTTPStatus.NOT_FOUND, {
"error": "not_found",
"message": "portal route not found",
}
def build_application() -> UnifiedPortalApplication:
state_dir = Path(os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
provenance_file = Path(
os.getenv(
"AIOS_PROVENANCE_STORE_FILE",
str(state_dir / "run-provenance.sqlite3"),
)
)
return UnifiedPortalApplication(
build_candidate_application(),
provenance_store_file=provenance_file,
)
def main() -> None:
host = os.getenv("AIOS_V2_HOST", "127.0.0.1")
port = int(os.getenv("AIOS_V2_PORT", "18920"))
server = CandidateHTTPServer((host, port), build_application())
print(f"AIOS V2 unified portal listening on {host}:{port}")
server.serve_forever()
if __name__ == "__main__":
main()
+111
View File
@@ -0,0 +1,111 @@
"""Server-side role and capability resolution for the unified AIOS portal."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Mapping, Sequence
from aios_v2.control_plane import RequestContext, Unauthorized
ROLE_CAPABILITIES: Mapping[str, frozenset[str]] = {
"platform_admin": frozenset({
"portal.access", "dashboard.read", "tenant.read", "tenant.manage",
"agent.read", "agent.manage", "task.read", "task.create", "task.approve",
"task.cancel", "task.dispatch", "policy.read", "audit.read", "evidence.read",
"runtime.read", "runtime.operate", "dispatch.read", "provenance.read",
"provenance.revoke", "external_task.read", "gitops.read", "release.read",
"release.operate", "worktree.read", "system.admin",
}),
"tenant_admin": frozenset({
"portal.access", "dashboard.read", "tenant.read", "agent.read", "agent.manage",
"task.read", "task.create", "task.approve", "task.cancel", "task.dispatch",
"policy.read", "audit.read", "evidence.read", "runtime.read", "dispatch.read",
"provenance.read", "provenance.revoke", "external_task.read", "gitops.read",
"release.read",
}),
"operator": frozenset({
"portal.access", "dashboard.read", "tenant.read", "agent.read", "task.read",
"task.create", "task.cancel", "task.dispatch", "runtime.read", "dispatch.read",
"evidence.read", "provenance.read", "external_task.read", "gitops.read",
"release.read",
}),
"approver": frozenset({
"portal.access", "dashboard.read", "tenant.read", "task.read", "task.approve",
"task.cancel", "policy.read", "audit.read", "provenance.read", "evidence.read",
"external_task.read", "release.read",
}),
"auditor": frozenset({
"portal.access", "dashboard.read", "tenant.read", "agent.read", "task.read",
"audit.read", "evidence.read", "runtime.read", "dispatch.read",
"provenance.read", "external_task.read", "gitops.read", "release.read",
"worktree.read",
}),
"runtime": frozenset({
"portal.access", "dashboard.read", "task.read", "runtime.read", "runtime.operate",
"dispatch.read", "evidence.read", "provenance.read", "external_task.read",
}),
"release_manager": frozenset({
"portal.access", "dashboard.read", "audit.read", "runtime.read", "dispatch.read",
"provenance.read", "external_task.read", "gitops.read", "release.read",
"release.operate", "worktree.read",
}),
"viewer": frozenset({
"portal.access", "dashboard.read", "task.read", "runtime.read",
"external_task.read",
}),
}
NAVIGATION: Sequence[Mapping[str, str]] = (
{"id": "overview", "label": "总览", "capability": "dashboard.read"},
{"id": "resources", "label": "租户与 Agent", "capability": "tenant.read"},
{"id": "tasks", "label": "任务中心", "capability": "task.read"},
{"id": "execution", "label": "执行中心", "capability": "external_task.read"},
{"id": "authorization", "label": "授权中心", "capability": "provenance.read"},
{"id": "runtime", "label": "运行与运维", "capability": "runtime.read"},
{"id": "evidence", "label": "执行证据", "capability": "evidence.read"},
{"id": "audit", "label": "审计中心", "capability": "audit.read"},
{"id": "release", "label": "GitOps 与发布", "capability": "release.read"},
)
@dataclass(frozen=True)
class ResolvedCapabilities:
tenant_id: str
principal_id: str
roles: tuple[str, ...]
capabilities: tuple[str, ...]
navigation: tuple[Mapping[str, str], ...]
def as_dict(self) -> dict[str, object]:
return {
"tenant_id": self.tenant_id,
"principal_id": self.principal_id,
"roles": list(self.roles),
"capabilities": list(self.capabilities),
"navigation": [dict(item) for item in self.navigation],
}
def resolve(context: RequestContext) -> ResolvedCapabilities:
capabilities: set[str] = set()
for role in context.roles:
capabilities.update(ROLE_CAPABILITIES.get(role, ()))
ordered = tuple(sorted(capabilities))
navigation = tuple(item for item in NAVIGATION if item["capability"] in capabilities)
return ResolvedCapabilities(
tenant_id=context.tenant_id,
principal_id=context.principal_id,
roles=tuple(sorted(context.roles)),
capabilities=ordered,
navigation=navigation,
)
def require(context: RequestContext, capability: str) -> None:
if capability not in resolve(context).capabilities:
raise Unauthorized(f"portal capability is required: {capability}")
def allows(context: RequestContext, capability: str) -> bool:
return capability in resolve(context).capabilities
+48
View File
@@ -0,0 +1,48 @@
"""Unified portal HTML export with V2R1C2 resource and execution centers.
The canonical dependency-free portal remains in ``aios_v2.console``. This
module applies deterministic, fail-fast extensions at import time so the
V2R1C1 production entrypoint remains unchanged while the V2R1C2 candidate gets
resource and portable-executor views.
"""
from __future__ import annotations
from aios_v2.console import CONSOLE_HTML as _BASE_PORTAL_HTML
def _extend_portal(source: str) -> str:
replacements = (
(
'<section id="overview" class="view active"></section>\n <section id="tasks" class="view"></section>\n <section id="authorization" class="view"></section>',
'<section id="overview" class="view active"></section>\n <section id="resources" class="view"></section>\n <section id="tasks" class="view"></section>\n <section id="execution" class="view"></section>\n <section id="authorization" class="view"></section>',
),
(
"const labels={overview:'系统总览',tasks:'任务中心',authorization:'授权中心'",
"const labels={overview:'系统总览',resources:'租户与 Agent',tasks:'任务中心',execution:'执行中心',authorization:'授权中心'",
),
(
"if(view==='overview')await overview();if(view==='tasks')await tasks();if(view==='authorization')await authorizations();",
"if(view==='overview')await overview();if(view==='resources')await resources();if(view==='tasks')await tasks();if(view==='execution')await execution();if(view==='authorization')await authorizations();",
),
(
"async function tasks(){const d=await api('/api/v2/portal/tasks');",
"""async function resources(){const d=await api('/api/v2/portal/resources');const t=d.tenant||{},agents=d.agents||[],ops=d.operations||{};const agentButton=ops.agent_create?'<button class=\"btn\" id=\"newAgent\">注册 Agent</button>':'';$('#resources').innerHTML=`<div class=\"cards\">${card('租户',t.tenant_id||'—',t.status||'')}${card('Agent',d.summary?.agents||0,`启用 ${d.summary?.enabled_agents||0}`)}${card('任务',d.summary?.tasks||0)}${card('Runtime 类型',(d.summary?.runtimes||[]).length,(d.summary?.runtimes||[]).join(', '))}</div><div class=\"grid\"><div class=\"panel\"><div class=\"panel-head\"><h2>当前租户</h2></div><div class=\"kv\"><div>Tenant ID</div><div>${esc(t.tenant_id||'—')}</div><div>名称</div><div>${esc(t.display_name||'—')}</div><div>状态</div><div>${pill(t.status||'unknown')}</div><div>创建时间</div><div>${esc(t.created_at||'—')}</div><div>更新时间</div><div>${esc(t.updated_at||'—')}</div></div></div><div class=\"panel\"><div class=\"panel-head\"><h2>Agent Registry</h2><div>${agentButton}</div></div>${table([['Agent',x=>esc(x.agent_id)],['名称',x=>esc(x.display_name)],['Runtime',x=>pill(x.runtime)],['能力',x=>(x.capabilities||[]).map(pill).join('')],['启用',x=>pill(x.enabled?'enabled':'disabled')],['任务数',x=>esc(x.task_count)]],agents)}</div></div>`;$('#newAgent')?.addEventListener('click',()=>{const agent_id=prompt('Agent ID');const display_name=prompt('显示名称',agent_id||'');const runtime=prompt('Runtime Provider','openclaw');const capabilities=prompt('能力,逗号分隔','');if(!agent_id||!display_name||!runtime)return;api('/api/v2/portal/agents',{method:'POST',body:JSON.stringify({agent_id,display_name,runtime,capabilities:String(capabilities||'').split(',').map(x=>x.trim()).filter(Boolean),metadata:{source:'unified-portal'}})}).then(resources).catch(err)})}
function executionStage(x){const s=x.stage||{},status=x.truth||{},record=status.record||{},gate=status.gate||{},d=x.dependency_gate||{},notifications=gate.notifications||[],evidence=record.evidence||{};const stateName=record.state||'WAITING_DEPENDENCY';const deps=(s.depends_on||[]).join(', ')||'根阶段';return `<div class=\"panel\" style=\"background:#fbfcfe\"><div class=\"panel-head\"><h2>${esc(s.stage_id)} · ${esc(s.role)}</h2><div>${pill(stateName)} ${pill(s.executor_type)}</div></div><div class=\"chain\"><div class=\"chain-item\"><b>Provider</b><div>${esc(s.executor_type)}</div><small>${esc(deps)}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Run</b><div>${esc(record.executor_run_id||'未注册')}</div><small>${esc(record.executor_id||'')}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Progress</b><div>${esc(record.current_step||0)}/${esc((record.manifest?.steps||s.steps||[]).length)}</div><small>${esc(record.heartbeat_at||'无心跳')}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Evidence</b><div>${Object.keys(evidence).length} 项</div><small>${Object.keys(evidence).slice(0,2).map(esc).join(', ')}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Notification</b><div>${notifications.length} 条</div><small>${notifications.map(n=>n.status).join(', ')||'无'}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Gate</b><div>${pill(gate.passed?'PASS':'BLOCKED')}</div><small>${d.passed?'依赖满足':'等待依赖'}</small></div></div>${Object.keys(record.error||{}).length?json(record.error):''}</div>`}
async function execution(){const d=await api('/api/v2/portal/execution');const providers=d.providers||[],plans=d.plans||[];$('#execution').innerHTML=`<div class=\"cards\">${card('Provider',providers.length)}${card('Execution Plan',plans.length)}${card('协议',d.protocol_version||'—')}${card('Plan Schema',d.plan_schema_version||'—')}</div><div class=\"panel\"><div class=\"panel-head\"><h2>Executor Provider Catalog</h2></div>${table([['Provider',x=>esc(x.executor_type)],['Adapter',x=>esc(x.adapter_version)],['协议',x=>esc(x.protocol_version)],['Trust Roles',x=>(x.trust_roles||[]).map(pill).join('')],['Capabilities',x=>(x.capabilities||[]).map(pill).join('')]],providers)}</div>${plans.map(p=>`<div class=\"panel\"><div class=\"panel-head\"><h2>${esc(p.plan_id)} · ${esc(p.target_version)}</h2><div>${pill(p.gate?.passed?'PASS':'BLOCKED')}</div></div><div class=\"kv\"><div>Source Branch</div><div>${esc(p.source_branch)}</div><div>Source OID</div><div>${esc(p.source_oid)}</div><div>Release Gate</div><div>${pill(p.gate?.passed?'PASS':'BLOCKED')}</div></div></div>${(p.snapshot?.stages||[]).map(executionStage).join('')}`).join('')||'<div class=\"empty\">尚未声明执行计划</div>'}`}
async function tasks(){const d=await api('/api/v2/portal/tasks');""",
),
)
result = source
for old, new in replacements:
if old not in result:
raise RuntimeError(f"unified portal extension anchor not found: {old[:100]}")
result = result.replace(old, new, 1)
return result
PORTAL_HTML = _extend_portal(_BASE_PORTAL_HTML)
if "\n+function executionStage" in PORTAL_HTML or "\n+async function execution" in PORTAL_HTML:
raise RuntimeError("execution center contains an invalid diff marker")
__all__ = ["PORTAL_HTML"]
@@ -0,0 +1,130 @@
"""HTTP facade for portable multi-provider execution plans."""
from __future__ import annotations
import urllib.parse
from http import HTTPStatus
from typing import Any, Mapping
from aios_v2.control_plane import RequestContext, Unauthorized
from aios_v2.execution_plan import ExecutionPlanCoordinator
from aios_v2.executor_protocol import ExecutorProviderRegistry, normalize_executor
from .capabilities import require
class ExecutionPlanAPI:
"""Portal read model and provider-authenticated plan discovery API."""
INTERNAL_PREFIX = "/api/v2/internal/execution-plans"
PORTAL_PATH = "/api/v2/portal/execution"
def __init__(
self,
coordinator: ExecutionPlanCoordinator,
*,
providers: ExecutorProviderRegistry,
) -> None:
self.coordinator = coordinator
self.providers = providers
@classmethod
def is_route(cls, raw_path: str) -> bool:
path = urllib.parse.urlparse(raw_path).path.rstrip("/") or "/"
return path == cls.PORTAL_PATH or path.startswith(cls.INTERNAL_PREFIX)
def _require_provider(
self,
context: RequestContext,
executor_type: str,
) -> str:
normalized = normalize_executor(executor_type)
if not self.providers.trusted(
executor_type=normalized,
roles=tuple(context.roles),
platform_admin="platform_admin" in context.roles,
):
raise Unauthorized(
f"trusted role for executor Provider is required: {normalized}"
)
return normalized
def _portal_snapshot(self) -> Mapping[str, Any]:
plans = []
for plan in self.coordinator.plan_store.list():
plans.append(
{
"plan_id": plan.plan_id,
"target_version": plan.target_version,
"source_branch": plan.source_branch,
"source_oid": plan.source_oid,
"snapshot": self.coordinator.snapshot(plan.plan_id),
"gate": self.coordinator.gate(plan.plan_id),
}
)
return {
"protocol_version": "aios.executor.v1",
"plan_schema_version": "aios.execution-plan.v2",
"providers": [
item.as_dict() for item in self.providers.list_descriptors()
],
"plans": plans,
}
def handle(
self,
method: str,
raw_path: str,
context: RequestContext,
body: Any,
) -> tuple[int, Mapping[str, Any]]:
del body
parsed = urllib.parse.urlparse(raw_path)
path = parsed.path.rstrip("/") or "/"
method = method.upper()
if method == "GET" and path == self.PORTAL_PATH:
require(context, "external_task.read")
return HTTPStatus.OK, self._portal_snapshot()
if not path.startswith(f"{self.INTERNAL_PREFIX}/"):
return HTTPStatus.NOT_FOUND, {
"error": "not_found",
"message": "execution plan route not found",
}
remainder = urllib.parse.unquote(path[len(self.INTERNAL_PREFIX) + 1 :])
parts = tuple(item for item in remainder.split("/") if item)
if len(parts) != 2:
return HTTPStatus.NOT_FOUND, {
"error": "not_found",
"message": "execution plan route not found",
}
plan_id, action = parts
query = urllib.parse.parse_qs(parsed.query)
executor_type = self._require_provider(
context,
str(query.get("executor", [""])[0]),
)
if method == "GET" and action == "discover":
items = self.coordinator.discover_ready(
plan_id=plan_id,
executor_type=executor_type,
)
return HTTPStatus.OK, {
"plan_id": plan_id,
"executor_type": executor_type,
"count": len(items),
"items": items,
}
if method == "GET" and action == "snapshot":
return HTTPStatus.OK, self.coordinator.snapshot(plan_id)
if method == "GET" and action == "gate":
return HTTPStatus.OK, self.coordinator.gate(plan_id)
return HTTPStatus.NOT_FOUND, {
"error": "not_found",
"message": "execution plan route not found",
}
@@ -0,0 +1,276 @@
"""Protected HTTP facade for portable executor truth and notification receipts."""
from __future__ import annotations
import urllib.parse
from http import HTTPStatus
from pathlib import Path
from typing import Any, Mapping, Sequence
from aios_v2.control_plane import RequestContext, Unauthorized
from aios_v2.executor_protocol import ExecutorProviderRegistry, normalize_executor
from aios_v2.external_task_control import ExternalTaskCoordinator
from aios_v2.external_task_query import list_records
from aios_v2.task_execution_registry import ExecutionTruthState
from .capabilities import require
def _body(value: Any) -> Mapping[str, Any]:
if not isinstance(value, Mapping):
raise ValueError("JSON body must be an object")
return value
def _required(value: Any, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
def _mapping_strings(value: Any, label: str) -> dict[str, str]:
if not isinstance(value, Mapping):
raise ValueError(f"{label} must be an object")
return {
_required(key, f"{label} key"): _required(item, f"{label}[{key}]")
for key, item in value.items()
}
class ExternalTaskAPI:
"""Fail-closed external task API shared by every registered Provider."""
INTERNAL_PREFIX = "/api/v2/internal/external-tasks"
def __init__(
self,
coordinator: ExternalTaskCoordinator,
*,
store_file: str | Path,
providers: ExecutorProviderRegistry,
) -> None:
self.coordinator = coordinator
self.store_file = Path(store_file).resolve()
self.providers = providers
@staticmethod
def is_route(path: str) -> bool:
normalized = path.rstrip("/") or "/"
return normalized.startswith(ExternalTaskAPI.INTERNAL_PREFIX) or normalized == "/api/v2/portal/external-tasks"
def _require_executor(self, context: RequestContext, executor_type: str) -> str:
normalized = normalize_executor(executor_type)
if not self.providers.trusted(
executor_type=normalized,
roles=tuple(context.roles),
platform_admin="platform_admin" in context.roles,
):
raise Unauthorized(
f"trusted role for executor Provider is required: {normalized}"
)
return normalized
def _task_executor(self, task_id: str) -> str:
return normalize_executor(
self.coordinator.registry.get(task_id).manifest.executor
)
@staticmethod
def _executor_identity(
context: RequestContext,
payload: Mapping[str, Any],
) -> str:
executor_id = _required(payload.get("executor_id"), "executor_id")
if executor_id != context.principal_id and "platform_admin" not in context.roles:
raise Unauthorized("executor_id must match trusted principal")
return executor_id
def declare_directory(self, directory: str | Path) -> list[Mapping[str, Any]]:
root = Path(directory).resolve()
if not root.exists():
return []
declared: list[Mapping[str, Any]] = []
for manifest in sorted(root.glob("*.json")):
record = self.coordinator.declare_file(manifest)
self.providers.validate_manifest(record.manifest)
declared.append(
{
"manifest": str(manifest),
"task_id": record.task_id,
"executor_type": record.manifest.executor,
"state": record.state.value,
}
)
return declared
def handle(
self,
method: str,
raw_path: str,
context: RequestContext,
body: Any,
) -> tuple[int, Mapping[str, Any]]:
parsed = urllib.parse.urlparse(raw_path)
path = parsed.path.rstrip("/") or "/"
method = method.upper()
payload = _body(body or {})
if method == "GET" and path == "/api/v2/portal/external-tasks":
require(context, "external_task.read")
records = list_records(self.store_file)
return HTTPStatus.OK, {
"count": len(records),
"items": [self.coordinator.status(item.task_id) for item in records],
}
if method == "GET" and path == f"{self.INTERNAL_PREFIX}/discover":
query = urllib.parse.parse_qs(parsed.query)
states = tuple(query.get("state", ()))
executor = self._require_executor(
context,
str(query.get("executor", [""])[0]),
)
records = list_records(
self.store_file,
executor=executor,
states=states,
)
return HTTPStatus.OK, {
"count": len(records),
"executor_type": executor,
"items": [item.as_dict() for item in records],
}
if method == "GET" and path == f"{self.INTERNAL_PREFIX}/notifications":
query = urllib.parse.parse_qs(parsed.query)
executor = self._require_executor(
context,
str(query.get("executor", [""])[0]),
)
task_id = query.get("task_id", [None])[0]
status = query.get("status", [None])[0]
items = self.coordinator.notifications.list(
task_id=task_id,
status=status,
)
filtered = [
item
for item in items
if self._task_executor(item.task_id) == executor
]
return HTTPStatus.OK, {
"count": len(filtered),
"executor_type": executor,
"items": [item.as_dict() for item in filtered],
}
notification_ack = self._match(
path,
f"{self.INTERNAL_PREFIX}/notifications/",
"/ack",
)
if method == "POST" and notification_ack:
notification = self.coordinator.notifications.get(notification_ack)
executor = self._task_executor(notification.task_id)
self._require_executor(context, executor)
self._executor_identity(context, payload)
record = self.coordinator.notifications.acknowledge(
notification_ack,
receipt_sha256=_required(
payload.get("receipt_sha256"),
"receipt_sha256",
),
)
return HTTPStatus.OK, {"notification": record.as_dict()}
truth_task = self._match(path, f"{self.INTERNAL_PREFIX}/", "/truth")
if method == "GET" and truth_task:
self._require_executor(context, self._task_executor(truth_task))
return HTTPStatus.OK, self.coordinator.status(truth_task)
for action in ("register", "retry", "claim", "start", "heartbeat", "succeed", "fail"):
task_id = self._match(path, f"{self.INTERNAL_PREFIX}/", f"/{action}")
if method != "POST" or not task_id:
continue
executor_type = self._task_executor(task_id)
self._require_executor(context, executor_type)
run_id = _required(payload.get("executor_run_id"), "executor_run_id")
if action == "register":
return HTTPStatus.OK, {
"record": self.coordinator.register(
task_id,
executor_run_id=run_id,
).as_dict()
}
if action == "retry":
return HTTPStatus.OK, {
"record": self.coordinator.retry_not_started(
task_id,
executor_run_id=run_id,
).as_dict()
}
executor_id = self._executor_identity(context, payload)
if action == "claim":
record = self.coordinator.claim(
task_id,
executor_run_id=run_id,
executor_id=executor_id,
)
elif action == "start":
record = self.coordinator.start(
task_id,
executor_run_id=run_id,
executor_id=executor_id,
)
elif action == "heartbeat":
record = self.coordinator.heartbeat(
task_id,
executor_run_id=run_id,
executor_id=executor_id,
current_step=int(payload.get("current_step")),
)
elif action == "succeed":
record = self.coordinator.succeed(
task_id,
executor_run_id=run_id,
executor_id=executor_id,
evidence=_mapping_strings(payload.get("evidence"), "evidence"),
)
else:
error = payload.get("error")
if not isinstance(error, Mapping):
raise ValueError("error must be an object")
evidence_value = payload.get("evidence")
evidence = (
_mapping_strings(evidence_value, "evidence")
if evidence_value is not None
else None
)
record = self.coordinator.fail(
task_id,
executor_run_id=run_id,
executor_id=executor_id,
error=dict(error),
evidence=evidence,
)
return HTTPStatus.OK, {"record": record.as_dict()}
return HTTPStatus.NOT_FOUND, {
"error": "not_found",
"message": "external task route not found",
}
@staticmethod
def _match(path: str, prefix: str, suffix: str) -> str | None:
if not path.startswith(prefix) or not path.endswith(suffix):
return None
middle = path[len(prefix) : len(path) - len(suffix)]
if not middle or "/" in middle:
return None
return urllib.parse.unquote(middle)
DISCOVERABLE_STATES: Sequence[str] = (
ExecutionTruthState.DECLARED.value,
ExecutionTruthState.REGISTERED.value,
)
@@ -0,0 +1,376 @@
"""V2R1C2 unified portal and portable executor release entrypoint.
The V2R1C1 production entrypoint remains unchanged. This candidate entrypoint
uses an isolated state directory, loads a versioned executor catalog, declares
portable multi-provider execution plans and exposes both portal read models and
provider-authenticated lifecycle APIs.
"""
from __future__ import annotations
import json
import os
import re
import urllib.parse
from dataclasses import replace
from http import HTTPStatus
from pathlib import Path
from typing import Any, Mapping, Sequence
import aios_v2.app as candidate_app_module
from aios_v2.app import (
CandidateApplication,
CandidateHTTPServer,
build_application as build_candidate_application,
)
from aios_v2.control_plane import RequestContext
from aios_v2.execution_plan import ExecutionPlanCoordinator, ExecutionPlanManifest
from aios_v2.executor_catalog import FileExecutorCatalogStore
from aios_v2.official_executor_providers import official_provider_factories
from .application import UnifiedPortalApplication, _json_value, _mapping
from .capabilities import allows, require
from .console import PORTAL_HTML
from .execution_plan_api import ExecutionPlanAPI
from .external_task_api import ExternalTaskAPI
_READ_BRIDGE_ROLES = frozenset({"approver", "runtime", "viewer", "release_manager"})
_GIT_OID = re.compile(r"^[0-9a-f]{40}$")
def _resolve_environment_value(value: Any, *, label: str) -> Any:
if not isinstance(value, str) or not value.startswith("ENV:"):
return value
variable = value[4:].strip()
if not variable:
raise ValueError(f"{label} environment variable name is empty")
resolved = str(os.getenv(variable, "")).strip()
if not resolved:
raise RuntimeError(f"required environment variable is not set: {variable}")
return resolved
def _load_execution_plan(path: str | Path) -> ExecutionPlanManifest:
source = Path(path).resolve()
value = json.loads(source.read_text(encoding="utf-8"))
if not isinstance(value, Mapping):
raise ValueError(f"execution plan must contain a JSON object: {source}")
payload = dict(value)
for label in ("source_branch", "source_oid", "target_version"):
payload[label] = _resolve_environment_value(payload.get(label), label=label)
source_oid = str(payload.get("source_oid") or "").strip().lower()
if not _GIT_OID.fullmatch(source_oid):
raise ValueError(
"execution plan source_oid must resolve to an exact 40-character Git OID"
)
payload["source_oid"] = source_oid
return ExecutionPlanManifest.from_dict(payload)
def _path_list(value: str) -> tuple[Path, ...]:
return tuple(
Path(item).expanduser().resolve()
for item in value.split(os.pathsep)
if item.strip()
)
class V2R1C2PortalApplication(UnifiedPortalApplication):
"""Unified portal candidate with provider-neutral execution governance."""
def __init__(
self,
base: CandidateApplication,
*,
provenance_store_file: str | Path,
external_task_api: ExternalTaskAPI,
execution_plan_api: ExecutionPlanAPI,
plan_coordinator: ExecutionPlanCoordinator,
catalog_snapshot: Mapping[str, Any],
declared_plans: Sequence[str],
) -> None:
super().__init__(base, provenance_store_file=provenance_store_file)
self.external_task_api = external_task_api
self.execution_plan_api = execution_plan_api
self.plan_coordinator = plan_coordinator
self.catalog_snapshot = dict(catalog_snapshot)
self.declared_plans = tuple(declared_plans)
def context(self, headers: Mapping[str, str]) -> RequestContext:
original = self.base.context(headers)
if original.roles.intersection(_READ_BRIDGE_ROLES):
return replace(original, roles=frozenset((*original.roles, "auditor")))
return original
def _resource_snapshot(self, context: RequestContext) -> Mapping[str, Any]:
service = self.orchestrator.control_plane
tenant = service.get_tenant(context)
agents = service.list_agents(context) if allows(context, "agent.read") else []
tasks = service.list_tasks(context) if allows(context, "task.read") else []
tasks_by_agent: dict[str, int] = {}
for task in tasks:
tasks_by_agent[task.agent_id] = tasks_by_agent.get(task.agent_id, 0) + 1
return {
"tenant": _json_value(tenant),
"agents": [
{
**_json_value(agent),
"task_count": tasks_by_agent.get(agent.agent_id, 0),
}
for agent in agents
],
"summary": {
"agents": len(agents),
"enabled_agents": sum(1 for agent in agents if agent.enabled),
"tasks": len(tasks),
"runtimes": sorted({agent.runtime for agent in agents}),
},
"operations": {
"tenant_create": allows(context, "tenant.manage"),
"agent_create": allows(context, "agent.manage"),
},
}
def _runtime_snapshot(
self,
context: RequestContext,
headers: Mapping[str, str],
) -> Mapping[str, Any]:
return {
**self._runtime(headers),
"dispatches": _json_value(
self.orchestrator.dispatch_repository.list(
tenant_id=context.tenant_id
)
),
"hermes_jobs": _json_value(
self.hermes.list_jobs(tenant_id=context.tenant_id)
),
"contract": {
"openclaw": os.getenv(
"AIOS_OPENCLAW_CONTRACT_VERSION",
"aios.openclaw.command.v2",
),
"signature_algorithm": os.getenv(
"AIOS_OPENCLAW_SIGNATURE_ALGORITHM",
"Ed25519",
),
"plugin_version": os.getenv(
"AIOS_OPENCLAW_PLUGIN_VERSION",
"2.1.0-rc.4",
),
"key_id": os.getenv(
"AIOS_OPENCLAW_SIGNING_KEY_ID",
"openclaw-v2r1-candidate-ed25519",
),
"executor_protocol": "aios.executor.v1",
"execution_plan": "aios.execution-plan.v2",
},
}
def _release_snapshot(self, context: RequestContext) -> Mapping[str, Any]:
plans = []
for plan_id in self.declared_plans:
plans.append(
{
"plan_id": plan_id,
"gate": self.plan_coordinator.gate(plan_id),
}
)
aggregate_passed = bool(plans) and all(
item["gate"]["passed"] for item in plans
)
return {
**self._gitops(context),
"version": os.getenv("AIOS_RELEASE_VERSION", "V2R1C2-CANDIDATE"),
"target_version": os.getenv("AIOS_TARGET_VERSION", "V2R1C2"),
"release_oid": os.getenv("AIOS_RELEASE_OID", "candidate-not-tagged"),
"source_oid": os.getenv("AIOS_RELEASE_SOURCE_OID", "missing"),
"tag": os.getenv("AIOS_RELEASE_TAG", "NOT_CREATED"),
"baseline": os.getenv("AIOS_BASELINE_VERSION", "V2R1C1"),
"previous_version": os.getenv("AIOS_PREVIOUS_VERSION", "V2R1C1"),
"status": os.getenv("AIOS_RELEASE_STATUS", "CANDIDATE_VALIDATION"),
"evidence_manifest_sha256": os.getenv(
"AIOS_RELEASE_EVIDENCE_SHA256",
"pending",
),
"executor_catalog": {
"path": self.catalog_snapshot.get("path"),
"sha256": self.catalog_snapshot.get("catalog_sha256"),
"protocol_version": "aios.executor.v1",
},
"execution_plans": plans,
"execution_gate": {
"passed": aggregate_passed,
"release_blocking": not aggregate_passed,
},
"operations": (
["review_release", "request_tag", "request_rollback"]
if allows(context, "release.operate")
else []
),
"note": (
"Release operations remain GitOps approval requests. The portal "
"never mutates tags, main, or production directly. Tag approval "
"must remain blocked until every required execution-plan gate passes."
),
}
def handle_json(
self,
method: str,
raw_path: str,
headers: Mapping[str, str],
body: Any,
) -> tuple[int, Mapping[str, Any]]:
parsed = urllib.parse.urlparse(raw_path)
path = parsed.path.rstrip("/") or "/"
method = method.upper()
if self.execution_plan_api.is_route(raw_path):
context = (
self.context(headers)
if path == ExecutionPlanAPI.PORTAL_PATH
else self.base.context(headers)
)
return self.execution_plan_api.handle(
method,
raw_path,
context,
body,
)
if self.external_task_api.is_route(path):
context = (
self.context(headers)
if path == "/api/v2/portal/external-tasks"
else self.base.context(headers)
)
return self.external_task_api.handle(
method,
raw_path,
context,
body,
)
context: RequestContext | None = None
if path in {
"/api/v2/portal/resources",
"/api/v2/portal/runtime",
"/api/v2/portal/release",
"/api/v2/portal/tenants",
"/api/v2/portal/agents",
}:
context = self.context(headers)
if method == "GET" and path == "/api/v2/portal/resources":
assert context is not None
require(context, "tenant.read")
return HTTPStatus.OK, self._resource_snapshot(context)
if method == "POST" and path == "/api/v2/portal/tenants":
assert context is not None
require(context, "tenant.manage")
return self.base.handle_json(
"POST",
"/api/v2/tenants",
headers,
_mapping(body),
)
if method == "POST" and path == "/api/v2/portal/agents":
assert context is not None
require(context, "agent.manage")
return self.base.handle_json(
"POST",
"/api/v2/agents",
headers,
_mapping(body),
)
if method == "GET" and path == "/api/v2/portal/runtime":
assert context is not None
require(context, "runtime.read")
return HTTPStatus.OK, self._runtime_snapshot(context, headers)
if method == "GET" and path == "/api/v2/portal/release":
assert context is not None
require(context, "release.read")
return HTTPStatus.OK, self._release_snapshot(context)
return super().handle_json(method, raw_path, headers, body)
def build_application() -> V2R1C2PortalApplication:
state_dir = Path(
os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2r1c2-portal")
).resolve()
state_dir.mkdir(parents=True, exist_ok=True)
provenance_file = Path(
os.getenv(
"AIOS_PROVENANCE_STORE_FILE",
str(state_dir / "run-provenance.sqlite3"),
)
).resolve()
truth_file = Path(
os.getenv(
"AIOS_EXTERNAL_TASK_STORE_FILE",
str(state_dir / "external-task-truth.sqlite3"),
)
).resolve()
catalog_file = Path(
os.getenv(
"AIOS_EXECUTOR_CATALOG_FILE",
str(state_dir / "executor-catalog.json"),
)
).resolve()
catalog_store = FileExecutorCatalogStore(catalog_file)
catalog_snapshot = catalog_store.initialize_defaults()
providers = catalog_store.registry(
provider_factories=official_provider_factories()
)
plan_coordinator = ExecutionPlanCoordinator.from_file(
truth_file,
providers=providers,
)
declared_plans: list[str] = []
plan_files = _path_list(os.getenv("AIOS_EXECUTION_PLAN_FILES", ""))
for plan_file in plan_files:
plan = _load_execution_plan(plan_file)
plan_coordinator.declare(plan)
declared_plans.append(plan.plan_id)
base: CandidateApplication = build_candidate_application()
external_task_api = ExternalTaskAPI(
plan_coordinator.task_coordinator,
store_file=truth_file,
providers=providers,
)
execution_plan_api = ExecutionPlanAPI(
plan_coordinator,
providers=providers,
)
return V2R1C2PortalApplication(
base,
provenance_store_file=provenance_file,
external_task_api=external_task_api,
execution_plan_api=execution_plan_api,
plan_coordinator=plan_coordinator,
catalog_snapshot=catalog_snapshot.as_dict(),
declared_plans=declared_plans,
)
def main() -> None:
candidate_app_module.CONSOLE_HTML = PORTAL_HTML
host = os.getenv("AIOS_V2_HOST", "127.0.0.1")
port = int(os.getenv("AIOS_V2_PORT", "18921"))
server = CandidateHTTPServer((host, port), build_application())
print(f"AIOS V2R1C2 unified portal listening on {host}:{port}")
server.serve_forever()
if __name__ == "__main__":
main()
@@ -0,0 +1,120 @@
"""Architect governance agent responsibilities.
This agent records requirement changes, maintains versioned human-readable
requirement/architecture/deployment/test documents, reviews architecture changes,
validates deployment docs against actual configuration, and produces version bump
recommendations.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class RequirementChange:
id: str
source: str
actor: str
requirement: str
created_at: str = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class DocumentRefreshPlan:
requirement_doc: bool
architecture_doc: bool
deployment_doc: bool
test_case_doc: bool
reason: str
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class ArchitectureReviewDecision:
status: str
findings: list[str]
optimization_recommendations: list[str]
version_bump: str
docs_to_refresh: DocumentRefreshPlan
created_at: str = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]:
return {
"status": self.status,
"findings": self.findings,
"optimization_recommendations": self.optimization_recommendations,
"version_bump": self.version_bump,
"docs_to_refresh": self.docs_to_refresh.to_dict(),
"created_at": self.created_at,
}
class ArchitectGovernanceAgent:
"""Independent architecture governance role."""
def record_requirement_change(self, source: str, actor: str, requirement: str) -> RequirementChange:
change_id = "REQ-" + datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
return RequirementChange(change_id, source, actor, requirement)
def review_architecture_change(self, proposal: dict[str, Any]) -> ArchitectureReviewDecision:
impact = proposal.get("impact", "medium")
findings = [
"requirement_traceability_checked",
"architecture_consistency_checked",
"deployment_doc_alignment_checked",
"test_case_doc_required",
"rollback_and_owner_gate_checked",
]
recommendations = [
"refresh user requirements document",
"refresh architecture design document",
"refresh engineering deployment document",
"generate acceptance test case document",
]
version_bump = self.decide_version_bump(proposal)
return ArchitectureReviewDecision(
status="review_required" if impact in {"high", "architecture"} else "pass",
findings=findings,
optimization_recommendations=recommendations,
version_bump=version_bump,
docs_to_refresh=DocumentRefreshPlan(True, True, True, True, "architecture or requirement changed"),
)
def decide_version_bump(self, proposal: dict[str, Any]) -> str:
if proposal.get("breaks_architecture") or proposal.get("major_capability"):
return "V"
if proposal.get("new_release_capability") or proposal.get("architecture_change"):
return "R"
return "C"
def validate_deployment_doc(self, deployment_doc: dict[str, Any], actual_config: dict[str, Any]) -> dict[str, Any]:
expected = set(deployment_doc.get("services", []))
actual = set(actual_config.get("services", []))
missing = sorted(expected - actual)
extra = sorted(actual - expected)
return {
"status": "pass" if not missing else "attention_required",
"missing_services": missing,
"extra_services": extra,
"checked_at": utc_now(),
}
__all__ = [
"RequirementChange",
"DocumentRefreshPlan",
"ArchitectureReviewDecision",
"ArchitectGovernanceAgent",
]
@@ -0,0 +1,32 @@
"""API for Architect Governance Center."""
try:
from fastapi import APIRouter
except Exception:
APIRouter = None
from .architect_governance_dashboard import ArchitectGovernanceDashboard
dashboard = ArchitectGovernanceDashboard()
def get_architect_governance_overview():
return dashboard.overview()
def build_router():
if APIRouter is None:
return None
router = APIRouter(prefix="/admin/ai-evolution/architect", tags=["architect-governance"])
@router.get("/overview", operation_id="getArchitectGovernanceOverview")
def overview():
return get_architect_governance_overview()
return router
router = build_router()
__all__ = ["router", "get_architect_governance_overview"]
@@ -0,0 +1,153 @@
"""Web UI dashboard model for Architect Governance Agent.
Shows requirement changes, architecture review findings, document refresh status,
deployment consistency, test case readiness, weekly research and V/R/C version
recommendations.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class ArchitectPanelMetric:
key: str
title: str
value: Any
status: str = "ok"
detail: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class ArchitectPanel:
key: str
title: str
description: str
metrics: list[ArchitectPanelMetric] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"key": self.key,
"title": self.title,
"description": self.description,
"metrics": [m.to_dict() for m in self.metrics],
}
class ArchitectGovernanceDashboard:
def overview(
self,
requirement_changes: list[dict[str, Any]] | None = None,
architecture_reviews: list[dict[str, Any]] | None = None,
document_status: dict[str, Any] | None = None,
research_status: dict[str, Any] | None = None,
version_recommendations: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
requirement_changes = requirement_changes or []
architecture_reviews = architecture_reviews or []
document_status = document_status or {}
research_status = research_status or {}
version_recommendations = version_recommendations or []
panels = [
self.requirement_panel(requirement_changes),
self.architecture_panel(architecture_reviews),
self.document_panel(document_status),
self.research_panel(research_status),
self.version_panel(version_recommendations),
]
return {
"module": "architect-governance",
"route": "/admin/ai-evolution/architect",
"title": "架构师 Agent 治理中心",
"status": "attention_required" if any(self._panel_warn(panel) for panel in panels) else "healthy",
"generated_at": utc_now(),
"panels": [panel.to_dict() for panel in panels],
"requirement_changes": requirement_changes,
"architecture_reviews": architecture_reviews,
"version_recommendations": version_recommendations,
}
def requirement_panel(self, changes: list[dict[str, Any]]) -> ArchitectPanel:
return ArchitectPanel(
key="requirements",
title="需求变化记录",
description="记录来自 Custom GPT、Codex、OpenClaw、Hermes 和用户反馈的需求变化。",
metrics=[
ArchitectPanelMetric("total_requirement_changes", "需求变化数量", len(changes)),
ArchitectPanelMetric(
"undocumented_changes",
"待刷新到用户需求文档",
len([c for c in changes if not c.get("documented")]),
"warn" if any(not c.get("documented") for c in changes) else "ok",
),
],
)
def architecture_panel(self, reviews: list[dict[str, Any]]) -> ArchitectPanel:
pending = [r for r in reviews if r.get("status") not in {"pass", "not_required"}]
return ArchitectPanel(
key="architecture_review",
title="全量架构审视",
description="展示架构一致性、扩展性、安全边界、可运维性和回滚策略审查。",
metrics=[
ArchitectPanelMetric("review_count", "架构审查数量", len(reviews)),
ArchitectPanelMetric("pending_reviews", "待处理架构审查", len(pending), "warn" if pending else "ok"),
],
)
def document_panel(self, status: dict[str, Any]) -> ArchitectPanel:
required_docs = ["requirements", "architecture", "deployment", "test_cases"]
missing = [doc for doc in required_docs if status.get(doc) not in {"ready", "updated"}]
return ArchitectPanel(
key="documents",
title="文档版本化状态",
description="用户需求、架构设计、工程部署、测试例文档刷新状态。",
metrics=[
ArchitectPanelMetric("requirements_doc", "用户需求文档", status.get("requirements", "unknown"), "warn" if "requirements" in missing else "ok"),
ArchitectPanelMetric("architecture_doc", "架构设计文档", status.get("architecture", "unknown"), "warn" if "architecture" in missing else "ok"),
ArchitectPanelMetric("deployment_doc", "工程部署文档", status.get("deployment", "unknown"), "warn" if "deployment" in missing else "ok"),
ArchitectPanelMetric("test_case_doc", "测试例文档", status.get("test_cases", "unknown"), "warn" if "test_cases" in missing else "ok"),
],
)
def research_panel(self, status: dict[str, Any]) -> ArchitectPanel:
overdue = bool(status.get("overdue"))
return ArchitectPanel(
key="weekly_research",
title="每周外部技术情报",
description="联网搜索 Agent/模型版本、GitHub top 实现、潜在安全影响。",
metrics=[
ArchitectPanelMetric("last_research_at", "最近调研时间", status.get("last_research_at", "never"), "warn" if overdue else "ok"),
ArchitectPanelMetric("findings", "发现数量", status.get("findings", 0)),
ArchitectPanelMetric("optimization_candidates", "可借鉴优化项", status.get("optimization_candidates", 0)),
],
)
def version_panel(self, recommendations: list[dict[str, Any]]) -> ArchitectPanel:
latest = recommendations[-1] if recommendations else {}
return ArchitectPanel(
key="version_decision",
title="V/R/C 版本建议",
description="由架构师 Agent 判断变更应进入 C、R 或 V。",
metrics=[
ArchitectPanelMetric("recommendation_count", "版本建议数量", len(recommendations)),
ArchitectPanelMetric("latest_recommendation", "最近建议", latest.get("version_bump", "none")),
ArchitectPanelMetric("reason", "建议原因", latest.get("reason", "")),
],
)
def _panel_warn(self, panel: ArchitectPanel) -> bool:
return any(metric.status == "warn" for metric in panel.metrics)
__all__ = ["ArchitectPanelMetric", "ArchitectPanel", "ArchitectGovernanceDashboard"]
@@ -0,0 +1,40 @@
"""Web UI model for Architect weekly research."""
from .architect_weekly_research_agent import ArchitectWeeklyResearchAgent
class ArchitectResearchDashboard:
def __init__(self, agent=None):
self.agent = agent or ArchitectWeeklyResearchAgent()
def overview(self):
return {
"module": "architect-weekly-research",
"route": "/admin/ai-evolution/architect/research",
"title": "架构师 Agent 技术情报中心",
"panels": [
{
"title": "Research Targets",
"data": self.agent.plan(),
},
{
"title": "Evaluation",
"fields": [
"relevance",
"impact",
"recommendation",
],
},
{
"title": "Version Decision",
"fields": [
"C",
"R",
"V",
],
},
],
}
__all__ = ["ArchitectResearchDashboard"]
@@ -0,0 +1,122 @@
"""Architect research timeline dashboard.
Shows weekly external research findings as a traceable architecture-impact flow:
research target -> finding -> impact -> recommendation -> version decision ->
governance event.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class ResearchTimelineStep:
id: str
title: str
step_type: str
status: str
source: str
timestamp: str
impact: str = "none"
version_bump: str = "none"
detail: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class ArchitectResearchTimeline:
ORDER = {
"research_planned": 10,
"finding_created": 20,
"impact_analyzed": 30,
"recommendation_created": 40,
"version_decision_created": 50,
"architecture_review_event_created": 60,
}
def build(self, findings: list[dict[str, Any]] | None = None, decisions: list[dict[str, Any]] | None = None) -> dict[str, Any]:
findings = findings or []
decisions = decisions or []
steps: list[ResearchTimelineStep] = [
ResearchTimelineStep(
id="research-plan",
title="每周技术情报调研计划",
step_type="research_planned",
status="ok",
source="architect-agent",
timestamp=utc_now(),
detail={"cadence": "weekly"},
)
]
for idx, finding in enumerate(findings):
steps.append(
ResearchTimelineStep(
id=finding.get("id", f"finding-{idx}"),
title=finding.get("title", "research finding"),
step_type="finding_created",
status="ok",
source=finding.get("source", "unknown"),
timestamp=finding.get("created_at", utc_now()),
impact=finding.get("impact", "none"),
detail=finding,
)
)
steps.append(
ResearchTimelineStep(
id=f"impact-{finding.get('id', idx)}",
title="架构影响分析",
step_type="impact_analyzed",
status="attention_required" if finding.get("impact") in {"architecture", "breaking", "new_capability"} else "ok",
source="architect-agent",
timestamp=utc_now(),
impact=finding.get("impact", "none"),
detail={"relevance": finding.get("relevance"), "recommendation": finding.get("recommendation")},
)
)
for idx, decision in enumerate(decisions):
steps.append(
ResearchTimelineStep(
id=decision.get("id", f"decision-{idx}"),
title="C/R/V 版本建议",
step_type="version_decision_created",
status=decision.get("status", "ok"),
source="architect-agent",
timestamp=decision.get("created_at", utc_now()),
version_bump=decision.get("version_bump", "none"),
detail=decision,
)
)
steps.sort(key=lambda s: (self.ORDER.get(s.step_type, 999), s.timestamp))
return {
"module": "architect-research-timeline",
"route": "/admin/ai-evolution/architect/research/timeline",
"title": "架构师技术情报时间线",
"status": self._status(steps),
"generated_at": utc_now(),
"steps": [step.to_dict() for step in steps],
"summary": self._summary(steps),
}
def _status(self, steps: list[ResearchTimelineStep]) -> str:
if any(step.status == "attention_required" for step in steps):
return "attention_required"
return "healthy"
def _summary(self, steps: list[ResearchTimelineStep]) -> dict[str, Any]:
return {
"findings": len([s for s in steps if s.step_type == "finding_created"]),
"architecture_impacts": len([s for s in steps if s.impact in {"architecture", "breaking", "new_capability"}]),
"version_decisions": len([s for s in steps if s.step_type == "version_decision_created"]),
"recommended_bumps": sorted({s.version_bump for s in steps if s.version_bump != "none"}),
}
__all__ = ["ResearchTimelineStep", "ArchitectResearchTimeline"]
@@ -0,0 +1,32 @@
"""API for architect research timeline."""
try:
from fastapi import APIRouter
except Exception:
APIRouter = None
from .architect_research_timeline import ArchitectResearchTimeline
timeline = ArchitectResearchTimeline()
def get_architect_research_timeline():
return timeline.build()
def build_router():
if APIRouter is None:
return None
router = APIRouter(prefix="/admin/ai-evolution/architect/research", tags=["architect-research"])
@router.get("/timeline", operation_id="getArchitectResearchTimeline")
def get_timeline():
return get_architect_research_timeline()
return router
router = build_router()
__all__ = ["router", "get_architect_research_timeline"]
@@ -0,0 +1,113 @@
"""Weekly external research loop for Architect Governance Agent.
The agent plans and normalizes weekly research tasks. Actual internet access is
provided by the runtime/search worker; this module defines the audited workflow,
findings, impact analysis, and V/R/C recommendations.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class ResearchTarget:
id: str
topic: str
source_type: str
query: str
cadence: str = "weekly"
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class ResearchFinding:
id: str
target_id: str
title: str
summary: str
source: str
relevance: str
impact: str
recommendation: str
created_at: str = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class ResearchDecision:
status: str
version_bump: str
reason: str
findings: list[ResearchFinding]
created_at: str = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]:
return {
"status": self.status,
"version_bump": self.version_bump,
"reason": self.reason,
"findings": [f.to_dict() for f in self.findings],
"created_at": self.created_at,
}
class ArchitectWeeklyResearchAgent:
DEFAULT_TARGETS = (
ResearchTarget("agent-frameworks", "Agent framework versions", "web", "latest multi-agent orchestration framework releases"),
ResearchTarget("model-ecosystem", "Model ecosystem changes", "web", "latest LLM model releases tool calling agent features"),
ResearchTarget("github-top-agentops", "GitHub top AgentOps implementations", "github", "top repositories agent observability multi agent orchestration"),
ResearchTarget("security-agent-risks", "Agent security changes", "web", "latest AI agent security sandbox prompt injection tool use risks"),
)
def plan(self) -> dict[str, Any]:
return {
"status": "planned",
"cadence": "weekly",
"targets": [target.to_dict() for target in self.DEFAULT_TARGETS],
"created_at": utc_now(),
}
def normalize_finding(
self,
target_id: str,
title: str,
summary: str,
source: str,
relevance: str = "medium",
impact: str = "none",
recommendation: str = "monitor",
) -> ResearchFinding:
return ResearchFinding(
id=f"finding_{uuid4().hex[:12]}",
target_id=target_id,
title=title,
summary=summary,
source=source,
relevance=relevance,
impact=impact,
recommendation=recommendation,
)
def decide(self, findings: list[ResearchFinding]) -> ResearchDecision:
if any(f.impact == "breaking" for f in findings):
return ResearchDecision("action_required", "V", "breaking external ecosystem change", findings)
if any(f.impact in {"architecture", "new_capability"} for f in findings):
return ResearchDecision("optimization_recommended", "R", "external capability may improve architecture", findings)
if any(f.impact in {"bugfix", "minor"} for f in findings):
return ResearchDecision("minor_change_recommended", "C", "minor compatible improvement", findings)
return ResearchDecision("no_action", "none", "no relevant external change", findings)
__all__ = ["ResearchTarget", "ResearchFinding", "ResearchDecision", "ArchitectWeeklyResearchAgent"]
@@ -0,0 +1,32 @@
"""API for architecture change governance dashboard."""
try:
from fastapi import APIRouter
except Exception:
APIRouter = None
from .architecture_change_dashboard import ArchitectureChangeDashboard
dashboard = ArchitectureChangeDashboard()
def get_architecture_change_overview():
return dashboard.overview()
def build_router():
if APIRouter is None:
return None
router = APIRouter(prefix="/admin/ai-evolution/architecture-changes", tags=["architecture-changes"])
@router.get("/overview", operation_id="getArchitectureChangeOverview")
def overview():
return get_architecture_change_overview()
return router
router = build_router()
__all__ = ["router", "get_architecture_change_overview"]
@@ -0,0 +1,90 @@
"""Architecture change classification.
Small compatible architecture changes may be handled automatically by the
Architect Agent with audit evidence. Large, risky, production-impacting or
breaking architecture changes require explicit Owner approval before
implementation.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any
@dataclass(frozen=True)
class ArchitectureChangeClassification:
level: str
owner_required: bool
auto_implement_allowed: bool
version_bump: str
reasons: list[str] = field(default_factory=list)
required_gates: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class ArchitectureChangeClassifier:
"""Classifies architecture changes into small/medium/large governance paths."""
LARGE_FLAGS = {
"breaking_change",
"breaks_architecture",
"production_architecture_update",
"security_boundary_change",
"cross_tenant_effect",
"cross_user_data_effect",
"data_migration",
"permission_model_change",
"runtime_infrastructure_change",
}
def classify(self, change: dict[str, Any]) -> ArchitectureChangeClassification:
risk = str(change.get("risk", "medium")).lower()
version_bump = str(change.get("version_bump", change.get("recommended_version_bump", "C"))).upper()
scope = str(change.get("scope", "component")).lower()
reasons: list[str] = []
for flag in sorted(self.LARGE_FLAGS):
if change.get(flag):
reasons.append(flag)
if risk in {"high", "critical"}:
reasons.append(f"risk={risk}")
if version_bump == "V":
reasons.append("version_bump=V")
if version_bump == "R" and scope in {"system", "platform", "production", "cross_agent", "cross_tenant"}:
reasons.append(f"version_bump=R scope={scope}")
if reasons:
return ArchitectureChangeClassification(
level="large",
owner_required=True,
auto_implement_allowed=False,
version_bump=version_bump,
reasons=reasons,
required_gates=["architect_review", "owner_architecture_approval", "test_gate", "promotion_gate"],
)
if risk == "low" and version_bump in {"C", "NONE"} and scope in {"component", "local", "documentation", "test"}:
return ArchitectureChangeClassification(
level="small",
owner_required=False,
auto_implement_allowed=True,
version_bump=version_bump,
reasons=["low_risk_compatible_change"],
required_gates=["architect_review", "audit", "test_gate"],
)
return ArchitectureChangeClassification(
level="medium",
owner_required=False,
auto_implement_allowed=False,
version_bump=version_bump,
reasons=["requires_architect_review_before_implementation"],
required_gates=["architect_review", "test_gate", "audit"],
)
__all__ = ["ArchitectureChangeClassification", "ArchitectureChangeClassifier"]
@@ -0,0 +1,93 @@
"""Architecture change governance dashboard.
Displays architecture change classification, owner approval requirements,
implementation readiness, version recommendation and audit evidence for Web UI.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any
from .architecture_change_classifier import ArchitectureChangeClassifier
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class ArchitectureChangeView:
change_id: str
title: str
level: str
owner_required: bool
auto_implement_allowed: bool
version_bump: str
status: str
reasons: list[str] = field(default_factory=list)
required_gates: list[str] = field(default_factory=list)
owner_decision: str | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class ArchitectureChangeDashboard:
def __init__(self, classifier: ArchitectureChangeClassifier | None = None):
self.classifier = classifier or ArchitectureChangeClassifier()
def overview(self, changes: list[dict[str, Any]] | None = None) -> dict[str, Any]:
changes = changes or []
views = [self._view(change) for change in changes]
return {
"module": "architecture-change-governance",
"route": "/admin/ai-evolution/architecture-changes",
"title": "架构变更治理中心",
"status": self._status(views),
"generated_at": utc_now(),
"summary": self._summary(views),
"changes": [view.to_dict() for view in views],
}
def _view(self, change: dict[str, Any]) -> ArchitectureChangeView:
classification = self.classifier.classify(change)
owner_decision = change.get("owner_decision")
if classification.owner_required and owner_decision != "approved":
status = "waiting_owner_approval"
elif classification.auto_implement_allowed:
status = "auto_implementation_allowed"
else:
status = "ready_after_required_gates"
return ArchitectureChangeView(
change_id=change.get("change_id", change.get("id", "unknown")),
title=change.get("title", "architecture change"),
level=classification.level,
owner_required=classification.owner_required,
auto_implement_allowed=classification.auto_implement_allowed,
version_bump=classification.version_bump,
status=status,
reasons=classification.reasons,
required_gates=classification.required_gates,
owner_decision=owner_decision,
)
def _status(self, views: list[ArchitectureChangeView]) -> str:
if any(view.status == "waiting_owner_approval" for view in views):
return "attention_required"
return "healthy"
def _summary(self, views: list[ArchitectureChangeView]) -> dict[str, Any]:
return {
"total": len(views),
"small": len([v for v in views if v.level == "small"]),
"medium": len([v for v in views if v.level == "medium"]),
"large": len([v for v in views if v.level == "large"]),
"waiting_owner_approval": len([v for v in views if v.status == "waiting_owner_approval"]),
"auto_allowed": len([v for v in views if v.auto_implement_allowed]),
"version_bumps": sorted({v.version_bump for v in views if v.version_bump and v.version_bump != "NONE"}),
}
__all__ = ["ArchitectureChangeView", "ArchitectureChangeDashboard"]
@@ -0,0 +1,89 @@
"""Owner approval gate for architecture changes.
Architecture changes may be proposed, reviewed and optimized by agents, but they
must not be implemented until owner approval is recorded. This gate sits before
implementation and before promotion.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class ArchitectureOwnerApprovalRequest:
request_id: str
change_id: str
title: str
reason: str
risk: str
architect_decision: str
status: str = "pending_owner_approval"
created_at: str = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class ArchitectureOwnerDecision:
request_id: str
change_id: str
decision: str
decided_by: str
reason: str = ""
decided_at: str = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class ArchitectureOwnerGate:
"""Blocks architecture implementation until owner explicitly approves."""
def create_request(self, change: dict[str, Any], architect_decision: dict[str, Any]) -> ArchitectureOwnerApprovalRequest:
return ArchitectureOwnerApprovalRequest(
request_id=f"ARCH-OWNER-{change.get('change_id', change.get('id', 'unknown'))}",
change_id=change.get("change_id", change.get("id", "unknown")),
title=change.get("title", "architecture change"),
reason=change.get("reason", "architecture impact requires owner approval"),
risk=change.get("risk", "medium"),
architect_decision=architect_decision.get("status", "reviewed"),
)
def can_implement(self, request: dict[str, Any], decision: dict[str, Any] | None = None) -> dict[str, Any]:
if not decision:
return {
"allowed": False,
"status": "waiting_owner_approval",
"reason": "architecture change requires owner approval before implementation",
"request": request,
}
if decision.get("decision") == "approved":
return {
"allowed": True,
"status": "owner_approved",
"reason": decision.get("reason", "approved by owner"),
"request": request,
"decision": decision,
}
return {
"allowed": False,
"status": "owner_rejected",
"reason": decision.get("reason", "rejected by owner"),
"request": request,
"decision": decision,
}
__all__ = [
"ArchitectureOwnerApprovalRequest",
"ArchitectureOwnerDecision",
"ArchitectureOwnerGate",
]
@@ -0,0 +1,79 @@
"""HTTP handler and display data for architecture owner gate."""
from __future__ import annotations
from dataclasses import asdict
from datetime import datetime, timezone
from typing import Any
from .architecture_owner_gate import ArchitectureOwnerGate, ArchitectureOwnerApprovalRequest
gate = ArchitectureOwnerGate()
# In-memory store for pending approval requests
_pending_requests: dict[str, dict[str, Any]] = {}
_decisions: dict[str, dict[str, Any]] = {}
def create_owner_approval_request(change: dict[str, Any], architect_decision: dict[str, Any]) -> dict[str, Any]:
req = gate.create_request(change, architect_decision)
d = asdict(req)
_pending_requests[req.change_id] = d
return d
def approve_request(change_id: str, decided_by: str = "owner", reason: str = "") -> dict[str, Any]:
req = _pending_requests.get(change_id)
if not req:
return {"error": f"no pending request for change_id={change_id}"}
decision = asdict(
ArchitectureOwnerApprovalRequest.__bases__[1].__new__(type("_ArchitectureOwnerDecision", (), {})) # noqa
)
try:
from .architecture_owner_gate import ArchitectureOwnerDecision
dec = ArchitectureOwnerDecision(
request_id=req["request_id"],
change_id=change_id,
decision="approved",
decided_by=decided_by,
reason=reason,
)
d = asdict(dec)
_decisions[change_id] = d
# remove from pending
_pending_requests.pop(change_id, None)
return {"allowed": True, "decision": d}
except Exception as exc:
return {"error": str(exc)}
def reject_request(change_id: str, decided_by: str = "owner", reason: str = "") -> dict[str, Any]:
from .architecture_owner_gate import ArchitectureOwnerDecision
req = _pending_requests.get(change_id)
if not req:
return {"error": f"no pending request for change_id={change_id}"}
try:
dec = ArchitectureOwnerDecision(
request_id=req["request_id"],
change_id=change_id,
decision="rejected",
decided_by=decided_by,
reason=reason,
)
d = asdict(dec)
_decisions[change_id] = d
_pending_requests.pop(change_id, None)
return {"allowed": False, "decision": d}
except Exception as exc:
return {"error": str(exc)}
def get_owner_gate_overview() -> dict[str, Any]:
return {
"pending_requests": list(_pending_requests.values()),
"decisions": list(_decisions.values()),
"pending_count": len(_pending_requests),
"approved_count": sum(1 for d in _decisions.values() if d.get("decision") == "approved"),
"rejected_count": sum(1 for d in _decisions.values() if d.get("decision") == "rejected"),
}
@@ -0,0 +1,40 @@
"""Independent architecture optimization agent."""
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
def utc_now():
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class ArchitectureReview:
agent: str
decision: str
findings: list[str]
created_at: str = utc_now()
def to_dict(self):
return asdict(self)
class ArchitectureReviewAgent:
"""Reviews architecture changes from system-wide optimal perspective."""
def review(self, proposal: dict) -> ArchitectureReview:
findings = [
"dependency_impact_checked",
"security_boundary_checked",
"scalability_checked",
"operability_checked",
"rollback_strategy_checked",
]
return ArchitectureReview(
"architecture-optimization-agent",
"pass" if proposal.get("architecture_change") else "not_required",
findings,
)
__all__ = ["ArchitectureReviewAgent", "ArchitectureReview"]
@@ -0,0 +1,103 @@
"""Change acceptance governance for AIOS.
Every feature change must provide matching tests. Every architecture change must
be reviewed by an independent architecture agent. User-facing changes must be
validated by an independent user acceptance agent.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class ChangeDescriptor:
change_id: str
change_type: str
title: str
files_changed: list[str]
tests: list[str] = field(default_factory=list)
user_facing: bool = False
architecture_impact: bool = False
risk: str = "low"
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class AcceptanceFinding:
check: str
status: str
detail: str
required: bool = True
def passed(self) -> bool:
return self.status == "pass" or not self.required
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class AcceptanceDecision:
status: str
findings: list[AcceptanceFinding]
decided_at: str = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]:
return {
"status": self.status,
"findings": [f.to_dict() for f in self.findings],
"decided_at": self.decided_at,
}
class ChangeAcceptanceGate:
def evaluate(self, change: ChangeDescriptor) -> AcceptanceDecision:
findings: list[AcceptanceFinding] = []
findings.append(self._tests_required(change))
findings.append(self._user_acceptance_required(change))
findings.append(self._architecture_review_required(change))
findings.append(self._risk_gate(change))
status = "pass" if all(f.passed() for f in findings) else "blocked"
return AcceptanceDecision(status, findings)
def _tests_required(self, change: ChangeDescriptor) -> AcceptanceFinding:
if change.tests:
return AcceptanceFinding("tests_present", "pass", "matching tests declared")
return AcceptanceFinding("tests_present", "fail", "every feature change requires tests")
def _user_acceptance_required(self, change: ChangeDescriptor) -> AcceptanceFinding:
if not change.user_facing:
return AcceptanceFinding("user_acceptance", "pass", "not user-facing")
has_user_test = any("user" in t.lower() or "acceptance" in t.lower() for t in change.tests)
return AcceptanceFinding(
"user_acceptance",
"pass" if has_user_test else "fail",
"user-facing change requires user acceptance test",
)
def _architecture_review_required(self, change: ChangeDescriptor) -> AcceptanceFinding:
if not change.architecture_impact:
return AcceptanceFinding("architecture_review", "pass", "no architecture impact")
has_arch_test = any("arch" in t.lower() or "architecture" in t.lower() for t in change.tests)
return AcceptanceFinding(
"architecture_review",
"pass" if has_arch_test else "fail",
"architecture change requires independent architect review artifact/test",
)
def _risk_gate(self, change: ChangeDescriptor) -> AcceptanceFinding:
if change.risk in {"low", "medium", "high"}:
return AcceptanceFinding("risk_classified", "pass", f"risk={change.risk}")
return AcceptanceFinding("risk_classified", "fail", "risk must be low/medium/high")
__all__ = ["ChangeDescriptor", "AcceptanceFinding", "AcceptanceDecision", "ChangeAcceptanceGate"]
@@ -0,0 +1,34 @@
"""API for AIOS change governance dashboard."""
from typing import Any
try:
from fastapi import APIRouter
except Exception:
APIRouter = None
from .change_governance_dashboard import ChangeGovernanceDashboard
dashboard = ChangeGovernanceDashboard()
def get_change_governance_overview(changes: list[dict[str, Any]] | None = None):
return dashboard.overview(changes)
def build_router():
if APIRouter is None:
return None
router = APIRouter(prefix="/admin/ai-evolution/governance", tags=["change-governance"])
@router.get("/overview", operation_id="getChangeGovernanceOverview")
def overview():
return get_change_governance_overview()
return router
router = build_router()
__all__ = ["router", "get_change_governance_overview"]
@@ -0,0 +1,93 @@
"""Web UI dashboard model for AIOS change governance.
Shows test gates, user acceptance, architecture review, risk gates and release
blocking status for human operators and owner review.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class GovernanceMetric:
key: str
title: str
value: Any
status: str = "ok"
detail: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class GovernancePanel:
key: str
title: str
metrics: list[GovernanceMetric] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"key": self.key,
"title": self.title,
"metrics": [m.to_dict() for m in self.metrics],
}
class ChangeGovernanceDashboard:
def overview(self, changes: list[dict[str, Any]] | None = None) -> dict[str, Any]:
changes = changes or []
blocked = [c for c in changes if c.get("status") == "blocked"]
waiting_arch = [c for c in changes if c.get("architecture_review") == "required"]
waiting_user = [c for c in changes if c.get("user_acceptance") == "required"]
missing_tests = [c for c in changes if not c.get("tests")]
panels = [
GovernancePanel(
"test_gate",
"测试 Gate",
[
GovernanceMetric("total_changes", "变更总数", len(changes)),
GovernanceMetric("missing_tests", "缺少测试的变更", len(missing_tests), "warn" if missing_tests else "ok"),
],
),
GovernancePanel(
"user_acceptance",
"用户验收 Agent",
[
GovernanceMetric("waiting_user_acceptance", "等待用户验收", len(waiting_user), "warn" if waiting_user else "ok"),
],
),
GovernancePanel(
"architecture_review",
"架构师 Agent",
[
GovernanceMetric("waiting_architecture_review", "等待架构审查", len(waiting_arch), "warn" if waiting_arch else "ok"),
],
),
GovernancePanel(
"release_gate",
"发布 Gate",
[
GovernanceMetric("blocked_changes", "阻塞发布的变更", len(blocked), "warn" if blocked else "ok"),
],
),
]
return {
"module": "change-governance",
"route": "/admin/ai-evolution/governance",
"title": "AIOS 变更治理中心",
"status": "attention_required" if blocked or missing_tests or waiting_arch or waiting_user else "healthy",
"generated_at": utc_now(),
"panels": [p.to_dict() for p in panels],
"changes": changes,
}
__all__ = ["GovernanceMetric", "GovernancePanel", "ChangeGovernanceDashboard"]
+36
View File
@@ -0,0 +1,36 @@
"""Git repository location resolver with validation.
Prevents hard-coded repository names from silently pointing to stale paths.
"""
from __future__ import annotations
import os
from pathlib import Path
class GitRepoLocator:
def __init__(self, root: str | None = None):
self.root = Path(root or os.getenv("AIOS_GITOPS_ROOT", "/data/aios-gitops/runtime/gitea-bin/data/repositories"))
def candidates(self, owner: str = "aios", names: tuple[str, ...] = ("aios.git", "aios-v23.git")):
return [self.root / owner / name for name in names]
def locate(self, owner: str = "aios") -> dict:
found = [str(path) for path in self.candidates(owner) if path.exists()]
return {
"root": str(self.root),
"owner": owner,
"found": found,
"healthy": len(found) == 1,
"warning": "multiple repositories found" if len(found) > 1 else None,
}
def require(self, owner: str = "aios") -> Path:
result = self.locate(owner)
if not result["healthy"]:
raise RuntimeError(f"Git repo resolution failed: {result}")
return Path(result["found"][0])
__all__ = ["GitRepoLocator"]
@@ -0,0 +1,148 @@
"""Bridge governance agents into the unified Agent Event Bus.
This module emits normalized governance events for requirement changes,
architecture reviews, user acceptance, test gates and version decisions. It uses
duck typing so it can work with the unified AgentEventBus when available, while
remaining testable without a running bus.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any, Protocol
from uuid import uuid4
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
class EventPublisher(Protocol):
def publish(
self,
kind: str,
source: str,
target: str,
summary: str,
payload: dict[str, Any],
correlation_id: str | None = None,
) -> dict[str, Any]:
...
@dataclass(frozen=True)
class GovernanceEventEnvelope:
id: str
kind: str
source: str
target: str
summary: str
payload: dict[str, Any]
correlation_id: str | None = None
created_at: str = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class InMemoryGovernanceEventBus:
"""Small fallback bus for tests and offline governance runs."""
def __init__(self) -> None:
self.events: list[dict[str, Any]] = []
def publish(
self,
kind: str,
source: str,
target: str,
summary: str,
payload: dict[str, Any],
correlation_id: str | None = None,
) -> dict[str, Any]:
event = GovernanceEventEnvelope(
id=f"evt_{uuid4().hex[:12]}",
kind=kind,
source=source,
target=target,
summary=summary,
payload=payload,
correlation_id=correlation_id,
).to_dict()
self.events.append(event)
return event
class GovernanceEventBridge:
"""Publishes governance lifecycle events to the Agent Event Bus."""
def __init__(self, bus: EventPublisher | None = None):
self.bus = bus or InMemoryGovernanceEventBus()
def requirement_changed(self, requirement: dict[str, Any]) -> dict[str, Any]:
return self.bus.publish(
kind="requirement_changed",
source=requirement.get("source", "unknown"),
target="architect-agent",
summary=requirement.get("requirement", "requirement changed"),
payload=requirement,
correlation_id=requirement.get("id"),
)
def architecture_review_started(self, proposal: dict[str, Any]) -> dict[str, Any]:
return self.bus.publish(
kind="architecture_review_started",
source="architect-agent",
target="architecture-review-agent",
summary=proposal.get("title", "architecture review started"),
payload=proposal,
correlation_id=proposal.get("change_id"),
)
def architecture_review_completed(self, decision: dict[str, Any]) -> dict[str, Any]:
return self.bus.publish(
kind="architecture_review_completed",
source="architecture-review-agent",
target="change-governance",
summary=f"architecture review {decision.get('status', 'completed')}",
payload=decision,
correlation_id=decision.get("change_id"),
)
def user_acceptance_completed(self, report: dict[str, Any]) -> dict[str, Any]:
return self.bus.publish(
kind="user_acceptance_completed",
source="user-acceptance-agent",
target="change-governance",
summary=f"user acceptance {report.get('decision', 'completed')}",
payload=report,
correlation_id=report.get("change_id"),
)
def test_gate_completed(self, result: dict[str, Any]) -> dict[str, Any]:
return self.bus.publish(
kind="test_gate_completed",
source="test-gate",
target="change-governance",
summary=f"test gate {result.get('status', 'completed')}",
payload=result,
correlation_id=result.get("change_id"),
)
def version_decision_created(self, decision: dict[str, Any]) -> dict[str, Any]:
return self.bus.publish(
kind="version_decision_created",
source="architect-agent",
target="owner-gate",
summary=f"version bump recommendation: {decision.get('version_bump', 'unknown')}",
payload=decision,
correlation_id=decision.get("change_id"),
)
__all__ = [
"GovernanceEventEnvelope",
"InMemoryGovernanceEventBus",
"GovernanceEventBridge",
]
@@ -0,0 +1,32 @@
"""API for governance timeline visualization."""
try:
from fastapi import APIRouter
except Exception:
APIRouter = None
from .governance_timeline_dashboard import GovernanceTimelineDashboard
dashboard = GovernanceTimelineDashboard()
def get_governance_timeline(events=None):
return dashboard.build(events)
def build_router():
if APIRouter is None:
return None
router = APIRouter(prefix="/admin/ai-evolution/governance", tags=["governance-timeline"])
@router.get("/timeline", operation_id="getGovernanceTimeline")
def timeline():
return get_governance_timeline()
return router
router = build_router()
__all__ = ["router", "get_governance_timeline"]
@@ -0,0 +1,106 @@
"""Governance timeline dashboard.
Turns governance events into a human-readable flow for Web UI so operators can
see why a change is blocked or ready for promotion.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
EVENT_ORDER = {
"requirement_changed": 10,
"architecture_review_started": 20,
"architecture_review_completed": 30,
"test_gate_completed": 40,
"user_acceptance_completed": 50,
"version_decision_created": 60,
"owner_gate_completed": 70,
"promotion_completed": 80,
}
@dataclass(frozen=True)
class GovernanceTimelineStep:
id: str
kind: str
title: str
actor: str
status: str
timestamp: str
correlation_id: str | None = None
detail: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class GovernanceTimelineDashboard:
def build(self, events: list[dict[str, Any]] | None = None) -> dict[str, Any]:
events = events or []
steps = [self._event_to_step(event) for event in events]
steps.sort(key=lambda step: (EVENT_ORDER.get(step.kind, 999), step.timestamp))
blockers = [step for step in steps if step.status in {"blocked", "fail", "attention_required"}]
return {
"module": "governance-timeline",
"route": "/admin/ai-evolution/governance/timeline",
"title": "治理事件时间线",
"status": "attention_required" if blockers else "healthy",
"generated_at": utc_now(),
"steps": [step.to_dict() for step in steps],
"blockers": [step.to_dict() for step in blockers],
"flow": self._flow_summary(steps),
}
def _event_to_step(self, event: dict[str, Any]) -> GovernanceTimelineStep:
payload = event.get("payload", {})
kind = event.get("kind", "unknown")
return GovernanceTimelineStep(
id=event.get("id", f"event-{len(str(event))}"),
kind=kind,
title=self._title(kind, payload),
actor=event.get("source", payload.get("actor", "unknown")),
status=payload.get("status") or payload.get("decision") or event.get("status", "ok"),
timestamp=event.get("created_at") or payload.get("created_at") or utc_now(),
correlation_id=event.get("correlation_id") or payload.get("change_id") or payload.get("id"),
detail=payload,
)
def _title(self, kind: str, payload: dict[str, Any]) -> str:
titles = {
"requirement_changed": "需求变化",
"architecture_review_started": "架构审查开始",
"architecture_review_completed": "架构审查完成",
"test_gate_completed": "测试 Gate 完成",
"user_acceptance_completed": "用户验收完成",
"version_decision_created": "版本建议产生",
"owner_gate_completed": "Owner Gate 完成",
"promotion_completed": "Promotion 完成",
}
return payload.get("title") or payload.get("summary") or titles.get(kind, kind)
def _flow_summary(self, steps: list[GovernanceTimelineStep]) -> dict[str, Any]:
present = {step.kind for step in steps}
required = [
"requirement_changed",
"architecture_review_completed",
"test_gate_completed",
"user_acceptance_completed",
"version_decision_created",
]
missing = [kind for kind in required if kind not in present]
return {
"required_steps": required,
"missing_steps": missing,
"ready_for_owner_gate": not missing,
}
__all__ = ["GovernanceTimelineStep", "GovernanceTimelineDashboard"]
@@ -0,0 +1,59 @@
"""Independent acceptance agents.
Simulates independent roles: user representative and architecture reviewer.
They provide acceptance evidence, not implementation decisions.
"""
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from typing import Any
def utc_now():
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class AcceptanceReport:
agent: str
role: str
decision: str
evidence: list[str]
created_at: str = utc_now()
def to_dict(self):
return asdict(self)
class UserAcceptanceAgent:
def review(self, change: dict[str, Any]) -> AcceptanceReport:
checks = [
"user_requirement_match",
"user_workflow_validation",
"permission_visibility_check",
]
return AcceptanceReport(
"user-acceptance-agent",
"user_representative",
"pass" if change.get("user_facing") else "not_required",
checks,
)
class ArchitectureAcceptanceAgent:
def review(self, change: dict[str, Any]) -> AcceptanceReport:
checks = [
"architecture_consistency",
"scalability_review",
"security_boundary_review",
"operability_review",
]
return AcceptanceReport(
"architecture-review-agent",
"system_architect",
"pass" if change.get("architecture_impact") else "not_required",
checks,
)
__all__ = ["AcceptanceReport", "UserAcceptanceAgent", "ArchitectureAcceptanceAgent"]
@@ -0,0 +1,92 @@
"""Agent adapters for AIOS Search Gateway.
All runtime agents should call SearchGatewayAgentAdapter instead of performing
ad-hoc network search. This gives consistent provider fallback, profile
resolution, evidence capture, audit and event visibility.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any
from .search_gateway_orchestrator import SearchGatewayOrchestrator
@dataclass(frozen=True)
class AgentSearchContext:
agent: str
user_id: str | None = None
group_id: str | None = None
role: str = "user"
group_profile: dict[str, Any] | None = None
user_profile: dict[str, Any] | None = None
session_profile: dict[str, Any] | None = None
authorized_sources: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class SearchGatewayAgentAdapter:
def __init__(self, orchestrator: SearchGatewayOrchestrator | None = None):
self.orchestrator = orchestrator or SearchGatewayOrchestrator()
def search(
self,
query: str,
context: AgentSearchContext,
intent: str = "general",
region: str | None = None,
time_range: str | None = None,
data_type: str | None = None,
domain_profiles: list[str] | None = None,
user_scope: str = "public",
correlation_id: str | None = None,
) -> dict[str, Any]:
payload = {
"query": query,
"requester": context.agent,
"intent": intent,
"region": region,
"time_range": time_range,
"data_type": data_type,
"domain_profiles": domain_profiles or [],
"user_scope": user_scope,
"authorized_sources": context.authorized_sources,
"group_profile": context.group_profile,
"user_profile": context.user_profile,
"session_profile": context.session_profile,
"correlation_id": correlation_id,
}
return self.orchestrator.execute(payload)
class OpenClawSearchClient(SearchGatewayAgentAdapter):
def search_public(self, query: str, context: AgentSearchContext, **kwargs) -> dict[str, Any]:
return self.search(query, context, user_scope="public", **kwargs)
class HermesSearchClient(SearchGatewayAgentAdapter):
def delegate_search(self, query: str, context: AgentSearchContext, **kwargs) -> dict[str, Any]:
return self.search(query, context, **kwargs)
class CodexSearchClient(SearchGatewayAgentAdapter):
def technical_search(self, query: str, context: AgentSearchContext, **kwargs) -> dict[str, Any]:
return self.search(query, context, intent=kwargs.pop("intent", "software_library"), **kwargs)
class CustomGPTSearchClient(SearchGatewayAgentAdapter):
def user_search(self, query: str, context: AgentSearchContext, **kwargs) -> dict[str, Any]:
return self.search(query, context, **kwargs)
__all__ = [
"AgentSearchContext",
"SearchGatewayAgentAdapter",
"OpenClawSearchClient",
"HermesSearchClient",
"CodexSearchClient",
"CustomGPTSearchClient",
]
@@ -0,0 +1,73 @@
"""Architect governance integration for Search Gateway.
Search Gateway is a shared platform capability. The Architect Agent periodically
reviews provider health, external search/browser capabilities, cost, security and
privacy impact, then emits C/R/V recommendations.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class SearchArchitectureFinding:
finding_id: str
title: str
impact: str
recommendation: str
evidence: list[str] = field(default_factory=list)
created_at: str = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class SearchArchitectureDecision:
status: str
version_bump: str
reason: str
findings: list[SearchArchitectureFinding]
owner_gate_required: bool
created_at: str = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]:
return {
"status": self.status,
"version_bump": self.version_bump,
"reason": self.reason,
"findings": [f.to_dict() for f in self.findings],
"owner_gate_required": self.owner_gate_required,
"created_at": self.created_at,
}
class SearchArchitectIntegration:
def review(self, signals: dict[str, Any]) -> SearchArchitectureDecision:
findings: list[SearchArchitectureFinding] = []
if signals.get("provider_breaking_change"):
findings.append(SearchArchitectureFinding("search-provider-breaking", "Provider breaking change", "breaking", "owner_review"))
if signals.get("new_search_capability"):
findings.append(SearchArchitectureFinding("search-new-capability", "New search capability available", "architecture", "evaluate_adoption"))
if signals.get("cost_increase"):
findings.append(SearchArchitectureFinding("search-cost", "Search provider cost increase", "minor", "optimize_provider_order"))
if signals.get("security_advisory"):
findings.append(SearchArchitectureFinding("search-security", "Search security advisory", "breaking", "owner_review"))
if any(f.impact == "breaking" for f in findings):
return SearchArchitectureDecision("action_required", "V", "breaking search ecosystem/security change", findings, True)
if any(f.impact == "architecture" for f in findings):
return SearchArchitectureDecision("optimization_recommended", "R", "new search capability may improve architecture", findings, True)
if findings:
return SearchArchitectureDecision("minor_change_recommended", "C", "compatible provider optimization", findings, False)
return SearchArchitectureDecision("no_action", "none", "no relevant search architecture change", findings, False)
__all__ = ["SearchArchitectureFinding", "SearchArchitectureDecision", "SearchArchitectIntegration"]
+27
View File
@@ -0,0 +1,27 @@
"""Audit storage for Search Gateway executions."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
class SearchAuditStore:
def __init__(self, root: str = "runtime/search-gateway/audit"):
self.root = Path(root)
self.root.mkdir(parents=True, exist_ok=True)
def append(self, record: dict[str, Any]) -> str:
path = self.root / f"{record.get('request_id', 'search')}.json"
path.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
return str(path)
def list_recent(self) -> list[dict[str, Any]]:
result = []
for path in sorted(self.root.glob("*.json")):
result.append(json.loads(path.read_text(encoding="utf-8")))
return result[-50:]
__all__ = ["SearchAuditStore"]
+118
View File
@@ -0,0 +1,118 @@
"""Search Gateway event bridge.
Publishes normalized search lifecycle events into the AIOS Agent Event Bus when a
bus is available, with an in-memory fallback for tests and offline runs.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any, Protocol
from uuid import uuid4
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
class SearchEventPublisher(Protocol):
def publish(
self,
kind: str,
source: str,
target: str,
summary: str,
payload: dict[str, Any],
correlation_id: str | None = None,
) -> dict[str, Any]:
...
@dataclass(frozen=True)
class SearchEvent:
id: str
kind: str
source: str
target: str
summary: str
payload: dict[str, Any]
correlation_id: str | None = None
created_at: str = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class InMemorySearchEventBus:
def __init__(self):
self.events: list[dict[str, Any]] = []
def publish(
self,
kind: str,
source: str,
target: str,
summary: str,
payload: dict[str, Any],
correlation_id: str | None = None,
) -> dict[str, Any]:
event = SearchEvent(
id=f"search_evt_{uuid4().hex[:12]}",
kind=kind,
source=source,
target=target,
summary=summary,
payload=payload,
correlation_id=correlation_id,
).to_dict()
self.events.append(event)
return event
class SearchEventBridge:
def __init__(self, bus: SearchEventPublisher | None = None):
self.bus = bus or InMemorySearchEventBus()
def search_started(self, request: dict[str, Any]) -> dict[str, Any]:
return self.bus.publish(
kind="search_started",
source=request.get("requester", "unknown"),
target="search-gateway",
summary=request.get("query", "search started"),
payload=request,
correlation_id=request.get("correlation_id"),
)
def provider_attempted(self, request_id: str, attempt: dict[str, Any]) -> dict[str, Any]:
return self.bus.publish(
kind="search_provider_attempted",
source="search-gateway",
target=attempt.get("provider", "unknown"),
summary=f"provider {attempt.get('provider', 'unknown')} {attempt.get('status', 'attempted')}",
payload=attempt,
correlation_id=request_id,
)
def fallback_triggered(self, request_id: str, reason: str) -> dict[str, Any]:
return self.bus.publish(
kind="search_fallback_triggered",
source="search-gateway",
target="search-provider-router",
summary=reason,
payload={"reason": reason},
correlation_id=request_id,
)
def search_completed(self, response: dict[str, Any]) -> dict[str, Any]:
return self.bus.publish(
kind="search_completed",
source="search-gateway",
target=response.get("requester", "requester"),
summary=f"search {response.get('status', 'completed')}",
payload=response,
correlation_id=response.get("request_id"),
)
__all__ = ["SearchEvent", "InMemorySearchEventBus", "SearchEventBridge"]
+252
View File
@@ -0,0 +1,252 @@
"""Governed general-purpose Search Gateway for AIOS.
The gateway coordinates query planning, provider routing, fallback, evidence
normalization and compliance checks. It is provider-agnostic: runtime workers plug
in public or explicitly authorized search providers.
Search is a shared capability. Groups and users may customize provider order,
trusted domains, query expansion hints and domain profiles, but customization may
not override hard authorization, privacy, safety or audit rules.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any, Protocol
from uuid import uuid4
from .search_profile_resolver import DomainQueryProfileRegistry, SearchProfile, SearchProfileResolver
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class SearchRequest:
query: str
requester: str
intent: str = "general"
region: str | None = None
time_range: str | None = None
data_type: str | None = None
user_scope: str = "public"
authorized_sources: list[str] = field(default_factory=list)
domain_profiles: list[str] = field(default_factory=list)
group_profile: dict[str, Any] | None = None
user_profile: dict[str, Any] | None = None
session_profile: dict[str, Any] | None = None
correlation_id: str | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class SearchEvidence:
title: str
url: str | None
source: str
snippet: str
evidence_type: str = "web"
confidence: str = "medium"
retrieved_at: str = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class SearchProviderAttempt:
provider: str
status: str
result_count: int = 0
reason: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class SearchResponse:
request_id: str
status: str
answer: str
evidence: list[SearchEvidence]
attempts: list[SearchProviderAttempt]
confidence: str
expanded_queries: list[str] = field(default_factory=list)
resolved_profile: dict[str, Any] | None = None
need_clarification: list[str] = field(default_factory=list)
blocked_reasons: list[str] = field(default_factory=list)
created_at: str = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]:
return {
"request_id": self.request_id,
"status": self.status,
"answer": self.answer,
"evidence": [e.to_dict() for e in self.evidence],
"attempts": [a.to_dict() for a in self.attempts],
"confidence": self.confidence,
"expanded_queries": self.expanded_queries,
"resolved_profile": self.resolved_profile,
"need_clarification": self.need_clarification,
"blocked_reasons": self.blocked_reasons,
"created_at": self.created_at,
}
class SearchProvider(Protocol):
name: str
def search(self, request: SearchRequest, queries: list[str]) -> tuple[list[SearchEvidence], SearchProviderAttempt]:
...
class QueryPlanner:
def __init__(self, domain_registry: DomainQueryProfileRegistry | None = None):
self.domain_registry = domain_registry or DomainQueryProfileRegistry()
def expand(self, request: SearchRequest, profile: SearchProfile) -> list[str]:
base = request.query.strip()
queries = [base]
if request.region:
queries.append(f"{request.region} {base}")
if request.time_range:
queries.append(f"{base} {request.time_range}")
domain_profiles = list(dict.fromkeys([request.intent] + request.domain_profiles + profile.domain_profiles))
hints = list(profile.query_expansion_hints) + self.domain_registry.hints_for(domain_profiles)
for hint in hints:
queries.append(f"{base} {hint}")
for domain in profile.trusted_domains:
queries.append(f"site:{domain} {base}")
return list(dict.fromkeys([q for q in queries if q]))
class SearchPolicyGuard:
"""Blocks non-public or unauthorized collection patterns."""
FORBIDDEN_TERMS = (
"绕过登录",
"绕过验证码",
"验证码绕过",
"破解登录",
"个人成绩",
"学生隐私",
"受保护平台批量爬取",
)
def evaluate(self, request: SearchRequest, profile: SearchProfile) -> list[str]:
q = request.query.lower()
reasons: list[str] = []
for term in self.FORBIDDEN_TERMS:
if term.lower() in q:
reasons.append(f"blocked_term:{term}")
if request.user_scope not in {"public", "authorized_user_file", "authorized_link", "internal_archive"}:
reasons.append(f"unsupported_scope:{request.user_scope}")
if request.data_type in {"personal_grade", "student_private_data", "private_person_data"} and request.user_scope != "authorized_user_file":
reasons.append("private_data_requires_user_authorized_file")
if not profile.audit_required:
reasons.append("audit_cannot_be_disabled")
if not profile.fallback_required:
reasons.append("fallback_cannot_be_disabled")
return reasons
class SearchGateway:
def __init__(self, providers: list[SearchProvider] | None = None):
self.providers = providers or []
self.profile_resolver = SearchProfileResolver()
self.planner = QueryPlanner()
self.guard = SearchPolicyGuard()
def search(self, request: SearchRequest) -> SearchResponse:
request_id = request.correlation_id or f"search_{uuid4().hex[:12]}"
profile = self.profile_resolver.resolve(request.group_profile, request.user_profile, request.session_profile)
expanded_queries = self.planner.expand(request, profile)
blocked = self.guard.evaluate(request, profile)
if blocked:
return SearchResponse(
request_id=request_id,
status="blocked",
answer="该搜索请求涉及未授权访问、验证码/登录绕过、受保护平台批量抓取或个人隐私数据,已阻断。可改为搜索公开网页、官方公告、公开文档,或处理用户授权上传的文件/链接。",
evidence=[],
attempts=[],
confidence="high",
expanded_queries=expanded_queries,
resolved_profile=profile.to_dict(),
blocked_reasons=blocked,
)
evidence: list[SearchEvidence] = []
attempts: list[SearchProviderAttempt] = []
providers = self._ordered_providers(profile)
for provider in providers:
try:
provider_evidence, attempt = provider.search(request, expanded_queries)
evidence.extend(provider_evidence)
attempts.append(attempt)
if provider_evidence:
break
except Exception as exc:
attempts.append(SearchProviderAttempt(provider=getattr(provider, "name", "unknown"), status="failed", reason=str(exc)))
if evidence:
return SearchResponse(
request_id=request_id,
status="found",
answer="已找到公开或授权来源结果。",
evidence=evidence,
attempts=attempts,
confidence=self._confidence(evidence),
expanded_queries=expanded_queries,
resolved_profile=profile.to_dict(),
)
return SearchResponse(
request_id=request_id,
status="not_found",
answer="未找到足够可靠的公开或授权结果;已记录 provider 尝试和失败原因。",
evidence=[],
attempts=attempts,
confidence="low",
expanded_queries=expanded_queries,
resolved_profile=profile.to_dict(),
need_clarification=self._clarification(request),
)
def _ordered_providers(self, profile: SearchProfile) -> list[SearchProvider]:
if not self.providers:
return []
by_name = {provider.name: provider for provider in self.providers}
ordered = [by_name[name] for name in profile.provider_order if name in by_name]
remaining = [provider for provider in self.providers if provider.name not in {p.name for p in ordered}]
return ordered + remaining
def _confidence(self, evidence: list[SearchEvidence]) -> str:
if any(e.confidence == "high" for e in evidence):
return "high"
if evidence:
return "medium"
return "low"
def _clarification(self, request: SearchRequest) -> list[str]:
if request.intent == "education_exam_data":
return ["请补充学校名称、联考名称、年级、科目,以及需要试卷/答案还是成绩统计。"]
if request.intent in {"company_research", "policy_regulation", "software_library", "product_research"}:
return ["请补充主体名称、地区/版本、时间范围、需要的数据类型或可信来源范围。"]
return ["请补充更具体的主体、平台、地区、时间、数据类型或授权文件/链接。"]
__all__ = [
"SearchRequest",
"SearchEvidence",
"SearchProviderAttempt",
"SearchResponse",
"SearchProvider",
"QueryPlanner",
"SearchPolicyGuard",
"SearchGateway",
]
+65
View File
@@ -0,0 +1,65 @@
"""API endpoints for AIOS Search Gateway."""
from typing import Any
try:
from fastapi import APIRouter
except Exception:
APIRouter = None
from .search_gateway import SearchGateway, SearchRequest
from .search_gateway_dashboard import SearchGatewayDashboard
gateway = SearchGateway()
dashboard = SearchGatewayDashboard()
def search_gateway_request(payload: dict[str, Any]) -> dict[str, Any]:
request = SearchRequest(**payload)
return gateway.search(request).to_dict()
def get_search_gateway_status() -> dict[str, Any]:
return {
"module": "search-gateway",
"status": "ready",
"providers_configured": len(gateway.providers),
"policy_guard": "enabled",
"profile_customization": "enabled",
"fallback_required_when_provider_unavailable": True,
}
def get_search_gateway_overview() -> dict[str, Any]:
return dashboard.overview()
def build_router():
if APIRouter is None:
return None
router = APIRouter(prefix="/admin/ai-evolution/search", tags=["search-gateway"])
@router.get("/status", operation_id="getSearchGatewayStatus")
def status():
return get_search_gateway_status()
@router.get("/overview", operation_id="getSearchGatewayOverview")
def overview():
return get_search_gateway_overview()
@router.post("/query", operation_id="runSearchGatewayQuery")
def query(payload: dict[str, Any]):
return search_gateway_request(payload)
return router
router = build_router()
__all__ = [
"router",
"search_gateway_request",
"get_search_gateway_status",
"get_search_gateway_overview",
]
@@ -0,0 +1,85 @@
"""Search Gateway Web UI dashboard model."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class SearchDashboardMetric:
key: str
title: str
value: Any
status: str = "ok"
detail: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class SearchDashboardPanel:
key: str
title: str
metrics: list[SearchDashboardMetric] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"key": self.key,
"title": self.title,
"metrics": [m.to_dict() for m in self.metrics],
}
class SearchGatewayDashboard:
def overview(self, search_runs: list[dict[str, Any]] | None = None, providers: list[dict[str, Any]] | None = None) -> dict[str, Any]:
search_runs = search_runs or []
providers = providers or []
blocked = [r for r in search_runs if r.get("status") == "blocked"]
not_found = [r for r in search_runs if r.get("status") == "not_found"]
provider_failures = [p for p in providers if p.get("status") == "failed"]
panels = [
SearchDashboardPanel(
"search_health",
"搜索健康度",
[
SearchDashboardMetric("total_runs", "搜索请求数", len(search_runs)),
SearchDashboardMetric("not_found", "未找到结果", len(not_found), "warn" if not_found else "ok"),
SearchDashboardMetric("blocked", "合规阻断", len(blocked), "warn" if blocked else "ok"),
],
),
SearchDashboardPanel(
"provider_health",
"Provider 可用性",
[
SearchDashboardMetric("providers", "Provider 数量", len(providers)),
SearchDashboardMetric("provider_failures", "Provider 失败", len(provider_failures), "warn" if provider_failures else "ok"),
],
),
SearchDashboardPanel(
"evidence_quality",
"证据质量",
[
SearchDashboardMetric("with_evidence", "有证据的回答", len([r for r in search_runs if r.get("evidence")])) ,
SearchDashboardMetric("fallback_required", "要求多源降级", True),
],
),
]
return {
"module": "search-gateway",
"route": "/admin/ai-evolution/search",
"title": "AIOS 联网搜索治理中心",
"status": "attention_required" if provider_failures else "healthy",
"generated_at": utc_now(),
"panels": [p.to_dict() for p in panels],
"recent_runs": search_runs[-20:],
}
__all__ = ["SearchDashboardMetric", "SearchDashboardPanel", "SearchGatewayDashboard"]
@@ -0,0 +1,41 @@
"""Runtime orchestrator joining Search Gateway, registry, audit and events."""
from __future__ import annotations
from typing import Any
from .search_audit_store import SearchAuditStore
from .search_event_bridge import SearchEventBridge
from .search_gateway import SearchGateway, SearchRequest
from .search_provider_registry import SearchProviderRegistry
class SearchGatewayOrchestrator:
def __init__(self):
self.registry = SearchProviderRegistry()
self.audit = SearchAuditStore()
self.events = SearchEventBridge()
self.gateway = SearchGateway(self.registry.ordered())
def execute(self, payload: dict[str, Any]) -> dict[str, Any]:
request = SearchRequest(**payload)
self.events.search_started(request.to_dict())
response = self.gateway.search(request)
result = response.to_dict()
for attempt in result.get("attempts", []):
self.events.provider_attempted(result["request_id"], attempt)
if len(result.get("attempts", [])) > 1:
self.events.fallback_triggered(result["request_id"], "multiple providers attempted")
self.audit.append(result)
self.events.search_completed(result)
return result
def status(self) -> dict[str, Any]:
return {
"registry": self.registry.status(),
"audit": "enabled",
"events": "enabled",
}
__all__ = ["SearchGatewayOrchestrator"]
@@ -0,0 +1,124 @@
"""Search profile resolver for general-purpose AIOS Search Gateway.
The Search Gateway is a shared platform capability. Groups and users may customize
provider preference, query expansion hints, trusted sources and domain profiles on
top of the shared base profile. Customization is intentionally layered and cannot
override hard safety, authorization or audit rules.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any
HARD_BLOCKED_BEHAVIORS = {
"bypass_login",
"bypass_captcha",
"scrape_private_student_grades",
"scrape_student_private_data",
"bulk_scrape_protected_platforms",
"collect_data_without_authorization",
}
@dataclass(frozen=True)
class SearchProfile:
profile_id: str
scope: str
provider_order: list[str] = field(default_factory=lambda: [
"cache",
"official_site",
"general_web",
"vertical_source",
"browser_fetch",
"user_authorized_file",
"internal_archive",
])
trusted_domains: list[str] = field(default_factory=list)
query_expansion_hints: list[str] = field(default_factory=list)
domain_profiles: list[str] = field(default_factory=list)
blocked_behaviors: set[str] = field(default_factory=lambda: set(HARD_BLOCKED_BEHAVIORS))
audit_required: bool = True
fallback_required: bool = True
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["blocked_behaviors"] = sorted(self.blocked_behaviors)
return data
class SearchProfileResolver:
"""Resolves base -> group -> user -> session search preferences."""
def base_profile(self) -> SearchProfile:
return SearchProfile(profile_id="base", scope="global")
def resolve(
self,
group_profile: dict[str, Any] | None = None,
user_profile: dict[str, Any] | None = None,
session_profile: dict[str, Any] | None = None,
) -> SearchProfile:
profile = self.base_profile().to_dict()
for overlay in (group_profile, user_profile, session_profile):
if overlay:
profile = self._merge(profile, overlay)
profile["blocked_behaviors"] = set(HARD_BLOCKED_BEHAVIORS)
profile["audit_required"] = True
profile["fallback_required"] = True
return SearchProfile(**profile)
def _merge(self, base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
merged = dict(base)
if overlay.get("provider_order"):
merged["provider_order"] = list(overlay["provider_order"])
for list_key in ("trusted_domains", "query_expansion_hints", "domain_profiles"):
merged[list_key] = self._dedupe(list(merged.get(list_key, [])) + list(overlay.get(list_key, [])))
merged["profile_id"] = overlay.get("profile_id", merged.get("profile_id", "custom"))
merged["scope"] = overlay.get("scope", merged.get("scope", "custom"))
return merged
def _dedupe(self, items: list[str]) -> list[str]:
return list(dict.fromkeys([item for item in items if item]))
class DomainQueryProfileRegistry:
"""Optional domain-specific expansions for the general Search Gateway."""
DEFAULT_PROFILES = {
"education_exam_data": [
"学校 年级 科目 试卷 答案 成绩统计",
"联考 月考 期中 期末 质量检测",
],
"policy_regulation": [
"官方 通知 政策 解读 实施细则",
"site:gov.cn OR site:edu.cn",
],
"software_library": [
"release notes changelog migration guide",
"official documentation GitHub issues",
],
"product_research": [
"specs review comparison price availability",
"official page manual support",
],
"company_research": [
"official announcement press release filings",
"leadership product financial report",
],
}
def hints_for(self, domain_profiles: list[str]) -> list[str]:
hints: list[str] = []
for profile in domain_profiles:
hints.extend(self.DEFAULT_PROFILES.get(profile, []))
return list(dict.fromkeys(hints))
__all__ = [
"HARD_BLOCKED_BEHAVIORS",
"SearchProfile",
"SearchProfileResolver",
"DomainQueryProfileRegistry",
]
@@ -0,0 +1,121 @@
"""Provider registry for the general AIOS Search Gateway.
The registry makes provider availability explicit and auditable. Provider
implementations are pluggable: production can register real public search,
official-site, vertical-source, browser-fetch, authorized-file or archive
providers without changing the Search Gateway core.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any
from .search_gateway import SearchEvidence, SearchProviderAttempt, SearchRequest
@dataclass(frozen=True)
class SearchProviderConfig:
name: str
provider_type: str
enabled: bool = True
requires_secret: bool = False
supports_public_web: bool = True
supports_authorized_sources: bool = False
health: str = "unknown"
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class NullSearchProvider:
"""Safe placeholder provider used until runtime wires a real adapter."""
def __init__(self, config: SearchProviderConfig):
self.config = config
self.name = config.name
def search(self, request: SearchRequest, queries: list[str]):
return [], SearchProviderAttempt(
provider=self.name,
status="not_configured" if self.config.enabled else "disabled",
result_count=0,
reason="provider adapter not wired in runtime",
)
class StaticArchiveProvider:
"""Small deterministic provider for tests and internal archived evidence."""
name = "internal_archive"
def __init__(self, records: list[dict[str, Any]] | None = None):
self.records = records or []
def search(self, request: SearchRequest, queries: list[str]):
evidence: list[SearchEvidence] = []
for record in self.records:
haystack = " ".join([record.get("title", ""), record.get("snippet", ""), record.get("tags", "")]).lower()
if any(q.lower() in haystack for q in queries):
evidence.append(
SearchEvidence(
title=record.get("title", "archive result"),
url=record.get("url"),
source=self.name,
snippet=record.get("snippet", ""),
evidence_type="internal_archive",
confidence=record.get("confidence", "medium"),
)
)
return evidence, SearchProviderAttempt(provider=self.name, status="ok", result_count=len(evidence))
class SearchProviderRegistry:
DEFAULT_PROVIDER_CONFIGS = [
SearchProviderConfig("cache", "cache", supports_public_web=False),
SearchProviderConfig("official_site", "official_site"),
SearchProviderConfig("general_web", "general_web"),
SearchProviderConfig("vertical_source", "vertical_source"),
SearchProviderConfig("browser_fetch", "browser_fetch"),
SearchProviderConfig("user_authorized_file", "authorized_file", supports_public_web=False, supports_authorized_sources=True),
SearchProviderConfig("internal_archive", "archive", supports_public_web=False),
]
def __init__(self):
self.configs: dict[str, SearchProviderConfig] = {}
self.providers: dict[str, Any] = {}
for config in self.DEFAULT_PROVIDER_CONFIGS:
self.register_config(config)
def register_config(self, config: SearchProviderConfig) -> None:
self.configs[config.name] = config
self.providers.setdefault(config.name, NullSearchProvider(config))
def register_provider(self, name: str, provider: Any, config: SearchProviderConfig | None = None) -> None:
if config:
self.configs[name] = config
elif name not in self.configs:
self.configs[name] = SearchProviderConfig(name=name, provider_type="custom")
self.providers[name] = provider
def ordered(self, order: list[str] | None = None) -> list[Any]:
order = order or list(self.configs)
result = [self.providers[name] for name in order if name in self.providers and self.configs[name].enabled]
result.extend([provider for name, provider in self.providers.items() if name not in order and self.configs[name].enabled])
return result
def status(self) -> dict[str, Any]:
return {
"providers": [self.configs[name].to_dict() for name in sorted(self.configs)],
"enabled": len([c for c in self.configs.values() if c.enabled]),
"configured": len([name for name, provider in self.providers.items() if not isinstance(provider, NullSearchProvider)]),
}
__all__ = [
"SearchProviderConfig",
"NullSearchProvider",
"StaticArchiveProvider",
"SearchProviderRegistry",
]

Some files were not shown because too many files have changed in this diff Show More