create-workframe 0.1.5 → 0.1.7
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.
- package/.dockerignore +22 -22
- package/.gitignore +73 -73
- package/LICENSE +201 -201
- package/NOTICE +12 -12
- package/README.md +40 -40
- package/SECURITY.md +38 -38
- package/bin/create-workframe.js +2816 -2814
- package/bin/workframe.js +329 -329
- package/docs/workspace-instructions/WORKFRAME_DISCORD.md +20 -20
- package/docs/workspace-instructions/WORKFRAME_DOCUMENTS_AND_ARTIFACTS.md +20 -20
- package/docs/workspace-instructions/WORKFRAME_KANBAN.md +20 -20
- package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +21 -21
- package/docs/workspace-instructions/WORKFRAME_ROUTING.md +29 -29
- package/docs/workspace-instructions/WORKFRAME_TELEGRAM.md +19 -19
- package/package.json +64 -64
- package/profiles/README.md +15 -15
- package/profiles/architect/AGENTS.md +29 -29
- package/profiles/architect/SOUL.md +44 -44
- package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/designer/AGENTS.md +26 -26
- package/profiles/designer/SOUL.md +31 -31
- package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/dev/AGENTS.md +28 -28
- package/profiles/dev/SOUL.md +31 -31
- package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/docs/AGENTS.md +27 -27
- package/profiles/docs/SOUL.md +30 -30
- package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/research/AGENTS.md +26 -26
- package/profiles/research/SOUL.md +31 -31
- package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/visionary/AGENTS.md +25 -25
- package/profiles/visionary/SOUL.md +31 -31
- package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/workframe-agent/AGENTS.md +37 -37
- package/profiles/workframe-agent/SETUP.md +185 -185
- package/profiles/workframe-agent/SOUL.md +61 -61
- package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
- package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
- package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
- package/prompts/WORKFRAME_PROMPT_TEMPLATES.md +16 -16
- package/rules/.hermes.md +11 -11
- package/rules/AGENTS.md +22 -22
- package/rules/workspace-README.md +5 -5
- package/scripts/apply-update-workframe.sh +91 -21
- package/scripts/bundle-workframe-ui.mjs +83 -83
- package/scripts/create_workframe_scaffold.py +648 -648
- package/scripts/ensure-compose-host-paths.mjs +51 -51
- package/scripts/lib/install-identity.mjs +212 -212
- package/scripts/lib/workframe-registry.mjs +290 -290
- package/scripts/new-project.mjs +68 -68
- package/scripts/select_agent_pack.py +31 -31
- package/scripts/set-compose-public-url.mjs +92 -92
- package/scripts/sync-canonical-to-package.mjs +164 -164
- package/shared/WORKFRAME_AGENT_LIBRARY.md +31 -31
- package/shared/WORKFRAME_AGENT_OPERATIONS.md +29 -29
- package/shared/WORKFRAME_AGENT_PACKS.json +64 -64
- package/shared/WORKFRAME_AGENT_PACKS.yaml +20 -20
- package/shared/WORKFRAME_CHAT_PERMISSION_MODEL.md +20 -20
- package/shared/WORKFRAME_HANDOFF_SCHEMA.md +25 -25
- package/shared/WORKFRAME_SKILL_CURATION.md +27 -27
- package/workframe-api/Dockerfile +14 -14
- package/workframe-api/README.md +24 -24
- package/workframe-api/action_proxy.py +131 -131
- package/workframe-api/auth_rate_limit.py +49 -49
- package/workframe-api/credential_vault.py +445 -445
- package/workframe-api/data/avatar-catalog.json +41 -41
- package/workframe-api/data/logo-catalog.json +14 -14
- package/workframe-api/data/user-avatar-catalog.json +18 -18
- package/workframe-api/email_sender.py +220 -220
- package/workframe-api/google_auth.py +90 -90
- package/workframe-api/install_api.py +359 -359
- package/workframe-api/internal_proxy_auth.py +150 -150
- package/workframe-api/llm_proxy.py +280 -280
- package/workframe-api/oidc_jwt.py +108 -108
- package/workframe-api/package.json +12 -12
- package/workframe-api/platform_auth.py +194 -194
- package/workframe-api/profile_secret_policy.py +86 -86
- package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
- package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
- package/workframe-api/public/assets/index-DglUqFB_.js +8 -8
- package/workframe-api/public/index.html +12 -12
- package/workframe-api/requirements.txt +2 -2
- package/workframe-api/server.py +20295 -19866
- package/workframe-api/site_meta.py +271 -271
- package/workframe-api/stack_config.py +435 -427
- package/workframe-api/test_ensure_native_profile.py +29 -0
- package/workframe-api/test_oauth_llm.py +58 -0
- package/workframe-api/test_provider_model_surface.py +31 -0
- package/workframe-api/test_stack_config_install_smtp.py +51 -0
- package/workframe-api/time-bind-chat.py +99 -99
- package/workframe-api/turn_credentials.py +226 -226
- package/workframe-api/updates.py +506 -506
- package/workframe-api/vault_kek.py +159 -159
- package/workframe-api/zk_auth.py +633 -633
- package/workframe-supervisor/Dockerfile +11 -11
- package/workframe-supervisor/profile_secret_policy.py +76 -76
- package/workframe-supervisor/server.py +860 -860
- package/workframe-ui/docker/nginx.conf +85 -85
- package/workframe-ui/public/assets/{arc-CnriPN3G.js → arc-Cn-lt9fT.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CCMNXHod.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DYYB1Ic2.js → architectureDiagram-3BPJPVTR-qXoGCqfH.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-Bn-fFOyj.js → blockDiagram-GPEHLZMM-DoopFlWj.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BPPDtAnD.js → c4Diagram-AAUBKEIU-BRa7hfzE.js} +1 -1
- package/workframe-ui/public/assets/channel-CtFV3sE3.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-Bz2CJiC3.js → chunk-2J33WTMH-Cl33E2hh.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-B8aMeE1B.js → chunk-3OPIFGDE-BRywYZQO.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-bLFHQTuT.js → chunk-4BX2VUAB-CI3xzp-0.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-BRxhx-lG.js → chunk-55IACEB6-Bkf3Ye3F.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BLyOgEJ8.js → chunk-5ZQYHXKU-C5RN3hUT.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-DiuvBl7G.js → chunk-727SXJPM-BaY0yDwt.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-BE1_aBdC.js → chunk-AQP2D5EJ-VMMBaDqd.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-BicGsmBs.js → chunk-BSJP7CBP-DlNZ1UV3.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-C3VTns3d.js → chunk-CSCIHK7Q-tT2HyVy0.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-DSiXY3bY.js → chunk-FMBD7UC4-CyACRaUT.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-Cvw5s3fR.js → chunk-KSCS5N6A-seK5WAZs.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-jJifGdIQ.js → chunk-L5ZTLDWV-r5cyG5AH.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-Maee1aqo.js → chunk-LZXEDZCA-SnbeVxsJ.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-C77N3VAM.js → chunk-ND2GUHAM-zbFgEXSJ.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-BY68lrPB.js → chunk-NZK2D7GU-UhZc8kOk.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-D_c4kWzh.js → chunk-O5CBEL6O-DHvu2iZ-.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-B1g7l1_B.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-B_qfZ6QP.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-DlQgvqBp.js → chunk-XPW4576I-CCPNKbzG.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CJoeCNq6.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CJoeCNq6.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CHCOHDSd.js → cose-bilkent-S5V4N54A-D1CLe6Y0.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-CKPcSYwU.js → dagre-BM42HDAG-DYHcGVs_.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-C6qe0jP-.js → diagram-2AECGRRQ-DVUkJ-II.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-DhQhrieU.js → diagram-5GNKFQAL-C9Y1Acc2.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-Bqf6Iw1r.js → diagram-KO2AKTUF-Db3zez-n.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt1ms2D2.js → diagram-LMA3HP47-BaOR5sIr.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-C0Rrxe1i.js → diagram-OG6HWLK6-fLBzMY5W.js} +1 -1
- package/workframe-ui/public/assets/{dist-BLcLhTn6.js → dist-uZYeD3o1.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-Cy1GbZVu.js → erDiagram-TEJ5UH35-BoCxDSyE.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Bw910jho.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-DL3ALqDG.js → flowDiagram-I6XJVG4X-BnYa3CAj.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-CMpwBhXQ.js → ganttDiagram-6RSMTGT7-Dm_e_Kaa.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-C0mwHUXL.js → gitGraph-WXDBUCRP-CBAj8ntv.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-6gzhWaF0.js → gitGraphDiagram-PVQCEYII-D5auJjJh.js} +1 -1
- package/workframe-ui/public/assets/index-DHLQNAXd.js +129 -0
- package/workframe-ui/public/assets/index-auJ0bLMU.css +1 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-Bb_wZTxU.js → info-J43DQDTF-BNMADxE2.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-601evYFV.js → infoDiagram-5YYISTIA-C8c45T4J.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-BYtF-wHm.js → ishikawaDiagram-YF4QCWOH-CyWuHB4e.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-BMizyHzC.js → journeyDiagram-JHISSGLW-B43pCuDT.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CLLJsdnh.js → kanban-definition-UN3LZRKU-Dyn480BI.js} +1 -1
- package/workframe-ui/public/assets/{line-BFiSvu-q.js → line-CNtIbnmt.js} +1 -1
- package/workframe-ui/public/assets/{linear-D6HYZ18_.js → linear-pmSg_4uV.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Cid5yDBQ.js → mermaid-parser.core-BX4tnojE.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-DqIiju5Z.js → mermaid.core-BtpBva5o.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-Bg7xJdsz.js → mindmap-definition-RKZ34NQL-DcMcB_69.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-1ebGt86D.js → packet-YPE3B663-KSzK9WlD.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DJ9wOBC_.js → pie-LRSECV5Y-DLOH6Mg8.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-BmK96Aus.js → pieDiagram-4H26LBE5-BhrX2aGk.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-ByUqtWPZ.js → quadrantDiagram-W4KKPZXB-B5dDjbTX.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-DN-Hkt96.js → radar-GUYGQ44K-BPdjqZiu.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-ByU2r8gb.js → requirementDiagram-4Y6WPE33-21Dq5e0N.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-elyWa0GK.js → sankeyDiagram-5OEKKPKP-CiA99dbO.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-CzZwsAiK.js → sequenceDiagram-3UESZ5HK-Bwpui7_x.js} +1 -1
- package/workframe-ui/public/assets/{src-5vgsM_dl.js → src-B3Axsi1U.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-B0O5w5lJ.js → stateDiagram-AJRCARHV-C76qwtE8.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-Dn0CDWJD.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-oyqa5w5y.js → timeline-definition-PNZ67QCA-CufInOyk.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-MCV12-eP.js → treeView-BLDUP644-C4sxh0R-.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-DiA1P9n5.js → treemap-LRROVOQU-BjV72ANV.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CO_4x7OR.js → vennDiagram-CIIHVFJN-DgsdR0Rl.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-CmQNv6Xv.js → wardley-L42UT6IY-DZ279G_t.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-CJxDkbIj.js → wardleyDiagram-YWT4CUSO-Bde25kvz.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-B02MxgzG.js → xychartDiagram-2RQKCTM6-B8VnS2Pb.js} +1 -1
- package/workframe-ui/public/favicon.svg +7 -7
- package/workframe-ui/public/icons.svg +24 -24
- package/workframe-ui/public/index.html +47 -47
- package/workframe-ui/public/manifest.webmanifest +18 -18
- package/workframe-ui/public/workframe-config.json +3 -3
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CFhr7xAe.js +0 -1
- package/workframe-ui/public/assets/channel-Dok_-T0o.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-C3wwLrTa.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-GFFSg2kf.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DKbGfT5Z.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DKbGfT5Z.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-C_PrceHe.js +0 -1
- package/workframe-ui/public/assets/index-DyO6K0TD.css +0 -1
- package/workframe-ui/public/assets/index-yvpSUs8T.js +0 -133
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-RXZRaSgT.js +0 -1
package/workframe-api/updates.py
CHANGED
|
@@ -1,506 +1,506 @@
|
|
|
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 _supervisor_configured() -> bool:
|
|
194
|
-
return bool(os.environ.get("WORKFRAME_SUPERVISOR_URL", "").strip()) and bool(
|
|
195
|
-
os.environ.get("WORKFRAME_SUPERVISOR_TOKEN", "").strip()
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def _supervisor_gateway_image_digest() -> tuple[str, str]:
|
|
200
|
-
"""Read gateway image digest via workframe-supervisor (SECURE_MODE API has no docker.sock)."""
|
|
201
|
-
base = str(os.environ.get("WORKFRAME_SUPERVISOR_URL", "")).rstrip("/")
|
|
202
|
-
token = str(os.environ.get("WORKFRAME_SUPERVISOR_TOKEN", "")).strip()
|
|
203
|
-
if not base or not token:
|
|
204
|
-
return "", ""
|
|
205
|
-
req = urllib.request.Request(
|
|
206
|
-
f"{base}/v1/gateway.image",
|
|
207
|
-
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "User-Agent": "workframe-api"},
|
|
208
|
-
)
|
|
209
|
-
try:
|
|
210
|
-
with urllib.request.urlopen(req, timeout=15.0) as resp:
|
|
211
|
-
data = json.loads(resp.read().decode("utf-8"))
|
|
212
|
-
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
|
|
213
|
-
return "", ""
|
|
214
|
-
if not isinstance(data, dict) or not data.get("ok"):
|
|
215
|
-
return "", ""
|
|
216
|
-
return str(data.get("digest") or "").strip(), str(data.get("ref") or "").strip()
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def _admin_stack_updates_enabled() -> bool:
|
|
220
|
-
if os.environ.get("WORKFRAME_ENABLE_ADMIN_UPDATES") == "1":
|
|
221
|
-
return True
|
|
222
|
-
return _supervisor_configured()
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
def _docker_apply_ready() -> tuple[bool, str | None]:
|
|
226
|
-
if not Path(DOCKER_SOCK).exists():
|
|
227
|
-
return False, "Docker socket is not available to the API container."
|
|
228
|
-
if not _compose_dir().joinpath("docker-compose.yml").is_file():
|
|
229
|
-
return False, "docker-compose.yml was not found for this stack."
|
|
230
|
-
if not _host_compose_ready():
|
|
231
|
-
return False, (
|
|
232
|
-
"Set WORKFRAME_HOST_COMPOSE_DIR to the host compose folder so updates run on the Docker host."
|
|
233
|
-
)
|
|
234
|
-
return True, None
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
def _update_apply_channel() -> tuple[str, bool, str | None]:
|
|
238
|
-
"""Returns (channel, ready, reason). channel: api_docker | supervisor | none."""
|
|
239
|
-
api_docker = Path(DOCKER_SOCK).exists()
|
|
240
|
-
if api_docker:
|
|
241
|
-
ok, reason = _docker_apply_ready()
|
|
242
|
-
if ok:
|
|
243
|
-
return "api_docker", True, None
|
|
244
|
-
if not _supervisor_configured():
|
|
245
|
-
return "none", False, reason
|
|
246
|
-
if _supervisor_configured():
|
|
247
|
-
if _script_path("apply-update-workframe.sh") is None and _script_path("apply-update-hermes.sh") is None:
|
|
248
|
-
return "supervisor", False, "Stack update scripts are missing from this install."
|
|
249
|
-
return "supervisor", True, None
|
|
250
|
-
if api_docker:
|
|
251
|
-
_, reason = _docker_apply_ready()
|
|
252
|
-
return "none", False, reason
|
|
253
|
-
return (
|
|
254
|
-
"none",
|
|
255
|
-
False,
|
|
256
|
-
"In-place updates need workframe-supervisor or Docker on the stack host.",
|
|
257
|
-
)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
def _product_state(*, update_available: bool, can_update: bool) -> str:
|
|
261
|
-
if update_available and can_update:
|
|
262
|
-
return "available"
|
|
263
|
-
if update_available:
|
|
264
|
-
return "blocked"
|
|
265
|
-
return "current"
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
def parse_hermes_version_output(text: str) -> str:
|
|
269
|
-
"""Extract semver from `hermes --version` stdout."""
|
|
270
|
-
match = re.search(r"Hermes Agent v(\d+\.\d+\.\d+)", str(text or ""))
|
|
271
|
-
return match.group(1) if match else ""
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
def _read_hermes_agent_version() -> str:
|
|
275
|
-
"""Native Hermes semver via gateway exec (lazy import avoids server load cycle)."""
|
|
276
|
-
try:
|
|
277
|
-
import server as _server # noqa: WPS433
|
|
278
|
-
|
|
279
|
-
return _server._hermes_agent_version()
|
|
280
|
-
except Exception: # noqa: BLE001
|
|
281
|
-
return ""
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
def _releases_manifest() -> dict[str, Any]:
|
|
285
|
-
if not RELEASES_URL:
|
|
286
|
-
return {}
|
|
287
|
-
try:
|
|
288
|
-
return _http_json(RELEASES_URL)
|
|
289
|
-
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
|
|
290
|
-
return {}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def updates_available(*, desktop_version: str = "", hermes_agent_version: str = "") -> dict[str, Any]:
|
|
294
|
-
compose_dir = _compose_dir()
|
|
295
|
-
project_root = _project_root()
|
|
296
|
-
api_docker = Path(DOCKER_SOCK).exists()
|
|
297
|
-
supervisor_ok = _supervisor_configured()
|
|
298
|
-
apply_channel, apply_ready, apply_reason = _update_apply_channel()
|
|
299
|
-
installed = _read_installed_workframe_version(project_root)
|
|
300
|
-
|
|
301
|
-
npm_latest = ""
|
|
302
|
-
try:
|
|
303
|
-
npm_latest = _npm_latest_version()
|
|
304
|
-
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
|
|
305
|
-
pass
|
|
306
|
-
|
|
307
|
-
releases = _releases_manifest()
|
|
308
|
-
workframe_latest = str(releases.get("workframe") or releases.get("create_workframe") or npm_latest or "")
|
|
309
|
-
desktop_latest = str(releases.get("desktop") or os.environ.get("WORKFRAME_DESKTOP_LATEST", "0.1.0"))
|
|
310
|
-
|
|
311
|
-
installed_pkg = installed.get("package") or installed.get("api") or ""
|
|
312
|
-
workframe_update = bool(workframe_latest and _version_lt(installed_pkg, workframe_latest))
|
|
313
|
-
|
|
314
|
-
hermes_digest, hermes_ref = ("", "")
|
|
315
|
-
if api_docker:
|
|
316
|
-
hermes_digest, hermes_ref = _container_image_digest(GATEWAY_CONTAINER)
|
|
317
|
-
elif supervisor_ok:
|
|
318
|
-
hermes_digest, hermes_ref = _supervisor_gateway_image_digest()
|
|
319
|
-
hermes_tag = hermes_ref.rsplit(":", 1)[-1] if hermes_ref and ":" in hermes_ref else HERMES_TAG
|
|
320
|
-
hermes_latest_digest = ""
|
|
321
|
-
try:
|
|
322
|
-
hermes_latest_digest = _docker_hub_digest(HERMES_IMAGE, HERMES_TAG)
|
|
323
|
-
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
|
|
324
|
-
pass
|
|
325
|
-
hermes_image_known = bool(api_docker or supervisor_ok)
|
|
326
|
-
hermes_update = bool(
|
|
327
|
-
hermes_image_known
|
|
328
|
-
and hermes_latest_digest
|
|
329
|
-
and hermes_digest
|
|
330
|
-
and hermes_digest != hermes_latest_digest,
|
|
331
|
-
)
|
|
332
|
-
|
|
333
|
-
desktop_installed = str(desktop_version or "").strip()
|
|
334
|
-
desktop_update = bool(desktop_latest and desktop_installed and _version_lt(desktop_installed, desktop_latest))
|
|
335
|
-
|
|
336
|
-
digest_short = hermes_latest_digest
|
|
337
|
-
if len(digest_short) > 28:
|
|
338
|
-
digest_short = digest_short[:28] + "…"
|
|
339
|
-
|
|
340
|
-
hermes_script_ok = _script_path("apply-update-hermes.sh") is not None
|
|
341
|
-
workframe_script_ok = _script_path("apply-update-workframe.sh") is not None
|
|
342
|
-
hermes_can_update = bool(apply_ready and hermes_script_ok)
|
|
343
|
-
workframe_can_update = bool(apply_ready and workframe_script_ok)
|
|
344
|
-
hermes_reason = apply_reason
|
|
345
|
-
if not hermes_reason and hermes_update and not hermes_script_ok:
|
|
346
|
-
hermes_reason = "Hermes update script is missing from this install."
|
|
347
|
-
workframe_reason = apply_reason
|
|
348
|
-
if not workframe_reason and workframe_update and not workframe_script_ok:
|
|
349
|
-
workframe_reason = "Workframe update script is missing from this install."
|
|
350
|
-
if not workframe_reason and workframe_update and not workframe_latest:
|
|
351
|
-
workframe_reason = "No published npm release to update to yet."
|
|
352
|
-
|
|
353
|
-
agent_version = str(hermes_agent_version or "").strip() or _read_hermes_agent_version()
|
|
354
|
-
hermes_current = agent_version or hermes_tag
|
|
355
|
-
|
|
356
|
-
return {
|
|
357
|
-
"ok": True,
|
|
358
|
-
"docker_available": apply_ready,
|
|
359
|
-
"docker_sock_on_api": api_docker,
|
|
360
|
-
"supervisor_configured": supervisor_ok,
|
|
361
|
-
"update_apply_channel": apply_channel if apply_ready else None,
|
|
362
|
-
"update_apply_ready": apply_ready,
|
|
363
|
-
"compose_dir": str(compose_dir),
|
|
364
|
-
"project_root": str(project_root),
|
|
365
|
-
"workframe": {
|
|
366
|
-
"current": installed_pkg,
|
|
367
|
-
"latest": workframe_latest,
|
|
368
|
-
"update_available": workframe_update,
|
|
369
|
-
"can_update": workframe_can_update,
|
|
370
|
-
"state": _product_state(update_available=workframe_update, can_update=workframe_can_update),
|
|
371
|
-
"reason": workframe_reason,
|
|
372
|
-
"update_mode": "docker-compose-rebuild",
|
|
373
|
-
"install_kind": "docker",
|
|
374
|
-
"components": ["ui", "api", "supervisor"],
|
|
375
|
-
},
|
|
376
|
-
"hermes": {
|
|
377
|
-
"current": hermes_current,
|
|
378
|
-
"agent_version": agent_version,
|
|
379
|
-
"image_tag": hermes_tag,
|
|
380
|
-
"latest": "",
|
|
381
|
-
"current_image": hermes_ref,
|
|
382
|
-
"current_digest": hermes_digest[:28] + "…" if len(hermes_digest) > 28 else hermes_digest,
|
|
383
|
-
"latest_digest": digest_short,
|
|
384
|
-
"image": f"{HERMES_IMAGE}:{HERMES_TAG}",
|
|
385
|
-
"update_available": hermes_update,
|
|
386
|
-
"can_update": hermes_can_update,
|
|
387
|
-
"state": _product_state(update_available=hermes_update, can_update=hermes_can_update),
|
|
388
|
-
"reason": hermes_reason,
|
|
389
|
-
"update_mode": "docker-compose-pull",
|
|
390
|
-
"install_kind": "docker",
|
|
391
|
-
"can_restart_gateway": bool(apply_ready and _script_path("restart-gateway-hermes.sh") is not None),
|
|
392
|
-
},
|
|
393
|
-
"desktop": {
|
|
394
|
-
"current": desktop_installed,
|
|
395
|
-
"latest": desktop_latest,
|
|
396
|
-
"update_available": desktop_update,
|
|
397
|
-
"can_update": False,
|
|
398
|
-
"state": "available" if desktop_update else "current",
|
|
399
|
-
"reason": "Desktop updates are distributed separately from the Docker stack.",
|
|
400
|
-
"update_mode": "manual-download",
|
|
401
|
-
"install_kind": "desktop",
|
|
402
|
-
"download_url": str(releases.get("desktop_download_url") or ""),
|
|
403
|
-
},
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
def _apply_env_for_target(target: str) -> dict[str, str]:
|
|
408
|
-
env = os.environ.copy()
|
|
409
|
-
env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
|
|
410
|
-
env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
|
|
411
|
-
if target in {"workframe", "all"}:
|
|
412
|
-
latest = ""
|
|
413
|
-
try:
|
|
414
|
-
latest = str(updates_available().get("workframe", {}).get("latest") or "").strip()
|
|
415
|
-
except Exception: # noqa: BLE001
|
|
416
|
-
latest = ""
|
|
417
|
-
if latest:
|
|
418
|
-
env["WORKFRAME_UPDATE_ALLOW_NPM"] = "1"
|
|
419
|
-
env["WORKFRAME_UPDATE_VERSION"] = latest
|
|
420
|
-
return env
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
def apply_update(target: str) -> dict[str, Any]:
|
|
424
|
-
if not _admin_stack_updates_enabled():
|
|
425
|
-
raise ValueError("admin_updates_disabled")
|
|
426
|
-
target = str(target or "all").strip().lower()
|
|
427
|
-
if target not in {"hermes", "workframe", "all"}:
|
|
428
|
-
raise ValueError("invalid_update_target")
|
|
429
|
-
_, apply_ready, apply_reason = _update_apply_channel()
|
|
430
|
-
if not apply_ready:
|
|
431
|
-
if not Path(DOCKER_SOCK).exists():
|
|
432
|
-
raise ValueError("docker_unavailable")
|
|
433
|
-
raise ValueError(str(apply_reason or "docker_apply_unavailable"))
|
|
434
|
-
if not Path(DOCKER_SOCK).exists():
|
|
435
|
-
raise ValueError("docker_unavailable")
|
|
436
|
-
|
|
437
|
-
scripts: list[str] = []
|
|
438
|
-
if target in {"hermes", "all"}:
|
|
439
|
-
script = _script_path("apply-update-hermes.sh")
|
|
440
|
-
if not script:
|
|
441
|
-
raise ValueError("update_script_missing:hermes")
|
|
442
|
-
scripts.append(str(script))
|
|
443
|
-
if target in {"workframe", "all"}:
|
|
444
|
-
script = _script_path("apply-update-workframe.sh")
|
|
445
|
-
if not script:
|
|
446
|
-
raise ValueError("update_script_missing:workframe")
|
|
447
|
-
scripts.append(str(script))
|
|
448
|
-
|
|
449
|
-
env = _apply_env_for_target(target)
|
|
450
|
-
|
|
451
|
-
logs: list[str] = []
|
|
452
|
-
for script in scripts:
|
|
453
|
-
proc = subprocess.run(
|
|
454
|
-
["bash", script],
|
|
455
|
-
capture_output=True,
|
|
456
|
-
text=True,
|
|
457
|
-
timeout=900,
|
|
458
|
-
env=env,
|
|
459
|
-
cwd=env["WORKFRAME_COMPOSE_DIR"],
|
|
460
|
-
)
|
|
461
|
-
logs.append(f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}")
|
|
462
|
-
if proc.returncode != 0:
|
|
463
|
-
raise ValueError(f"update_failed:{Path(script).name}")
|
|
464
|
-
|
|
465
|
-
return {"ok": True, "target": target, "log": "\n".join(logs)[-12000:]}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
def restart_gateway() -> dict[str, Any]:
|
|
469
|
-
if not _admin_stack_updates_enabled():
|
|
470
|
-
raise ValueError("admin_updates_disabled")
|
|
471
|
-
_, apply_ready, apply_reason = _update_apply_channel()
|
|
472
|
-
if not apply_ready:
|
|
473
|
-
if not Path(DOCKER_SOCK).exists():
|
|
474
|
-
raise ValueError("docker_unavailable")
|
|
475
|
-
raise ValueError(str(apply_reason or "docker_apply_unavailable"))
|
|
476
|
-
if not Path(DOCKER_SOCK).exists():
|
|
477
|
-
raise ValueError("docker_unavailable")
|
|
478
|
-
script = _script_path("restart-gateway-hermes.sh")
|
|
479
|
-
if not script:
|
|
480
|
-
raise ValueError("restart_script_missing:gateway")
|
|
481
|
-
|
|
482
|
-
env = os.environ.copy()
|
|
483
|
-
env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
|
|
484
|
-
env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
|
|
485
|
-
proc = subprocess.run(
|
|
486
|
-
["bash", str(script)],
|
|
487
|
-
capture_output=True,
|
|
488
|
-
text=True,
|
|
489
|
-
timeout=300,
|
|
490
|
-
env=env,
|
|
491
|
-
cwd=env["WORKFRAME_COMPOSE_DIR"],
|
|
492
|
-
)
|
|
493
|
-
log = f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}"
|
|
494
|
-
if proc.returncode != 0:
|
|
495
|
-
raise ValueError("restart_failed:gateway")
|
|
496
|
-
return {"ok": True, "target": "gateway", "log": log[-12000:]}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
if __name__ == "__main__":
|
|
500
|
-
assert _version_lt("0.1.0", "0.1.1")
|
|
501
|
-
assert not _version_lt("0.1.0", "0.1.0")
|
|
502
|
-
assert parse_hermes_version_output("Hermes Agent v0.17.0 (2026.6.19)") == "0.17.0"
|
|
503
|
-
ch, ready, _ = _update_apply_channel()
|
|
504
|
-
assert ch in {"api_docker", "supervisor", "none"}
|
|
505
|
-
assert ready is False or ch != "none"
|
|
506
|
-
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 _supervisor_configured() -> bool:
|
|
194
|
+
return bool(os.environ.get("WORKFRAME_SUPERVISOR_URL", "").strip()) and bool(
|
|
195
|
+
os.environ.get("WORKFRAME_SUPERVISOR_TOKEN", "").strip()
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _supervisor_gateway_image_digest() -> tuple[str, str]:
|
|
200
|
+
"""Read gateway image digest via workframe-supervisor (SECURE_MODE API has no docker.sock)."""
|
|
201
|
+
base = str(os.environ.get("WORKFRAME_SUPERVISOR_URL", "")).rstrip("/")
|
|
202
|
+
token = str(os.environ.get("WORKFRAME_SUPERVISOR_TOKEN", "")).strip()
|
|
203
|
+
if not base or not token:
|
|
204
|
+
return "", ""
|
|
205
|
+
req = urllib.request.Request(
|
|
206
|
+
f"{base}/v1/gateway.image",
|
|
207
|
+
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "User-Agent": "workframe-api"},
|
|
208
|
+
)
|
|
209
|
+
try:
|
|
210
|
+
with urllib.request.urlopen(req, timeout=15.0) as resp:
|
|
211
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
212
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
|
|
213
|
+
return "", ""
|
|
214
|
+
if not isinstance(data, dict) or not data.get("ok"):
|
|
215
|
+
return "", ""
|
|
216
|
+
return str(data.get("digest") or "").strip(), str(data.get("ref") or "").strip()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _admin_stack_updates_enabled() -> bool:
|
|
220
|
+
if os.environ.get("WORKFRAME_ENABLE_ADMIN_UPDATES") == "1":
|
|
221
|
+
return True
|
|
222
|
+
return _supervisor_configured()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _docker_apply_ready() -> tuple[bool, str | None]:
|
|
226
|
+
if not Path(DOCKER_SOCK).exists():
|
|
227
|
+
return False, "Docker socket is not available to the API container."
|
|
228
|
+
if not _compose_dir().joinpath("docker-compose.yml").is_file():
|
|
229
|
+
return False, "docker-compose.yml was not found for this stack."
|
|
230
|
+
if not _host_compose_ready():
|
|
231
|
+
return False, (
|
|
232
|
+
"Set WORKFRAME_HOST_COMPOSE_DIR to the host compose folder so updates run on the Docker host."
|
|
233
|
+
)
|
|
234
|
+
return True, None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _update_apply_channel() -> tuple[str, bool, str | None]:
|
|
238
|
+
"""Returns (channel, ready, reason). channel: api_docker | supervisor | none."""
|
|
239
|
+
api_docker = Path(DOCKER_SOCK).exists()
|
|
240
|
+
if api_docker:
|
|
241
|
+
ok, reason = _docker_apply_ready()
|
|
242
|
+
if ok:
|
|
243
|
+
return "api_docker", True, None
|
|
244
|
+
if not _supervisor_configured():
|
|
245
|
+
return "none", False, reason
|
|
246
|
+
if _supervisor_configured():
|
|
247
|
+
if _script_path("apply-update-workframe.sh") is None and _script_path("apply-update-hermes.sh") is None:
|
|
248
|
+
return "supervisor", False, "Stack update scripts are missing from this install."
|
|
249
|
+
return "supervisor", True, None
|
|
250
|
+
if api_docker:
|
|
251
|
+
_, reason = _docker_apply_ready()
|
|
252
|
+
return "none", False, reason
|
|
253
|
+
return (
|
|
254
|
+
"none",
|
|
255
|
+
False,
|
|
256
|
+
"In-place updates need workframe-supervisor or Docker on the stack host.",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _product_state(*, update_available: bool, can_update: bool) -> str:
|
|
261
|
+
if update_available and can_update:
|
|
262
|
+
return "available"
|
|
263
|
+
if update_available:
|
|
264
|
+
return "blocked"
|
|
265
|
+
return "current"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def parse_hermes_version_output(text: str) -> str:
|
|
269
|
+
"""Extract semver from `hermes --version` stdout."""
|
|
270
|
+
match = re.search(r"Hermes Agent v(\d+\.\d+\.\d+)", str(text or ""))
|
|
271
|
+
return match.group(1) if match else ""
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _read_hermes_agent_version() -> str:
|
|
275
|
+
"""Native Hermes semver via gateway exec (lazy import avoids server load cycle)."""
|
|
276
|
+
try:
|
|
277
|
+
import server as _server # noqa: WPS433
|
|
278
|
+
|
|
279
|
+
return _server._hermes_agent_version()
|
|
280
|
+
except Exception: # noqa: BLE001
|
|
281
|
+
return ""
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _releases_manifest() -> dict[str, Any]:
|
|
285
|
+
if not RELEASES_URL:
|
|
286
|
+
return {}
|
|
287
|
+
try:
|
|
288
|
+
return _http_json(RELEASES_URL)
|
|
289
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
|
|
290
|
+
return {}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def updates_available(*, desktop_version: str = "", hermes_agent_version: str = "") -> dict[str, Any]:
|
|
294
|
+
compose_dir = _compose_dir()
|
|
295
|
+
project_root = _project_root()
|
|
296
|
+
api_docker = Path(DOCKER_SOCK).exists()
|
|
297
|
+
supervisor_ok = _supervisor_configured()
|
|
298
|
+
apply_channel, apply_ready, apply_reason = _update_apply_channel()
|
|
299
|
+
installed = _read_installed_workframe_version(project_root)
|
|
300
|
+
|
|
301
|
+
npm_latest = ""
|
|
302
|
+
try:
|
|
303
|
+
npm_latest = _npm_latest_version()
|
|
304
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
releases = _releases_manifest()
|
|
308
|
+
workframe_latest = str(releases.get("workframe") or releases.get("create_workframe") or npm_latest or "")
|
|
309
|
+
desktop_latest = str(releases.get("desktop") or os.environ.get("WORKFRAME_DESKTOP_LATEST", "0.1.0"))
|
|
310
|
+
|
|
311
|
+
installed_pkg = installed.get("package") or installed.get("api") or ""
|
|
312
|
+
workframe_update = bool(workframe_latest and _version_lt(installed_pkg, workframe_latest))
|
|
313
|
+
|
|
314
|
+
hermes_digest, hermes_ref = ("", "")
|
|
315
|
+
if api_docker:
|
|
316
|
+
hermes_digest, hermes_ref = _container_image_digest(GATEWAY_CONTAINER)
|
|
317
|
+
elif supervisor_ok:
|
|
318
|
+
hermes_digest, hermes_ref = _supervisor_gateway_image_digest()
|
|
319
|
+
hermes_tag = hermes_ref.rsplit(":", 1)[-1] if hermes_ref and ":" in hermes_ref else HERMES_TAG
|
|
320
|
+
hermes_latest_digest = ""
|
|
321
|
+
try:
|
|
322
|
+
hermes_latest_digest = _docker_hub_digest(HERMES_IMAGE, HERMES_TAG)
|
|
323
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
|
|
324
|
+
pass
|
|
325
|
+
hermes_image_known = bool(api_docker or supervisor_ok)
|
|
326
|
+
hermes_update = bool(
|
|
327
|
+
hermes_image_known
|
|
328
|
+
and hermes_latest_digest
|
|
329
|
+
and hermes_digest
|
|
330
|
+
and hermes_digest != hermes_latest_digest,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
desktop_installed = str(desktop_version or "").strip()
|
|
334
|
+
desktop_update = bool(desktop_latest and desktop_installed and _version_lt(desktop_installed, desktop_latest))
|
|
335
|
+
|
|
336
|
+
digest_short = hermes_latest_digest
|
|
337
|
+
if len(digest_short) > 28:
|
|
338
|
+
digest_short = digest_short[:28] + "…"
|
|
339
|
+
|
|
340
|
+
hermes_script_ok = _script_path("apply-update-hermes.sh") is not None
|
|
341
|
+
workframe_script_ok = _script_path("apply-update-workframe.sh") is not None
|
|
342
|
+
hermes_can_update = bool(apply_ready and hermes_script_ok)
|
|
343
|
+
workframe_can_update = bool(apply_ready and workframe_script_ok)
|
|
344
|
+
hermes_reason = apply_reason
|
|
345
|
+
if not hermes_reason and hermes_update and not hermes_script_ok:
|
|
346
|
+
hermes_reason = "Hermes update script is missing from this install."
|
|
347
|
+
workframe_reason = apply_reason
|
|
348
|
+
if not workframe_reason and workframe_update and not workframe_script_ok:
|
|
349
|
+
workframe_reason = "Workframe update script is missing from this install."
|
|
350
|
+
if not workframe_reason and workframe_update and not workframe_latest:
|
|
351
|
+
workframe_reason = "No published npm release to update to yet."
|
|
352
|
+
|
|
353
|
+
agent_version = str(hermes_agent_version or "").strip() or _read_hermes_agent_version()
|
|
354
|
+
hermes_current = agent_version or hermes_tag
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
"ok": True,
|
|
358
|
+
"docker_available": apply_ready,
|
|
359
|
+
"docker_sock_on_api": api_docker,
|
|
360
|
+
"supervisor_configured": supervisor_ok,
|
|
361
|
+
"update_apply_channel": apply_channel if apply_ready else None,
|
|
362
|
+
"update_apply_ready": apply_ready,
|
|
363
|
+
"compose_dir": str(compose_dir),
|
|
364
|
+
"project_root": str(project_root),
|
|
365
|
+
"workframe": {
|
|
366
|
+
"current": installed_pkg,
|
|
367
|
+
"latest": workframe_latest,
|
|
368
|
+
"update_available": workframe_update,
|
|
369
|
+
"can_update": workframe_can_update,
|
|
370
|
+
"state": _product_state(update_available=workframe_update, can_update=workframe_can_update),
|
|
371
|
+
"reason": workframe_reason,
|
|
372
|
+
"update_mode": "docker-compose-rebuild",
|
|
373
|
+
"install_kind": "docker",
|
|
374
|
+
"components": ["ui", "api", "supervisor"],
|
|
375
|
+
},
|
|
376
|
+
"hermes": {
|
|
377
|
+
"current": hermes_current,
|
|
378
|
+
"agent_version": agent_version,
|
|
379
|
+
"image_tag": hermes_tag,
|
|
380
|
+
"latest": "",
|
|
381
|
+
"current_image": hermes_ref,
|
|
382
|
+
"current_digest": hermes_digest[:28] + "…" if len(hermes_digest) > 28 else hermes_digest,
|
|
383
|
+
"latest_digest": digest_short,
|
|
384
|
+
"image": f"{HERMES_IMAGE}:{HERMES_TAG}",
|
|
385
|
+
"update_available": hermes_update,
|
|
386
|
+
"can_update": hermes_can_update,
|
|
387
|
+
"state": _product_state(update_available=hermes_update, can_update=hermes_can_update),
|
|
388
|
+
"reason": hermes_reason,
|
|
389
|
+
"update_mode": "docker-compose-pull",
|
|
390
|
+
"install_kind": "docker",
|
|
391
|
+
"can_restart_gateway": bool(apply_ready and _script_path("restart-gateway-hermes.sh") is not None),
|
|
392
|
+
},
|
|
393
|
+
"desktop": {
|
|
394
|
+
"current": desktop_installed,
|
|
395
|
+
"latest": desktop_latest,
|
|
396
|
+
"update_available": desktop_update,
|
|
397
|
+
"can_update": False,
|
|
398
|
+
"state": "available" if desktop_update else "current",
|
|
399
|
+
"reason": "Desktop updates are distributed separately from the Docker stack.",
|
|
400
|
+
"update_mode": "manual-download",
|
|
401
|
+
"install_kind": "desktop",
|
|
402
|
+
"download_url": str(releases.get("desktop_download_url") or ""),
|
|
403
|
+
},
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _apply_env_for_target(target: str) -> dict[str, str]:
|
|
408
|
+
env = os.environ.copy()
|
|
409
|
+
env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
|
|
410
|
+
env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
|
|
411
|
+
if target in {"workframe", "all"}:
|
|
412
|
+
latest = ""
|
|
413
|
+
try:
|
|
414
|
+
latest = str(updates_available().get("workframe", {}).get("latest") or "").strip()
|
|
415
|
+
except Exception: # noqa: BLE001
|
|
416
|
+
latest = ""
|
|
417
|
+
if latest:
|
|
418
|
+
env["WORKFRAME_UPDATE_ALLOW_NPM"] = "1"
|
|
419
|
+
env["WORKFRAME_UPDATE_VERSION"] = latest
|
|
420
|
+
return env
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def apply_update(target: str) -> dict[str, Any]:
|
|
424
|
+
if not _admin_stack_updates_enabled():
|
|
425
|
+
raise ValueError("admin_updates_disabled")
|
|
426
|
+
target = str(target or "all").strip().lower()
|
|
427
|
+
if target not in {"hermes", "workframe", "all"}:
|
|
428
|
+
raise ValueError("invalid_update_target")
|
|
429
|
+
_, apply_ready, apply_reason = _update_apply_channel()
|
|
430
|
+
if not apply_ready:
|
|
431
|
+
if not Path(DOCKER_SOCK).exists():
|
|
432
|
+
raise ValueError("docker_unavailable")
|
|
433
|
+
raise ValueError(str(apply_reason or "docker_apply_unavailable"))
|
|
434
|
+
if not Path(DOCKER_SOCK).exists():
|
|
435
|
+
raise ValueError("docker_unavailable")
|
|
436
|
+
|
|
437
|
+
scripts: list[str] = []
|
|
438
|
+
if target in {"hermes", "all"}:
|
|
439
|
+
script = _script_path("apply-update-hermes.sh")
|
|
440
|
+
if not script:
|
|
441
|
+
raise ValueError("update_script_missing:hermes")
|
|
442
|
+
scripts.append(str(script))
|
|
443
|
+
if target in {"workframe", "all"}:
|
|
444
|
+
script = _script_path("apply-update-workframe.sh")
|
|
445
|
+
if not script:
|
|
446
|
+
raise ValueError("update_script_missing:workframe")
|
|
447
|
+
scripts.append(str(script))
|
|
448
|
+
|
|
449
|
+
env = _apply_env_for_target(target)
|
|
450
|
+
|
|
451
|
+
logs: list[str] = []
|
|
452
|
+
for script in scripts:
|
|
453
|
+
proc = subprocess.run(
|
|
454
|
+
["bash", script],
|
|
455
|
+
capture_output=True,
|
|
456
|
+
text=True,
|
|
457
|
+
timeout=900,
|
|
458
|
+
env=env,
|
|
459
|
+
cwd=env["WORKFRAME_COMPOSE_DIR"],
|
|
460
|
+
)
|
|
461
|
+
logs.append(f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}")
|
|
462
|
+
if proc.returncode != 0:
|
|
463
|
+
raise ValueError(f"update_failed:{Path(script).name}")
|
|
464
|
+
|
|
465
|
+
return {"ok": True, "target": target, "log": "\n".join(logs)[-12000:]}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def restart_gateway() -> dict[str, Any]:
|
|
469
|
+
if not _admin_stack_updates_enabled():
|
|
470
|
+
raise ValueError("admin_updates_disabled")
|
|
471
|
+
_, apply_ready, apply_reason = _update_apply_channel()
|
|
472
|
+
if not apply_ready:
|
|
473
|
+
if not Path(DOCKER_SOCK).exists():
|
|
474
|
+
raise ValueError("docker_unavailable")
|
|
475
|
+
raise ValueError(str(apply_reason or "docker_apply_unavailable"))
|
|
476
|
+
if not Path(DOCKER_SOCK).exists():
|
|
477
|
+
raise ValueError("docker_unavailable")
|
|
478
|
+
script = _script_path("restart-gateway-hermes.sh")
|
|
479
|
+
if not script:
|
|
480
|
+
raise ValueError("restart_script_missing:gateway")
|
|
481
|
+
|
|
482
|
+
env = os.environ.copy()
|
|
483
|
+
env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
|
|
484
|
+
env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
|
|
485
|
+
proc = subprocess.run(
|
|
486
|
+
["bash", str(script)],
|
|
487
|
+
capture_output=True,
|
|
488
|
+
text=True,
|
|
489
|
+
timeout=300,
|
|
490
|
+
env=env,
|
|
491
|
+
cwd=env["WORKFRAME_COMPOSE_DIR"],
|
|
492
|
+
)
|
|
493
|
+
log = f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}"
|
|
494
|
+
if proc.returncode != 0:
|
|
495
|
+
raise ValueError("restart_failed:gateway")
|
|
496
|
+
return {"ok": True, "target": "gateway", "log": log[-12000:]}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
if __name__ == "__main__":
|
|
500
|
+
assert _version_lt("0.1.0", "0.1.1")
|
|
501
|
+
assert not _version_lt("0.1.0", "0.1.0")
|
|
502
|
+
assert parse_hermes_version_output("Hermes Agent v0.17.0 (2026.6.19)") == "0.17.0"
|
|
503
|
+
ch, ready, _ = _update_apply_channel()
|
|
504
|
+
assert ch in {"api_docker", "supervisor", "none"}
|
|
505
|
+
assert ready is False or ch != "none"
|
|
506
|
+
print("updates module ok")
|