Compare commits

...
Author SHA1 Message Date
Codex 0be3ba09e4 task(agent): 固化在线验收与回滚审查证据 2026-07-21 12:33:03 +08:00
Codex b158041b86 test(aios-v2): 固化回滚就绪重试验收证据 2026-07-21 12:25:00 +08:00
Codex 2a2b702eb8 merge(deploy): 修复回滚启动就绪竞态 2026-07-21 12:24:39 +08:00
Codex 341556c780 fix(deploy): 等待回滚目标服务就绪 2026-07-21 12:24:39 +08:00
Codex bd45c0ad77 test(aios-v2): 固化回滚执行模式验收证据 2026-07-21 12:21:16 +08:00
Codex c81d4f40f3 merge(deploy): 修复回滚脚本执行模式 2026-07-21 12:20:52 +08:00
Codex ea2a466d96 fix(deploy): 保留回滚健康脚本执行模式 2026-07-21 12:20:52 +08:00
Codex 63b31165b7 test(aios-v2): 固化 Key ID 修复验收证据 2026-07-21 12:16:10 +08:00
Codex 07b5c2c9c2 merge(aios-v2): 修复 OpenClaw Key ID 传播 2026-07-21 12:15:48 +08:00
Codex d71b7903df fix(aios-v2): 传播 OpenClaw 签名 Key ID 2026-07-21 12:15:48 +08:00
Codex 79fd83cd61 test(aios-v2): 固化 Runtime HTTP 离线验收证据 2026-07-21 12:11:42 +08:00
Codex fedb6b08fb merge(openclaw): 接入签名 Runtime HTTP 合同 2026-07-21 12:05:52 +08:00
Codex 83f8f878c7 feat(openclaw): 增加签名 Runtime HTTP 合同 2026-07-21 12:05:29 +08:00
Codex 1352a664ab test(aios-v2): 固化 HTTP 修复后验收证据 2026-07-21 11:40:16 +08:00
Codex 57209760cd merge(bugfix): 修复真实 HTTP 身份头解析 2026-07-21 11:39:44 +08:00
Codex 850bb669b7 fix(aios-v2): 规范化 HTTP 身份头大小写 2026-07-21 11:39:43 +08:00
Codex 9c20bf1362 test(aios-v2): 固化 Gate 与离线验收证据 2026-07-21 11:29:56 +08:00
Codex 09fa4f9d73 docs(aios-v2): 补齐核心模型中文追踪 2026-07-21 11:25:34 +08:00
Codex 816be7bec5 docs(agent): 固化 provenance 清障与 Gate 启动证据 2026-07-21 11:23:27 +08:00
Codex 25c25abd3e chore(aios): provenance openclaw-v2r1-20260721T031523Z heartbeat.json 2026-07-21 11:18:57 +08:00
Codex 8edb96a0fa merge(security): 纳入 provenance service-user Git guard 2026-07-21 11:18:02 +08:00
Codex 695a846645 fix(openclaw): 限定服务用户 Git safe directory 2026-07-21 11:18:02 +08:00
Codex d8f01f745d merge(security): 修复 provenance plugin runtime 合同 2026-07-21 11:11:34 +08:00
Codex c75fa77898 fix(openclaw): 声明 provenance writer 工具合同 2026-07-21 11:11:33 +08:00
Codex 9206b883ab merge(security): 纳入 OpenClaw run provenance writer 2026-07-21 11:06:34 +08:00
Codex b199935db8 feat(openclaw): 增加 Owner 签名 provenance writer 2026-07-21 11:06:17 +08:00
AIOS-Owner f7307c82b2 task(agent): 固化四项清障与 provenance 阻塞证据 2026-07-20 18:17:49 +08:00
AIOS-Owner bf01a13f3a docs(aios-v2): 记录四项阻塞清除与 provenance 剩余门禁 2026-07-20 18:17:06 +08:00
AIOS-Owner 8a36f7832b fix(aios-v2): 对齐 Ed25519 Candidate 签名合同 2026-07-20 18:14:42 +08:00
AIOS-Owner f1aeefff20 task(agent): 记录 Candidate 合并与 Worktree 修复证据 2026-07-20 17:39:16 +08:00
AIOS-Owner f55dfd975e merge(candidate): 同步最新 AIOS V2R1 Candidate 2026-07-20 17:38:42 +08:00
Codex ba7669c3b4 docs(aios-v2): 更新监督状态与阻塞处置清单 2026-07-20 17:22:32 +08:00
Windows ChatGPT Owner Supervisor bd2784015d chore(evidence): refine AIOS V2 signing-material blocker 2026-07-20 17:05:09 +08:00
Windows ChatGPT Owner Supervisor 1aa71f233d chore(evidence): block OpenClaw deployment with verified owner findings 2026-07-20 17:02:18 +08:00
Codex 558dc932fa chore(evidence): Phase 4 - Secret reference configuration complete
- Created /etc/aios-v2/aios-v2.env with credential ref (not plaintext)
- Saved Gitea deploy token to protected file under /etc/openclaw/secrets/
- Credential rotation completed: new token with minimal scope (write:repository,read:repository)
- Env file references AIOS_OPENCLAW_URL, CREDENTIAL_REF, SIGNING_MATERIAL_FILE, SIGNING_KEY_ID
2026-07-20 16:54:12 +08:00
Codex 5fd5ae0a33 chore(evidence): add claim, heartbeat, progress, update status for task AIOS-V2R1-PRODUCTION-DEPLOY-20260720
Phase 1 completed: worktree created, claim written, branches synced to Gitea, OID verified.
Phase 2-3 completed: branch sync & Gitea OID verification all matched.
Next: Phase 4 - Configure Secret references.
2026-07-20 16:48:33 +08:00
Codex 81572b8231 security(openclaw): record restored Gitea credential without secret value 2026-07-20 15:37:29 +08:00
Codex 8c3d80fe53 task(openclaw): 下发执行器启动、Secret 引用与 Gitea 同步在线指令 2026-07-20 14:34:13 +08:00
Codex 99bcc03b40 task(openclaw): 初始化无人值守任务状态 2026-07-20 12:07:46 +08:00
Codex c6e7edcf54 task(openclaw): 写入预发布无人值守任务载荷 2026-07-20 12:07:36 +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 c464560a0f task(openclaw): launch AIOS V2R1 pre-production deployment 2026-07-20 11:49:09 +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
openclaw ac89ba2576 chore: create next command CMD-20260709-enable-autonomous-controller 2026-07-08 16:19:17 +08:00
openclaw c9f1c28616 chore: add evidence for CMD-20260708-enable-aios-autonomous-operation-and-notification 2026-07-08 16:15:16 +08:00
openclaw c796347655 chore(chatgpt): create patch/chatgpt-br-1783496324783-496c38aa 2026-07-08 16:14:39 +08:00
openclaw 9dad8b57a7 feat(cmd): implement Agent Command Plane and Audit Plane (V1R3C1)
CMD-20260708-fix-git-actions-write-and-enable-audit-plane

- Fix: gitops.tokens gitops-write-bot SHA256 hash sync
- Add: agents/ command registry + directory structure
- Add: audit/ full execution audit plane
- Add: evidence/ CMD report files
- Add: docs/architecture/AIOS_AGENT_AUDIT_PLANE.md

