create-workframe 0.1.11 → 0.1.13
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/README.md +1 -1
- package/bin/create-workframe.js +2 -2
- package/bin/workframe.js +130 -2
- package/docs/security.md +85 -0
- package/package.json +1 -1
- package/scripts/bundle-workframe-ui.mjs +9 -0
- package/scripts/sync-canonical-to-package.mjs +9 -0
- package/scripts/verify-public-deploy.sh +121 -3
- package/workframe-api/action_proxy.py +23 -17
- package/workframe-api/activity_feed.py +798 -0
- package/workframe-api/api_meta.py +157 -0
- package/workframe-api/auth_gate.py +566 -0
- package/workframe-api/avatar_registry.py +343 -0
- package/workframe-api/broker_audit.py +164 -0
- package/workframe-api/cell_authority.py +231 -0
- package/workframe-api/chat_bind.py +319 -0
- package/workframe-api/chat_sessions.py +509 -0
- package/workframe-api/chat_stream.py +694 -0
- package/workframe-api/cleanup_dogfood_smoke.py +252 -0
- package/workframe-api/credential_broker.py +162 -0
- package/workframe-api/credential_resolve.py +202 -0
- package/workframe-api/credential_store.py +245 -0
- package/workframe-api/crew_registry.py +250 -0
- package/workframe-api/db_schema.py +755 -0
- package/workframe-api/docker_gateway.py +166 -0
- package/workframe-api/doctor_runtime.py +109 -0
- package/workframe-api/domain/__init__.py +47 -0
- package/workframe-api/domain/entities.py +252 -0
- package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
- package/workframe-api/egress_policy.py +42 -0
- package/workframe-api/handler_modules/__init__.py +17 -0
- package/workframe-api/handler_modules/handler_admin.py +542 -0
- package/workframe-api/handler_modules/handler_auth.py +512 -0
- package/workframe-api/handler_modules/handler_chat.py +641 -0
- package/workframe-api/handler_modules/handler_install.py +178 -0
- package/workframe-api/handler_modules/handler_provider.py +325 -0
- package/workframe-api/handler_modules/handler_workspace.py +1420 -0
- package/workframe-api/health_monitor.py +80 -0
- package/workframe-api/hermes_admin.py +756 -0
- package/workframe-api/hermes_profiles.py +1328 -0
- package/workframe-api/install_api.py +123 -0
- package/workframe-api/kanban_cron.py +473 -0
- package/workframe-api/lane_bindings.py +423 -0
- package/workframe-api/llm_proxy.py +17 -43
- package/workframe-api/mention_helpers.py +180 -0
- package/workframe-api/mention_invoke.py +431 -0
- package/workframe-api/model_surface.py +1139 -0
- package/workframe-api/oauth_pending.py +85 -0
- package/workframe-api/oauth_redirect.py +381 -0
- package/workframe-api/openrouter_catalog.py +4 -4
- package/workframe-api/package.json +2 -2
- package/workframe-api/profile_api_lifecycle.py +66 -0
- package/workframe-api/profile_config_yaml.py +126 -0
- package/workframe-api/profile_gateway.py +436 -0
- package/workframe-api/provider_bindings.py +673 -0
- package/workframe-api/provider_bootstrap.py +347 -0
- package/workframe-api/provider_catalog.py +180 -0
- package/workframe-api/rooms.py +1613 -0
- package/workframe-api/route_registry.py +470 -0
- package/workframe-api/run-typecheck.mjs +26 -0
- package/workframe-api/run_authority.py +287 -0
- package/workframe-api/run_ledger.py +470 -0
- package/workframe-api/run_surface_wiring.py +344 -0
- package/workframe-api/runtime_cohort.py +752 -0
- package/workframe-api/runtime_tokens.py +127 -0
- package/workframe-api/server.py +1376 -19305
- package/workframe-api/snapshot_feed.py +49 -0
- package/workframe-api/stack_config.py +33 -1
- package/workframe-api/supervisor_client.py +154 -0
- package/workframe-api/test_api_meta_build_stamp.py +34 -0
- package/workframe-api/test_cell_authority.py +79 -0
- package/workframe-api/test_chat_bind.py +48 -0
- package/workframe-api/test_credential_lease_matrix.py +171 -0
- package/workframe-api/test_credential_resolve.py +51 -0
- package/workframe-api/test_credential_store.py +27 -0
- package/workframe-api/test_dogfood_flows.py +346 -0
- package/workframe-api/test_domain_entities.py +152 -0
- package/workframe-api/test_exception_hygiene.py +12 -0
- package/workframe-api/test_hermes_admin.py +72 -0
- package/workframe-api/test_install_wizard_resume.py +78 -0
- package/workframe-api/test_local_bootstrap.py +67 -0
- package/workframe-api/test_mention_helpers.py +35 -0
- package/workframe-api/test_mention_invoke.py +26 -0
- package/workframe-api/test_model_surface_consistency.py +117 -0
- package/workframe-api/test_oauth_pending.py +41 -0
- package/workframe-api/test_profile_model_yaml.py +99 -0
- package/workframe-api/test_provider_bindings.py +52 -0
- package/workframe-api/test_provider_catalog.py +28 -0
- package/workframe-api/test_public_routes.py +41 -0
- package/workframe-api/test_route_registry.py +245 -0
- package/workframe-api/test_run_authority.py +112 -0
- package/workframe-api/test_run_ledger.py +157 -0
- package/workframe-api/test_run_surface_wiring.py +130 -0
- package/workframe-api/test_runtime_cohort.py +85 -0
- package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
- package/workframe-api/test_server_reexports.py +99 -0
- package/workframe-api/test_stack_config_install_smtp.py +7 -0
- package/workframe-api/time-bind-steps.py +75 -0
- package/workframe-api/time-perf.py +81 -0
- package/workframe-api/time-warm.py +107 -0
- package/workframe-api/turn_credentials.py +28 -13
- package/workframe-api/turn_overlay.py +715 -0
- package/workframe-api/updates.py +16 -1
- package/workframe-api/user_prefs.py +387 -0
- package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
- package/workframe-api/workspace_bootstrap.py +536 -0
- package/workframe-api/workspace_files.py +344 -0
- package/workframe-api/workspace_messaging.py +206 -0
- package/workframe-api/zk_auth.py +3 -2
- package/workframe-supervisor/server.py +10 -1
- package/workframe-supervisor/test_supervisor_negative.py +75 -0
- package/workframe-ui/public/assets/{arc-DjpS9a2b.js → arc-aUUffclm.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
- package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
- package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-mQWmB3z6.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
- package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
- package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
- package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-BTCEHb7l.js} +1 -1
- package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-CkbydpnK.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-DvrtArpk.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-yj_1x_qj.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
- package/workframe-ui/public/assets/{src-CY_By-r7.js → src-D6mvlP96.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
- package/workframe-ui/public/index.html +7 -6
- package/workframe-ui/public/workframe-build.json +5 -0
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +0 -1
- package/workframe-ui/public/assets/channel-D63s_yN9.js +0 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-CsRdBqUB.js +0 -2
- package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +0 -1
- package/workframe-ui/public/assets/index-B08aShJy.css +0 -1
- package/workframe-ui/public/assets/index-DCF3W3G_.js +0 -129
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +0 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""CellAuthorityGate — install/cell authority (WF-007)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from domain.entities import Cell, DeploymentMode, Grant, GrantCapability
|
|
12
|
+
|
|
13
|
+
CellOperation = Literal["create", "open", "update", "connect", "adopt"]
|
|
14
|
+
CellDecisionKind = Literal["allow", "allow_readonly", "deny", "needs_user_action"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class CellAuthorityDecision:
|
|
19
|
+
operation: CellOperation
|
|
20
|
+
decision: CellDecisionKind
|
|
21
|
+
reason: str
|
|
22
|
+
cell: Cell | None
|
|
23
|
+
mutation_plan: tuple[str, ...] = field(default_factory=tuple)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _read_json(path: Path) -> dict[str, Any] | None:
|
|
27
|
+
try:
|
|
28
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
29
|
+
except (OSError, json.JSONDecodeError):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_cell_from_manifest(install_root: str | Path) -> Cell | None:
|
|
34
|
+
root = Path(install_root).resolve()
|
|
35
|
+
manifest_path = root / "workframe-manifest.json"
|
|
36
|
+
data = _read_json(manifest_path)
|
|
37
|
+
if not data:
|
|
38
|
+
return None
|
|
39
|
+
layout = data.get("layout") if isinstance(data.get("layout"), dict) else {}
|
|
40
|
+
security = data.get("security") if isinstance(data.get("security"), dict) else {}
|
|
41
|
+
docker = data.get("docker") if isinstance(data.get("docker"), dict) else {}
|
|
42
|
+
mode_raw = str(security.get("deployment_mode") or data.get("deployment_mode") or "single_user_local")
|
|
43
|
+
try:
|
|
44
|
+
mode = DeploymentMode(mode_raw)
|
|
45
|
+
except ValueError:
|
|
46
|
+
mode = DeploymentMode.SINGLE_USER_LOCAL
|
|
47
|
+
return Cell(
|
|
48
|
+
cell_id=str(data.get("install_id") or data.get("project_name") or root.name),
|
|
49
|
+
install_root=str(root),
|
|
50
|
+
manifest_path=str(manifest_path),
|
|
51
|
+
package_name=str(data.get("package_name") or "create-workframe"),
|
|
52
|
+
package_version=str(data.get("package_version") or ""),
|
|
53
|
+
deployment_mode=mode,
|
|
54
|
+
created_at=None, # type: ignore[arg-type]
|
|
55
|
+
git_ref=data.get("git_ref"),
|
|
56
|
+
packed_artifact_digest=data.get("packed_artifact_digest"),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _dir_nonempty(path: Path) -> bool:
|
|
61
|
+
if not path.is_dir():
|
|
62
|
+
return False
|
|
63
|
+
try:
|
|
64
|
+
return any(path.iterdir())
|
|
65
|
+
except OSError:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def evaluate_create(
|
|
70
|
+
target: str | Path,
|
|
71
|
+
*,
|
|
72
|
+
force: bool = False,
|
|
73
|
+
package_version: str = "",
|
|
74
|
+
packed_digest: str | None = None,
|
|
75
|
+
) -> CellAuthorityDecision:
|
|
76
|
+
root = Path(target).resolve()
|
|
77
|
+
if root.exists() and _dir_nonempty(root) and not force:
|
|
78
|
+
return CellAuthorityDecision(
|
|
79
|
+
operation="create",
|
|
80
|
+
decision="deny",
|
|
81
|
+
reason="target_not_empty",
|
|
82
|
+
cell=None,
|
|
83
|
+
mutation_plan=(),
|
|
84
|
+
)
|
|
85
|
+
plan = (
|
|
86
|
+
"assert target writable",
|
|
87
|
+
"write scaffold tree",
|
|
88
|
+
f"record package_version={package_version or 'unknown'}",
|
|
89
|
+
"atomic write workframe-manifest.json",
|
|
90
|
+
"verify layout Agents/ Files/",
|
|
91
|
+
)
|
|
92
|
+
cell = Cell(
|
|
93
|
+
cell_id=root.name,
|
|
94
|
+
install_root=str(root),
|
|
95
|
+
manifest_path=str(root / "workframe-manifest.json"),
|
|
96
|
+
package_name="create-workframe",
|
|
97
|
+
package_version=package_version,
|
|
98
|
+
deployment_mode=DeploymentMode.SINGLE_USER_LOCAL,
|
|
99
|
+
created_at=None, # type: ignore[arg-type]
|
|
100
|
+
packed_artifact_digest=packed_digest,
|
|
101
|
+
)
|
|
102
|
+
return CellAuthorityDecision(
|
|
103
|
+
operation="create",
|
|
104
|
+
decision="allow",
|
|
105
|
+
reason="empty_target_or_force",
|
|
106
|
+
cell=cell,
|
|
107
|
+
mutation_plan=plan,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def evaluate_open(install_root: str | Path, *, doctor_report: dict[str, Any] | None = None) -> CellAuthorityDecision:
|
|
112
|
+
root = Path(install_root).resolve()
|
|
113
|
+
cell = load_cell_from_manifest(root)
|
|
114
|
+
if not cell:
|
|
115
|
+
return CellAuthorityDecision(
|
|
116
|
+
operation="open",
|
|
117
|
+
decision="deny",
|
|
118
|
+
reason="manifest_missing",
|
|
119
|
+
cell=None,
|
|
120
|
+
)
|
|
121
|
+
manifest_layout = _read_json(Path(cell.manifest_path)) or {}
|
|
122
|
+
layout = manifest_layout.get("layout") if isinstance(manifest_layout.get("layout"), dict) else {}
|
|
123
|
+
expected_ws = str(layout.get("workspace") or "Files")
|
|
124
|
+
if not (root / expected_ws).is_dir():
|
|
125
|
+
return CellAuthorityDecision(
|
|
126
|
+
operation="open",
|
|
127
|
+
decision="deny",
|
|
128
|
+
reason="workspace_mount_missing",
|
|
129
|
+
cell=cell,
|
|
130
|
+
)
|
|
131
|
+
if doctor_report and doctor_report.get("decision") == "needs_user_action":
|
|
132
|
+
return CellAuthorityDecision(
|
|
133
|
+
operation="open",
|
|
134
|
+
decision="needs_user_action",
|
|
135
|
+
reason="doctor_prerequisites",
|
|
136
|
+
cell=cell,
|
|
137
|
+
)
|
|
138
|
+
return CellAuthorityDecision(
|
|
139
|
+
operation="open",
|
|
140
|
+
decision="allow_readonly",
|
|
141
|
+
reason="manifest_and_layout_ok",
|
|
142
|
+
cell=cell,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def evaluate_update(
|
|
147
|
+
install_root: str | Path,
|
|
148
|
+
*,
|
|
149
|
+
open_decision: CellAuthorityDecision | None = None,
|
|
150
|
+
user_ack: bool = False,
|
|
151
|
+
) -> CellAuthorityDecision:
|
|
152
|
+
opened = open_decision or evaluate_open(install_root)
|
|
153
|
+
if opened.decision != "allow_readonly":
|
|
154
|
+
return CellAuthorityDecision(
|
|
155
|
+
operation="update",
|
|
156
|
+
decision="deny",
|
|
157
|
+
reason=f"open_blocked:{opened.reason}",
|
|
158
|
+
cell=opened.cell,
|
|
159
|
+
)
|
|
160
|
+
if not user_ack:
|
|
161
|
+
return CellAuthorityDecision(
|
|
162
|
+
operation="update",
|
|
163
|
+
decision="needs_user_action",
|
|
164
|
+
reason="explicit_user_ack_required",
|
|
165
|
+
cell=opened.cell,
|
|
166
|
+
mutation_plan=(
|
|
167
|
+
"snapshot manifest digest",
|
|
168
|
+
"apply bounded file mutations",
|
|
169
|
+
"verify health",
|
|
170
|
+
"record evidence artifact",
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
return CellAuthorityDecision(
|
|
174
|
+
operation="update",
|
|
175
|
+
decision="allow",
|
|
176
|
+
reason="open_ok_and_ack",
|
|
177
|
+
cell=opened.cell,
|
|
178
|
+
mutation_plan=(
|
|
179
|
+
"snapshot manifest digest",
|
|
180
|
+
"apply bounded file mutations",
|
|
181
|
+
"verify health",
|
|
182
|
+
"record evidence artifact",
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def evaluate_connect(*, remote_attested: bool = False) -> CellAuthorityDecision:
|
|
188
|
+
if not remote_attested:
|
|
189
|
+
return CellAuthorityDecision(
|
|
190
|
+
operation="connect",
|
|
191
|
+
decision="deny",
|
|
192
|
+
reason="remote_not_attested",
|
|
193
|
+
cell=None,
|
|
194
|
+
)
|
|
195
|
+
return CellAuthorityDecision(
|
|
196
|
+
operation="connect",
|
|
197
|
+
decision="allow_readonly",
|
|
198
|
+
reason="remote_attested",
|
|
199
|
+
cell=None,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def evaluate_adopt(*, runtime_kind: str = "") -> CellAuthorityDecision:
|
|
204
|
+
# ponytail: adopt blocked until WF-014/NS-P4 — always deny non-proven adapters
|
|
205
|
+
if runtime_kind and runtime_kind not in ("hermes_managed", ""):
|
|
206
|
+
return CellAuthorityDecision(
|
|
207
|
+
operation="adopt",
|
|
208
|
+
decision="deny",
|
|
209
|
+
reason="adapter_not_proven",
|
|
210
|
+
cell=None,
|
|
211
|
+
)
|
|
212
|
+
return CellAuthorityDecision(
|
|
213
|
+
operation="adopt",
|
|
214
|
+
decision="deny",
|
|
215
|
+
reason="adopt_deferred_wf014",
|
|
216
|
+
cell=None,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def file_write_grant(cell: Cell) -> Grant:
|
|
221
|
+
return Grant(
|
|
222
|
+
grant_id=f"cell-fw-{cell.cell_id}",
|
|
223
|
+
run_id="",
|
|
224
|
+
capability=GrantCapability.FILE_WRITE,
|
|
225
|
+
scope={"install_root": cell.install_root},
|
|
226
|
+
granted_by="cell_authority_gate",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def cell_id_from_env() -> str:
|
|
231
|
+
return str(os.environ.get("WORKFRAME_PROJECT") or os.environ.get("PROJECT_NAME") or "default")
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""WF-032 extract: chat bind and room session handlers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import sqlite3
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import lane_bindings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _srv():
|
|
14
|
+
import server as srv
|
|
15
|
+
|
|
16
|
+
return srv
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def room_chat_bind(room_id: str, payload: dict[str, Any], user_id: str = "") -> dict[str, Any]:
|
|
20
|
+
"""Room-scoped bind — identity from room.agent_profile_id, not the URL."""
|
|
21
|
+
body = dict(payload or {})
|
|
22
|
+
body["room_id"] = str(room_id or "").strip()
|
|
23
|
+
return profile_chat_bind("", body, user_id)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def profile_chat_bind(profile: str, payload: dict[str, Any], user_id: str = "") -> dict[str, Any]:
|
|
27
|
+
session = lane_bindings.profile_chat_session(profile, payload, user_id)
|
|
28
|
+
sid = str(session.get("session_id") or "").strip()
|
|
29
|
+
hermes_prof = str(session.get("profile") or _srv().resolve_validated_profile(profile))
|
|
30
|
+
template_prof = str(session.get("template_profile") or "").strip()
|
|
31
|
+
if not template_prof:
|
|
32
|
+
template_prof = (
|
|
33
|
+
_srv()._runtime_template_slug(hermes_prof)
|
|
34
|
+
if _srv()._is_runtime_profile_slug(hermes_prof)
|
|
35
|
+
else _srv().resolve_validated_profile(profile)
|
|
36
|
+
)
|
|
37
|
+
workspace_id = str(session.get("workspace_id") or payload.get("workspace_id") or "").strip()
|
|
38
|
+
history = _srv().chat_messages(hermes_prof, sid)
|
|
39
|
+
# ponytail: cohort manifest is lazy — GET /api/me/cohort; bind must stay fast
|
|
40
|
+
cohort: list[dict[str, Any]] = []
|
|
41
|
+
display_name = (
|
|
42
|
+
_srv()._runtime_display_label(user_id, template_prof, workspace_id)
|
|
43
|
+
if user_id and _srv()._is_runtime_profile_slug(hermes_prof)
|
|
44
|
+
else _srv()._profile_display_name(hermes_prof, workspace_id)
|
|
45
|
+
)
|
|
46
|
+
return {
|
|
47
|
+
"ok": True,
|
|
48
|
+
"profile": session.get("profile") or profile,
|
|
49
|
+
"template_profile": template_prof,
|
|
50
|
+
"agent_display_name": display_name,
|
|
51
|
+
"cohort": cohort,
|
|
52
|
+
"session_id": sid,
|
|
53
|
+
"title": session.get("title") or "",
|
|
54
|
+
"created": bool(session.get("created")),
|
|
55
|
+
"api_port": session.get("api_port"),
|
|
56
|
+
"llm_ready": bool(session.get("llm_ready")),
|
|
57
|
+
"has_llm_provider": bool(session.get("has_llm_provider")),
|
|
58
|
+
"messages": history.get("messages") or [],
|
|
59
|
+
"session": history.get("session") or {},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _room_session_rows(conn: sqlite3.Connection, room_id: str) -> list[sqlite3.Row]:
|
|
64
|
+
return conn.execute(
|
|
65
|
+
"""
|
|
66
|
+
SELECT rs.*, ap.slug AS agent_slug, ap.display_name AS agent_display_name
|
|
67
|
+
FROM room_sessions rs
|
|
68
|
+
LEFT JOIN agent_profiles ap ON ap.id = rs.agent_profile_id
|
|
69
|
+
WHERE rs.room_id = ? AND rs.deleted_at IS NULL
|
|
70
|
+
ORDER BY rs.updated_at DESC, rs.created_at DESC
|
|
71
|
+
""",
|
|
72
|
+
(room_id,),
|
|
73
|
+
).fetchall()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def list_room_sessions(room_id: str, user_id: str) -> dict[str, Any]:
|
|
77
|
+
room_id = str(room_id or "").strip()
|
|
78
|
+
user_id = str(user_id or "").strip()
|
|
79
|
+
if not room_id or not user_id:
|
|
80
|
+
raise ValueError("room_id and user required")
|
|
81
|
+
conn = _srv()._workframe_db()
|
|
82
|
+
try:
|
|
83
|
+
room = conn.execute(
|
|
84
|
+
"SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL",
|
|
85
|
+
(room_id,),
|
|
86
|
+
).fetchone()
|
|
87
|
+
if not room:
|
|
88
|
+
raise ValueError("room_not_found")
|
|
89
|
+
if not _srv()._user_can_access_room(conn, room_id, user_id):
|
|
90
|
+
raise ValueError("room_access_denied")
|
|
91
|
+
workspace_id = str(room["workspace_id"])
|
|
92
|
+
rows = _room_session_rows(conn, room_id)
|
|
93
|
+
finally:
|
|
94
|
+
conn.close()
|
|
95
|
+
|
|
96
|
+
sessions: list[dict[str, Any]] = []
|
|
97
|
+
for row in rows:
|
|
98
|
+
template_slug = str(row["agent_slug"] or "").strip()
|
|
99
|
+
agent_db_id = str(row["agent_profile_id"] or "").strip()
|
|
100
|
+
if not template_slug or not agent_db_id:
|
|
101
|
+
continue
|
|
102
|
+
sid = str(row["session_id"] or "").strip()
|
|
103
|
+
try:
|
|
104
|
+
hermes_prof = _srv()._resolve_chat_hermes_profile(template_slug, user_id, room_id, workspace_id)
|
|
105
|
+
if not sid or not _srv()._session_exists(hermes_prof, sid):
|
|
106
|
+
continue
|
|
107
|
+
info = _srv()._session_info(hermes_prof, sid)
|
|
108
|
+
except ValueError:
|
|
109
|
+
# ponytail: skip orphaned bindings (e.g. deleted smoke-agent profiles)
|
|
110
|
+
continue
|
|
111
|
+
sessions.append(
|
|
112
|
+
{
|
|
113
|
+
"id": str(row["id"]),
|
|
114
|
+
"room_id": room_id,
|
|
115
|
+
"agent_profile_id": agent_db_id,
|
|
116
|
+
"agent_slug": template_slug,
|
|
117
|
+
"hermes_profile": hermes_prof,
|
|
118
|
+
"session_id": sid,
|
|
119
|
+
"title": _srv()._resolved_session_title(hermes_prof, sid, str(row["title"] or "")),
|
|
120
|
+
"status": str(row["status"] or "active"),
|
|
121
|
+
"message_count": int(info.get("message_count") or 0),
|
|
122
|
+
"created_at": _srv()._iso_from_unix(row["created_at"]),
|
|
123
|
+
"updated_at": _srv()._iso_from_unix(row["updated_at"]),
|
|
124
|
+
"active": str(row["status"] or "") == "active",
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
return {"ok": True, "room_id": room_id, "sessions": sessions}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def profile_chat_activate_room_session(
|
|
131
|
+
room_id: str,
|
|
132
|
+
session_id: str,
|
|
133
|
+
user_id: str,
|
|
134
|
+
template_prof: str = "",
|
|
135
|
+
*,
|
|
136
|
+
source_id: str = "ui",
|
|
137
|
+
client_id: str = "default",
|
|
138
|
+
binding_version: int = 0,
|
|
139
|
+
) -> dict[str, Any]:
|
|
140
|
+
room_id = str(room_id or "").strip()
|
|
141
|
+
session_id = str(session_id or "").strip()
|
|
142
|
+
user_id = str(user_id or "").strip()
|
|
143
|
+
if not room_id or not session_id or not user_id:
|
|
144
|
+
raise ValueError("room_id, session_id, and user required")
|
|
145
|
+
|
|
146
|
+
conn = _srv()._workframe_db()
|
|
147
|
+
try:
|
|
148
|
+
room = conn.execute(
|
|
149
|
+
"SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL",
|
|
150
|
+
(room_id,),
|
|
151
|
+
).fetchone()
|
|
152
|
+
if not room:
|
|
153
|
+
raise ValueError("room_not_found")
|
|
154
|
+
if not _srv()._user_can_access_room(conn, room_id, user_id):
|
|
155
|
+
raise ValueError("room_access_denied")
|
|
156
|
+
workspace_id = str(room["workspace_id"])
|
|
157
|
+
row = conn.execute(
|
|
158
|
+
"""
|
|
159
|
+
SELECT rs.*, ap.slug AS agent_slug
|
|
160
|
+
FROM room_sessions rs
|
|
161
|
+
LEFT JOIN agent_profiles ap ON ap.id = rs.agent_profile_id
|
|
162
|
+
WHERE rs.room_id = ? AND rs.session_id = ? AND rs.deleted_at IS NULL
|
|
163
|
+
LIMIT 1
|
|
164
|
+
""",
|
|
165
|
+
(room_id, session_id),
|
|
166
|
+
).fetchone()
|
|
167
|
+
if not row:
|
|
168
|
+
raise ValueError("room_session_not_found")
|
|
169
|
+
template_slug = str(template_prof or row["agent_slug"] or "").strip()
|
|
170
|
+
if not template_slug:
|
|
171
|
+
raise ValueError("agent_profile_not_found")
|
|
172
|
+
agent_db_id = str(row["agent_profile_id"] or "").strip()
|
|
173
|
+
hermes_prof = _srv()._resolve_chat_hermes_profile(template_slug, user_id, room_id, workspace_id)
|
|
174
|
+
if not _srv()._session_exists(hermes_prof, session_id):
|
|
175
|
+
raise ValueError("session_not_found")
|
|
176
|
+
gateway_sid = str(row["gateway_session_id"] or f"api:{hermes_prof}:{session_id}").strip()
|
|
177
|
+
title = str(_srv()._session_info(hermes_prof, session_id).get("title") or row["title"] or "")
|
|
178
|
+
_srv()._upsert_room_session(
|
|
179
|
+
conn,
|
|
180
|
+
room_id=room_id,
|
|
181
|
+
agent_profile_id=agent_db_id,
|
|
182
|
+
session_id=session_id,
|
|
183
|
+
gateway_session_id=gateway_sid,
|
|
184
|
+
created_by=user_id,
|
|
185
|
+
title=title,
|
|
186
|
+
)
|
|
187
|
+
conn.commit()
|
|
188
|
+
finally:
|
|
189
|
+
conn.close()
|
|
190
|
+
|
|
191
|
+
lane_bindings._sync_lane_binding(
|
|
192
|
+
hermes_prof,
|
|
193
|
+
source_id,
|
|
194
|
+
client_id,
|
|
195
|
+
binding_version,
|
|
196
|
+
session_id,
|
|
197
|
+
gateway_sid,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if user_id:
|
|
201
|
+
_srv()._reconcile_profile_llm_for_user(hermes_prof, user_id, workspace_id)
|
|
202
|
+
llm_provider = _srv()._llm_billing_provider(hermes_prof, user_id=user_id, workspace_id=workspace_id)
|
|
203
|
+
llm_ready = _srv()._overlay_chat_llm_env(hermes_prof, user_id, workspace_id, llm_provider)
|
|
204
|
+
|
|
205
|
+
history = _srv().chat_messages(hermes_prof, session_id)
|
|
206
|
+
cohort = _srv().ensure_user_agent_cohort(user_id, workspace_id)
|
|
207
|
+
return {
|
|
208
|
+
"ok": True,
|
|
209
|
+
"profile": hermes_prof,
|
|
210
|
+
"template_profile": template_slug,
|
|
211
|
+
"agent_display_name": _srv()._runtime_display_label(user_id, template_slug, workspace_id),
|
|
212
|
+
"cohort": cohort,
|
|
213
|
+
"room_id": room_id,
|
|
214
|
+
"session_id": session_id,
|
|
215
|
+
"title": title,
|
|
216
|
+
"created": False,
|
|
217
|
+
"resumed": True,
|
|
218
|
+
"llm_ready": llm_ready,
|
|
219
|
+
"has_llm_provider": llm_ready,
|
|
220
|
+
"messages": history.get("messages") or [],
|
|
221
|
+
"session": history.get("session") or {},
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def profile_chat_message(profile: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
226
|
+
source_id = str(payload.get("source_id") or "ui").strip() or "ui"
|
|
227
|
+
client_id = str(payload.get("client_id") or "default").strip() or "default"
|
|
228
|
+
session_id = str(payload.get("session_id") or "").strip()
|
|
229
|
+
text = str(payload.get("text") or "").strip()
|
|
230
|
+
if not session_id:
|
|
231
|
+
raise ValueError("session_id required")
|
|
232
|
+
if not text:
|
|
233
|
+
raise ValueError("text required")
|
|
234
|
+
payer = str(payload.get("user_id") or "").strip()
|
|
235
|
+
workspace_id = str(payload.get("workspace_id") or "").strip()
|
|
236
|
+
room_id = str(payload.get("room_id") or "").strip()
|
|
237
|
+
hermes_prof, _template_prof = _srv()._resolve_bind_profile_arg(
|
|
238
|
+
profile, payer, room_id, workspace_id,
|
|
239
|
+
)
|
|
240
|
+
if payer:
|
|
241
|
+
_srv()._reconcile_profile_llm_for_user(hermes_prof, payer, workspace_id)
|
|
242
|
+
llm_provider = _srv()._llm_billing_provider(hermes_prof, user_id=payer, workspace_id=workspace_id)
|
|
243
|
+
lifecycle = _srv().ensure_profile_api(
|
|
244
|
+
hermes_prof,
|
|
245
|
+
payer,
|
|
246
|
+
workspace_id,
|
|
247
|
+
)
|
|
248
|
+
turn_run_id = str(uuid.uuid4())
|
|
249
|
+
try:
|
|
250
|
+
if payer:
|
|
251
|
+
_srv()._overlay_turn_provider_env(
|
|
252
|
+
hermes_prof, payer, workspace_id, llm_provider, turn_run_id,
|
|
253
|
+
)
|
|
254
|
+
_srv()._overlay_turn_user_env(hermes_prof, payer, workspace_id, turn_run_id)
|
|
255
|
+
turn_body = _srv()._profile_turn_payload(hermes_prof, text, room_id)
|
|
256
|
+
if payer and workspace_id:
|
|
257
|
+
_srv()._inject_turn_credentials(turn_body, payer, workspace_id, llm_provider)
|
|
258
|
+
status, data = _srv()._profile_api_request(
|
|
259
|
+
hermes_prof,
|
|
260
|
+
"POST",
|
|
261
|
+
f"/api/sessions/{urllib.parse.quote(session_id, safe='')}/chat",
|
|
262
|
+
turn_body,
|
|
263
|
+
)
|
|
264
|
+
if status >= 300:
|
|
265
|
+
raise ValueError(f"session chat failed: {data}")
|
|
266
|
+
assistant = ""
|
|
267
|
+
if isinstance(data, dict):
|
|
268
|
+
msg = data.get("message")
|
|
269
|
+
if isinstance(msg, dict):
|
|
270
|
+
assistant = str(msg.get("content") or "")
|
|
271
|
+
lane_bindings.chat_dispatch(
|
|
272
|
+
{
|
|
273
|
+
"profile": hermes_prof,
|
|
274
|
+
"session_id": session_id,
|
|
275
|
+
"gateway_session_id": f"api:{hermes_prof}:{session_id}",
|
|
276
|
+
"source_id": source_id,
|
|
277
|
+
"client_id": client_id,
|
|
278
|
+
"room_id": room_id,
|
|
279
|
+
"user_id": payer,
|
|
280
|
+
"text": text,
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
return {
|
|
284
|
+
"ok": True,
|
|
285
|
+
"profile": hermes_prof,
|
|
286
|
+
"session_id": session_id,
|
|
287
|
+
"api_port": lifecycle["api_port"],
|
|
288
|
+
"assistant": assistant,
|
|
289
|
+
}
|
|
290
|
+
finally:
|
|
291
|
+
if payer:
|
|
292
|
+
_srv()._revoke_turn_credential_lease(turn_run_id, hermes_prof)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _enrich_room_chat_payload(payload: dict[str, Any], user_id: str) -> dict[str, Any]:
|
|
296
|
+
body = dict(payload) if isinstance(payload, dict) else {}
|
|
297
|
+
body["user_id"] = user_id
|
|
298
|
+
room_id = str(body.get("room_id") or "").strip()
|
|
299
|
+
if room_id and not str(body.get("workspace_id") or "").strip():
|
|
300
|
+
try:
|
|
301
|
+
conn = _srv()._workframe_db()
|
|
302
|
+
row = conn.execute(
|
|
303
|
+
"SELECT workspace_id FROM rooms WHERE id = ? AND deleted_at IS NULL",
|
|
304
|
+
(room_id,),
|
|
305
|
+
).fetchone()
|
|
306
|
+
conn.close()
|
|
307
|
+
if row:
|
|
308
|
+
body["workspace_id"] = str(row["workspace_id"])
|
|
309
|
+
except Exception: # noqa: BLE001
|
|
310
|
+
pass
|
|
311
|
+
return body
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _extract_title(text: str, fallback: str) -> str:
|
|
315
|
+
for line in text.splitlines():
|
|
316
|
+
m = re.match(r"^#\s+(.+)$", line.strip())
|
|
317
|
+
if m:
|
|
318
|
+
return m.group(1).strip()
|
|
319
|
+
return fallback
|