create-workframe 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/LICENSE +201 -201
  2. package/NOTICE +12 -12
  3. package/README.md +8 -92
  4. package/SECURITY.md +38 -40
  5. package/bin/workframe.js +329 -329
  6. package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +1 -1
  7. package/docs/workspace-instructions/WORKFRAME_ROUTING.md +8 -8
  8. package/package.json +3 -6
  9. package/profiles/architect/AGENTS.md +29 -29
  10. package/profiles/architect/SOUL.md +2 -2
  11. package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
  12. package/profiles/designer/AGENTS.md +26 -26
  13. package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
  14. package/profiles/dev/AGENTS.md +28 -28
  15. package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
  16. package/profiles/docs/AGENTS.md +27 -27
  17. package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
  18. package/profiles/research/AGENTS.md +26 -26
  19. package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
  20. package/profiles/visionary/AGENTS.md +25 -25
  21. package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
  22. package/profiles/workframe-agent/AGENTS.md +37 -37
  23. package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
  24. package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
  25. package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
  26. package/rules/workspace-README.md +5 -5
  27. package/scripts/bundle-workframe-ui.mjs +3 -3
  28. package/scripts/ensure-compose-host-paths.mjs +51 -51
  29. package/scripts/lib/install-identity.mjs +212 -212
  30. package/scripts/set-compose-public-url.mjs +92 -92
  31. package/scripts/sync-canonical-to-package.mjs +27 -9
  32. package/shared/WORKFRAME_AGENT_LIBRARY.md +17 -17
  33. package/shared/WORKFRAME_AGENT_OPERATIONS.md +15 -15
  34. package/shared/WORKFRAME_AGENT_PACKS.json +18 -18
  35. package/shared/WORKFRAME_AGENT_PACKS.yaml +8 -8
  36. package/shared/WORKFRAME_SKILL_CURATION.md +4 -4
  37. package/workframe-api/README.md +26 -28
  38. package/workframe-api/action_proxy.py +131 -131
  39. package/workframe-api/auth_rate_limit.py +49 -49
  40. package/workframe-api/credential_vault.py +445 -445
  41. package/workframe-api/data/avatar-catalog.json +41 -41
  42. package/workframe-api/email_sender.py +220 -220
  43. package/workframe-api/google_auth.py +90 -90
  44. package/workframe-api/install_api.py +359 -359
  45. package/workframe-api/internal_proxy_auth.py +150 -150
  46. package/workframe-api/llm_proxy.py +277 -277
  47. package/workframe-api/oidc_jwt.py +108 -108
  48. package/workframe-api/package.json +12 -13
  49. package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
  50. package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
  51. package/workframe-api/requirements.txt +2 -2
  52. package/workframe-api/site_meta.py +271 -271
  53. package/workframe-api/stack_config.py +427 -427
  54. package/workframe-api/time-bind-chat.py +99 -99
  55. package/workframe-api/turn_credentials.py +226 -226
  56. package/workframe-api/updates.py +417 -417
  57. package/workframe-api/vault_kek.py +159 -159
  58. package/workframe-api/zk_auth.py +633 -633
  59. package/workframe-supervisor/Dockerfile +11 -11
  60. package/workframe-supervisor/server.py +787 -787
  61. package/workframe-ui/docker/nginx.conf +85 -85
  62. package/workframe-ui/public/assets/{arc-CBDYvkAF.js → arc-COAT3laO.js} +1 -1
  63. package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +1 -0
  64. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-XnBRKeW0.js → architectureDiagram-3BPJPVTR-BFjWV24l.js} +1 -1
  65. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-VYHUfVhd.js → blockDiagram-GPEHLZMM-DSQLPfrj.js} +1 -1
  66. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BTjUcJpm.js → c4Diagram-AAUBKEIU-DKEHv1t2.js} +1 -1
  67. package/workframe-ui/public/assets/channel-g7r_RGaY.js +1 -0
  68. package/workframe-ui/public/assets/{chunk-2J33WTMH-w7uu7R-b.js → chunk-2J33WTMH-DHZg-DUi.js} +1 -1
  69. package/workframe-ui/public/assets/{chunk-3OPIFGDE-Cb9LtnDX.js → chunk-3OPIFGDE-BB-OYTfp.js} +1 -1
  70. package/workframe-ui/public/assets/{chunk-4BX2VUAB-DiQ-qCwH.js → chunk-4BX2VUAB-C93q0YIm.js} +1 -1
  71. package/workframe-ui/public/assets/{chunk-55IACEB6-C-mLFr7z.js → chunk-55IACEB6-MAYniqik.js} +1 -1
  72. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DOesfiCI.js → chunk-5ZQYHXKU-ChgN6YJs.js} +1 -1
  73. package/workframe-ui/public/assets/{chunk-727SXJPM-BJ3oBZuz.js → chunk-727SXJPM-B_FYwdAv.js} +1 -1
  74. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CCA6xpGs.js → chunk-AQP2D5EJ-1_Hw_h1A.js} +1 -1
  75. package/workframe-ui/public/assets/{chunk-BSJP7CBP-a0cMNFb2.js → chunk-BSJP7CBP-CFiDQ1Rv.js} +1 -1
  76. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-kuqN8EIY.js → chunk-CSCIHK7Q-DZ9UMTlB.js} +1 -1
  77. package/workframe-ui/public/assets/{chunk-FMBD7UC4-DyPgYHCg.js → chunk-FMBD7UC4-DlMlyFgw.js} +1 -1
  78. package/workframe-ui/public/assets/{chunk-KSCS5N6A-CdUuvR0V.js → chunk-KSCS5N6A-DHXtQ_Hf.js} +1 -1
  79. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-Dq9NoWmK.js → chunk-L5ZTLDWV-CuQzg-QG.js} +1 -1
  80. package/workframe-ui/public/assets/{chunk-LZXEDZCA-p74rddlO.js → chunk-LZXEDZCA-BHzjzCGg.js} +2 -2
  81. package/workframe-ui/public/assets/{chunk-ND2GUHAM-DBD2u1Gz.js → chunk-ND2GUHAM-DHXx05n2.js} +1 -1
  82. package/workframe-ui/public/assets/{chunk-NZK2D7GU-BeIeYFnd.js → chunk-NZK2D7GU-CV5pmDM_.js} +1 -1
  83. package/workframe-ui/public/assets/{chunk-O5CBEL6O-ClHc56ib.js → chunk-O5CBEL6O-6tkCHxsV.js} +1 -1
  84. package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +1 -0
  85. package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +1 -0
  86. package/workframe-ui/public/assets/{chunk-XPW4576I-EFr8R_1p.js → chunk-XPW4576I-BClwIiCp.js} +1 -1
  87. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +1 -0
  88. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +1 -0
  89. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-C7aPBODd.js → cose-bilkent-S5V4N54A-DOrGV6DQ.js} +1 -1
  90. package/workframe-ui/public/assets/{dagre-BM42HDAG-BdU1Rv-H.js → dagre-BM42HDAG-DXTPvJkX.js} +1 -1
  91. package/workframe-ui/public/assets/{diagram-2AECGRRQ-DWowSo85.js → diagram-2AECGRRQ-xX_v-pbf.js} +1 -1
  92. package/workframe-ui/public/assets/{diagram-5GNKFQAL-MnxBbceO.js → diagram-5GNKFQAL-Cd2pXbBe.js} +1 -1
  93. package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQaLRXFf.js → diagram-KO2AKTUF-Df3XvUtk.js} +1 -1
  94. package/workframe-ui/public/assets/{diagram-LMA3HP47-CQaBud9k.js → diagram-LMA3HP47-CsijIPaD.js} +1 -1
  95. package/workframe-ui/public/assets/{diagram-OG6HWLK6-D8bAXbY9.js → diagram-OG6HWLK6-aq5fmfHd.js} +1 -1
  96. package/workframe-ui/public/assets/{dist-DGpTLHr_.js → dist-D1c0mkbB.js} +1 -1
  97. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-1E-xSvBK.js → erDiagram-TEJ5UH35-DnFysVRY.js} +1 -1
  98. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +1 -0
  99. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CgOVD5hu.js → flowDiagram-I6XJVG4X-C6Ebi3su.js} +1 -1
  100. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-JFYAIauo.js → ganttDiagram-6RSMTGT7-BQXQtUpa.js} +1 -1
  101. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-B9REenIl.js → gitGraph-WXDBUCRP-Dt0zIs_M.js} +1 -1
  102. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BQ7NcMSn.js → gitGraphDiagram-PVQCEYII-BF8gHzRn.js} +1 -1
  103. package/workframe-ui/public/assets/index-DpoUZAxh.css +1 -0
  104. package/workframe-ui/public/assets/{index-Dnw6vjqb.js → index-lRpzpNPT.js} +2 -2
  105. package/workframe-ui/public/assets/{info-J43DQDTF-CL6-eTjH.js → info-J43DQDTF-CSmszQJT.js} +1 -1
  106. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-LJTODW4W.js → infoDiagram-5YYISTIA-CVTKGW6p.js} +1 -1
  107. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-bchrQVuo.js → ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js} +1 -1
  108. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DkrvYuxP.js → journeyDiagram-JHISSGLW-r3wD68_T.js} +1 -1
  109. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DFRbj0IG.js → kanban-definition-UN3LZRKU-Il8VglqN.js} +1 -1
  110. package/workframe-ui/public/assets/{line-Vd48P7-O.js → line-oyjpfz2A.js} +1 -1
  111. package/workframe-ui/public/assets/{linear-Ckizh2G7.js → linear-Cf7p5tVp.js} +1 -1
  112. package/workframe-ui/public/assets/{mermaid-parser.core-Bkimsnqj.js → mermaid-parser.core-YmbZ-AfY.js} +2 -2
  113. package/workframe-ui/public/assets/{mermaid.core-x0TvVuPo.js → mermaid.core-BFdCAqCo.js} +3 -3
  114. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-6ykAFPEz.js → mindmap-definition-RKZ34NQL-Cy2iCtEl.js} +1 -1
  115. package/workframe-ui/public/assets/{packet-YPE3B663-Dw3xgMDt.js → packet-YPE3B663-DwOBZL6K.js} +1 -1
  116. package/workframe-ui/public/assets/{pie-LRSECV5Y-DATysawG.js → pie-LRSECV5Y-04PPhnKK.js} +1 -1
  117. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-SJKD1S0S.js → pieDiagram-4H26LBE5-LxIpgHqi.js} +1 -1
  118. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-BrYDZX8q.js → quadrantDiagram-W4KKPZXB-0nBYfYm4.js} +1 -1
  119. package/workframe-ui/public/assets/{radar-GUYGQ44K-BmWYPCds.js → radar-GUYGQ44K-D2-vBqps.js} +1 -1
  120. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DwL9Mc8e.js → requirementDiagram-4Y6WPE33-DbuU0nlu.js} +1 -1
  121. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DYIFsL8h.js → sankeyDiagram-5OEKKPKP-B2hQ6B2x.js} +1 -1
  122. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-0-FPkFk8.js → sequenceDiagram-3UESZ5HK-BBrU30e1.js} +1 -1
  123. package/workframe-ui/public/assets/{src-B_od6b6h.js → src-BJEDmV70.js} +1 -1
  124. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BQCiBk6u.js → stateDiagram-AJRCARHV-7FGO4kkH.js} +1 -1
  125. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DLTSizMg.js +1 -0
  126. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DS3tFcXj.js → timeline-definition-PNZ67QCA-ptDm4rCN.js} +1 -1
  127. package/workframe-ui/public/assets/{treeView-BLDUP644-DSyUCKLY.js → treeView-BLDUP644-CS6Z-0q8.js} +1 -1
  128. package/workframe-ui/public/assets/{treemap-LRROVOQU-CEZaNh5Y.js → treemap-LRROVOQU-DqV4Y2VA.js} +1 -1
  129. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CD-Vc9NF.js → vennDiagram-CIIHVFJN-C0UrZJYt.js} +1 -1
  130. package/workframe-ui/public/assets/{wardley-L42UT6IY-Drq5w1Mc.js → wardley-L42UT6IY-bNDN3_Sa.js} +1 -1
  131. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DouXDJoF.js → wardleyDiagram-YWT4CUSO-jWiJsefM.js} +1 -1
  132. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DDf_Lol5.js → xychartDiagram-2RQKCTM6-Dsh_fLCy.js} +1 -1
  133. package/workframe-ui/public/favicon.svg +7 -7
  134. package/workframe-ui/public/index.html +50 -50
  135. package/workframe-ui/public/workframe-config.json +3 -3
  136. package/scripts/security_audit.py +0 -156
  137. package/scripts/test-scaffold.mjs +0 -390
  138. package/workframe-api/tests/__init__.py +0 -0
  139. package/workframe-api/tests/db_setup.py +0 -13
  140. package/workframe-api/tests/test_admin_updates_gated.py +0 -30
  141. package/workframe-api/tests/test_agent_dm_bootstrap.py +0 -196
  142. package/workframe-api/tests/test_agent_profile_sync.py +0 -76
  143. package/workframe-api/tests/test_auth_email.py +0 -222
  144. package/workframe-api/tests/test_auth_hole_fix_selfcheck.py +0 -99
  145. package/workframe-api/tests/test_auth_rate_limit.py +0 -19
  146. package/workframe-api/tests/test_avatar_resolve.py +0 -77
  147. package/workframe-api/tests/test_child_soul_template.py +0 -71
  148. package/workframe-api/tests/test_credential_canary.py +0 -135
  149. package/workframe-api/tests/test_credential_isolation.py +0 -448
  150. package/workframe-api/tests/test_credential_resolution.py +0 -206
  151. package/workframe-api/tests/test_device_oauth.py +0 -108
  152. package/workframe-api/tests/test_doctor_repair.py +0 -103
  153. package/workframe-api/tests/test_ensure_profile_api.py +0 -77
  154. package/workframe-api/tests/test_gateway_compose_security.py +0 -136
  155. package/workframe-api/tests/test_install_secure_host.py +0 -39
  156. package/workframe-api/tests/test_internal_proxy_auth.py +0 -125
  157. package/workframe-api/tests/test_invite_runtime_bootstrap.py +0 -72
  158. package/workframe-api/tests/test_kanban_delegation.py +0 -185
  159. package/workframe-api/tests/test_llm_proxy.py +0 -155
  160. package/workframe-api/tests/test_login_access_policy.py +0 -183
  161. package/workframe-api/tests/test_mvp_model_bootstrap.py +0 -75
  162. package/workframe-api/tests/test_onboarding_bootstrap.py +0 -248
  163. package/workframe-api/tests/test_platform_auth.py +0 -47
  164. package/workframe-api/tests/test_profile_config_path.py +0 -56
  165. package/workframe-api/tests/test_profile_config_yaml_repair.py +0 -63
  166. package/workframe-api/tests/test_profile_create.py +0 -72
  167. package/workframe-api/tests/test_profile_identity_overlay.py +0 -61
  168. package/workframe-api/tests/test_profile_install_health.py +0 -45
  169. package/workframe-api/tests/test_profile_secret_policy.py +0 -57
  170. package/workframe-api/tests/test_profile_workspace_cwd.py +0 -34
  171. package/workframe-api/tests/test_provider_bootstrap.py +0 -75
  172. package/workframe-api/tests/test_provider_connect.py +0 -54
  173. package/workframe-api/tests/test_room_crud.py +0 -192
  174. package/workframe-api/tests/test_room_tenancy.py +0 -701
  175. package/workframe-api/tests/test_runtime_identity_backfill.py +0 -34
  176. package/workframe-api/tests/test_site_meta.py +0 -81
  177. package/workframe-api/tests/test_soul_stub.py +0 -42
  178. package/workframe-api/tests/test_space_member_sync.py +0 -99
  179. package/workframe-api/tests/test_stripe_stack_config.py +0 -37
  180. package/workframe-api/tests/test_supervisor_lifecycle.py +0 -52
  181. package/workframe-api/tests/test_turn_credential_vault.py +0 -125
  182. package/workframe-api/tests/test_updates.py +0 -176
  183. package/workframe-api/tests/test_user_cohort.py +0 -113
  184. package/workframe-api/tests/test_vault_envelope.py +0 -110
  185. package/workframe-api/tests/test_workspace_members.py +0 -183
  186. package/workframe-api/tests/test_workspace_messaging_sync.py +0 -125
  187. package/workframe-api/tests/test_workspace_provider_list.py +0 -57
  188. package/workframe-supervisor/tests/test_exec_guard.py +0 -42
  189. package/workframe-supervisor/tests/test_server_import.py +0 -21
  190. package/workframe-ui/public/assets/architecture-7EHR7CIX-CtbQKTuT.js +0 -1
  191. package/workframe-ui/public/assets/channel-Dy4Z4-jn.js +0 -1
  192. package/workframe-ui/public/assets/chunk-QZHKN3VN-CtBEchFK.js +0 -1
  193. package/workframe-ui/public/assets/chunk-WU5MYG2G-B9pBtriN.js +0 -1
  194. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BMAEA8jI.js +0 -1
  195. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BMAEA8jI.js +0 -1
  196. package/workframe-ui/public/assets/eventmodeling-FCH6USID-D75cstNT.js +0 -1
  197. package/workframe-ui/public/assets/index-DpAGxump.css +0 -1
  198. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-B89jAMFF.js +0 -1