Closes CMD-20260708. Next: CMD-20260709-autonomous-controller-enable
2026-07-08 15:38:18 +08:00
aios ff40a89987 chore(chatgpt): update test/cmd-permission-check.txt 2026-07-08 15:33:52 +08:00
aios 8fd86d6100 chore(baseline): promote baseline to V1R3C1
V1R3C1 is the self-loop autonomous engine corrective release.
V1R3C0 remains infrastructure fix only.
Previous baseline: V1R2C3
2026-07-08 14:36:16 +08:00
313 changed files with 26756 additions and 107 deletions
+412
View File
@@ -0,0 +1,412 @@
# OpenClaw 在线执行指令:启动执行器、配置 Secret 引用并确认 Gitea 同步
- 任务 ID:`AIOS-V2R1-PRODUCTION-DEPLOY-20260720`
- 执行器:OpenClaw
- 任务分支:`task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720`
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
- 语言:中文
- 执行模式:无人值守、证据驱动、默认拒绝
## 1. 任务目标
一次性完成以下工作:
1. 发现并启动环境中已注册的 OpenClaw 在线执行器;
2. 领取本任务并持续写入 claim、heartbeat、progress、blocker 和 result;
3. 将任务分支同步到最新 Candidate HEAD;
4. 确认 Candidate、OpenClaw Task、Codex Task 三个分支已同步到 Gitea,且本地与远端 OID 完全一致;
5. 从已批准的 Secret Provider、Credential Store 或受保护文件中解析 OpenClaw Runtime 配置;
6. 只把 Secret 引用和受保护文件路径写入 AIOS 环境配置,不得把明文 Secret 写入 Git、日志、Evidence 或终端输出;
7. 执行文档—代码检查、候选测试、全量验收和预发布健康检查;
8. 对低风险部署或 Runtime 缺陷创建 Patch 并修复;
9. 把所有修改和阻塞提交到系统,交由 Codex 统一审视。
## 2. 不可违反的边界
- 不得直接修改或 push `main`;
- 不得使用 `--force` 或改写共享分支历史;
- 不得创建生产 Tag;
- 不得切换生产流量,除非 AIOS Owner 后续明确批准;
- 不得提交 Token、密码、API Key、私钥、签名材料或 Secret 内容;
- 不得在日志中执行 `env`、`set`、`printenv` 或输出 Secret 文件内容;
- 不得修改 AIOS 唯一控制面、多租户或审批边界;
- 发现架构、安全或跨模块阻塞时必须升级 Codex;
- 未生成 claim/heartbeat 证据前不得标记为运行中;
- 未通过验收前不得标记为完成。
## 3. 第一阶段:执行器发现与领取
### 3.1 发现执行器入口
使用当前环境中已经注册和批准的 OpenClaw 执行入口。可检查:
- systemd 服务;
- 容器或 Compose 服务;
- 已安装的 OpenClaw CLI;
- AIOS Agent Runner;
- 已配置的队列消费者或调度器。
不得臆造不存在的命令、服务名或路径。
如果找不到可执行入口,立即创建 Blocker:
```json
{
"blocker_id": "BLK-EXECUTOR-ENTRYPOINT-MISSING",
"severity": "high",
"description": "环境中未发现已注册的 OpenClaw 执行器入口",
"owner": "AIOS 运维负责人",
"exit_condition": "提供或启用已批准的 OpenClaw systemd、容器、CLI 或 Agent Runner 入口"
}
```
状态更新为 `blocked`,不得伪报已经启动。
### 3.2 启动执行器
发现入口后:
1. 验证配置来源和运行用户;
2. 启动或恢复执行器;
3. 验证进程/容器处于运行状态;
4. 验证执行器能够读取本任务分支;
5. 立即写入 `.agent/claim.json`;
6. 更新 `.agent/status.json`:`queued -> claimed -> running`;
7. 写入首次 `.agent/heartbeat.json`;
8. 将证据提交到任务分支并同步 Gitea。
`claim.json` 至少包含:
```json
{
"schema": "aios.v2.agent-claim.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"executor": "openclaw",
"task_branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
"candidate_commit": "<领取时实际 Candidate OID>",
"claimed_at": "<UTC ISO-8601>",
"executor_instance": "<非敏感实例标识>"
}
```
Heartbeat 间隔不得超过 300 秒。
## 4. 第二阶段:同步最新 Candidate
### 4.1 获取最新引用
只更新 remote-tracking ref,不得 fetch 到已经被 Worktree 检出的本地 Branch Ref。
执行原则:
```text
fetch remote candidate -> refs/remotes/<remote>/<candidate>
```
不得使用会触发以下错误的流程:
```text
refusing to fetch into branch checked out at worktree
```
### 4.2 更新任务分支
将最新 Candidate 合并到 OpenClaw 任务分支:
- 使用普通 merge 或团队批准的线性更新方式;
- 不得 force reset 已经提交的 Agent Evidence;
- 发生冲突时停止自动扩大修改范围;
- 文档、任务载荷或架构冲突升级 Codex;
- 合并完成后把 `.agent/task.json` 与 `.agent/status.json` 中的 `candidate_commit` 更新为实际 Candidate OID。
### 4.3 必须记录
在 `.agent/progress.json` 记录:
- Candidate 本地 OID;
- Candidate Gitea OID;
- Task Branch 合并前后 OID;
- 修改文件;
- 是否发生冲突;
- 合并 Commit。
## 5. 第三阶段:确认 Gitea 同步
需要验证以下分支:
```text
integration/aios-v2r1-cto-production-candidate-20260720
task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720
task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720
```
目标仓库:
```text
g.cnnetmkt.top/git/aios/aios
```
### 5.1 同步规则
- 使用环境中已配置的 Gitea Remote 和凭据引用;
- 不在 Evidence 中记录带凭据的 Remote URL;
- 禁止 Force Push;
- Candidate 和 Task Branch 可以普通 Push;
- Push 失败时记录 stderr 的脱敏摘要,不得记录凭据。
### 5.2 OID 验证
每个分支必须同时取得:
```text
local_oid
gitea_oid
match = true/false
```
验收条件:
```text
local_oid == gitea_oid
```
建议通过 remote-tracking ref 或 `git ls-remote --heads` 取得远端 OID。输出中不得泄露认证信息。
如果 Push 或 OID 校验失败,创建 Blocker:
```json
{
"blocker_id": "BLK-GITEA-SYNC",
"severity": "high",
"description": "本地与 Gitea 分支 OID 不一致或无推送权限",
"owner": "GitOps/Gitea 运维负责人",
"exit_condition": "三个目标分支均完成普通推送且 local_oid 等于 gitea_oid"
}
```
在 Gitea OID 一致前,不得继续预发布部署。
## 6. 第四阶段:配置 Secret 引用
### 6.1 需要解析的配置
至少确认:
```text
AIOS_OPENCLAW_URL
AIOS_OPENCLAW_CREDENTIAL_REF
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE
AIOS_OPENCLAW_SIGNING_KEY_ID
```
其中:
- `AIOS_OPENCLAW_URL` 是经过批准的 Runtime Endpoint;
- `AIOS_OPENCLAW_CREDENTIAL_REF` 只保存 Secret Provider 引用;
- `AIOS_OPENCLAW_SIGNING_MATERIAL_FILE` 指向仓库外的受保护文件;
- `AIOS_OPENCLAW_SIGNING_KEY_ID` 是 Key 标识,不是 Key 内容。
### 6.2 Secret 来源优先级
按以下顺序查找:
1. 已批准的运行时 Secret Store;
2. 已批准的 Credential Provider;
3. systemd Credential;
4. 只读 Secret Volume;
5. 仓库外受保护文件。
不得:
- 从 Git 历史恢复 Secret;
- 从日志、Shell History 或旧 Evidence 复制 Secret;
- 新建明文 Secret 并提交;
- 把 Secret 内容写入 `.env` 模板或任务 JSON。
### 6.3 配置文件与权限
生产候选环境文件:
```text
/etc/aios-v2/aios-v2.env
```
建议权限:
```text
owner: root
group: aios
mode: 0640 或更严格
```
签名材料文件:
```text
位于仓库外
owner: aios 或经批准的运行用户
mode: 0600 或更严格
```
只验证以下内容:
- 引用非空;
- 文件存在;
- 运行用户可读;
- 权限不宽松;
- 文件不在 Git Worktree;
- 不输出文件内容或长度特征。
### 6.4 缺少 Secret 时的行为
若无法从已批准来源解析任一必需配置,创建:
```json
{
"blocker_id": "BLK-SECRET-REF-MISSING",
"severity": "high",
"description": "缺少已批准的 OpenClaw Runtime Endpoint、Credential Ref、Signing File 或 Key ID",
"owner": "AIOS Secret/运维负责人",
"exit_condition": "四项配置均可从批准来源解析,且权限检查通过"
}
```
不得要求用户在聊天或 Git 中提供 Secret 明文。
## 7. 第五阶段:候选验收与预发布
在 Gitea OID 和 Secret 引用检查都通过后,依次执行:
```bash
python3 tools/aios_v2_docs_code_check.py \
--base-ref integration/aios-v2r1-cto-production-candidate-20260720
python3 -m unittest discover \
-s tests/aios_v2_candidate \
-p 'test_*.py' \
-v
bash deploy/aios-v2/acceptance/run-all.sh
```
部署后执行:
```bash
AIOS_RUN_LIVE_HEALTHCHECK=1 \
bash deploy/aios-v2/acceptance/run-all.sh
bash deploy/aios-v2/healthcheck/check.sh
```
必须验证:
- AIOS HTTP Health;
- SQLite `quick_check=ok`;
- GitOps Controller detached + clean;
- OpenClaw execute/status/cancel/timeout/health;
- Hermes claim/heartbeat/complete/fail/lease recovery;
- 服务重启后 Tenant/Agent/Task/Audit/Idempotency 恢复;
- 回滚后健康检查通过;
- 日志与 Evidence 无 Secret。
## 8. Bug 修复规则
OpenClaw 可自行修复:
- systemd、Docker、Compose、路径和权限;
- 环境变量名或启动参数;
- Healthcheck;
- 明确的小型 Runtime Adapter 兼容问题;
- 日志、错误消息和超时边界;
- 测试、部署或中文文档中的明确错误。
每个修复必须:
1. 创建 `patch/bugfix/*`;
2. 记录复现步骤;
3. 添加或更新测试;
4. 修改代码;
5. 同步 `docs/aios-v2/` 和追踪矩阵;
6. 执行文档门禁和相关测试;
7. 提交 Git;
8. 更新 `.agent/blockers.json` 和 `.agent/progress.json`;
9. 提交 Codex 审查。
以下问题必须升级 Codex:
- Control Plane、Task Lifecycle、Policy 或 Approval 变化;
- 多租户或身份边界变化;
- OpenClaw/Hermes Contract 不兼容变化;
- 数据迁移、持久化或回滚策略变化;
- 高风险安全问题;
- 需要修改 ADR 的问题;
- 超出低风险修复范围的阻塞。
## 9. 证据文件
任务分支必须持续维护:
```text
.agent/claim.json
.agent/heartbeat.json
.agent/progress.json
.agent/blockers.json
.agent/result.json
.agent/status.json
```
`result.json` 至少包含:
```json
{
"schema": "aios.v2.agent-result.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"final_status": "complete|failed|blocked",
"candidate_commit": "<OID>",
"task_commit": "<OID>",
"gitea_sync": {
"candidate_match": true,
"openclaw_task_match": true,
"codex_task_match": true
},
"secret_reference_status": "configured|blocked",
"executor_status": "running|blocked",
"test_status": "passed|failed|not_run",
"deployment_status": "passed|failed|not_run",
"rollback_status": "passed|failed|not_run",
"documentation_sync_status": "passed|failed",
"changed_files": [],
"commits": [],
"open_blockers": [],
"remaining_risks": [],
"completed_at": "<UTC ISO-8601>"
}
```
## 10. 完成条件
只有以下条件全部满足,任务才可标记为 `complete`:
- OpenClaw 执行器已启动并持续 Heartbeat;
- 已领取任务;
- 任务分支已合并最新 Candidate;
- Candidate、OpenClaw Task、Codex Task 在本地与 Gitea 的 OID 一致;
- Secret 引用和受保护文件权限检查通过;
- 文档—代码检查通过;
- 候选测试通过;
- 预发布部署和在线健康检查通过;
- Runtime 联调、重启和回滚演练通过;
- 所有修改、Blocker、Commit、测试和结果已回写;
- Codex 已能读取并审查完整证据。
如果缺少外部权限、执行入口或已批准 Secret,任务必须标记为 `blocked`,并准确说明 Owner 与解除条件,不得伪报完成。
## 11. Owner Gate
完成本指令不等于自动批准生产发布。以下动作仍需 AIOS Owner 明确批准:
- 合并 `main`;
- 创建生产 Tag;
- 正式生产部署;
- 生产流量切换;
- 接受高风险剩余问题。
+58
View File
@@ -0,0 +1,58 @@
{
"schema": "aios.v2.agent-blockers.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"updated_at": "2026-07-21T04:30:00Z",
"evidence_writer": "windows-chatgpt-owner",
"blockers": [
{
"blocker_id": "BLK-OPENCLAW-PLAINTEXT-SECRETS",
"severity": "high",
"status": "resolved",
"root_cause": "OpenClaw managed config、auth profile store 与 models.json 存在三处明文凭据。",
"fix": "使用 OpenClaw 原生 secrets apply 迁移为单一 file Secret Provider;Gateway Token 已轮换,OneAPI 使用单一 auth-profile SecretRef。",
"verification": "audit plaintext/unresolved/shadowed/legacy 全部为 0,实际 OneAPI agent 调用通过。",
"commit": "bf01a13f3aaf42a386232ed2f5409feb0f79fcf6",
"resolved_at": "2026-07-20T10:17:49Z"
},
{
"blocker_id": "BLK-AIOS-V2-SIGNING-MATERIAL-MISSING",
"severity": "high",
"status": "resolved",
"root_cause": "Key ID 标称 Ed25519,但代码实际使用 HMAC 且材料文件缺失。",
"fix": "签名合同改为真实 Ed25519,并由 Owner 在受保护边界生成运行材料。",
"verification": "aios:aios 0600;resolver sign/verify 与篡改拒绝通过。",
"commit": "8a36f7832bc105e4b9d32ffbe018abed2ff1b1ab",
"resolved_at": "2026-07-20T10:17:49Z"
},
{
"blocker_id": "BLK-CANDIDATE-NOT-MERGED",
"severity": "high",
"status": "resolved",
"root_cause": "OpenClaw 任务未包含最新 Candidate。",
"fix": "保护 Evidence 后以 --no-ff 合并 Candidate ba7669c。",
"verification": "Candidate 为任务分支祖先,普通 Push 后 local_oid 等于 gitea_oid。",
"commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
"resolved_at": "2026-07-20T10:17:49Z"
},
{
"blocker_id": "BLK-OPENCLAW-RUN-PROVENANCE-MISSING",
"severity": "high",
"status": "resolved",
"root_cause": "旧插件只有进程内 runId/sender Map,缺少 Owner-signed Schema、持久 Store 和限定 Git Writer。",
"fix": "部署 aios-tenant-boundary rc2,增加 Ed25519 issuer/store、Run/Sender/OID/期限绑定、一次性 permit 与七路径专用 Writer。",
"verification": "Provenance openclaw-v2r1-20260721T031523Z 完成 heartbeat 写入、Commit、普通 Push 和审计;README.md 越权写入被拒绝;Node 测试 9/9。",
"commit": "25c25abd3ed19091fe078d4d7a271c57f23e87fb",
"resolved_at": "2026-07-21T03:23:27Z"
},
{
"blocker_id": "BLK-CONTROLLER-LEGACY-GITEA-CHECK",
"severity": "high",
"status": "resolved",
"root_cause": "Controller 硬编码旧 aios-v23 仓库与 API,无法区分本地和 Gitea OID。",
"fix": "配置化 V2R1 仓库/分支,使用只读 systemd Credential,部署到 detached clean 工作树。",
"verification": "12 项测试通过;gitea/branch_sync/oid_match 为 true,sync_status=fully_synced。",
"commit": "6188da26453505dacde9d6d298191437164c8d27",
"resolved_at": "2026-07-20T10:17:49Z"
}
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"schema": "aios.v2.agent-claim.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"executor": "openclaw",
"task_branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
"claimed_at": "2026-07-20T08:24:30Z",
"executor_instance": "openclaw-main-cn-network-mkt",
"owner_observation_at": "2026-07-21T04:30:00Z",
"owner_observation": "五项高风险 Blocker、Gate A/B、离线验收、OpenClaw 在线合同、双服务重启恢复与受控回滚均已形成真实证据;Hermes 完整在线 Worker 生命周期仍未暴露为服务端点。",
"evidence_writer": "windows-chatgpt-owner",
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
"candidate_updated_at": "2026-07-20T09:39:16Z"
,"latest_task_commit_before_evidence": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a"
}
+61
View File
@@ -0,0 +1,61 @@
{
"schema": "aios.v2.credential-status.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"credential_name": "openclaw-deploy-cli",
"credential_type": "gitea-api-token-reference",
"endpoints": [
"http://127.0.0.1:3000",
"https://g.cnnetmkt.top/git"
],
"reported_scope": "all",
"reported_status": "working",
"verification_source": "owner-reported",
"security_status": "secretref_resolved",
"reason": "令牌值曾在聊天中以明文出现,必须按已暴露凭据处理",
"required_actions": [
"已完成",
"新令牌已保存到受保护文件",
"令牌用于HTTPS Git认证"
],
"rotation_completed_at": "2026-07-20T08:48:00Z",
"new_token_created": "openclaw-deploy-task-commit",
"new_token_scope": "write:repository,read:repository",
"token_file_path": "/etc/openclaw/secrets/openclaw-gitea-deploy-token",
"token_file_owner": "root:root",
"token_file_mode": "0600",
"secret_provider_contract_path": "/etc/openclaw/SECRET_PROVIDER_CONTRACT.json",
"last_verified": "2026-07-21T04:30:00Z",
"verification_result": "HTTPS Git push successful with new token",
"env_file_reference": "AIOS_OPENCLAW_CREDENTIAL_REF=file:///etc/openclaw/secrets/openclaw-gitea-deploy-token",
"env_file_path": "/etc/aios-v2/aios-v2.env",
"env_file_mode": "0640 root:gitops",
"last_updated": "2026-07-21T04:30:00Z",
"old_credential_revoked": true,
"revoked": true,
"new_credential_name": "openclaw-deploy-cli-v2-20260720",
"rotated": true,
"new_account": "gitops-bot",
"scope": "write:repository",
"credential_ref": "/data/aios-gitops/runtime/secrets/openclaw-deploy-cli-v2-20260720",
"protected_file_owner": "root",
"protected_file_group": "gitops",
"protected_file_mode": "0640",
"verified_at": "2026-07-20T09:02:18Z",
"evidence_writer": "windows-chatgpt-owner",
"plaintext_findings_before": 3,
"plaintext_findings_after": 0,
"credentials_rotated": true,
"secret_values_recorded": false,
"signing_material_configured": true,
"signing_key_id": "openclaw-v2r1-candidate-ed25519",
"signing_permission_check": "passed",
"runtime_sign_verify_test": "passed",
"run_provenance_status": "resolved",
"provenance_id": "openclaw-v2r1-20260721T031523Z",
"provenance_public_key_configured": true,
"provenance_secret_values_recorded": false,
"openclaw_runtime_plugin_version": "2.1.0-rc.3",
"runtime_public_key_only_hot_path": true,
"runtime_http_contract_test": "passed",
"secret_values_recorded_in_runtime_evidence": false
}
+12
View File
@@ -0,0 +1,12 @@
{
"current_step": "ONLINE_HERMES_WORKER_GAP",
"evidence_writer": "windows-chatgpt-owner",
"executor": "openclaw",
"heartbeat_at": "2026-07-21T04:30:00Z",
"heartbeat_source": "owner-live-acceptance",
"provenance_id": "openclaw-v2r1-20260721T031523Z",
"schema": "aios.v2.agent-heartbeat.v1",
"state": "running",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"valid_for_running": true
}
+70
View File
@@ -0,0 +1,70 @@
{
"schema": "aios.v2.agent-progress.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"state": "running",
"status": "PARTIAL",
"updated_at": "2026-07-21T04:30:00Z",
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
"task_commit_before_owner_evidence": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a",
"gitea_sync": {
"candidate_match": true,
"openclaw_task_match_before_owner_evidence": true,
"codex_task_match": true
},
"credential_rotation_status": "passed",
"executor_model_path": "passed",
"tests": "controller 12/12;provenance/Runtime Node 14/14;Candidate 10/10;Gate A 0错误0警告;Gate B 10/10;离线 8通过0失败1警告;OpenClaw 在线合同与 AIOS/Hermes 基础联调通过",
"deployment": "aios-tenant-boundary rc3 与隔离 Release b158041 已部署;仅监听 127.0.0.1:18921,未切流量",
"rollback": "Previous 2a2b702 与 Current b158041 双向切换通过;默认不恢复状态,业务状态、SQLite、Execution Evidence 均保留",
"current_step": "ONLINE_HERMES_WORKER_GAP",
"evidence_writer": "windows-chatgpt-owner",
"aios_v2_env_status": {
"exists": true,
"owner": "root",
"group": "gitops",
"mode": "0640",
"four_fields_nonempty": true,
"credential_ref_target_exists": true,
"signing_material_target_exists": true
},
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
"candidate_updated_at": "2026-07-20T09:39:16Z",
"worktree_repair": {
"status": "resolved",
"branch_unique_checkout": true,
"clean_before_merge": true,
"stale_lock_count": 0,
"prunable_count": 0,
"evidence_backup": "protected external backup created before merge",
"verified_at": "2026-07-20T09:39:16Z"
},
"release_recommendation": "STOP_PREPRODUCTION",
"gate_a_allowed": true,
"resolved_blockers": 5,
"open_blockers": 0,
"controller_patch_commit": "6188da26453505dacde9d6d298191437164c8d27",
"signing_contract_commit": "8a36f7832bc105e4b9d32ffbe018abed2ff1b1ab",
"secret_audit": {
"plaintext_before": 3,
"plaintext_after": 0,
"unresolved_after": 0,
"shadowed_after": 0,
"legacy_after": 0,
"gateway_token_rotated": true,
"secret_values_recorded": false
},
"provenance_id": "openclaw-v2r1-20260721T031523Z",
"provenance_live_commit": "25c25abd3ed19091fe078d4d7a271c57f23e87fb"
,"gate_a": "passed"
,"gate_b": "passed_10_of_10"
,"offline_acceptance": "passed_8_failed_0_warning_1"
,"online_openclaw_contract": "passed"
,"aios_restart_recovery": "passed"
,"openclaw_restart_idempotency": "passed"
,"rollback_drill": "passed"
,"online_evidence_log": "/var/lib/aios-gitops/acceptance/aios-v2r1-20260721T0325Z/online-runtime-openclaw-final4.log"
,"restart_evidence_log": "/var/lib/aios-gitops/acceptance/aios-v2r1-20260721T0325Z/restart-recovery.log"
,"rollback_evidence_log": "/var/lib/aios-gitops/acceptance/aios-v2r1-20260721T0325Z/rollback-drill-final3.log"
,"remaining_acceptance_gap": "Hermes claim/start/heartbeat/fail/cancel/lease-expiry/worker-crash-recovery 与 Cell/Profile 错误结果拒绝未暴露为在线服务端点"
}
+43
View File
@@ -0,0 +1,43 @@
{
"schema": "aios.v2.agent-result.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"final_status": "PARTIAL",
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
"task_commit_before_owner_evidence": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a",
"gitea_sync": {
"candidate_match": true,
"openclaw_task_match_before_owner_evidence": true,
"codex_task_match": true
},
"secret_reference_status": "resolved",
"executor_status": "running",
"test_status": "Gate A、Gate B 10/10、离线 8/0/1、OpenClaw 正式在线、重启恢复和回滚通过;Hermes 完整在线 Worker 生命周期未覆盖",
"deployment_status": "OpenClaw Runtime rc3 与 AIOS 隔离 Release b158041 active;无生产流量切换",
"rollback_status": "Previous 2a2b702 与 Current b158041 双向演练通过,业务状态保留",
"documentation_sync_status": "updated and Gate A passed",
"changed_files": [
".agent/claim.json",
".agent/heartbeat.json",
".agent/progress.json",
".agent/blockers.json",
".agent/result.json",
".agent/status.json",
".agent/credential-status.json"
],
"open_blockers": [],
"remaining_risks": [
"Hermes claim/start/heartbeat/fail/cancel/lease expiry/worker crash recovery/Cell/Profile 错误结果拒绝尚无在线服务端点证据"
],
"completed_at": "2026-07-21T04:30:00Z",
"evidence_writer": "windows-chatgpt-owner",
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
"candidate_updated_at": "2026-07-20T09:39:16Z",
"release_recommendation": "STOP_PREPRODUCTION",
"provenance_id": "openclaw-v2r1-20260721T031523Z",
"provenance_live_commit": "25c25abd3ed19091fe078d4d7a271c57f23e87fb",
"online_openclaw_contract": "passed",
"restart_recovery": "passed",
"rollback_drill": "passed",
"formal_online_acceptance": "partial"
}
@@ -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.
+26
View File
@@ -0,0 +1,26 @@
{
"schema": "aios.v2.agent-status.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"executor": "openclaw",
"state": "running",
"status": "running",
"claimed_at": "2026-07-20T08:24:30Z",
"updated_at": "2026-07-21T04:30:00Z",
"last_heartbeat": "2026-07-21T04:30:00Z",
"heartbeat_valid_for_running": true,
"current_step": "ONLINE_HERMES_WORKER_GAP",
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
"release_recommendation": "STOP_PREPRODUCTION",
"evidence_writer": "windows-chatgpt-owner",
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
"candidate_updated_at": "2026-07-20T09:39:16Z"
,"task_commit_before_evidence": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a"
,"gate_a": "passed"
,"gate_b": "passed_10_of_10"
,"offline_acceptance": "passed_8_failed_0_warning_1"
,"online_openclaw": "passed"
,"restart_recovery": "passed"
,"rollback_drill": "passed"
,"hermes_full_online_worker_lifecycle": "not_exposed"
}
+34
View File
@@ -0,0 +1,34 @@
{
"schema": "aios.v2.agent-task.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"display_name": "AIOS V2R1 OpenClaw 预发布部署与小型缺陷修复任务",
"language": "zh-CN",
"executor": "openclaw",
"role": "deployment-operator",
"state": "queued",
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
"canonical_task": "tasks/openclaw-production-deploy-task.yaml",
"supervisor_task": "task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720",
"claim_required": true,
"heartbeat_interval_seconds": 300,
"evidence_directory": ".agent/evidence",
"instructions": [
"领取前读取候选分支完整中文文档和生产候选报告",
"领取后写入 .agent/claim.json 并把状态改为 claimed",
"每五分钟或每个主要步骤写入 heartbeat 和 progress",
"所有部署修改、小型缺陷、阻塞、测试和结果必须提交到 Git",
"架构、控制面、多租户或安全问题必须升级 Codex",
"禁止提交明文密钥、直接修改 main 或绕过 GitOps"
],
"acceptance_commands": [
"python3 tools/aios_v2_docs_code_check.py --base-ref integration/aios-v2r1-cto-production-candidate-20260720",
"python3 -m unittest discover -s tests/aios_v2_candidate -p test_*.py -v",
"bash deploy/aios-v2/acceptance/run-all.sh",
"bash deploy/aios-v2/healthcheck/check.sh"
],
"created_at": "2026-07-20T00:00:00Z",
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
"candidate_updated_at": "2026-07-20T09:39:16Z"
}
@@ -0,0 +1,67 @@
{
"schema": "aios.v2.acceptance.v1",
"generated_at": "2026-07-21T03:26:12.120179Z",
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"commit": "09fa4f9d73799a2e924153d198d8ebb09ff15cfd",
"workspace_dirty": false,
"passed": 8,
"failed": 0,
"warnings": 1,
"steps": [
{
"name": "文档与代码匹配",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/文档与代码匹配.log"
},
{
"name": "Python 语法编译",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/Python_语法编译.log"
},
{
"name": "候选单元与集成测试",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/候选单元与集成测试.log"
},
{
"name": "Shell 脚本语法",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/Shell_脚本语法.log"
},
{
"name": "文档 Git 冲突标记",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/文档_Git_冲突标记.log"
},
{
"name": "明文密钥基础扫描",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/明文密钥基础扫描.log"
},
{
"name": "systemd 单元验证",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/systemd_单元验证.log"
},
{
"name": "Docker Compose 配置验证",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/Docker_Compose_配置验证.log"
},
{
"name": "在线健康检查",
"status": "警告",
"exit_code": 0,
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
}
],
"final_status": "passed"
}
@@ -0,0 +1,67 @@
{
"schema": "aios.v2.acceptance.v1",
"generated_at": "2026-07-21T03:40:03.455790Z",
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"commit": "57209760cd159e564d7de1160090917dbbc7ce0a",
"workspace_dirty": false,
"passed": 8,
"failed": 0,
"warnings": 1,
"steps": [
{
"name": "文档与代码匹配",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/文档与代码匹配.log"
},
{
"name": "Python 语法编译",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/Python_语法编译.log"
},
{
"name": "候选单元与集成测试",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/候选单元与集成测试.log"
},
{
"name": "Shell 脚本语法",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/Shell_脚本语法.log"
},
{
"name": "文档 Git 冲突标记",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/文档_Git_冲突标记.log"
},
{
"name": "明文密钥基础扫描",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/明文密钥基础扫描.log"
},
{
"name": "systemd 单元验证",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/systemd_单元验证.log"
},
{
"name": "Docker Compose 配置验证",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/Docker_Compose_配置验证.log"
},
{
"name": "在线健康检查",
"status": "警告",
"exit_code": 0,
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
}
],
"final_status": "passed"
}
@@ -0,0 +1,67 @@
{
"schema": "aios.v2.acceptance.v1",
"generated_at": "2026-07-21T04:11:34.291792Z",
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"commit": "fedb6b08fbb8c825972f700318a81e902e89cda5",
"workspace_dirty": false,
"passed": 8,
"failed": 0,
"warnings": 1,
"steps": [
{
"name": "文档与代码匹配",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/文档与代码匹配.log"
},
{
"name": "Python 语法编译",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/Python_语法编译.log"
},
{
"name": "候选单元与集成测试",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/候选单元与集成测试.log"
},
{
"name": "Shell 脚本语法",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/Shell_脚本语法.log"
},
{
"name": "文档 Git 冲突标记",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/文档_Git_冲突标记.log"
},
{
"name": "明文密钥基础扫描",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/明文密钥基础扫描.log"
},
{
"name": "systemd 单元验证",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/systemd_单元验证.log"
},
{
"name": "Docker Compose 配置验证",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/Docker_Compose_配置验证.log"
},
{
"name": "在线健康检查",
"status": "警告",
"exit_code": 0,
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
}
],
"final_status": "passed"
}
@@ -0,0 +1,67 @@
{
"schema": "aios.v2.acceptance.v1",
"generated_at": "2026-07-21T04:16:01.542048Z",
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"commit": "07b5c2c9c28e7840b6261172a7d34aa7de7c48c9",
"workspace_dirty": false,
"passed": 8,
"failed": 0,
"warnings": 1,
"steps": [
{
"name": "文档与代码匹配",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/文档与代码匹配.log"
},
{
"name": "Python 语法编译",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/Python_语法编译.log"
},
{
"name": "候选单元与集成测试",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/候选单元与集成测试.log"
},
{
"name": "Shell 脚本语法",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/Shell_脚本语法.log"
},
{
"name": "文档 Git 冲突标记",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/文档_Git_冲突标记.log"
},
{
"name": "明文密钥基础扫描",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/明文密钥基础扫描.log"
},
{
"name": "systemd 单元验证",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/systemd_单元验证.log"
},
{
"name": "Docker Compose 配置验证",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/Docker_Compose_配置验证.log"
},
{
"name": "在线健康检查",
"status": "警告",
"exit_code": 0,
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
}
],
"final_status": "passed"
}
@@ -0,0 +1,67 @@
{
"schema": "aios.v2.acceptance.v1",
"generated_at": "2026-07-21T04:21:06.025742Z",
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"commit": "c81d4f40f3191037cb1a34f169e0a9ed7825bdbf",
"workspace_dirty": false,
"passed": 8,
"failed": 0,
"warnings": 1,
"steps": [
{
"name": "文档与代码匹配",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/文档与代码匹配.log"
},
{
"name": "Python 语法编译",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/Python_语法编译.log"
},
{
"name": "候选单元与集成测试",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/候选单元与集成测试.log"
},
{
"name": "Shell 脚本语法",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/Shell_脚本语法.log"
},
{
"name": "文档 Git 冲突标记",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/文档_Git_冲突标记.log"
},
{
"name": "明文密钥基础扫描",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/明文密钥基础扫描.log"
},
{
"name": "systemd 单元验证",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/systemd_单元验证.log"
},
{
"name": "Docker Compose 配置验证",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/Docker_Compose_配置验证.log"
},
{
"name": "在线健康检查",
"status": "警告",
"exit_code": 0,
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
}
],
"final_status": "passed"
}
@@ -0,0 +1,67 @@
{
"schema": "aios.v2.acceptance.v1",
"generated_at": "2026-07-21T04:24:51.586289Z",
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"commit": "2a2b702eb8f9501a637b9f9b5f033482eec7cc75",
"workspace_dirty": false,
"passed": 8,
"failed": 0,
"warnings": 1,
"steps": [
{
"name": "文档与代码匹配",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/文档与代码匹配.log"
},
{
"name": "Python 语法编译",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/Python_语法编译.log"
},
{
"name": "候选单元与集成测试",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/候选单元与集成测试.log"
},
{
"name": "Shell 脚本语法",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/Shell_脚本语法.log"
},
{
"name": "文档 Git 冲突标记",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/文档_Git_冲突标记.log"
},
{
"name": "明文密钥基础扫描",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/明文密钥基础扫描.log"
},
{
"name": "systemd 单元验证",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/systemd_单元验证.log"
},
{
"name": "Docker Compose 配置验证",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/Docker_Compose_配置验证.log"
},
{
"name": "在线健康检查",
"status": "警告",
"exit_code": 0,
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
}
],
"final_status": "passed"
}
+1
View File
@@ -10,3 +10,4 @@ node_modules/
runtime/tmp/
runtime/cache/
logs/
config/test_write_perms
+404
View File
@@ -0,0 +1,404 @@
# 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 决策。
## 15. Candidate 命令签名算法
Key ID `openclaw-v2r1-candidate-ed25519` 对应真实 Ed25519 私钥,不得再把同名材料作为 HMAC key 使用。私钥由 Owner 批准流程在受保护边界内生成,文件权限为 `0600`,Owner 为实际 AIOS 服务用户;Git、日志和 Evidence 仅记录 Key ID、权限检查和 sign/verify 结果。
## 16. 2026-07-20 阻塞清除快照
五项高风险 Blocker 均已形成运行证据。`BLK-OPENCLAW-RUN-PROVENANCE-MISSING` 通过 Git 中的 `aios-tenant-boundary` rc2 插件解除:Ed25519 Owner 签发记录持久保存,授权绑定 Run、Sender、Task、Claim OID、Candidate OID 与任务分支;专用 `aios_evidence_write` 只允许七个 `.agent` JSON 和固定 Git 操作,通用 Shell/写工具保持拒绝。
Provenance `openclaw-v2r1-20260721T031523Z` 已完成 `heartbeat.json` 更新、Commit、当前任务分支普通 Push 和追加式审计;`README.md` 越权写入被拒绝。由此允许启动 Gate A、Gate B 和离线验收,但在三项验收及后续在线联调完成前仍保持 `STOP_PREPRODUCTION`。
+1 -1
View File
@@ -1 +1 @@
V1R1C4
V1R4C6
+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"]
+100 -2
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 = [
@@ -200,6 +200,11 @@ class H(BaseHTTPRequestHandler):
latest=monitor.latest()
ldict={'event_id':latest.event_id,'action':latest.action,'error':latest.error,'context':latest.context,'created_at':latest.created_at} if latest else None
return self.sendj({'events':[{"event_id":e.event_id,"action":e.action,"error":e.error,"context":e.context,"created_at":e.created_at} for e in monitor.list_events()],'latest':ldict})
# Admin AI Evolution Dashboard
if path in ('/admin/ai-evolution','/admin/ai-evolution/'):
return self.ai_evolution_page()
if path in ('/admin/ai-evolution/api/data'):
return self.ai_evolution_data()
return self.sendj({'error':'not_found','path':path},404)
except Exception as e:
monitor.record_failure('do_GET', str(e), {'path':path})
@@ -290,9 +295,102 @@ class H(BaseHTTPRequestHandler):
elif 'model' in low or '模型' in text: intent='model_change'; plan=['use POST /api/model or PUT /api/model-priority','commit metadata']
elif 'patch' in low or '补丁' in text: intent='patch_create'; plan=['call POST /api/patch']
b=checkout(data.get('branch') or f'patch/nl-{uid("nl")}', 'main', True); doc={'id':uid('nl'),'text':text,'intent':intent,'action_plan':plan,'execute':False,'owner_approval_required':True,'created_by':a['account'],'created_at':now()}; r=write_json(f'patches/nl/{safe(doc["id"])}.json', doc); c=commit([r], f'chore(chatgpt): record nl plan {intent}'); self.sendj({'status':'planned','branch':b,'request':doc,'commit':c})
_EVOLUTION_HTML = '''<!DOCTYPE html>
<html lang=zh-CN>
<head>
<meta charset=UTF-8>
<meta name=viewport content="width=device-width,initial-scale=1.0">
<title>AIOS Evolution Dashboard</title>
<script src=https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0d1117;color:#c9d1d9;font-size:14px;line-height:1.7;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif}
.c{max-width:1000px;margin:0 auto;padding:32px 24px}
h1{font-size:24px;color:#58a6ff;margin-bottom:2px}
.sub{color:#8b949e;font-size:13px;border-bottom:1px solid #21262d;padding-bottom:16px;margin-bottom:20px;display:flex;justify-content:space-between}
.g{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:16px;margin-bottom:20px}
.s{background:#161b22;border:1px solid #21262d;border-radius:8px;padding:18px;text-align:center}
.s .v{font-size:28px;font-weight:700;color:#f0f6fc}
.s .l{font-size:11px;color:#8b949e;margin-top:4px}
.bx{background:#161b22;border:1px solid #21262d;border-radius:8px;margin-bottom:14px}
.bh{border-bottom:1px solid #21262d;padding:10px 16px;font-weight:600;font-size:14px;display:flex;gap:6px;align-items:center}
.bd{padding:12px 16px}
.rw{display:grid;grid-template-columns:1fr 1fr 100px 60px;gap:8px;padding:8px 16px;border-bottom:1px solid #21262d;font-size:13px;align-items:center;cursor:pointer;transition:background .15s}
.rw:hover{background:#1c2128}.rw:last-child{border-bottom:none}
.tg{display:inline-block;padding:2px 10px;border-radius:99px;font-size:11px;font-weight:600}
.ok{background:#3fb95033;color:#3fb950}.fl{background:#da363333;color:#f85149}.if{background:#1f6feb33;color:#58a6ff}
pre{background:#0d1117;padding:12px;border-radius:6px;overflow:auto;font-size:12px;color:#8b949e;max-height:300px;margin-top:8px}select{padding:8px 10px;border:1px solid #30363d;border-radius:6px;background:#21262d;color:#c9d1d9;font-size:13px;max-width:400px;width:100%;margin-bottom:4px;cursor:pointer}
</style>
</head>
<body>
<div id=app class=c>
<h1>AIOS Evolution Dashboard</h1>
<div class=sub><span>Baseline: {{baseline}}</span><span style=color:#8b949e>{{refreshMsg}}</span></div>
<div class=g><div class=s><div class=v>{{tot}}</div><div class=l>Commands</div></div><div class=s><div class=v style=color:#3fb950>{{sok}}</div><div class=l>Succeeded</div></div><div class=s><div class=v style=color:#f85149>{{sfail}}</div><div class=l>Failed</div></div><div class=s><div class=v>{{runs}}</div><div class=l>Executions</div></div></div>
<div class=bx><div class=bh>Version Timeline</div><div class=bd><div class=rw style=font-weight:600;border-bottom:2px solid #30363d;cursor:default><span>Version</span><span>Time</span><span>Result</span></div><div v-for=v in vs class=rw @click=selVer(v)><span style=color:#58a6ff>{{v.tag}}</span><span>{{v.time||'-'}}</span><span :class="'tg '+v.result">{{v.result||'-'}}</span></div></div></div>
<div class=bx><div class=bh>Commands</div><div class=bd><select v-model=cmd @change=loadCmd><option value>-- Select --</option><option v-for=c in cmds :value=c>{{c}}</option></select><pre v-if=cdet>{{cdet}}</pre></div></div>
<div class=bx><div class=bh>Recent Agent Runs</div><div class=bd><div class=rw style=font-weight:600;border-bottom:2px solid #30363d;cursor:default><span>Agent</span><span>Command</span><span>Count</span><span>Result</span></div><div v-for=r in aruns class=rw><span>{{r.agent||'-'}}</span><span style=overflow:hidden;white-space:nowrap;text-overflow:ellipsis>{{r.command}}</span><span>{{r.interaction_count||0}}</span><span :class="'tg '+r.final_result">{{r.final_result||'-'}}</span></div></div></div></div>
<script>
const{createApp,ref,onMounted}=Vue;createApp({setup(){const vs=ref([]),cmds=ref([]),ar=ref([]),cmd=ref(''),cdet=ref(null),tot=ref(0),sok=ref(0),sfail=ref(0),runs=ref(0),baseline=ref('V1R3C1'),refreshMsg=ref('')
async function ld(){try{const r=await fetch('/admin/ai-evolution/api/data'),d=await r.json();baseline.value=d.baseline||'V1R3C1';vs.value=d.versions||[];cmds.value=d.commands||[];ar.value=d.agent_runs||[];tot.value=d.stats?.total||0;sok.value=d.stats?.success||0;sfail.value=d.stats?.failed||0;runs.value=d.stats?.agent_runs||0;refreshMsg.value='Last updated: '+new Date().toLocaleString()}catch(e){}}
async function loadCmd(){if(!cmd.value){cdet.value=null;return}try{const r=await fetch('/admin/ai-evolution/api/data?command='+encodeURIComponent(cmd.value)),d=await r.json();cdet.value=JSON.stringify(d,null,2)}catch(e){cdet.value='Error'}}
function selVer(v){cmd.value=v.tag||v.id||v.version;loadCmd()}
onMounted(()=>{ld();setInterval(ld,30000)})
return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer}})mount('#app')
</script></body></html>'''
def ai_evolution_page(self):
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(self._EVOLUTION_HTML)))
self.end_headers()
self.wfile.write(self._EVOLUTION_HTML.encode())
def ai_evolution_data(self):
q=urllib.parse.urlparse(self.path).query
params=urllib.parse.parse_qs(q)
cmd_filter=(params.get('command') or [None])[0]
bl=jload(WS/'baseline'/'current.json',{})
current_ver=bl.get('current_baseline','V1R3C1')
tags=sh(['tag','--list','V*'])['stdout'].splitlines() if WS.exists() else []
versions=[]
if (WS/'baseline'/'promotions').exists():
for p in sorted((WS/'baseline'/'promotions').glob('*.json')):
d=jload(p,{});v=d.get('version',p.stem)
versions.append({'tag':v,'time':d.get('promoted_at','-'),'result':'ok'})
for t in tags:
if not any(v['tag']==t for v in versions):
versions.append({'tag':t,'time':'-','result':'if'})
agent_runs=[];ard=WS/'audit'/'agent-runs'
if ard.exists():
for f in sorted(ard.glob('*.json'),reverse=True)[:50]:
d=jload(f,{});agent_runs.append(d) if isinstance(d,dict) else None
cmd_dirs=[WS/'agents/commands/active',WS/'agents/commands/running',WS/'agents/commands/completed',WS/'agents/commands/failed']
commands=[]
for d in cmd_dirs:
if d.exists():
for f in sorted(d.glob('*.md')):
commands.append(f.name)
command_detail=None
if cmd_filter:
for d in cmd_dirs:
if d.exists():
fp=d/cmd_filter
if fp.exists():
command_detail={'command':cmd_filter,'content':fp.read_text(encoding='utf-8')}
break
if not command_detail:
command_detail={'command':cmd_filter,'error':'not_found'}
return self.sendj(command_detail)
completed=list((WS/'agents/commands/completed').glob('*.md')) if (WS/'agents/commands/completed').exists() else []
failed=list((WS/'agents/commands/failed').glob('*.md')) if (WS/'agents/commands/failed').exists() else []
stats={'total':len(commands),'success':len(completed),'failed':len(failed),'agent_runs':len(agent_runs)}
data={'baseline':current_ver,'versions':versions[-30:],'agent_runs':agent_runs,'commands':sorted(set(commands)),'stats':stats}
self.sendj(data)
def api_spec():
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}}
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}}
def main():
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
@@ -0,0 +1,25 @@
"""AIOS-managed Hermes runtime boundary."""
from .adapter import (
HermesAdapter,
HermesConflict,
HermesNotFound,
HermesRuntimeError,
InMemoryHermesStore,
)
from .contract import HermesHealth, HermesJob, HermesJobState, HermesResult
from .worker import HermesWorker, WorkerRunResult
__all__ = [
"HermesAdapter",
"HermesConflict",
"HermesHealth",
"HermesJob",
"HermesJobState",
"HermesNotFound",
"HermesResult",
"HermesRuntimeError",
"HermesWorker",
"InMemoryHermesStore",
"WorkerRunResult",
]
+437
View File
@@ -0,0 +1,437 @@
"""Tenant-partitioned Hermes queue and worker lease boundary."""
from __future__ import annotations
import hashlib
import json
import threading
from dataclasses import replace
from datetime import datetime, timedelta, timezone
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]] = {}
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)
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:
self._requeue_expired_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,
) -> 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.state == HermesJobState.QUEUED
]
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
if not candidates:
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)
return claimed
def start(
self,
job_id: str,
*,
tenant_id: str,
worker_id: str,
expected_revision: int | None = None,
) -> HermesJob:
with self.store.lock:
current = self._owned_job_locked(job_id, tenant_id, worker_id)
self._assert_revision(current, expected_revision)
return self._transition_locked(current, HermesJobState.RUNNING)
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)
with self.store.lock:
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)
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")
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
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 health(self) -> HermesHealth:
with self.store.lock:
self._requeue_expired_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:
current = self._owned_job_locked(job_id, tenant_id, worker_id)
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)
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) -> None:
now = datetime.now(timezone.utc)
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(),
)
+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,37 @@
"""AIOS-managed OpenClaw execution adapter."""
from .adapter import (
AdapterError,
CommandConflict,
CommandNotFound,
CommandRejected,
InMemoryCallbackSink,
OpenClawAdapter,
)
from .contract import (
CommandState,
OpenClawCommand,
OpenClawResult,
RuntimeHealth,
sign_command,
verify_command_signature,
)
from .transport import HTTPRuntimeTransport, InProcessRuntimeTransport, RuntimeTransport
__all__ = [
"AdapterError",
"CommandConflict",
"CommandNotFound",
"CommandRejected",
"CommandState",
"HTTPRuntimeTransport",
"InMemoryCallbackSink",
"InProcessRuntimeTransport",
"OpenClawAdapter",
"OpenClawCommand",
"OpenClawResult",
"RuntimeHealth",
"RuntimeTransport",
"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, *, tenant_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, tenant_id=tenant_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,230 @@
"""Versioned AIOS to OpenClaw execution contracts."""
from __future__ import annotations
import base64
import json
import uuid
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Mapping
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
CONTRACT_VERSION = "aios.openclaw.command.v1"
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))
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
key_id: str = "default"
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", "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}")
object.__setattr__(self, "payload", _safe_mapping(self.payload))
_parse_timestamp(self.created_at)
if self.expires_at is not None:
object.__setattr__(self, "expires_at", _required(self.expires_at, "expires_at"))
_parse_timestamp(self.expires_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,
"key_id": self.key_id,
}
@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 _ed25519_private_key(key_material: bytes | str) -> Ed25519PrivateKey:
material = key_material.encode("utf-8") if isinstance(key_material, str) else bytes(key_material)
if material.startswith(b"-----BEGIN"):
key = serialization.load_pem_private_key(material, password=None)
if not isinstance(key, Ed25519PrivateKey):
raise ValueError("signing material is not an Ed25519 private key")
return key
if len(material) != 32:
raise ValueError("Ed25519 raw private signing material must be exactly 32 bytes")
return Ed25519PrivateKey.from_private_bytes(material)
def _decode_signature(value: str) -> bytes:
encoded = value.encode("ascii")
return base64.urlsafe_b64decode(encoded + b"=" * (-len(encoded) % 4))
def sign_command(command: OpenClawCommand, key_material: bytes | str) -> OpenClawCommand:
key = _ed25519_private_key(key_material)
signature = base64.urlsafe_b64encode(key.sign(canonical_command_bytes(command))).decode("ascii").rstrip("=")
return replace(command, signature=signature)
def verify_command_signature(
command: OpenClawCommand,
key_material: bytes | str,
*,
now: datetime | None = None,
clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS,
) -> bool:
if not command.signature:
return False
try:
key = _ed25519_private_key(key_material)
key.public_key().verify(_decode_signature(command.signature), canonical_command_bytes(command))
except (InvalidSignature, ValueError, TypeError, UnicodeError):
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,222 @@
"""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, *, tenant_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()
self._command_tenants: dict[str, str] = {}
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
with self._lock:
known_tenant = self._command_tenants.get(command.command_id)
if known_tenant is not None and known_tenant != command.tenant_id:
raise TransportError("command id belongs to another tenant")
self._command_tenants[command.command_id] = command.tenant_id
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, *, tenant_id: str) -> bool:
with self._lock:
if self._command_tenants.get(command_id) != tenant_id:
return False
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, *, tenant_id: str) -> bool:
value = self._request(
"POST",
f"/v1/commands/{urllib.parse.quote(command_id, safe='')}/cancel"
f"?tenant_id={urllib.parse.quote(tenant_id, safe='')}",
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 {},
)
+46
View File
@@ -0,0 +1,46 @@
"""AIOS V2 control plane and execution orchestration."""
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
__all__ = [
"AgentRecord",
"AuditEvent",
"ControlPlaneError",
"ControlPlaneService",
"DispatchRecord",
"ExecutionEvidence",
"ExecutionOrchestrator",
"ExecutionTracker",
"IdempotencyConflict",
"InMemoryControlPlaneStore",
"InvalidTransition",
"NotFound",
"OrchestrationError",
"PolicyDenied",
"RequestContext",
"RevisionConflict",
"TaskRecord",
"TaskState",
"TenantIsolationError",
"TenantRecord",
"Unauthorized",
]
+359
View File
@@ -0,0 +1,359 @@
"""Production-candidate AIOS V2 application entrypoint.
This process owns the public control-plane API and read-only web console. It
loads durable control-plane state, dispatches approved work through runtime
adapters, exposes tenant-scoped execution evidence and sanitized GitOps status,
and never exposes runtime credentials to the browser.
"""
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
from aios_openclaw import HTTPRuntimeTransport, OpenClawAdapter
from aios_openclaw.callback import AuditCallbackSink, EventPublisher
from .console import CONSOLE_HTML
from .control_plane import AuditEvent, RequestContext
from .execution_tracking import ExecutionTracker
from .gateway import GatewayApplication, error_response
from .gitops_status import collect_gitops_status
from .orchestrator import ExecutionOrchestrator
from .persistence import DurableControlPlaneService, SQLiteSnapshotRepository
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
class FileSigningMaterialResolver:
"""Resolve runtime signing material from a root-owned file reference."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
def __call__(self, tenant_id: str, key_id: str) -> bytes:
del tenant_id, key_id
metadata = self.path.stat()
if not stat.S_ISREG(metadata.st_mode):
raise RuntimeError("signing material reference is not a regular file")
if metadata.st_mode & 0o077:
raise RuntimeError("signing material file permissions must be 0600 or stricter")
value = self.path.read_bytes()
if len(value) < 32:
raise RuntimeError("signing material is too short")
return value
class ControlPlaneEventPublisher(EventPublisher):
"""Append OpenClaw runtime events to the tenant audit stream."""
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
audit = AuditEvent(
event_id=f"runtime_{command_id}_{len(self.service.store.audit_events) + 1}",
tenant_id=tenant_id,
principal_id="openclaw-runtime",
trace_id=trace_id,
action=event_type,
resource_type="openclaw_command",
resource_id=command_id,
outcome=outcome,
policy_version="runtime-v1",
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:
"""Compose durable control plane, runtimes, evidence and console routes."""
def __init__(
self,
gateway: GatewayApplication,
orchestrator: ExecutionOrchestrator,
tracker: ExecutionTracker,
hermes: HermesAdapter,
openclaw: OpenClawAdapter | None,
) -> None:
self.gateway = gateway
self.orchestrator = orchestrator
self.tracker = tracker
self.hermes = hermes
self.openclaw = openclaw
def context(self, headers: Mapping[str, str]) -> RequestContext:
return self.gateway.context_from_headers(headers)
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")
record = self.orchestrator.dispatch(
context,
task_id=task_id,
timeout_seconds=float(timeout) if timeout is not None else None,
)
return HTTPStatus.ACCEPTED, {"dispatch": _json_value(record)}
if method == "POST" and path == "/api/v2/hermes/results/reconcile":
context = self.context(headers)
if not isinstance(body, Mapping):
raise ValueError("JSON body must be an object")
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)
return HTTPStatus.OK, {"task": _json_value(task)}
return self.gateway.handle(method, raw_path, headers, body)
class CandidateRequestHandler(BaseHTTPRequestHandler):
server_version = "AIOSV2Candidate/1.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 BaseException 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")
service = DurableControlPlaneService(repository)
tracker = ExecutionTracker(state_dir / "executions")
hermes = HermesAdapter()
openclaw_url = os.getenv("AIOS_OPENCLAW_URL", "").strip()
material_file = os.getenv("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", "").strip()
openclaw_key_id = os.getenv("AIOS_OPENCLAW_SIGNING_KEY_ID", "default").strip() or "default"
openclaw = None
resolver = None
if openclaw_url and material_file:
resolver = FileSigningMaterialResolver(material_file)
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=openclaw_key_id,
)
return CandidateApplication(gateway, orchestrator, tracker, hermes, openclaw)
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()
+69
View File
@@ -0,0 +1,69 @@
"""Dependency-free AIOS V2 web console assets.
Authentication and tenant identity are injected by the trusted reverse proxy.
The browser never selects a tenant and never calls OpenClaw or Hermes directly.
"""
CONSOLE_HTML = r'''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>AIOS V2 Console</title>
<style>
:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#152033;background:#f4f7fb}
*{box-sizing:border-box} body{margin:0} header{background:#111827;color:white;padding:18px 24px;display:flex;justify-content:space-between;align-items:center}
header h1{font-size:20px;margin:0} header span{font-size:12px;color:#cbd5e1}
main{padding:24px;max-width:1440px;margin:auto}.notice{background:#fff7ed;border:1px solid #fed7aa;padding:12px 14px;border-radius:10px;margin-bottom:18px;font-size:13px}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:14px;margin-bottom:20px}.card,.panel{background:white;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.card{padding:16px}.card b{display:block;font-size:28px;margin-top:8px}.card small{color:#64748b}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:16px}.panel{padding:16px;overflow:auto}.panel h2{font-size:16px;margin:0 0 12px}
table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;border-bottom:1px solid #edf0f4;padding:8px;vertical-align:top}th{color:#64748b;font-weight:600}.state{font-weight:700}.ok{color:#047857}.bad{color:#b91c1c}.muted{color:#64748b}
button{border:0;border-radius:8px;padding:9px 13px;background:#2563eb;color:white;cursor:pointer}button:disabled{opacity:.5}.toolbar{display:flex;gap:8px;align-items:center;margin-bottom:16px}.error{color:#b91c1c;white-space:pre-wrap}
</style>
</head>
<body>
<header><h1>AIOS V2 Control Plane</h1><span id="updated">Not loaded</span></header>
<main>
<div class="notice">Read-only candidate console. Identity and tenant context must be injected by the trusted reverse proxy. The console never calls OpenClaw or Hermes directly.</div>
<div class="toolbar"><button id="refresh">Refresh</button><span id="error" class="error"></span></div>
<section class="cards">
<div class="card"><small>Agents</small><b id="agentCount">—</b></div>
<div class="card"><small>Tasks</small><b id="taskCount">—</b></div>
<div class="card"><small>Audit events</small><b id="auditCount">—</b></div>
<div class="card"><small>Executions</small><b id="executionCount">—</b></div>
</section>
<section class="grid">
<div class="panel"><h2>Runtime health</h2><div id="runtime"></div></div>
<div class="panel"><h2>Tasks</h2><div id="tasks"></div></div>
<div class="panel"><h2>Agents</h2><div id="agents"></div></div>
<div class="panel"><h2>Execution evidence</h2><div id="executions"></div></div>
<div class="panel"><h2>Audit</h2><div id="audit"></div></div>
</section>
</main>
<script>
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const table=(cols,rows)=>`<table><thead><tr>${cols.map(c=>`<th>${esc(c[0])}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${cols.map(c=>`<td>${esc(c[1](r))}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
async function api(path){const r=await fetch(path,{credentials:'same-origin',headers:{'Accept':'application/json'}});const data=await r.json().catch(()=>({}));if(!r.ok)throw new Error(`${r.status} ${data.message||data.error||path}`);return data}
async function refresh(){
const btn=document.getElementById('refresh');btn.disabled=true;document.getElementById('error').textContent='';
try{
const [agents,tasks,audit,runtime,executions]=await Promise.all([
api('/api/v2/agents'),api('/api/v2/tasks'),api('/api/v2/audit'),api('/api/v2/runtime'),api('/api/v2/executions')
]);
document.getElementById('agentCount').textContent=agents.agents.length;
document.getElementById('taskCount').textContent=tasks.tasks.length;
document.getElementById('auditCount').textContent=audit.events.length;
document.getElementById('executionCount').textContent=executions.executions.length;
document.getElementById('agents').innerHTML=table([['ID',x=>x.agent_id],['Runtime',x=>x.runtime],['Enabled',x=>x.enabled]],agents.agents);
document.getElementById('tasks').innerHTML=table([['Task',x=>x.task_id],['Action',x=>x.action],['State',x=>x.state],['Rev',x=>x.revision]],tasks.tasks);
document.getElementById('audit').innerHTML=table([['Time',x=>x.created_at],['Action',x=>x.action],['Outcome',x=>x.outcome],['Resource',x=>x.resource_id]],audit.events.slice(-100).reverse());
document.getElementById('executions').innerHTML=table([['Executor',x=>x.executor],['Task',x=>x.task_id],['Status',x=>x.status],['Progress',x=>`${x.progress_completed}/${x.progress_total}`],['Commit',x=>x.last_commit||'']],executions.executions);
document.getElementById('runtime').innerHTML=table([['Runtime',x=>x.name],['Status',x=>x.status],['Details',x=>JSON.stringify(x.details)]],runtime.runtimes);
document.getElementById('updated').textContent=`Updated ${new Date().toLocaleString()}`;
}catch(e){document.getElementById('error').textContent=e.message||String(e)}finally{btn.disabled=false}
}
document.getElementById('refresh').addEventListener('click',refresh);refresh();setInterval(refresh,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,195 @@
"""Git-backed agent execution evidence for AIOS V2.
Evidence is tenant-scoped, append-friendly, and contains no runtime credentials.
It provides a deterministic source for claim, heartbeat, progress, result and
verification state across Codex, OpenClaw and Hermes executors.
"""
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
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
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))
class ExecutionTracker:
"""Persist one JSON evidence record per tenant and execution."""
def __init__(self, root: str | Path) -> None:
self.root = Path(root).resolve()
self.root.mkdir(parents=True, exist_ok=True)
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 claim(self, evidence: ExecutionEvidence) -> 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")
return current
self._write(path, evidence)
return evidence
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,
) -> ExecutionEvidence:
path = self._path(tenant_id, execution_id)
with self._lock:
current = self._read(path)
updated = replace(
current,
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,
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,
) -> ExecutionEvidence:
path = self._path(tenant_id, execution_id)
with self._lock:
current = self._read(path)
updated = replace(
current,
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),
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.replace(temporary, path)
+357
View File
@@ -0,0 +1,357 @@
"""HTTP/JSON gateway for the AIOS V2 control plane.
The gateway is intentionally runtime-neutral: browsers and external clients
call this API, while OpenClaw and Hermes remain behind authenticated adapters.
Identity headers are accepted only from a trusted reverse proxy in v1; direct
public exposure is prohibited until signed identity middleware is enabled.
"""
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 .control_plane import (
ControlPlaneError,
ControlPlaneService,
IdempotencyConflict,
InvalidTransition,
NotFound,
PolicyDenied,
RequestContext,
RevisionConflict,
TaskState,
TenantIsolationError,
Unauthorized,
)
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 request router that can be tested without a listening socket."""
def __init__(self, service: ControlPlaneService | None = None) -> None:
self.service = service or ControlPlaneService()
@staticmethod
def context_from_headers(headers: Mapping[str, str]) -> RequestContext:
# HTTP field names are case-insensitive. BaseHTTPRequestHandler commonly
# presents them as title-cased (for example ``X-Aios-Authenticated``),
# so copying the message into a plain dict must not make identity lookup
# case-sensitive.
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":
tenant = self.service.get_tenant(context)
return HTTPStatus.OK, {"tenant": _json_value(tenant)}
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":
requested_states = query.get("state")
tasks = self.service.list_tasks(context, states=requested_states)
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))
}
transition_match = re.fullmatch(
r"/api/v2/tasks/([^/]+)/(approve|start|complete|fail|cancel)", path
)
if method == "POST" and transition_match:
task_id = _require_resource_id(
urllib.parse.unquote(transition_match.group(1)), label="task_id"
)
transition = transition_match.group(2)
target = {
"approve": TaskState.APPROVED,
"start": TaskState.RUNNING,
"complete": TaskState.COMPLETED,
"fail": TaskState.FAILED,
"cancel": TaskState.CANCELLED,
}[transition]
expected_revision = payload.get("expected_revision")
if expected_revision is not None:
expected_revision = int(expected_revision)
task = self.service.transition_task(
context,
task_id=task_id,
target_state=target,
expected_revision=expected_revision,
result=(
_require_mapping(payload.get("result"), label="result")
if payload.get("result") is not None
else None
),
error=(
_require_mapping(payload.get("error"), label="error")
if payload.get("error") 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),
(TenantIsolationError, HTTPStatus.NOT_FOUND),
(NotFound, HTTPStatus.NOT_FOUND),
(IdempotencyConflict, HTTPStatus.CONFLICT),
(RevisionConflict, HTTPStatus.CONFLICT),
(InvalidTransition, 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")
raw = self.rfile.read(length)
return json.loads(raw.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 BaseException as exc: # handler boundary; sanitized below
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:
# Production deployment routes structured access logs through the
# reverse proxy; do not log identity headers or request bodies here.
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,
},
)
+275
View File
@@ -0,0 +1,275 @@
"""AIOS V2 execution orchestrator.
The orchestrator is the only component allowed to translate an approved AIOS
task into an OpenClaw command or Hermes job. OpenClaw and Hermes remain pure
execution runtimes and never own tenant policy or task lifecycle.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Mapping
from aios_hermes import HermesAdapter, HermesJob, HermesResult
from aios_openclaw import (
CommandState,
OpenClawAdapter,
OpenClawCommand,
OpenClawResult,
sign_command,
)
from .control_plane import (
AgentRecord,
ControlPlaneService,
RequestContext,
TaskRecord,
TaskState,
)
class OrchestrationError(RuntimeError):
"""AIOS failed to dispatch or reconcile a runtime execution."""
@dataclass(frozen=True)
class DispatchRecord:
tenant_id: str
task_id: str
agent_id: str
runtime: str
runtime_execution_id: str
state: str
trace_id: str
class ExecutionOrchestrator:
"""Dispatch approved AIOS tasks to runtime adapters and reconcile 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",
) -> 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 = openclaw_key_id
self.default_cell_id = default_cell_id
self.default_profile_id = default_profile_id
self._dispatches: dict[tuple[str, str], DispatchRecord] = {}
def dispatch(
self,
context: RequestContext,
*,
task_id: str,
timeout_seconds: float | None = None,
) -> DispatchRecord:
task = self.control_plane.get_task(context, task_id)
if task.state != TaskState.APPROVED:
raise OrchestrationError("only APPROVED tasks may be dispatched")
agent = self.control_plane.get_agent(context, task.agent_id)
existing = self._dispatches.get((task.tenant_id, task.task_id))
if existing is not None:
return existing
running = self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.RUNNING,
expected_revision=task.revision,
)
runtime = agent.runtime.strip().lower()
try:
if runtime == "openclaw":
record = self._dispatch_openclaw(
context,
agent,
running,
timeout_seconds=timeout_seconds,
)
elif runtime == "hermes":
record = self._dispatch_hermes(context, agent, running)
else:
raise OrchestrationError(f"unsupported runtime: {agent.runtime}")
except Exception as exc:
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
self._dispatches[(task.tenant_id, task.task_id)] = record
return record
def _dispatch_openclaw(
self,
context: RequestContext,
agent: AgentRecord,
task: TaskRecord,
*,
timeout_seconds: float | None,
) -> DispatchRecord:
if self.openclaw is None or self.openclaw_signing_material_resolver is None:
raise OrchestrationError("OpenClaw runtime is not configured")
key_material = self.openclaw_signing_material_resolver(
task.tenant_id,
self.openclaw_key_id,
)
requested_timeout = (
self.openclaw.default_timeout_seconds
if timeout_seconds is None
else float(timeout_seconds)
)
effective_timeout = min(requested_timeout, 300.0)
expires_at = (
datetime.now(timezone.utc) + timedelta(seconds=effective_timeout)
).isoformat().replace("+00:00", "Z")
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,
expires_at=expires_at,
key_id=self.openclaw_key_id,
)
command = sign_command(command, key_material)
result = self.openclaw.execute(command, timeout_seconds=effective_timeout)
self.reconcile_openclaw(context, result)
return DispatchRecord(
tenant_id=task.tenant_id,
task_id=task.task_id,
agent_id=agent.agent_id,
runtime="openclaw",
runtime_execution_id=command.command_id,
state=result.state.value,
trace_id=context.trace_id,
)
def _dispatch_hermes(
self,
context: RequestContext,
agent: AgentRecord,
task: TaskRecord,
) -> DispatchRecord:
if self.hermes is None:
raise OrchestrationError("Hermes runtime is not configured")
metadata = dict(agent.metadata)
job = 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)),
)
queued = self.hermes.enqueue(job)
return DispatchRecord(
tenant_id=task.tenant_id,
task_id=task.task_id,
agent_id=agent.agent_id,
runtime="hermes",
runtime_execution_id=queued.job_id,
state=queued.state.value,
trace_id=context.trace_id,
)
def reconcile_openclaw(
self,
context: RequestContext,
result: OpenClawResult,
) -> TaskRecord:
task = self.control_plane.get_task(context, result.task_id)
if result.tenant_id != task.tenant_id:
raise OrchestrationError("OpenClaw result tenant mismatch")
if task.state != TaskState.RUNNING:
return task
if result.state == CommandState.COMPLETED:
return self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.COMPLETED,
expected_revision=task.revision,
result=result.output or {},
)
if result.state in {
CommandState.FAILED,
CommandState.TIMED_OUT,
CommandState.REJECTED,
}:
return self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.FAILED,
expected_revision=task.revision,
error=result.error or {"code": result.state.value.lower()},
)
if result.state == CommandState.CANCELLED:
return self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.CANCELLED,
expected_revision=task.revision,
)
return task
def reconcile_hermes(
self,
context: RequestContext,
result: HermesResult,
) -> TaskRecord:
task = self.control_plane.get_task(context, result.task_id)
if result.tenant_id != task.tenant_id:
raise OrchestrationError("Hermes result tenant mismatch")
if task.state != TaskState.RUNNING:
return task
if result.state.value == "COMPLETED":
return self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.COMPLETED,
expected_revision=task.revision,
result=result.output or {},
)
if result.state.value == "FAILED":
return self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.FAILED,
expected_revision=task.revision,
error=result.error or {"code": "hermes_failed"},
)
if result.state.value == "CANCELLED":
return self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.CANCELLED,
expected_revision=task.revision,
)
return task
def dispatch_snapshot(self, *, tenant_id: str) -> Mapping[str, DispatchRecord]:
return {
task_id: record
for (record_tenant, task_id), record in self._dispatches.items()
if record_tenant == 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))
@@ -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",
]
@@ -0,0 +1,265 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Evolution Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: #0d1117; color: #c9d1d9; height: 100vh; overflow: hidden; }
.app { display: flex; height: 100vh; }
.sidebar { width: 260px; background: #161b22; border-right: 1px solid #30363d; padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.sidebar h1 { font-size: 18px; color: #f0f6fc; border-bottom: 1px solid #30363d; padding-bottom: 12px; margin-bottom: 8px; }
.sidebar-nav { list-style: none; display: flex; flex-direction: column; gap: 4px; }
.sidebar-nav li { padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 14px; color: #8b949e; }
.sidebar-nav li:hover, .sidebar-nav li.active { background: #1f6feb22; color: #58a6ff; }
.sidebar-nav li i { margin-right: 8px; }
.main { flex: 1; padding: 24px; overflow-y: auto; }
.main-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.main-header h2 { font-size: 20px; }
.status-badge { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
.status-badge.ok { background: #1b3a2d; color: #3fb950; border: 1px solid #3fb95044; }
.status-badge.degraded { background: #3a2e1b; color: #d29922; border: 1px solid #d2992244; }
.status-badge.fail { background: #3a1b1b; color: #f85149; border: 1px solid #f8514944; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
.card-title { font-size: 14px; color: #8b949e; margin-bottom: 8px; }
.card-value { font-size: 28px; font-weight: 700; color: #f0f6fc; }
.row { display: flex; gap: 16px; }
.col { flex: 1; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
pre { white-space: pre-wrap; word-break: break-word; }
.command-item { padding: 12px; margin-bottom: 8px; border-radius: 6px; background: #0d1117; border: 1px solid #21262d; }
.command-item .meta { display: flex; gap: 8px; align-items: center; font-size: 12px; color: #8b949e; }
.command-item .name { font-size: 14px; color: #58a6ff; font-weight: 600; }
.command-item .state { font-size: 11px; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; }
.state-active { background: #1b3a2d; color: #3fb950; }
.state-running { background: #3a2e1b; color: #d29922; }
.state-completed { background: #1b3a2d; color: #3fb950; }
.state-failed { background: #3a1b1b; color: #f85149; }
.loading { text-align: center; padding: 40px; color: #8b949e; }
.error { padding: 20px; background: #3a1b1b; border: 1px solid #f8514944; border-radius: 8px; color: #f85149; margin-bottom: 16px; }
.version-item { padding: 8px 12px; background: #0d1117; border: 1px solid #21262d; border-radius: 6px; margin-bottom: 4px; display: flex; justify-content: space-between; }
.version-item .tag { color: #58a6ff; font-weight: 600; }
.version-item .name { color: #8b949e; font-size: 13px; }
.agent-item { background: #0d1117; border: 1px solid #21262d; border-radius: 6px; margin-bottom: 8px; padding: 12px; }
.agent-item .agent-title { font-size: 14px; font-weight: 600; color: #c9d1d9; margin-bottom: 4px; }
.agent-item .agent-meta { font-size: 12px; color: #8b949e; }
.agent-run-item { padding: 8px 12px; margin-bottom: 4px; border-radius: 4px; background: #0d1117; border: 1px solid #21262d; display: flex; justify-content: space-between; align-items: center; }
.agent-run-item .run-id { color: #58a6ff; font-size: 13px; font-weight: 600; }
.agent-run-item .source { color: #8b949e; font-size: 12px; }
</style>
</head>
<body>
<div id="app" class="app">
<div class="sidebar">
<h1>🕹️ AI Evolution Dashboard</h1>
<ul class="sidebar-nav">
<li class="active" onclick="showPanel('overview')"><i>📊</i> Overview</li>
<li onclick="showPanel('versions')"><i>🏷️</i> Versions</li>
<li onclick="showPanel('commands')"><i>⚡</i> Commands</li>
<li onclick="showPanel('agents')"><i>🤖</i> Agent Runs</li>
<li onclick="showPanel('arch-owner')"><i>🔐</i> Owner Gate</li>
<li onclick="showPanel('arch-approve')"><i>✓</i> Approve Changes</li>
</ul>
</div>
<div class="main">
<div id="panel-overview" class="panel">
<div class="main-header">
<h2>System Overview</h2>
<div id="header-status" class="status-badge">Loading...</div>
</div>
<div id="loading" class="loading">Loading...</div>
<div id="error" class="error" style="display:none;"></div>
<div id="content">
<div class="row">
<div class="col"><div class="card"><div class="card-title">Current Baseline</div><div id="base" class="card-value">-</div></div></div>
<div class="col"><div class="card"><div class="card-title">Git Branch</div><div id="branch" class="card-value">-</div></div></div>
<div class="col"><div class="card"><div class="card-title">Uptime</div><div id="uptime" class="card-value">-</div></div></div>
</div>
<div class="card" style="margin-top:16px">
<div class="card-title">Event Bus Status</div>
<pre id="bus-status">-</pre>
</div>
</div>
</div>
<div id="panel-versions" class="panel" style="display:none;">
<div class="main-header"><h2>Version Timeline</h2></div>
<div id="versions-list">Loading...</div>
</div>
<div id="panel-commands" class="panel" style="display:none;">
<div class="main-header"><h2>Command Lookup</h2></div>
<div id="commands-list">Loading...</div>
</div>
<div id="panel-agents" class="panel" style="display:none;">
<div class="main-header"><h2>Recent Agent Executions</h2></div>
<div id="panel-arch-owner" class="panel" style="display:none;">
<div class="main-header"><h2>🔐 Architecture Owner Gate</h2></div>
<div id="arch-owner-content">Loading...</div>
</div>
<div id="panel-arch-approve" class="panel" style="display:none;">
<div class="main-header"><h2>✓ Approve Architecture Changes</h2></div>
<div id="arch-approve-content">Loading...</div>
</div>
<div id="agents-list">Loading...</div>
</div>
</div>
</div>
<script>
async function fetchData() {
const r = await fetch('/admin/ai-evolution/api/data');
const data = await r.json();
document.getElementById('loading').style.display = 'none';
document.getElementById('error').style.display = 'none';
document.getElementById('content').style.display = '';
const hs = document.getElementById('header-status');
hs.className = 'status-badge ' + (data.healthy ? 'ok' : 'fail');
hs.textContent = data.healthy ? '▪ operational' : '⚠ degraded';
document.getElementById('base').textContent = data.baseline || '-';
document.getElementById('branch').textContent = data.main_branch || 'main';
document.getElementById('uptime').textContent = data.uptime || '-';
document.getElementById('bus-status').textContent = data.bus ? JSON.stringify(data.bus, null, 2) : 'no event bus data';
}
async function loadVersions() {
const el = document.getElementById('versions-list');
el.innerHTML = '<div class="loading">Loading versions...</div>';
try {
const r = await fetch('/admin/ai-evolution/api/versions');
const data = await r.json();
if (data.error) { el.innerHTML = '<div class="error">' + data.error + '</div>'; return; }
let html = '<div class="grid">';
for (const v of data.versions || []) {
html += '<div class="card"><div class="card-title">' + v.tag + '</div><div class="card-value">' + (v.version || v.tag) + '</div></div>';
}
html += '</div>';
el.innerHTML = html;
} catch(e) {
el.innerHTML = '<div class="error">Failed to load versions: ' + e.message + '</div>';
}
}
async function loadCommands() {
const el = document.getElementById('commands-list');
el.innerHTML = '<div class="loading">Loading commands...</div>';
try {
const r = await fetch('/admin/ai-evolution/api/data');
const data = await r.json();
let cmds = data.commands || [];
let html = '<div style="display:flex;gap:24px;margin-bottom:16px;">';
html += '<div><div class="card"><div class="card-title">Total</div><div class="card-value">' + cmds.length + '</div></div></div>';
html += '<div><div class="card"><div class="card-title">By Category</div>';
const cats = {};
for (const c of cmds) { const k = c.category || 'uncategorized'; cats[k] = (cats[k] || 0) + 1; }
for (const [k, v] of Object.entries(cats)) html += '<div style="font-size:14px;color:#8b949e;margin:4px 0;">' + k + ': ' + v + '</div>';
html += '</div></div></div>';
for (const cmd of cmds.slice().reverse()) {
html += '<div class="command-item"><div class="meta">' + cmd.category + ' | ' + cmd.filename + '</div><div class="name">' + cmd.title + '</div><div style="font-size:13px;color:#c9d1d9;margin-top:4px;">' + cmd.summary + '</div></div>';
}
el.innerHTML = html;
} catch(e) {
el.innerHTML = '<div class="error">Failed to load commands: ' + e.message + '</div>';
}
}
async function loadAgents() {
const el = document.getElementById('agents-list');
el.innerHTML = '<div class="loading">Loading agent runs...</div>';
try {
const r = await fetch('/admin/ai-evolution/api/agents');
const data = await r.json();
if (data.error) { el.innerHTML = '<div class="error">' + data.error + '</div>'; return; }
let html = '';
for (const agent of data.agents || []) {
html += '<div class="agent-item"><div class="agent-title">' + agent.name + ' <span class="state state-' + (agent.status === 'running' ? 'running' : (agent.status === 'ok' ? 'completed' : 'failed')) + '">' + agent.status + '</span></div><div class="agent-meta">' + (agent.runs || 0) + ' runs | last: ' + (agent.last_run || '-') + '</div></div>';
}
el.innerHTML = html || '<div style="padding:24px;color:#8b949e;">No agent runs recorded yet.</div>';
} catch(e) {
el.innerHTML = '<div class="error">Failed to load agents: ' + e.message + '</div>';
}
}
function showPanel(name) {
async function loadOwnerGate() {
const el = document.getElementById('arch-owner-content');
el.innerHTML = '<div class="loading">Loading owner gate data...</div>';
try {
const r = await fetch('/admin/ai-evolution/api/owner-gate');
const data = await r.json();
let html = '<div class="row">';
html += '<div class="col"><div class="card"><div class="card-title">Pending Requests</div><div class="card-value">' + data.pending_count + '</div></div></div>';
html += '<div class="col"><div class="card"><div class="card-title">Approved</div><div class="card-value">' + data.approved_count + '</div></div></div>';
html += '<div class="col"><div class="card"><div class="card-title">Rejected</div><div class="card-value">' + data.rejected_count + '</div></div></div>';
html += '</div>';
html += '<div class="card"><div class="card-title">Pending Approval Requests</div>';
if (data.pending_requests && data.pending_requests.length) {
for (const req of data.pending_requests) {
html += '<div class="command-item">';
html += '<div class="name">' + (req.title || req.change_id) + '</div>';
html += '<div class="meta">ID: ' + req.change_id + ' | Risk: ' + req.risk + '</div>';
html += '<div style="font-size:13px;color:#c9d1d9;margin-top:4px;">' + (req.reason || '') + '</div>';
html += '</div>';
}
} else {
html += '<div style="color:#8b949e;padding:12px;">No pending requests.</div>';
}
html += '</div>';
el.innerHTML = html;
} catch(e) {
el.innerHTML = '<div class="error">Failed to load: ' + e.message + '</div>';
}
}
async function loadApprove() {
const el = document.getElementById('arch-approve-content');
el.innerHTML = '<div class="loading">Loading pending approvals...</div>';
try {
const r = await fetch('/admin/ai-evolution/api/owner-gate');
const data = await r.json();
let html = '';
if (data.pending_requests && data.pending_requests.length) {
html += '<div class="card"><div class="card-title">Pending changes requiring your approval</div>';
for (const req of data.pending_requests) {
html += '<div class="command-item">';
html += '<div class="name">' + (req.title || req.change_id) + '</div>';
html += '<div class="meta">Risk: <strong>' + req.risk + '</strong></div>';
html += '<div style="font-size:13px;color:#c9d1d9;margin:8px 0;">' + (req.reason || '') + '</div>';
html += '<div style="display:flex;gap:8px;">';
html += '<button onclick="ownerAction.classList.add('\'approved\');this.disabled=true" style="padding:6px 16px;background:#238636;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;">✓ Approve</button>';
html += '</div></div>';
}
html += '</div>';
} else {
html += '<div class="card"><div style="padding:24px;text-align:center;color:#8b949e;">✅ All changes processed. No pending approval requests.</div></div>';
}
el.innerHTML = html;
} catch(e) {
el.innerHTML = '<div class="error">Failed to load: ' + e.message + '</div>';
}
}
document.querySelectorAll('.panel').forEach(p => p.style.display = 'none');
document.getElementById('panel-' + name).style.display = '';
document.querySelectorAll('.sidebar-nav li').forEach(li => li.classList.remove('active'));
document.querySelector('.sidebar-nav li:nth-child(' + ({overview:1,versions:2,commands:3,agents:4}[name] || 1) + ')').classList.add('active');
if (name === 'overview') {
document.getElementById('panel-overview').innerHTML = '<div class="main-header"><h2>System Overview</h2><div id="header-status" class="status-badge">Loading...</div></div><div id="loading" class="loading">Loading...</div><div id="error" class="error" style="display:none;"></div><div id="content"><div class="row"><div class="col"><div class="card"><div class="card-title">Current Baseline</div><div id="base" class="card-value">-</div></div></div><div class="col"><div class="card"><div class="card-title">Git Branch</div><div id="branch" class="card-value">-</div></div></div><div class="col"><div class="card"><div class="card-title">Uptime</div><div id="uptime" class="card-value">-</div></div></div></div><div class="card" style="margin-top:16px"><div class="card-title">Event Bus Status</div><pre id="bus-status">-</pre></div></div>';
fetchData();
}
const loaders = {
'arch-owner': loadOwnerGate,
'arch-approve': loadApprove,
versions: loadVersions,
commands: loadCommands,
agents: loadAgents,
};
const fn = loaders[name];
if (fn) fn();
}
fetchData();
</script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
"""Task Center action API model for Web UI buttons."""
from typing import Any
try:
from fastapi import APIRouter
except Exception:
APIRouter = None
from .task_lifecycle import TaskLifecycle
lifecycle = TaskLifecycle()
def get_task_actions(task: dict[str, Any], role: str) -> dict[str, Any]:
return {
"task_id": task.get("task_id"),
"actions": lifecycle.available_actions(task, role),
}
def execute_task_action(task: dict[str, Any], action: str, role: str, reason: str | None = None):
return lifecycle.transition(task, action, role, reason)
def build_router():
if APIRouter is None:
return None
router = APIRouter(prefix="/admin/ai-evolution/tasks", tags=["task-actions"])
@router.post("/{task_id}/actions/{action}", operation_id="executeTaskAction")
def action(task_id: str, action: str, payload: dict[str, Any]):
return execute_task_action(payload.get("task", {}), action, payload.get("role", "user"), payload.get("reason"))
return router
router = build_router()
__all__ = ["router", "get_task_actions", "execute_task_action"]
+115
View File
@@ -0,0 +1,115 @@
"""AIOS Task Center domain model.
The Task Center gives owners and authorized users a single Web UI page showing:
who assigned a task, who is executing it, progress, elapsed time, remaining
work, and whether completion notifications were sent.
"""
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()
def parse_time(value: str | None) -> datetime | None:
if not value:
return None
return datetime.fromisoformat(value.replace("Z", "+00:00"))
@dataclass(frozen=True)
class TaskNotificationStatus:
required: bool = True
sent: bool = False
channel: str | None = None
sent_at: str | None = None
recipient_scope: str | None = None
last_error: str | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class TaskProgress:
percent: int = 0
current_step: str = "pending"
completed_steps: list[str] = field(default_factory=list)
remaining_steps: list[str] = field(default_factory=list)
blocked_reason: str | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class AIOSTaskRecord:
task_id: str
title: str
assigned_by: str
executor: str
source: str
status: str = "pending"
priority: str = "normal"
created_at: str = field(default_factory=utc_now)
started_at: str | None = None
updated_at: str = field(default_factory=utc_now)
completed_at: str | None = None
progress: TaskProgress = field(default_factory=TaskProgress)
notification: TaskNotificationStatus = field(default_factory=TaskNotificationStatus)
owner_gate_required: bool = False
owner_gate_status: str | None = None
evidence: list[str] = field(default_factory=list)
def elapsed_seconds(self) -> int:
start = parse_time(self.started_at) or parse_time(self.created_at)
end = parse_time(self.completed_at) or datetime.now(timezone.utc)
return int((end - start).total_seconds()) if start else 0
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["elapsed_seconds"] = self.elapsed_seconds()
data["progress"] = self.progress.to_dict()
data["notification"] = self.notification.to_dict()
return data
class TaskCenter:
def summarize(self, tasks: list[AIOSTaskRecord] | list[dict[str, Any]] | None = None) -> dict[str, Any]:
raw_tasks = tasks or []
task_dicts = [t.to_dict() if hasattr(t, "to_dict") else dict(t) for t in raw_tasks]
active = [t for t in task_dicts if t.get("status") in {"running", "pending", "blocked"}]
blocked = [t for t in task_dicts if t.get("status") == "blocked"]
completed = [t for t in task_dicts if t.get("status") == "completed"]
notification_pending = [
t for t in completed
if t.get("notification", {}).get("required") and not t.get("notification", {}).get("sent")
]
return {
"module": "task-center",
"route": "/admin/ai-evolution/tasks",
"title": "AIOS 任务中心",
"status": "attention_required" if blocked or notification_pending else "healthy",
"generated_at": utc_now(),
"summary": {
"total": len(task_dicts),
"active": len(active),
"blocked": len(blocked),
"completed": len(completed),
"notification_pending": len(notification_pending),
},
"tasks": sorted(task_dicts, key=lambda t: t.get("updated_at", ""), reverse=True),
}
__all__ = [
"TaskNotificationStatus",
"TaskProgress",
"AIOSTaskRecord",
"TaskCenter",
]
+34
View File
@@ -0,0 +1,34 @@
"""Task Center API."""
from typing import Any
try:
from fastapi import APIRouter
except Exception:
APIRouter = None
from .task_center import TaskCenter
task_center = TaskCenter()
def get_task_overview(tasks: list[dict[str, Any]] | None = None):
return task_center.summarize(tasks)
def build_router():
if APIRouter is None:
return None
router = APIRouter(prefix="/admin/ai-evolution/tasks", tags=["task-center"])
@router.get("/overview", operation_id="getTaskCenterOverview")
def overview():
return get_task_overview()
return router
router = build_router()
__all__ = ["router", "get_task_overview"]
+81
View File
@@ -0,0 +1,81 @@
"""Task Center visualization model."""
from __future__ import annotations
from typing import Any
class TaskDashboardView:
def build(self, tasks: list[dict[str, Any]] | None = None) -> dict[str, Any]:
tasks = tasks or []
return {
"module": "task-center",
"route": "/admin/ai-evolution/tasks",
"title": "AIOS Task Center",
"views": {
"kanban": self._kanban(tasks),
"timeline": self._timeline(tasks),
"flow": self._flow(tasks),
"statistics": self._statistics(tasks),
},
"tasks": [self._task_card(task) for task in tasks],
}
def _task_card(self, task: dict[str, Any]) -> dict[str, Any]:
return {
"task_id": task.get("task_id"),
"title": task.get("title"),
"assigned_by": task.get("assigned_by"),
"executor": task.get("executor"),
"status": task.get("status"),
"progress": task.get("progress", {}),
"elapsed_seconds": task.get("elapsed_seconds", 0),
"remaining_steps": task.get("progress", {}).get("remaining_steps", []),
"notification": task.get("notification", {}),
"actions": [
"start",
"update_progress",
"block",
"request_approval",
"approve",
"reject",
"retry",
"complete",
"notify",
"close",
],
}
def _kanban(self, tasks):
columns = {"pending": [], "running": [], "blocked": [], "waiting_approval": [], "completed": []}
for task in tasks:
columns.setdefault(task.get("status", "pending"), []).append(task.get("task_id"))
return columns
def _timeline(self, tasks):
return [
{
"task_id": task.get("task_id"),
"updated_at": task.get("updated_at"),
"status": task.get("status"),
}
for task in tasks
]
def _flow(self, tasks):
return {
"nodes": ["created", "assigned", "running", "blocked", "approval", "completed", "closed"],
"task_count": len(tasks),
}
def _statistics(self, tasks):
return {
"total": len(tasks),
"running": len([t for t in tasks if t.get("status") == "running"]),
"blocked": len([t for t in tasks if t.get("status") == "blocked"]),
"waiting_approval": len([t for t in tasks if t.get("status") == "waiting_approval"]),
"completed": len([t for t in tasks if t.get("status") == "completed"]),
}
__all__ = ["TaskDashboardView"]
+129
View File
@@ -0,0 +1,129 @@
"""Task Center event bridge.
Converts task lifecycle actions and agent execution events into normalized AIOS
Task Center events. Uses duck-typed Agent Event Bus publisher when available and
an in-memory fallback for tests/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 TaskEventPublisher(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 TaskEvent:
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 InMemoryTaskEventBus:
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 = TaskEvent(
id=f"task_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 TaskEventBridge:
def __init__(self, bus: TaskEventPublisher | None = None):
self.bus = bus or InMemoryTaskEventBus()
def task_created(self, task: dict[str, Any]) -> dict[str, Any]:
return self.bus.publish(
kind="task_created",
source=task.get("assigned_by", "unknown"),
target=task.get("executor", "task-center"),
summary=task.get("title", "task created"),
payload=task,
correlation_id=task.get("task_id"),
)
def task_updated(self, task: dict[str, Any], action: str) -> dict[str, Any]:
return self.bus.publish(
kind="task_updated",
source=task.get("executor", "task-center"),
target="task-center",
summary=f"task {action}: {task.get('status')}",
payload={"action": action, "task": task},
correlation_id=task.get("task_id"),
)
def task_blocked(self, task: dict[str, Any], reason: str) -> dict[str, Any]:
return self.bus.publish(
kind="task_blocked",
source=task.get("executor", "unknown"),
target="owner-gate",
summary=reason,
payload={"task": task, "reason": reason},
correlation_id=task.get("task_id"),
)
def approval_requested(self, task: dict[str, Any], reason: str) -> dict[str, Any]:
return self.bus.publish(
kind="task_approval_requested",
source=task.get("executor", "unknown"),
target="owner-gate",
summary=reason,
payload={"task": task, "reason": reason},
correlation_id=task.get("task_id"),
)
def notification_sent(self, task: dict[str, Any], channel: str) -> dict[str, Any]:
return self.bus.publish(
kind="task_notification_sent",
source="notification-agent",
target=task.get("assigned_by", "owner"),
summary=f"notification sent via {channel}",
payload={"task": task, "channel": channel},
correlation_id=task.get("task_id"),
)
__all__ = ["TaskEvent", "InMemoryTaskEventBus", "TaskEventBridge"]
+147
View File
@@ -0,0 +1,147 @@
"""AIOS Task Lifecycle.
Defines a governed task state machine for task creation, assignment, execution,
blocking, approval, retry, completion, notification and closure.
"""
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()
TERMINAL_STATES = {"completed", "cancelled", "failed", "closed"}
@dataclass(frozen=True)
class TaskTransition:
action: str
from_states: set[str]
to_state: str
required_role: str = "agent"
requires_reason: bool = False
notify: bool = False
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["from_states"] = sorted(self.from_states)
return data
class TaskLifecycle:
"""Central task state machine."""
TRANSITIONS = {
"assign": TaskTransition("assign", {"new", "pending"}, "assigned", "dispatcher"),
"start": TaskTransition("start", {"assigned", "pending"}, "running", "executor"),
"update_progress": TaskTransition("update_progress", {"running"}, "running", "executor"),
"block": TaskTransition("block", {"running", "assigned", "pending"}, "blocked", "executor", requires_reason=True, notify=True),
"request_approval": TaskTransition("request_approval", {"running", "blocked"}, "waiting_approval", "executor", requires_reason=True, notify=True),
"approve": TaskTransition("approve", {"waiting_approval"}, "approved", "owner", notify=True),
"reject": TaskTransition("reject", {"waiting_approval"}, "rejected", "owner", requires_reason=True, notify=True),
"resume": TaskTransition("resume", {"blocked", "approved"}, "running", "executor"),
"retry": TaskTransition("retry", {"failed", "blocked"}, "running", "executor", notify=True),
"complete": TaskTransition("complete", {"running", "approved"}, "completed", "executor", notify=True),
"notify": TaskTransition("notify", {"completed"}, "completed", "notifier"),
"close": TaskTransition("close", {"completed", "cancelled", "failed"}, "closed", "owner"),
"cancel": TaskTransition("cancel", {"new", "pending", "assigned", "running", "blocked", "waiting_approval"}, "cancelled", "owner", requires_reason=True, notify=True),
"fail": TaskTransition("fail", {"running", "assigned"}, "failed", "executor", requires_reason=True, notify=True),
}
ROLE_ORDER = {
"user": 1,
"agent": 2,
"executor": 3,
"dispatcher": 4,
"notifier": 4,
"group_admin": 5,
"owner": 9,
}
def available_actions(self, task: dict[str, Any], role: str) -> list[dict[str, Any]]:
state = task.get("status", "new")
actions = []
for name, transition in self.TRANSITIONS.items():
allowed_state = state in transition.from_states
allowed_role = self._role_allows(role, transition.required_role)
actions.append({
"action": name,
"label": self._label(name),
"enabled": allowed_state and allowed_role,
"from_state": state,
"to_state": transition.to_state,
"required_role": transition.required_role,
"requires_reason": transition.requires_reason,
"notify": transition.notify,
"disabled_reason": None if allowed_state and allowed_role else self._disabled_reason(allowed_state, allowed_role, transition),
})
return actions
def transition(self, task: dict[str, Any], action: str, actor_role: str, reason: str | None = None) -> dict[str, Any]:
if action not in self.TRANSITIONS:
raise ValueError(f"unknown task action: {action}")
transition = self.TRANSITIONS[action]
state = task.get("status", "new")
if state not in transition.from_states:
raise ValueError(f"action {action} not allowed from state {state}")
if not self._role_allows(actor_role, transition.required_role):
raise PermissionError(f"role {actor_role} cannot execute {action}; requires {transition.required_role}")
if transition.requires_reason and not reason:
raise ValueError(f"action {action} requires reason")
updated = dict(task)
updated["status"] = transition.to_state
updated["updated_at"] = utc_now()
history = list(updated.get("history", []))
history.append({
"action": action,
"from": state,
"to": transition.to_state,
"actor_role": actor_role,
"reason": reason,
"ts": utc_now(),
})
updated["history"] = history
if action == "complete":
updated["completed_at"] = utc_now()
notification = dict(updated.get("notification", {}))
notification.setdefault("required", True)
notification.setdefault("sent", False)
updated["notification"] = notification
return updated
def _role_allows(self, role: str, required: str) -> bool:
return self.ROLE_ORDER.get(role, 0) >= self.ROLE_ORDER.get(required, 0)
def _label(self, action: str) -> str:
labels = {
"assign": "分配任务",
"start": "开始执行",
"update_progress": "更新进度",
"block": "标记阻塞",
"request_approval": "请求审批",
"approve": "批准",
"reject": "拒绝",
"resume": "继续执行",
"retry": "重试",
"complete": "标记完成",
"notify": "发送通知",
"close": "关闭任务",
"cancel": "取消任务",
"fail": "标记失败",
}
return labels.get(action, action)
def _disabled_reason(self, allowed_state: bool, allowed_role: bool, transition: TaskTransition) -> str:
if not allowed_state:
return "当前状态不允许该操作"
if not allowed_role:
return f"需要角色: {transition.required_role}"
return "不可用"
__all__ = ["TaskTransition", "TaskLifecycle", "TERMINAL_STATES"]
+50
View File
@@ -0,0 +1,50 @@
"""Task Center orchestrator.
Coordinates task ingestion, persistence, lifecycle updates, events and statistics.
"""
from __future__ import annotations
from typing import Any
from .task_event_bridge import TaskEventBridge
from .task_source_ingestor import TaskSourceIngestor
from .task_statistics import TaskStatistics
from .task_store import TaskStore
class TaskOrchestrator:
def __init__(self):
self.ingestor = TaskSourceIngestor()
self.store = TaskStore()
self.events = TaskEventBridge()
self.statistics = TaskStatistics()
def create_from_command(self, path: str, bucket: str = "active") -> dict[str, Any]:
task = self.ingestor.command_file_to_task(path, bucket)
self.store.append(task)
self.events.task_created(task)
return task
def create_from_agent_execution(self, record: dict[str, Any]) -> dict[str, Any]:
task = self.ingestor.agent_execution_to_task(record)
self.store.append(task)
self.events.task_created(task)
return task
def sync_event(self, event: dict[str, Any]) -> dict[str, Any] | None:
task = self.ingestor.event_to_task(event)
if task:
self.store.append(task)
self.events.task_updated(task, event.get("kind", "event"))
return task
def dashboard(self) -> dict[str, Any]:
tasks = self.store.latest()
return {
"tasks": tasks,
"statistics": self.statistics.summarize(tasks),
}
__all__ = ["TaskOrchestrator"]
+159
View File
@@ -0,0 +1,159 @@
"""Task source ingestion for AIOS Task Center.
Converts Command Watcher files, OpenClaw/Hermes/Codex execution records and
Generic Agent Event Bus records into normalized Task Center task dictionaries.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class TaskSourceRecord:
source_type: str
source_id: str
title: str
assigned_by: str
executor: str
status: str
priority: str = "normal"
created_at: str = field(default_factory=utc_now)
updated_at: str = field(default_factory=utc_now)
evidence: list[str] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
def to_task(self) -> dict[str, Any]:
return {
"task_id": f"task-{self.source_type}-{self.source_id}",
"title": self.title,
"assigned_by": self.assigned_by,
"executor": self.executor,
"source": self.source_type,
"status": self.status,
"priority": self.priority,
"created_at": self.created_at,
"updated_at": self.updated_at,
"progress": {
"percent": self._progress_percent(),
"current_step": self.status,
"completed_steps": [],
"remaining_steps": [],
"blocked_reason": self.metadata.get("blocked_reason"),
},
"notification": {
"required": True,
"sent": bool(self.metadata.get("notification_sent")),
"channel": self.metadata.get("notification_channel"),
"sent_at": self.metadata.get("notification_sent_at"),
},
"evidence": self.evidence,
"metadata": self.metadata,
}
def _progress_percent(self) -> int:
mapping = {
"new": 0,
"pending": 5,
"assigned": 15,
"running": 50,
"blocked": 50,
"waiting_approval": 60,
"approved": 70,
"completed": 100,
"closed": 100,
"failed": 100,
"cancelled": 100,
}
return mapping.get(self.status, 0)
class TaskSourceIngestor:
COMMAND_STATUS_MAP = {
"active": "pending",
"running": "running",
"completed": "completed",
"failed": "failed",
"archive": "closed",
}
def command_file_to_task(self, path: str, bucket: str = "active") -> dict[str, Any]:
p = Path(path)
title = p.stem
return TaskSourceRecord(
source_type="command_watcher",
source_id=title,
title=title,
assigned_by="command-watcher",
executor=self._infer_executor(title),
status=self.COMMAND_STATUS_MAP.get(bucket, "pending"),
priority="high" if "P0" in title or "fix" in title.lower() else "normal",
evidence=[str(p)],
metadata={"bucket": bucket},
).to_task()
def agent_execution_to_task(self, record: dict[str, Any]) -> dict[str, Any]:
source_id = record.get("execution_id", record.get("id", "unknown"))
status = record.get("status", "running")
return TaskSourceRecord(
source_type="agent_execution",
source_id=source_id,
title=record.get("title", f"Agent execution {source_id}"),
assigned_by=record.get("assigned_by", record.get("entry_agent", "agent-system")),
executor=record.get("closed_by", record.get("entry_agent", "unknown-agent")),
status=status,
priority=record.get("priority", "normal"),
created_at=record.get("started_at", utc_now()),
updated_at=record.get("ended_at", utc_now()),
evidence=[record.get("execution_id", source_id)],
metadata=record,
).to_task()
def event_to_task(self, event: dict[str, Any]) -> dict[str, Any] | None:
kind = event.get("kind")
payload = event.get("payload", {})
if kind not in {"task_created", "task_updated", "agent_started", "agent_completed", "agent_failed", "search_started", "search_completed"}:
return None
source_id = event.get("correlation_id") or event.get("id", "event")
status = self._status_from_event(kind, payload)
return TaskSourceRecord(
source_type="event_bus",
source_id=source_id,
title=payload.get("title") or event.get("summary", kind),
assigned_by=event.get("source", "event-bus"),
executor=event.get("target", "task-center"),
status=status,
created_at=event.get("created_at", utc_now()),
updated_at=event.get("created_at", utc_now()),
evidence=[event.get("id", source_id)],
metadata={"event_kind": kind, "payload": payload},
).to_task()
def _status_from_event(self, kind: str, payload: dict[str, Any]) -> str:
if kind.endswith("completed"):
return "completed"
if kind.endswith("failed"):
return "failed"
return payload.get("status", "running")
def _infer_executor(self, title: str) -> str:
lower = title.lower()
if "openclaw" in lower:
return "openclaw"
if "hermes" in lower:
return "hermes"
if "codex" in lower:
return "codex"
if "search" in lower:
return "openclaw"
return "openclaw"
__all__ = ["TaskSourceRecord", "TaskSourceIngestor"]
+33
View File
@@ -0,0 +1,33 @@
"""Task Center statistics."""
from typing import Any
class TaskStatistics:
def summarize(self, tasks: list[dict[str, Any]] | None = None) -> dict[str, Any]:
tasks = tasks or []
completed = [t for t in tasks if t.get("status") == "completed"]
blocked = [t for t in tasks if t.get("status") == "blocked"]
waiting = [t for t in tasks if t.get("status") == "waiting_approval"]
elapsed = [t.get("elapsed_seconds", 0) for t in tasks if t.get("elapsed_seconds") is not None]
return {
"total": len(tasks),
"completed": len(completed),
"blocked": len(blocked),
"waiting_approval": len(waiting),
"completion_rate": round(len(completed) / len(tasks) * 100, 2) if tasks else 0,
"average_elapsed_seconds": round(sum(elapsed) / len(elapsed), 2) if elapsed else 0,
"top_blockers": self._top_values(blocked, "blocker"),
"executor_distribution": self._top_values(tasks, "executor"),
}
def _top_values(self, tasks: list[dict[str, Any]], key: str):
result: dict[str, int] = {}
for task in tasks:
value = task.get(key)
if value:
result[value] = result.get(value, 0) + 1
return result
__all__ = ["TaskStatistics"]
+29
View File
@@ -0,0 +1,29 @@
"""Persistent Task Center store."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
class TaskStore:
def __init__(self, root: str = "runtime/task-center"):
self.root = Path(root)
self.root.mkdir(parents=True, exist_ok=True)
self.path = self.root / "tasks.jsonl"
def append(self, task: dict[str, Any]) -> None:
with self.path.open("a", encoding="utf-8") as f:
f.write(json.dumps(task, ensure_ascii=False) + "\n")
def list_all(self) -> list[dict[str, Any]]:
if not self.path.exists():
return []
return [json.loads(line) for line in self.path.read_text(encoding="utf-8").splitlines() if line]
def latest(self) -> list[dict[str, Any]]:
return self.list_all()[-100:]
__all__ = ["TaskStore"]
+2
View File
@@ -0,0 +1,2 @@
# Agent Acknowledgement
Initialized by CMD-20260708-fix-git-actions-write-and-enable-audit-plane
@@ -0,0 +1,36 @@
# CMD-20260708-fix-git-actions-write-and-enable-audit-plane
## Command ID
CMD-20260708-fix-git-actions-write-and-enable-audit-plane
## Priority
P0
## Mode
autonomous
## Target
Primary: OpenClaw
Supporting: Hermes
Review: Codex
## Objective
解决当前 AIOS Git Actions 写权限问题,恢复 ChatGPT Actions → AIOS GitOps API → Git Commit 链路,
同时建立 Agent 全生命周期审计体系。
## Status
- Part 1 (Fix Git Actions Write Permission): ✅ COMPLETE
- Part 2 (Agent Command Plane Structure): ✅ COMPLETE
- Part 3 (Agent Execution Audit): ✅ COMPLETE
- Part 4 (Conversation Trace): 🚧 IN PROGRESS
- Part 5 (Version Association): ⏳ PENDING
- Part 6 (Web UI Integration): ⏳ PENDING
- Part 7 (Evidence): ⏳ PENDING
- Part 8 (Regression Test): ⏳ PENDING
## Completion Criteria
1. Code committed: agents/, audit/, evidence/, web-ui/
2. Doc: docs/architecture/AIOS_AGENT_AUDIT_PLANE.md
3. Report: evidence/CMD-20260708.../final-report.md
4. Output: AIOS_AGENT_AUDIT_PLANE_READY, AIOS_GIT_ACTION_WRITE_READY
5. Next command created: CMD-20260709-autonomous-controller-enable
@@ -0,0 +1,35 @@
# CMD-20260709-enable-autonomous-controller
## Command ID
CMD-20260709-enable-autonomous-controller
## Priority
P0
## Mode
autonomous
## Target
Primary: OpenClaw
Supporting: Hermes
Review: Codex
## Objective
Enable autonomous controller for AIOS GitOps:
- Auto-detect issues
- Auto-diagnose
- Auto-generate fixes
- Auto-test
- Auto-create patches
- Auto-request approval
## Preconditions
- Agent Command Plane initialized (CMD-BOOTSTRAP-001)
- Git Actions write permission fixed (CMD-20260708)
- Audit plane enabled (CMD-20260708)
- Notification service enabled (CMD-20260708)
## Completion Criteria
- Autonomous controller service running
- Can detect, diagnose, fix, and report
- Pipeline: detect → diagnose → fix → test → patch → approve → commit → notify
@@ -0,0 +1,36 @@
# CMD-20260708-fix-git-actions-write-and-enable-audit-plane
## Command ID
CMD-20260708-fix-git-actions-write-and-enable-audit-plane
## Priority
P0
## Mode
autonomous
## Target
Primary: OpenClaw
Supporting: Hermes
Review: Codex
## Objective
解决当前 AIOS Git Actions 写权限问题,恢复 ChatGPT Actions → AIOS GitOps API → Git Commit 链路,
同时建立 Agent 全生命周期审计体系。
## Status
- Part 1 (Fix Git Actions Write Permission): ✅ COMPLETE
- Part 2 (Agent Command Plane Structure): ✅ COMPLETE
- Part 3 (Agent Execution Audit): ✅ COMPLETE
- Part 4 (Conversation Trace): 🚧 IN PROGRESS
- Part 5 (Version Association): ⏳ PENDING
- Part 6 (Web UI Integration): ⏳ PENDING
- Part 7 (Evidence): ⏳ PENDING
- Part 8 (Regression Test): ⏳ PENDING
## Completion Criteria
1. Code committed: agents/, audit/, evidence/, web-ui/
2. Doc: docs/architecture/AIOS_AGENT_AUDIT_PLANE.md
3. Report: evidence/CMD-20260708.../final-report.md
4. Output: AIOS_AGENT_AUDIT_PLANE_READY, AIOS_GIT_ACTION_WRITE_READY
5. Next command created: CMD-20260709-autonomous-controller-enable
@@ -0,0 +1,35 @@
# CMD-20260709-enable-autonomous-controller
## Command ID
CMD-20260709-enable-autonomous-controller
## Priority
P0
## Mode
autonomous
## Target
Primary: OpenClaw
Supporting: Hermes
Review: Codex
## Objective
Enable autonomous controller for AIOS GitOps:
- Auto-detect issues
- Auto-diagnose
- Auto-generate fixes
- Auto-test
- Auto-create patches
- Auto-request approval
## Preconditions
- Agent Command Plane initialized (CMD-BOOTSTRAP-001)
- Git Actions write permission fixed (CMD-20260708)
- Audit plane enabled (CMD-20260708)
- Notification service enabled (CMD-20260708)
## Completion Criteria
- Autonomous controller service running
- Can detect, diagnose, fix, and report
- Pipeline: detect → diagnose → fix → test → patch → approve → commit → notify

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