@@ -1,417 +1,417 @@
1
- """Admin stack updates — version checks + safe in-place apply (preserves runtime/DB)."""
2
- from __future__ import annotations
3
-
4
- import json
5
- import os
6
- import re
7
- import subprocess
8
- import urllib.error
9
- import urllib.parse
10
- import urllib.request
11
- from pathlib import Path
12
- from typing import Any
13
-
14
- HERMES_IMAGE = os.environ.get("WORKFRAME_HERMES_IMAGE", "nousresearch/hermes-agent")
15
- HERMES_TAG = os.environ.get("WORKFRAME_HERMES_TAG", "latest")
16
- NPM_PACKAGE = os.environ.get("WORKFRAME_NPM_PACKAGE", "create-workframe")
17
- RELEASES_URL = str(os.environ.get("WORKFRAME_RELEASES_URL", "")).strip()
18
- DOCKER_SOCK = os.environ.get("DOCKER_SOCK", "/var/run/docker.sock")
19
- GATEWAY_CONTAINER = os.environ.get("WORKFRAME_GATEWAY_CONTAINER", "workframe-gateway")
20
- API_VERSION = str(os.environ.get("WORKFRAME_API_VERSION", "")).strip()
21
-
22
-
23
- def _version_tuple(raw: str) -> tuple[int, ...]:
24
- text = re.sub(r"^workframe-api-", "", str(raw or "").strip())
25
- nums: list[int] = []
26
- for part in re.split(r"[.+_-]", text):
27
- if part.isdigit():
28
- nums.append(int(part))
29
- elif nums:
30
- break
31
- return tuple(nums)
32
-
33
-
34
- def _version_lt(current: str, latest: str) -> bool:
35
- cur = str(current or "").strip()
36
- lat = str(latest or "").strip()
37
- if not lat:
38
- return False
39
- if not cur:
40
- return True
41
- return _version_tuple(cur) < _version_tuple(lat)
42
-
43
-
44
- def _http_json(url: str, timeout: float = 12.0) -> dict[str, Any]:
45
- req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "workframe-api"})
46
- with urllib.request.urlopen(req, timeout=timeout) as resp:
47
- data = json.loads(resp.read().decode("utf-8"))
48
- return data if isinstance(data, dict) else {}
49
-
50
-
51
- def _npm_latest_version() -> str:
52
- data = _http_json(f"https://registry.npmjs.org/{urllib.parse.quote(NPM_PACKAGE)}/latest")
53
- return str(data.get("version") or "").strip()
54
-
55
-
56
- def _docker_hub_digest(repo: str, tag: str) -> str:
57
- url = f"https://hub.docker.com/v2/repositories/{repo}/tags/{urllib.parse.quote(tag)}"
58
- data = _http_json(url)
59
- # ponytail: tag digest matches docker pull :tag RepoDigests; images[0] may be arm64 on multi-arch repos
60
- top = str(data.get("digest") or "").strip()
61
- if top:
62
- return top
63
- for entry in data.get("images") or []:
64
- if not isinstance(entry, dict) or not entry.get("digest"):
65
- continue
66
- if entry.get("architecture") == "amd64" and entry.get("os") == "linux":
67
- return str(entry["digest"]).strip()
68
- for entry in data.get("images") or []:
69
- if isinstance(entry, dict) and entry.get("digest"):
70
- return str(entry["digest"]).strip()
71
- return ""
72
-
73
-
74
- def _docker_sock_request(method: str, path: str, body: bytes | None = None) -> tuple[int, Any]:
75
- import http.client
76
- import socket as pysocket
77
-
78
- if not Path(DOCKER_SOCK).exists():
79
- return 0, {"error": "docker_socket_missing"}
80
- conn = http.client.HTTPConnection("localhost", timeout=120)
81
- conn.sock = pysocket.socket(pysocket.AF_UNIX, pysocket.SOCK_STREAM)
82
- conn.sock.connect(DOCKER_SOCK)
83
- headers = {"Content-Type": "application/json"} if body else {}
84
- conn.request(method, path, body=body, headers=headers)
85
- resp = conn.getresponse()
86
- raw = resp.read()
87
- conn.close()
88
- if not raw:
89
- return resp.status, {}
90
- try:
91
- return resp.status, json.loads(raw.decode("utf-8"))
92
- except json.JSONDecodeError:
93
- return resp.status, raw.decode("utf-8", errors="replace")
94
-
95
-
96
- def _container_image_digest(name: str) -> tuple[str, str]:
97
- status, data = _docker_sock_request("GET", f"/containers/{name}/json")
98
- if status != 200 or not isinstance(data, dict):
99
- return "", ""
100
- image_id = str(data.get("Image") or "")
101
- ist, idata = _docker_sock_request("GET", f"/images/{image_id}/json")
102
- digest = ""
103
- ref = HERMES_IMAGE
104
- if ist == 200 and isinstance(idata, dict):
105
- digests = idata.get("RepoDigests") or []
106
- if digests:
107
- digest = str(digests[0]).split("@")[-1]
108
- tags = idata.get("RepoTags") or []
109
- if tags:
110
- ref = str(tags[0])
111
- return digest, ref
112
-
113
-
114
- def _read_installed_workframe_version(project_root: Path) -> dict[str, str]:
115
- out = {"api": API_VERSION, "package": "", "manifest_generator": ""}
116
- manifest = project_root / "workframe-manifest.json"
117
- if manifest.is_file():
118
- try:
119
- data = json.loads(manifest.read_text(encoding="utf-8"))
120
- out["package"] = str(data.get("package_version") or "")
121
- out["manifest_generator"] = str(data.get("generator") or "")
122
- except Exception: # noqa: BLE001
123
- pass
124
- if not out["api"]:
125
- try:
126
- import server as _server # noqa: WPS433
127
-
128
- out["api"] = str(getattr(_server, "VERSION", ""))
129
- except Exception: # noqa: BLE001
130
- pass
131
- if not out["package"]:
132
- out["package"] = out["api"]
133
- return out
134
-
135
-
136
- def _compose_dir() -> Path:
137
- for raw in (
138
- os.environ.get("WORKFRAME_HOST_COMPOSE_DIR", ""),
139
- os.environ.get("WORKFRAME_COMPOSE_DIR", ""),
140
- os.environ.get("WORKFRAME_PROJECT_ROOT", ""),
141
- "/compose",
142
- "/project",
143
- ):
144
- p = Path(str(raw or "").strip())
145
- if p.is_dir() and (p / "docker-compose.yml").is_file():
146
- return p
147
- return Path(".")
148
-
149
-
150
- def _project_root() -> Path:
151
- for raw in (os.environ.get("WORKFRAME_PROJECT_ROOT", ""), "/project", os.environ.get("WORKFRAME_COMPOSE_DIR", "")):
152
- p = Path(str(raw or "").strip())
153
- if p.is_dir() and (p / "workframe-manifest.json").is_file():
154
- return p
155
- for raw in (os.environ.get("WORKFRAME_PROJECT_ROOT", ""), "/project", os.environ.get("WORKFRAME_COMPOSE_DIR", "")):
156
- p = Path(str(raw or "").strip())
157
- if p.is_dir() and (p / "docker-compose.yml").is_file():
158
- return p
159
- return _compose_dir()
160
-
161
-
162
- def _script_path(name: str) -> Path | None:
163
- roots = [
164
- Path(f"/opt/install/scripts/{name}"),
165
- Path(f"/opt/install/scripts/workframe/{name}"),
166
- ]
167
- mode = str(os.environ.get("WORKFRAME_DEPLOYMENT_MODE") or "trusted_team").strip().lower()
168
- if mode == "single_user_local":
169
- roots.extend(
170
- [
171
- _project_root() / "scripts" / "workframe" / name,
172
- _project_root() / "scripts" / name,
173
- ],
174
- )
175
- for path in roots:
176
- if path.is_file():
177
- return path
178
- return None
179
-
180
-
181
- def _host_compose_ready() -> bool:
182
- host_raw = str(os.environ.get("WORKFRAME_HOST_COMPOSE_DIR", "")).strip()
183
- if not host_raw:
184
- return False
185
- host = Path(host_raw)
186
- if host.is_dir() and (host / "docker-compose.yml").is_file():
187
- return True
188
- # ponytail: Windows host paths are not visible inside the API container — trust /compose mount
189
- compose = _compose_dir()
190
- return compose.joinpath("docker-compose.yml").is_file()
191
-
192
-
193
- def _docker_apply_ready() -> tuple[bool, str | None]:
194
- if not Path(DOCKER_SOCK).exists():
195
- return False, "Docker socket is not available to the API container."
196
- if not _compose_dir().joinpath("docker-compose.yml").is_file():
197
- return False, "docker-compose.yml was not found for this stack."
198
- if not _host_compose_ready():
199
- return False, (
200
- "Set WORKFRAME_HOST_COMPOSE_DIR to the host compose folder so updates run on the Docker host."
201
- )
202
- return True, None
203
-
204
-
205
- def _product_state(*, update_available: bool, can_update: bool) -> str:
206
- if update_available and can_update:
207
- return "available"
208
- if update_available:
209
- return "blocked"
210
- return "current"
211
-
212
-
213
- def parse_hermes_version_output(text: str) -> str:
214
- """Extract semver from `hermes --version` stdout."""
215
- match = re.search(r"Hermes Agent v(\d+\.\d+\.\d+)", str(text or ""))
216
- return match.group(1) if match else ""
217
-
218
-
219
- def _read_hermes_agent_version() -> str:
220
- """Native Hermes semver via gateway exec (lazy import avoids server load cycle)."""
221
- try:
222
- import server as _server # noqa: WPS433
223
-
224
- return _server._hermes_agent_version()
225
- except Exception: # noqa: BLE001
226
- return ""
227
-
228
-
229
- def _releases_manifest() -> dict[str, Any]:
230
- if not RELEASES_URL:
231
- return {}
232
- try:
233
- return _http_json(RELEASES_URL)
234
- except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
235
- return {}
236
-
237
-
238
- def updates_available(*, desktop_version: str = "", hermes_agent_version: str = "") -> dict[str, Any]:
239
- compose_dir = _compose_dir()
240
- project_root = _project_root()
241
- docker_ok = Path(DOCKER_SOCK).exists()
242
- installed = _read_installed_workframe_version(project_root)
243
-
244
- npm_latest = ""
245
- try:
246
- npm_latest = _npm_latest_version()
247
- except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
248
- pass
249
-
250
- releases = _releases_manifest()
251
- workframe_latest = str(releases.get("workframe") or releases.get("create_workframe") or npm_latest or "")
252
- desktop_latest = str(releases.get("desktop") or os.environ.get("WORKFRAME_DESKTOP_LATEST", "0.1.0"))
253
-
254
- installed_pkg = installed.get("package") or installed.get("api") or ""
255
- workframe_update = bool(workframe_latest and _version_lt(installed_pkg, workframe_latest))
256
-
257
- hermes_digest, hermes_ref = _container_image_digest(GATEWAY_CONTAINER)
258
- hermes_tag = hermes_ref.rsplit(":", 1)[-1] if hermes_ref and ":" in hermes_ref else HERMES_TAG
259
- hermes_latest_digest = ""
260
- try:
261
- hermes_latest_digest = _docker_hub_digest(HERMES_IMAGE, HERMES_TAG)
262
- except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
263
- pass
264
- hermes_update = bool(
265
- docker_ok
266
- and hermes_latest_digest
267
- and hermes_digest
268
- and hermes_digest != hermes_latest_digest,
269
- )
270
-
271
- desktop_installed = str(desktop_version or "").strip()
272
- desktop_update = bool(desktop_latest and desktop_installed and _version_lt(desktop_installed, desktop_latest))
273
-
274
- digest_short = hermes_latest_digest
275
- if len(digest_short) > 28:
276
- digest_short = digest_short[:28] + "…"
277
-
278
- docker_apply_ok, docker_apply_reason = _docker_apply_ready()
279
- hermes_script_ok = _script_path("apply-update-hermes.sh") is not None
280
- workframe_script_ok = _script_path("apply-update-workframe.sh") is not None
281
- hermes_can_update = bool(docker_apply_ok and hermes_script_ok)
282
- workframe_can_update = bool(docker_apply_ok and workframe_script_ok)
283
- hermes_reason = docker_apply_reason
284
- if not hermes_reason and hermes_update and not hermes_script_ok:
285
- hermes_reason = "Hermes update script is missing from this install."
286
- workframe_reason = docker_apply_reason
287
- if not workframe_reason and workframe_update and not workframe_script_ok:
288
- workframe_reason = "Workframe update script is missing from this install."
289
- if not workframe_reason and workframe_update and not workframe_latest:
290
- workframe_reason = "No published npm release to update to yet."
291
-
292
- agent_version = str(hermes_agent_version or "").strip() or _read_hermes_agent_version()
293
- hermes_current = agent_version or hermes_tag
294
-
295
- return {
296
- "ok": True,
297
- "docker_available": docker_ok,
298
- "compose_dir": str(compose_dir),
299
- "project_root": str(project_root),
300
- "workframe": {
301
- "current": installed_pkg,
302
- "latest": workframe_latest,
303
- "update_available": workframe_update,
304
- "can_update": workframe_can_update,
305
- "state": _product_state(update_available=workframe_update, can_update=workframe_can_update),
306
- "reason": workframe_reason,
307
- "update_mode": "docker-compose-rebuild",
308
- "install_kind": "docker",
309
- "components": ["ui", "api", "supervisor"],
310
- },
311
- "hermes": {
312
- "current": hermes_current,
313
- "agent_version": agent_version,
314
- "image_tag": hermes_tag,
315
- "latest": "",
316
- "current_image": hermes_ref,
317
- "current_digest": hermes_digest[:28] + "…" if len(hermes_digest) > 28 else hermes_digest,
318
- "latest_digest": digest_short,
319
- "image": f"{HERMES_IMAGE}:{HERMES_TAG}",
320
- "update_available": hermes_update,
321
- "can_update": hermes_can_update,
322
- "state": _product_state(update_available=hermes_update, can_update=hermes_can_update),
323
- "reason": hermes_reason,
324
- "update_mode": "docker-compose-pull",
325
- "install_kind": "docker",
326
- "can_restart_gateway": bool(docker_apply_ok and _script_path("restart-gateway-hermes.sh") is not None),
327
- },
328
- "desktop": {
329
- "current": desktop_installed,
330
- "latest": desktop_latest,
331
- "update_available": desktop_update,
332
- "can_update": False,
333
- "state": "available" if desktop_update else "current",
334
- "reason": "Desktop updates are distributed separately from the Docker stack.",
335
- "update_mode": "manual-download",
336
- "install_kind": "desktop",
337
- "download_url": str(releases.get("desktop_download_url") or ""),
338
- },
339
- }
340
-
341
-
342
- def apply_update(target: str) -> dict[str, Any]:
343
- if os.environ.get("WORKFRAME_ENABLE_ADMIN_UPDATES") != "1":
344
- raise ValueError("admin_updates_disabled")
345
- target = str(target or "all").strip().lower()
346
- if target not in {"hermes", "workframe", "all"}:
347
- raise ValueError("invalid_update_target")
348
- if not Path(DOCKER_SOCK).exists():
349
- raise ValueError("docker_unavailable")
350
-
351
- scripts: list[str] = []
352
- if target in {"hermes", "all"}:
353
- script = _script_path("apply-update-hermes.sh")
354
- if not script:
355
- raise ValueError("update_script_missing:hermes")
356
- scripts.append(str(script))
357
- if target in {"workframe", "all"}:
358
- script = _script_path("apply-update-workframe.sh")
359
- if not script:
360
- raise ValueError("update_script_missing:workframe")
361
- scripts.append(str(script))
362
-
363
- env = os.environ.copy()
364
- env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
365
- env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
366
-
367
- logs: list[str] = []
368
- for script in scripts:
369
- proc = subprocess.run(
370
- ["bash", script],
371
- capture_output=True,
372
- text=True,
373
- timeout=900,
374
- env=env,
375
- cwd=env["WORKFRAME_COMPOSE_DIR"],
376
- )
377
- logs.append(f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}")
378
- if proc.returncode != 0:
379
- raise ValueError(f"update_failed:{Path(script).name}")
380
-
381
- return {"ok": True, "target": target, "log": "\n".join(logs)[-12000:]}
382
-
383
-
384
- def restart_gateway() -> dict[str, Any]:
385
- if os.environ.get("WORKFRAME_ENABLE_ADMIN_UPDATES") != "1":
386
- raise ValueError("admin_updates_disabled")
387
- if not Path(DOCKER_SOCK).exists():
388
- raise ValueError("docker_unavailable")
389
- docker_apply_ok, docker_apply_reason = _docker_apply_ready()
390
- if not docker_apply_ok:
391
- raise ValueError(str(docker_apply_reason or "docker_apply_unavailable"))
392
- script = _script_path("restart-gateway-hermes.sh")
393
- if not script:
394
- raise ValueError("restart_script_missing:gateway")
395
-
396
- env = os.environ.copy()
397
- env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
398
- env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
399
- proc = subprocess.run(
400
- ["bash", str(script)],
401
- capture_output=True,
402
- text=True,
403
- timeout=300,
404
- env=env,
405
- cwd=env["WORKFRAME_COMPOSE_DIR"],
406
- )
407
- log = f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}"
408
- if proc.returncode != 0:
409
- raise ValueError("restart_failed:gateway")
410
- return {"ok": True, "target": "gateway", "log": log[-12000:]}
411
-
412
-
413
- if __name__ == "__main__":
414
- assert _version_lt("0.1.0", "0.1.1")
415
- assert not _version_lt("0.1.0", "0.1.0")
416
- assert parse_hermes_version_output("Hermes Agent v0.17.0 (2026.6.19)") == "0.17.0"
417
- print("updates module ok")
1
+ """Admin stack updates — version checks + safe in-place apply (preserves runtime/DB)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ HERMES_IMAGE = os.environ.get("WORKFRAME_HERMES_IMAGE", "nousresearch/hermes-agent")
15
+ HERMES_TAG = os.environ.get("WORKFRAME_HERMES_TAG", "latest")
16
+ NPM_PACKAGE = os.environ.get("WORKFRAME_NPM_PACKAGE", "create-workframe")
17
+ RELEASES_URL = str(os.environ.get("WORKFRAME_RELEASES_URL", "")).strip()
18
+ DOCKER_SOCK = os.environ.get("DOCKER_SOCK", "/var/run/docker.sock")
19
+ GATEWAY_CONTAINER = os.environ.get("WORKFRAME_GATEWAY_CONTAINER", "workframe-gateway")
20
+ API_VERSION = str(os.environ.get("WORKFRAME_API_VERSION", "")).strip()
21
+
22
+
23
+ def _version_tuple(raw: str) -> tuple[int, ...]:
24
+ text = re.sub(r"^workframe-api-", "", str(raw or "").strip())
25
+ nums: list[int] = []
26
+ for part in re.split(r"[.+_-]", text):
27
+ if part.isdigit():
28
+ nums.append(int(part))
29
+ elif nums:
30
+ break
31
+ return tuple(nums)
32
+
33
+
34
+ def _version_lt(current: str, latest: str) -> bool:
35
+ cur = str(current or "").strip()
36
+ lat = str(latest or "").strip()
37
+ if not lat:
38
+ return False
39
+ if not cur:
40
+ return True
41
+ return _version_tuple(cur) < _version_tuple(lat)
42
+
43
+
44
+ def _http_json(url: str, timeout: float = 12.0) -> dict[str, Any]:
45
+ req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "workframe-api"})
46
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
47
+ data = json.loads(resp.read().decode("utf-8"))
48
+ return data if isinstance(data, dict) else {}
49
+
50
+
51
+ def _npm_latest_version() -> str:
52
+ data = _http_json(f"https://registry.npmjs.org/{urllib.parse.quote(NPM_PACKAGE)}/latest")
53
+ return str(data.get("version") or "").strip()
54
+
55
+
56
+ def _docker_hub_digest(repo: str, tag: str) -> str:
57
+ url = f"https://hub.docker.com/v2/repositories/{repo}/tags/{urllib.parse.quote(tag)}"
58
+ data = _http_json(url)
59
+ # ponytail: tag digest matches docker pull :tag RepoDigests; images[0] may be arm64 on multi-arch repos
60
+ top = str(data.get("digest") or "").strip()
61
+ if top:
62
+ return top
63
+ for entry in data.get("images") or []:
64
+ if not isinstance(entry, dict) or not entry.get("digest"):
65
+ continue
66
+ if entry.get("architecture") == "amd64" and entry.get("os") == "linux":
67
+ return str(entry["digest"]).strip()
68
+ for entry in data.get("images") or []:
69
+ if isinstance(entry, dict) and entry.get("digest"):
70
+ return str(entry["digest"]).strip()
71
+ return ""
72
+
73
+
74
+ def _docker_sock_request(method: str, path: str, body: bytes | None = None) -> tuple[int, Any]:
75
+ import http.client
76
+ import socket as pysocket
77
+
78
+ if not Path(DOCKER_SOCK).exists():
79
+ return 0, {"error": "docker_socket_missing"}
80
+ conn = http.client.HTTPConnection("localhost", timeout=120)
81
+ conn.sock = pysocket.socket(pysocket.AF_UNIX, pysocket.SOCK_STREAM)
82
+ conn.sock.connect(DOCKER_SOCK)
83
+ headers = {"Content-Type": "application/json"} if body else {}
84
+ conn.request(method, path, body=body, headers=headers)
85
+ resp = conn.getresponse()
86
+ raw = resp.read()
87
+ conn.close()
88
+ if not raw:
89
+ return resp.status, {}
90
+ try:
91
+ return resp.status, json.loads(raw.decode("utf-8"))
92
+ except json.JSONDecodeError:
93
+ return resp.status, raw.decode("utf-8", errors="replace")
94
+
95
+
96
+ def _container_image_digest(name: str) -> tuple[str, str]:
97
+ status, data = _docker_sock_request("GET", f"/containers/{name}/json")
98
+ if status != 200 or not isinstance(data, dict):
99
+ return "", ""
100
+ image_id = str(data.get("Image") or "")
101
+ ist, idata = _docker_sock_request("GET", f"/images/{image_id}/json")
102
+ digest = ""
103
+ ref = HERMES_IMAGE
104
+ if ist == 200 and isinstance(idata, dict):
105
+ digests = idata.get("RepoDigests") or []
106
+ if digests:
107
+ digest = str(digests[0]).split("@")[-1]
108
+ tags = idata.get("RepoTags") or []
109
+ if tags:
110
+ ref = str(tags[0])
111
+ return digest, ref
112
+
113
+
114
+ def _read_installed_workframe_version(project_root: Path) -> dict[str, str]:
115
+ out = {"api": API_VERSION, "package": "", "manifest_generator": ""}
116
+ manifest = project_root / "workframe-manifest.json"
117
+ if manifest.is_file():
118
+ try:
119
+ data = json.loads(manifest.read_text(encoding="utf-8"))
120
+ out["package"] = str(data.get("package_version") or "")
121
+ out["manifest_generator"] = str(data.get("generator") or "")
122
+ except Exception: # noqa: BLE001
123
+ pass
124
+ if not out["api"]:
125
+ try:
126
+ import server as _server # noqa: WPS433
127
+
128
+ out["api"] = str(getattr(_server, "VERSION", ""))
129
+ except Exception: # noqa: BLE001
130
+ pass
131
+ if not out["package"]:
132
+ out["package"] = out["api"]
133
+ return out
134
+
135
+
136
+ def _compose_dir() -> Path:
137
+ for raw in (
138
+ os.environ.get("WORKFRAME_HOST_COMPOSE_DIR", ""),
139
+ os.environ.get("WORKFRAME_COMPOSE_DIR", ""),
140
+ os.environ.get("WORKFRAME_PROJECT_ROOT", ""),
141
+ "/compose",
142
+ "/project",
143
+ ):
144
+ p = Path(str(raw or "").strip())
145
+ if p.is_dir() and (p / "docker-compose.yml").is_file():
146
+ return p
147
+ return Path(".")
148
+
149
+
150
+ def _project_root() -> Path:
151
+ for raw in (os.environ.get("WORKFRAME_PROJECT_ROOT", ""), "/project", os.environ.get("WORKFRAME_COMPOSE_DIR", "")):
152
+ p = Path(str(raw or "").strip())
153
+ if p.is_dir() and (p / "workframe-manifest.json").is_file():
154
+ return p
155
+ for raw in (os.environ.get("WORKFRAME_PROJECT_ROOT", ""), "/project", os.environ.get("WORKFRAME_COMPOSE_DIR", "")):
156
+ p = Path(str(raw or "").strip())
157
+ if p.is_dir() and (p / "docker-compose.yml").is_file():
158
+ return p
159
+ return _compose_dir()
160
+
161
+
162
+ def _script_path(name: str) -> Path | None:
163
+ roots = [
164
+ Path(f"/opt/install/scripts/{name}"),
165
+ Path(f"/opt/install/scripts/workframe/{name}"),
166
+ ]
167
+ mode = str(os.environ.get("WORKFRAME_DEPLOYMENT_MODE") or "trusted_team").strip().lower()
168
+ if mode == "single_user_local":
169
+ roots.extend(
170
+ [
171
+ _project_root() / "scripts" / "workframe" / name,
172
+ _project_root() / "scripts" / name,
173
+ ],
174
+ )
175
+ for path in roots:
176
+ if path.is_file():
177
+ return path
178
+ return None
179
+
180
+
181
+ def _host_compose_ready() -> bool:
182
+ host_raw = str(os.environ.get("WORKFRAME_HOST_COMPOSE_DIR", "")).strip()
183
+ if not host_raw:
184
+ return False
185
+ host = Path(host_raw)
186
+ if host.is_dir() and (host / "docker-compose.yml").is_file():
187
+ return True
188
+ # ponytail: Windows host paths are not visible inside the API container — trust /compose mount
189
+ compose = _compose_dir()
190
+ return compose.joinpath("docker-compose.yml").is_file()
191
+
192
+
193
+ def _docker_apply_ready() -> tuple[bool, str | None]:
194
+ if not Path(DOCKER_SOCK).exists():
195
+ return False, "Docker socket is not available to the API container."
196
+ if not _compose_dir().joinpath("docker-compose.yml").is_file():
197
+ return False, "docker-compose.yml was not found for this stack."
198
+ if not _host_compose_ready():
199
+ return False, (
200
+ "Set WORKFRAME_HOST_COMPOSE_DIR to the host compose folder so updates run on the Docker host."
201
+ )
202
+ return True, None
203
+
204
+
205
+ def _product_state(*, update_available: bool, can_update: bool) -> str:
206
+ if update_available and can_update:
207
+ return "available"
208
+ if update_available:
209
+ return "blocked"
210
+ return "current"
211
+
212
+
213
+ def parse_hermes_version_output(text: str) -> str:
214
+ """Extract semver from `hermes --version` stdout."""
215
+ match = re.search(r"Hermes Agent v(\d+\.\d+\.\d+)", str(text or ""))
216
+ return match.group(1) if match else ""
217
+
218
+
219
+ def _read_hermes_agent_version() -> str:
220
+ """Native Hermes semver via gateway exec (lazy import avoids server load cycle)."""
221
+ try:
222
+ import server as _server # noqa: WPS433
223
+
224
+ return _server._hermes_agent_version()
225
+ except Exception: # noqa: BLE001
226
+ return ""
227
+
228
+
229
+ def _releases_manifest() -> dict[str, Any]:
230
+ if not RELEASES_URL:
231
+ return {}
232
+ try:
233
+ return _http_json(RELEASES_URL)
234
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
235
+ return {}
236
+
237
+
238
+ def updates_available(*, desktop_version: str = "", hermes_agent_version: str = "") -> dict[str, Any]:
239
+ compose_dir = _compose_dir()
240
+ project_root = _project_root()
241
+ docker_ok = Path(DOCKER_SOCK).exists()
242
+ installed = _read_installed_workframe_version(project_root)
243
+
244
+ npm_latest = ""
245
+ try:
246
+ npm_latest = _npm_latest_version()
247
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
248
+ pass
249
+
250
+ releases = _releases_manifest()
251
+ workframe_latest = str(releases.get("workframe") or releases.get("create_workframe") or npm_latest or "")
252
+ desktop_latest = str(releases.get("desktop") or os.environ.get("WORKFRAME_DESKTOP_LATEST", "0.1.0"))
253
+
254
+ installed_pkg = installed.get("package") or installed.get("api") or ""
255
+ workframe_update = bool(workframe_latest and _version_lt(installed_pkg, workframe_latest))
256
+
257
+ hermes_digest, hermes_ref = _container_image_digest(GATEWAY_CONTAINER)
258
+ hermes_tag = hermes_ref.rsplit(":", 1)[-1] if hermes_ref and ":" in hermes_ref else HERMES_TAG
259
+ hermes_latest_digest = ""
260
+ try:
261
+ hermes_latest_digest = _docker_hub_digest(HERMES_IMAGE, HERMES_TAG)
262
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
263
+ pass
264
+ hermes_update = bool(
265
+ docker_ok
266
+ and hermes_latest_digest
267
+ and hermes_digest
268
+ and hermes_digest != hermes_latest_digest,
269
+ )
270
+
271
+ desktop_installed = str(desktop_version or "").strip()
272
+ desktop_update = bool(desktop_latest and desktop_installed and _version_lt(desktop_installed, desktop_latest))
273
+
274
+ digest_short = hermes_latest_digest
275
+ if len(digest_short) > 28:
276
+ digest_short = digest_short[:28] + "…"
277
+
278
+ docker_apply_ok, docker_apply_reason = _docker_apply_ready()
279
+ hermes_script_ok = _script_path("apply-update-hermes.sh") is not None
280
+ workframe_script_ok = _script_path("apply-update-workframe.sh") is not None
281
+ hermes_can_update = bool(docker_apply_ok and hermes_script_ok)
282
+ workframe_can_update = bool(docker_apply_ok and workframe_script_ok)
283
+ hermes_reason = docker_apply_reason
284
+ if not hermes_reason and hermes_update and not hermes_script_ok:
285
+ hermes_reason = "Hermes update script is missing from this install."
286
+ workframe_reason = docker_apply_reason
287
+ if not workframe_reason and workframe_update and not workframe_script_ok:
288
+ workframe_reason = "Workframe update script is missing from this install."
289
+ if not workframe_reason and workframe_update and not workframe_latest:
290
+ workframe_reason = "No published npm release to update to yet."
291
+
292
+ agent_version = str(hermes_agent_version or "").strip() or _read_hermes_agent_version()
293
+ hermes_current = agent_version or hermes_tag
294
+
295
+ return {
296
+ "ok": True,
297
+ "docker_available": docker_ok,
298
+ "compose_dir": str(compose_dir),
299
+ "project_root": str(project_root),
300
+ "workframe": {
301
+ "current": installed_pkg,
302
+ "latest": workframe_latest,
303
+ "update_available": workframe_update,
304
+ "can_update": workframe_can_update,
305
+ "state": _product_state(update_available=workframe_update, can_update=workframe_can_update),
306
+ "reason": workframe_reason,
307
+ "update_mode": "docker-compose-rebuild",
308
+ "install_kind": "docker",
309
+ "components": ["ui", "api", "supervisor"],
310
+ },
311
+ "hermes": {
312
+ "current": hermes_current,
313
+ "agent_version": agent_version,
314
+ "image_tag": hermes_tag,
315
+ "latest": "",
316
+ "current_image": hermes_ref,
317
+ "current_digest": hermes_digest[:28] + "…" if len(hermes_digest) > 28 else hermes_digest,
318
+ "latest_digest": digest_short,
319
+ "image": f"{HERMES_IMAGE}:{HERMES_TAG}",
320
+ "update_available": hermes_update,
321
+ "can_update": hermes_can_update,
322
+ "state": _product_state(update_available=hermes_update, can_update=hermes_can_update),
323
+ "reason": hermes_reason,
324
+ "update_mode": "docker-compose-pull",
325
+ "install_kind": "docker",
326
+ "can_restart_gateway": bool(docker_apply_ok and _script_path("restart-gateway-hermes.sh") is not None),
327
+ },
328
+ "desktop": {
329
+ "current": desktop_installed,
330
+ "latest": desktop_latest,
331
+ "update_available": desktop_update,
332
+ "can_update": False,
333
+ "state": "available" if desktop_update else "current",
334
+ "reason": "Desktop updates are distributed separately from the Docker stack.",
335
+ "update_mode": "manual-download",
336
+ "install_kind": "desktop",
337
+ "download_url": str(releases.get("desktop_download_url") or ""),
338
+ },
339
+ }
340
+
341
+
342
+ def apply_update(target: str) -> dict[str, Any]:
343
+ if os.environ.get("WORKFRAME_ENABLE_ADMIN_UPDATES") != "1":
344
+ raise ValueError("admin_updates_disabled")
345
+ target = str(target or "all").strip().lower()
346
+ if target not in {"hermes", "workframe", "all"}:
347
+ raise ValueError("invalid_update_target")
348
+ if not Path(DOCKER_SOCK).exists():
349
+ raise ValueError("docker_unavailable")
350
+
351
+ scripts: list[str] = []
352
+ if target in {"hermes", "all"}:
353
+ script = _script_path("apply-update-hermes.sh")
354
+ if not script:
355
+ raise ValueError("update_script_missing:hermes")
356
+ scripts.append(str(script))
357
+ if target in {"workframe", "all"}:
358
+ script = _script_path("apply-update-workframe.sh")
359
+ if not script:
360
+ raise ValueError("update_script_missing:workframe")
361
+ scripts.append(str(script))
362
+
363
+ env = os.environ.copy()
364
+ env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
365
+ env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
366
+
367
+ logs: list[str] = []
368
+ for script in scripts:
369
+ proc = subprocess.run(
370
+ ["bash", script],
371
+ capture_output=True,
372
+ text=True,
373
+ timeout=900,
374
+ env=env,
375
+ cwd=env["WORKFRAME_COMPOSE_DIR"],
376
+ )
377
+ logs.append(f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}")
378
+ if proc.returncode != 0:
379
+ raise ValueError(f"update_failed:{Path(script).name}")
380
+
381
+ return {"ok": True, "target": target, "log": "\n".join(logs)[-12000:]}
382
+
383
+
384
+ def restart_gateway() -> dict[str, Any]:
385
+ if os.environ.get("WORKFRAME_ENABLE_ADMIN_UPDATES") != "1":
386
+ raise ValueError("admin_updates_disabled")
387
+ if not Path(DOCKER_SOCK).exists():
388
+ raise ValueError("docker_unavailable")
389
+ docker_apply_ok, docker_apply_reason = _docker_apply_ready()
390
+ if not docker_apply_ok:
391
+ raise ValueError(str(docker_apply_reason or "docker_apply_unavailable"))
392
+ script = _script_path("restart-gateway-hermes.sh")
393
+ if not script:
394
+ raise ValueError("restart_script_missing:gateway")
395
+
396
+ env = os.environ.copy()
397
+ env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
398
+ env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
399
+ proc = subprocess.run(
400
+ ["bash", str(script)],
401
+ capture_output=True,
402
+ text=True,
403
+ timeout=300,
404
+ env=env,
405
+ cwd=env["WORKFRAME_COMPOSE_DIR"],
406
+ )
407
+ log = f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}"
408
+ if proc.returncode != 0:
409
+ raise ValueError("restart_failed:gateway")
410
+ return {"ok": True, "target": "gateway", "log": log[-12000:]}
411
+
412
+
413
+ if __name__ == "__main__":
414
+ assert _version_lt("0.1.0", "0.1.1")
415
+ assert not _version_lt("0.1.0", "0.1.0")
416
+ assert parse_hermes_version_output("Hermes Agent v0.17.0 (2026.6.19)") == "0.17.0"
417
+ print("updates module ok")