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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
5
6
|
import os
|
|
6
7
|
import re
|
|
7
8
|
import sqlite3
|
|
@@ -17,6 +18,37 @@ from email_sender import send_email_with_config
|
|
|
17
18
|
HERMES_DATA = Path(os.environ.get("HERMES_DATA", "/opt/data"))
|
|
18
19
|
NATIVE_PROFILE = os.environ.get("WORKFRAME_NATIVE_PROFILE", "workframe-agent").strip() or "workframe-agent"
|
|
19
20
|
|
|
21
|
+
INSTALL_WIZARD_STEPS = frozenset({
|
|
22
|
+
"intro",
|
|
23
|
+
"welcome",
|
|
24
|
+
"publish",
|
|
25
|
+
"smtp",
|
|
26
|
+
"admin_auth",
|
|
27
|
+
"workframe",
|
|
28
|
+
"billing",
|
|
29
|
+
"integrations",
|
|
30
|
+
"profile",
|
|
31
|
+
"agent",
|
|
32
|
+
"agent_model",
|
|
33
|
+
"invites",
|
|
34
|
+
"done",
|
|
35
|
+
})
|
|
36
|
+
INSTALL_WIZARD_STEP_ORDER = (
|
|
37
|
+
"intro",
|
|
38
|
+
"welcome",
|
|
39
|
+
"publish",
|
|
40
|
+
"smtp",
|
|
41
|
+
"admin_auth",
|
|
42
|
+
"workframe",
|
|
43
|
+
"billing",
|
|
44
|
+
"integrations",
|
|
45
|
+
"profile",
|
|
46
|
+
"agent",
|
|
47
|
+
"agent_model",
|
|
48
|
+
"invites",
|
|
49
|
+
"done",
|
|
50
|
+
)
|
|
51
|
+
|
|
20
52
|
|
|
21
53
|
def _user_count(db_path: str) -> int:
|
|
22
54
|
try:
|
|
@@ -34,6 +66,97 @@ def install_window_open(db_path: str) -> bool:
|
|
|
34
66
|
return not bool(stack_config.get_stack_config().get("install_complete"))
|
|
35
67
|
|
|
36
68
|
|
|
69
|
+
def install_owner_claimed(db_path: str) -> bool:
|
|
70
|
+
"""True once the first install admin has verified and owns the default workspace."""
|
|
71
|
+
try:
|
|
72
|
+
conn = sqlite3.connect(db_path, timeout=2.0)
|
|
73
|
+
row = conn.execute(
|
|
74
|
+
"""
|
|
75
|
+
SELECT 1 FROM workspaces
|
|
76
|
+
WHERE deleted_at IS NULL
|
|
77
|
+
AND TRIM(COALESCE(owner_id, '')) != ''
|
|
78
|
+
LIMIT 1
|
|
79
|
+
""",
|
|
80
|
+
).fetchone()
|
|
81
|
+
conn.close()
|
|
82
|
+
return bool(row)
|
|
83
|
+
except (sqlite3.Error, OSError):
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def install_mutations_require_owner(db_path: str) -> bool:
|
|
88
|
+
"""After admin verify, install stack mutations require the owner session."""
|
|
89
|
+
return install_owner_claimed(db_path) or stack_config.install_admin_verified()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _default_workspace_settings(db_path: str) -> dict[str, Any]:
|
|
93
|
+
try:
|
|
94
|
+
conn = sqlite3.connect(db_path, timeout=2.0)
|
|
95
|
+
row = conn.execute(
|
|
96
|
+
"""
|
|
97
|
+
SELECT settings_json FROM workspaces
|
|
98
|
+
WHERE deleted_at IS NULL
|
|
99
|
+
ORDER BY CASE WHEN slug = 'default' THEN 0 ELSE 1 END, created_at ASC
|
|
100
|
+
LIMIT 1
|
|
101
|
+
""",
|
|
102
|
+
).fetchone()
|
|
103
|
+
conn.close()
|
|
104
|
+
if not row:
|
|
105
|
+
return {}
|
|
106
|
+
raw = str(row[0] or "{}")
|
|
107
|
+
parsed = json.loads(raw)
|
|
108
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
109
|
+
except (sqlite3.Error, OSError, json.JSONDecodeError, TypeError):
|
|
110
|
+
return {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _wizard_step_index(step: str) -> int:
|
|
114
|
+
try:
|
|
115
|
+
return INSTALL_WIZARD_STEP_ORDER.index(step)
|
|
116
|
+
except ValueError:
|
|
117
|
+
return -1
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _derive_install_wizard_step(db_path: str) -> str:
|
|
121
|
+
cfg = stack_config.get_stack_config()
|
|
122
|
+
mode = str(cfg.get("deployment_mode") or "").strip()
|
|
123
|
+
if not mode:
|
|
124
|
+
return "welcome"
|
|
125
|
+
if mode == "public_multi_user" and not str(cfg.get("app_base_url") or "").strip():
|
|
126
|
+
return "publish"
|
|
127
|
+
if mode != "single_user_local" and not stack_config.install_admin_verified() and not install_owner_claimed(db_path):
|
|
128
|
+
return "smtp"
|
|
129
|
+
|
|
130
|
+
settings = _default_workspace_settings(db_path)
|
|
131
|
+
if not settings.get("admin_onboarding_done"):
|
|
132
|
+
return "workframe"
|
|
133
|
+
if not settings.get("admin_integrations_done"):
|
|
134
|
+
return "billing"
|
|
135
|
+
|
|
136
|
+
return "profile"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def resolve_install_wizard_step(db_path: str) -> str:
|
|
140
|
+
"""Resume ConciergeFlow at the last persisted or derived wizard step."""
|
|
141
|
+
derived = _derive_install_wizard_step(db_path)
|
|
142
|
+
raw = stack_config.read_stack_raw()
|
|
143
|
+
saved = str(raw.get("wizard_step") or "").strip()
|
|
144
|
+
if saved in INSTALL_WIZARD_STEPS:
|
|
145
|
+
if _wizard_step_index(derived) > _wizard_step_index(saved):
|
|
146
|
+
return derived
|
|
147
|
+
return saved
|
|
148
|
+
return derived
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def install_wizard_public_payload(db_path: str) -> dict[str, Any]:
|
|
152
|
+
admin_verified = stack_config.install_admin_verified() or install_owner_claimed(db_path)
|
|
153
|
+
return {
|
|
154
|
+
"admin_verified": admin_verified,
|
|
155
|
+
"resume_step": resolve_install_wizard_step(db_path),
|
|
156
|
+
"owner_claimed": install_owner_claimed(db_path),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
37
160
|
def _hermes_native_present() -> bool:
|
|
38
161
|
for slug in (NATIVE_PROFILE, "workframe-agent"):
|
|
39
162
|
if not slug:
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""WF-032 extract: kanban_cron."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import queue
|
|
7
|
+
import re
|
|
8
|
+
import sqlite3
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from http.server import BaseHTTPRequestHandler
|
|
15
|
+
|
|
16
|
+
import run_ledger
|
|
17
|
+
import user_prefs
|
|
18
|
+
from domain.entities import ActorType, FundingSource, RunStatus, RunSurface
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _srv():
|
|
22
|
+
import server as srv
|
|
23
|
+
|
|
24
|
+
return srv
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _iter_kanban_dbs() -> list[Path]:
|
|
28
|
+
dbs = [_srv().HERMES_DATA / "kanban.db"]
|
|
29
|
+
boards_root = _srv().HERMES_DATA / "kanban" / "boards"
|
|
30
|
+
if boards_root.is_dir():
|
|
31
|
+
for board_dir in boards_root.iterdir():
|
|
32
|
+
if board_dir.is_dir():
|
|
33
|
+
candidate = board_dir / "kanban.db"
|
|
34
|
+
if candidate.is_file():
|
|
35
|
+
dbs.append(candidate)
|
|
36
|
+
return dbs
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _refresh_active_kanban_assignee_credentials() -> None:
|
|
40
|
+
"""Keep kanban worker profile .env aligned with assignee owner (not last chatter)."""
|
|
41
|
+
assignees: set[str] = set()
|
|
42
|
+
for db_path in _iter_kanban_dbs():
|
|
43
|
+
conn = _srv()._ro_sqlite_live(db_path)
|
|
44
|
+
if not conn:
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
rows = conn.execute(
|
|
48
|
+
"""
|
|
49
|
+
SELECT DISTINCT assignee FROM tasks
|
|
50
|
+
WHERE status IN ('ready', 'running', 'scheduled', 'triage')
|
|
51
|
+
AND assignee LIKE 'u-%'
|
|
52
|
+
"""
|
|
53
|
+
).fetchall()
|
|
54
|
+
for row in rows:
|
|
55
|
+
slug = _srv().safe_profile_slug(str(row["assignee"] or "").strip())
|
|
56
|
+
if slug:
|
|
57
|
+
assignees.add(slug)
|
|
58
|
+
except sqlite3.Error:
|
|
59
|
+
pass
|
|
60
|
+
finally:
|
|
61
|
+
conn.close()
|
|
62
|
+
for assignee in assignees:
|
|
63
|
+
resolved = _srv()._resolve_runtime_owner(assignee)
|
|
64
|
+
if not resolved:
|
|
65
|
+
continue
|
|
66
|
+
owner_id, workspace_id = resolved
|
|
67
|
+
_srv()._prepare_runtime_profile_credentials(assignee, owner_id, workspace_id)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _kanban_credential_guard_daemon() -> None:
|
|
71
|
+
while True:
|
|
72
|
+
try:
|
|
73
|
+
_refresh_active_kanban_assignee_credentials()
|
|
74
|
+
except Exception: # noqa: BLE001
|
|
75
|
+
pass
|
|
76
|
+
time.sleep(5)
|
|
77
|
+
def validate_kanban_assignee(
|
|
78
|
+
assignee: str,
|
|
79
|
+
acting_user_id: str,
|
|
80
|
+
workspace_id: str,
|
|
81
|
+
*,
|
|
82
|
+
delegate_user_ids: frozenset[str] | None = None,
|
|
83
|
+
allow_template: bool = False,
|
|
84
|
+
) -> tuple[bool, str]:
|
|
85
|
+
"""Check whether acting_user may create/dispatch a task to assignee.
|
|
86
|
+
|
|
87
|
+
Returns (ok, reason_or_owner_user_id).
|
|
88
|
+
"""
|
|
89
|
+
assignee = _srv().safe_profile_slug(str(assignee or "").strip())
|
|
90
|
+
acting = str(acting_user_id or "").strip()
|
|
91
|
+
workspace_id = str(workspace_id or "").strip()
|
|
92
|
+
if not assignee:
|
|
93
|
+
return False, "assignee_required"
|
|
94
|
+
if not acting or not workspace_id:
|
|
95
|
+
return False, "acting_user_required"
|
|
96
|
+
if delegate_user_ids is None:
|
|
97
|
+
delegate_user_ids = frozenset(_srv()._delegation_grantor_ids_for_grantee(acting, workspace_id))
|
|
98
|
+
if _srv()._is_runtime_profile_slug(assignee):
|
|
99
|
+
owner = _srv()._user_id_for_runtime_slug(assignee, workspace_id)
|
|
100
|
+
if not owner:
|
|
101
|
+
return False, "assignee_not_in_workspace"
|
|
102
|
+
allowed = {acting} | set(delegate_user_ids or ())
|
|
103
|
+
if owner in allowed:
|
|
104
|
+
return True, owner
|
|
105
|
+
return False, "assignee_owner_forbidden"
|
|
106
|
+
if allow_template:
|
|
107
|
+
return True, "template"
|
|
108
|
+
return False, "template_assignee_forbidden"
|
|
109
|
+
def _hermes_kanban_db_path(board_slug: str) -> Path:
|
|
110
|
+
slug = _srv().safe_profile_slug(str(board_slug or "default").strip()) or "default"
|
|
111
|
+
if slug == "default":
|
|
112
|
+
return _srv().HERMES_DATA / "kanban.db"
|
|
113
|
+
return _srv().HERMES_DATA / "kanban" / "boards" / slug / "kanban.db"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _workspace_kanban_board_row(workspace_id: str) -> sqlite3.Row | None:
|
|
117
|
+
conn = _srv()._workframe_db()
|
|
118
|
+
try:
|
|
119
|
+
return conn.execute(
|
|
120
|
+
"""
|
|
121
|
+
SELECT * FROM workspace_kanban_boards
|
|
122
|
+
WHERE workspace_id = ? AND deleted_at IS NULL
|
|
123
|
+
LIMIT 1
|
|
124
|
+
""",
|
|
125
|
+
(workspace_id,),
|
|
126
|
+
).fetchone()
|
|
127
|
+
finally:
|
|
128
|
+
conn.close()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def ensure_workspace_kanban_board(workspace_id: str) -> str:
|
|
132
|
+
workspace_id = str(workspace_id or "").strip()
|
|
133
|
+
row = _workspace_kanban_board_row(workspace_id)
|
|
134
|
+
if row:
|
|
135
|
+
return str(row["hermes_board_slug"])
|
|
136
|
+
conn = _srv()._workframe_db()
|
|
137
|
+
try:
|
|
138
|
+
ws = conn.execute(
|
|
139
|
+
"SELECT slug, display_name FROM workspaces WHERE id = ? AND deleted_at IS NULL",
|
|
140
|
+
(workspace_id,),
|
|
141
|
+
).fetchone()
|
|
142
|
+
if not ws:
|
|
143
|
+
raise ValueError("workspace_not_found")
|
|
144
|
+
board_slug = re.sub(r"[^a-z0-9-]+", "-", str(ws["slug"] or workspace_id).lower()).strip("-")[:40]
|
|
145
|
+
if not board_slug:
|
|
146
|
+
board_slug = f"ws-{workspace_id[:8].lower()}"
|
|
147
|
+
display_name = str(ws["display_name"] or board_slug)
|
|
148
|
+
now = _srv()._utc_now()
|
|
149
|
+
conn.execute(
|
|
150
|
+
"""
|
|
151
|
+
INSERT INTO workspace_kanban_boards (
|
|
152
|
+
id, workspace_id, hermes_board_slug, display_name, created_at, updated_at
|
|
153
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
154
|
+
""",
|
|
155
|
+
(str(uuid.uuid4()), workspace_id, board_slug, display_name, now, now),
|
|
156
|
+
)
|
|
157
|
+
conn.commit()
|
|
158
|
+
finally:
|
|
159
|
+
conn.close()
|
|
160
|
+
primary = _srv()._primary_profile()
|
|
161
|
+
if primary:
|
|
162
|
+
code, out = _srv()._gateway_exec(
|
|
163
|
+
primary,
|
|
164
|
+
["kanban", "boards", "create", board_slug, "--name", display_name],
|
|
165
|
+
)
|
|
166
|
+
if code != 0 and "already exists" not in out.lower():
|
|
167
|
+
pass # ponytail: board row is source of truth; Hermes may already have the board
|
|
168
|
+
return board_slug
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def kanban_proxy_list_tasks(workspace_id: str, user_id: str) -> dict[str, Any]:
|
|
172
|
+
workspace_id = str(workspace_id or "").strip()
|
|
173
|
+
user_id = str(user_id or "").strip()
|
|
174
|
+
if not _srv()._user_is_workspace_member(user_id, workspace_id):
|
|
175
|
+
raise PermissionError("forbidden")
|
|
176
|
+
row = _workspace_kanban_board_row(workspace_id)
|
|
177
|
+
board_slug = str(row["hermes_board_slug"]) if row else "default"
|
|
178
|
+
db = _hermes_kanban_db_path(board_slug)
|
|
179
|
+
out: dict[str, Any] = {
|
|
180
|
+
"ok": True,
|
|
181
|
+
"board": board_slug,
|
|
182
|
+
"workspace_id": workspace_id,
|
|
183
|
+
"tasks": [],
|
|
184
|
+
"total": 0,
|
|
185
|
+
}
|
|
186
|
+
conn = _srv()._ro_sqlite_live(db)
|
|
187
|
+
if not conn:
|
|
188
|
+
return out
|
|
189
|
+
try:
|
|
190
|
+
rows = conn.execute(
|
|
191
|
+
"""
|
|
192
|
+
SELECT id, title, status, assignee, priority, created_at, completed_at
|
|
193
|
+
FROM tasks
|
|
194
|
+
ORDER BY created_at DESC
|
|
195
|
+
LIMIT 100
|
|
196
|
+
"""
|
|
197
|
+
).fetchall()
|
|
198
|
+
tasks = [
|
|
199
|
+
{
|
|
200
|
+
"id": r["id"],
|
|
201
|
+
"title": r["title"],
|
|
202
|
+
"status": r["status"],
|
|
203
|
+
"assignee": r["assignee"] or "",
|
|
204
|
+
"priority": r["priority"] or 0,
|
|
205
|
+
"created_at": _srv()._iso_from_unix(r["created_at"]),
|
|
206
|
+
"completed_at": _srv()._iso_from_unix(r["completed_at"]) if r["completed_at"] else None,
|
|
207
|
+
}
|
|
208
|
+
for r in rows
|
|
209
|
+
]
|
|
210
|
+
out["tasks"] = tasks
|
|
211
|
+
out["total"] = len(tasks)
|
|
212
|
+
except sqlite3.Error:
|
|
213
|
+
pass
|
|
214
|
+
finally:
|
|
215
|
+
conn.close()
|
|
216
|
+
return out
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _record_kanban_dispatch_run(
|
|
220
|
+
*,
|
|
221
|
+
workspace_id: str,
|
|
222
|
+
user_id: str,
|
|
223
|
+
runtime: str,
|
|
224
|
+
title: str,
|
|
225
|
+
board: str,
|
|
226
|
+
) -> str:
|
|
227
|
+
"""WF-NS-P2: audit trail for kanban task dispatch (not an LLM gate)."""
|
|
228
|
+
run_id = str(uuid.uuid4())
|
|
229
|
+
run_ledger.ensure_schema()
|
|
230
|
+
conn = _srv()._workframe_db()
|
|
231
|
+
try:
|
|
232
|
+
run_ledger.insert_run(
|
|
233
|
+
conn,
|
|
234
|
+
run_id=run_id,
|
|
235
|
+
workspace_id=workspace_id,
|
|
236
|
+
surface=RunSurface.KANBAN,
|
|
237
|
+
actor_type=ActorType.USER,
|
|
238
|
+
actor_id=user_id,
|
|
239
|
+
triggering_user_id=user_id,
|
|
240
|
+
agent_id=runtime,
|
|
241
|
+
runtime_binding_id=runtime,
|
|
242
|
+
status=RunStatus.RUNNING,
|
|
243
|
+
payer_user_id=user_id,
|
|
244
|
+
funding_source=FundingSource.BYOK,
|
|
245
|
+
profile_slug=runtime,
|
|
246
|
+
)
|
|
247
|
+
run_ledger.insert_run_event(
|
|
248
|
+
conn,
|
|
249
|
+
run_id=run_id,
|
|
250
|
+
event_type="kanban.task_created",
|
|
251
|
+
payload={"title": title, "board": board, "assignee": runtime},
|
|
252
|
+
)
|
|
253
|
+
run_ledger.complete_run(
|
|
254
|
+
conn,
|
|
255
|
+
run_id,
|
|
256
|
+
model="",
|
|
257
|
+
provider="",
|
|
258
|
+
funding_source=FundingSource.BYOK,
|
|
259
|
+
payer_user_id=user_id,
|
|
260
|
+
receipt={"title": title, "board": board, "assignee": runtime},
|
|
261
|
+
)
|
|
262
|
+
conn.commit()
|
|
263
|
+
finally:
|
|
264
|
+
conn.close()
|
|
265
|
+
return run_id
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def kanban_proxy_create_task(workspace_id: str, user_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
269
|
+
workspace_id = str(workspace_id or "").strip()
|
|
270
|
+
user_id = str(user_id or "").strip()
|
|
271
|
+
if not _srv()._user_is_workspace_member(user_id, workspace_id):
|
|
272
|
+
raise PermissionError("forbidden")
|
|
273
|
+
title = str(payload.get("title") or "").strip()
|
|
274
|
+
if not title:
|
|
275
|
+
raise ValueError("title_required")
|
|
276
|
+
raw_assignee = str(payload.get("assignee") or payload.get("template_slug") or "").strip()
|
|
277
|
+
assignee_owner = str(payload.get("assignee_user_id") or user_id).strip()
|
|
278
|
+
if _srv()._is_runtime_profile_slug(_srv().safe_profile_slug(raw_assignee)):
|
|
279
|
+
runtime = _srv().safe_profile_slug(raw_assignee)
|
|
280
|
+
elif raw_assignee:
|
|
281
|
+
runtime = _srv().resolve_runtime_assignee(raw_assignee, assignee_owner, workspace_id)
|
|
282
|
+
else:
|
|
283
|
+
raise ValueError("assignee_required")
|
|
284
|
+
ok, reason = validate_kanban_assignee(runtime, user_id, workspace_id)
|
|
285
|
+
if not ok:
|
|
286
|
+
raise PermissionError(str(reason))
|
|
287
|
+
board = ensure_workspace_kanban_board(workspace_id)
|
|
288
|
+
orchestrator = _srv().resolve_runtime_assignee("workframe-agent", user_id, workspace_id)
|
|
289
|
+
args = ["kanban", "--board", board, "create", title, "--assignee", runtime]
|
|
290
|
+
workspace_kind = str(payload.get("workspace_kind") or "scratch").strip()
|
|
291
|
+
if workspace_kind:
|
|
292
|
+
args.extend(["--workspace-kind", workspace_kind])
|
|
293
|
+
body = str(payload.get("body") or "").strip()
|
|
294
|
+
if body:
|
|
295
|
+
args.extend(["--body", body])
|
|
296
|
+
code, out = _srv()._gateway_exec(orchestrator, args)
|
|
297
|
+
if code != 0:
|
|
298
|
+
raise ValueError(f"kanban_create_failed: {out.strip()}")
|
|
299
|
+
run_id = _record_kanban_dispatch_run(
|
|
300
|
+
workspace_id=workspace_id,
|
|
301
|
+
user_id=user_id,
|
|
302
|
+
runtime=runtime,
|
|
303
|
+
title=title,
|
|
304
|
+
board=board,
|
|
305
|
+
)
|
|
306
|
+
owner_id = assignee_owner
|
|
307
|
+
if _srv()._is_runtime_profile_slug(runtime):
|
|
308
|
+
resolved = _srv()._resolve_runtime_owner(runtime)
|
|
309
|
+
if resolved:
|
|
310
|
+
owner_id, workspace_id = resolved[0], resolved[1] or workspace_id
|
|
311
|
+
_srv()._prepare_runtime_profile_credentials(runtime, owner_id, workspace_id)
|
|
312
|
+
return {
|
|
313
|
+
"ok": True,
|
|
314
|
+
"board": board,
|
|
315
|
+
"assignee": runtime,
|
|
316
|
+
"title": title,
|
|
317
|
+
"output": out.strip(),
|
|
318
|
+
"run_id": run_id,
|
|
319
|
+
}
|
|
320
|
+
def _cron_plain_english(schedule: str) -> str:
|
|
321
|
+
s = (schedule or "").strip()
|
|
322
|
+
if not s:
|
|
323
|
+
return "No schedule"
|
|
324
|
+
if s.startswith("every "):
|
|
325
|
+
return s.capitalize()
|
|
326
|
+
parts = s.split()
|
|
327
|
+
if len(parts) == 5:
|
|
328
|
+
minute, hour, dom, month, dow = parts
|
|
329
|
+
if dom == "*" and month == "*" and dow == "*":
|
|
330
|
+
return f"Every day at {hour.zfill(2)}:{minute.zfill(2)} UTC"
|
|
331
|
+
if dom == "*" and month == "*" and dow != "*":
|
|
332
|
+
return f"Cron: {s}"
|
|
333
|
+
return f"Cron: {s}"
|
|
334
|
+
def kanban_data() -> dict[str, Any]:
|
|
335
|
+
db = _srv().HERMES_DATA / "kanban.db"
|
|
336
|
+
out: dict[str, Any] = {
|
|
337
|
+
"ok": False,
|
|
338
|
+
"total": 0,
|
|
339
|
+
"tasks": [],
|
|
340
|
+
"stats": {"todo": 0, "scheduled": 0, "ready": 0, "running": 0, "blocked": 0, "done": 0},
|
|
341
|
+
}
|
|
342
|
+
conn = _srv()._ro_sqlite_live(db)
|
|
343
|
+
if not conn:
|
|
344
|
+
return out
|
|
345
|
+
try:
|
|
346
|
+
rows = conn.execute(
|
|
347
|
+
"""
|
|
348
|
+
SELECT id, title, status, assignee, priority, created_at, completed_at
|
|
349
|
+
FROM tasks
|
|
350
|
+
ORDER BY created_at DESC
|
|
351
|
+
LIMIT 100
|
|
352
|
+
"""
|
|
353
|
+
).fetchall()
|
|
354
|
+
tasks = [
|
|
355
|
+
{
|
|
356
|
+
"id": r["id"],
|
|
357
|
+
"title": r["title"],
|
|
358
|
+
"status": r["status"],
|
|
359
|
+
"assignee": r["assignee"] or "",
|
|
360
|
+
"priority": r["priority"] or 0,
|
|
361
|
+
"created_at": _srv()._iso_from_unix(r["created_at"]),
|
|
362
|
+
"completed_at": _srv()._iso_from_unix(r["completed_at"]) if r["completed_at"] else None,
|
|
363
|
+
}
|
|
364
|
+
for r in rows
|
|
365
|
+
]
|
|
366
|
+
out["ok"] = True
|
|
367
|
+
out["tasks"] = tasks
|
|
368
|
+
out["total"] = len(tasks)
|
|
369
|
+
for r in rows:
|
|
370
|
+
st = str(r["status"] or "todo").lower()
|
|
371
|
+
if st in out["stats"]:
|
|
372
|
+
out["stats"][st] += 1
|
|
373
|
+
elif st == "triage":
|
|
374
|
+
out["stats"]["todo"] += 1
|
|
375
|
+
except sqlite3.Error:
|
|
376
|
+
pass
|
|
377
|
+
finally:
|
|
378
|
+
conn.close()
|
|
379
|
+
return out
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _hermes_cron_jobs(profile: str) -> list[dict[str, Any]]:
|
|
383
|
+
paths = [
|
|
384
|
+
_srv()._profile_dir(profile) / "cron" / "jobs.json",
|
|
385
|
+
_srv().HERMES_DATA / "cron" / "jobs.json",
|
|
386
|
+
]
|
|
387
|
+
jobs: list[dict[str, Any]] = []
|
|
388
|
+
for path in paths:
|
|
389
|
+
if not path.is_file():
|
|
390
|
+
continue
|
|
391
|
+
try:
|
|
392
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
393
|
+
except (OSError, json.JSONDecodeError):
|
|
394
|
+
continue
|
|
395
|
+
if isinstance(raw, dict):
|
|
396
|
+
items = raw.get("jobs") or raw.get("items") or []
|
|
397
|
+
elif isinstance(raw, list):
|
|
398
|
+
items = raw
|
|
399
|
+
else:
|
|
400
|
+
items = []
|
|
401
|
+
for job in items:
|
|
402
|
+
if not isinstance(job, dict):
|
|
403
|
+
continue
|
|
404
|
+
schedule = job.get("schedule") or job.get("cron") or job.get("every") or ""
|
|
405
|
+
command = job.get("prompt") or job.get("message") or job.get("name") or ""
|
|
406
|
+
jobs.append(
|
|
407
|
+
{
|
|
408
|
+
"id": job.get("id") or job.get("name") or "",
|
|
409
|
+
"name": job.get("name") or job.get("id") or "job",
|
|
410
|
+
"schedule": schedule,
|
|
411
|
+
"command": command[:200],
|
|
412
|
+
"enabled": job.get("enabled", True),
|
|
413
|
+
"owner": "hermes",
|
|
414
|
+
"label": "hermes",
|
|
415
|
+
"category": "hermes",
|
|
416
|
+
"source": str(path),
|
|
417
|
+
"description": _cron_plain_english(str(schedule)),
|
|
418
|
+
"prompt_preview": command[:120],
|
|
419
|
+
}
|
|
420
|
+
)
|
|
421
|
+
return jobs
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _system_cron_jobs() -> list[dict[str, Any]]:
|
|
425
|
+
jobs: list[dict[str, Any]] = []
|
|
426
|
+
candidates = [
|
|
427
|
+
Path("/etc/crontab"),
|
|
428
|
+
Path("/var/spool/cron/crontabs/root"),
|
|
429
|
+
]
|
|
430
|
+
cron_d = Path("/etc/cron.d")
|
|
431
|
+
if cron_d.is_dir():
|
|
432
|
+
candidates.extend(sorted(cron_d.glob("*")))
|
|
433
|
+
for path in candidates:
|
|
434
|
+
if not path.is_file():
|
|
435
|
+
continue
|
|
436
|
+
try:
|
|
437
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
438
|
+
except OSError:
|
|
439
|
+
continue
|
|
440
|
+
for line in text.splitlines():
|
|
441
|
+
line = line.strip()
|
|
442
|
+
if not line or line.startswith("#"):
|
|
443
|
+
continue
|
|
444
|
+
parts = line.split(None, 5 if path.name == "crontab" or "crontabs" in str(path) else 6)
|
|
445
|
+
if len(parts) < 6:
|
|
446
|
+
continue
|
|
447
|
+
if path.name == "crontab" or "crontabs" in str(path):
|
|
448
|
+
schedule = " ".join(parts[:5])
|
|
449
|
+
command = parts[5] if len(parts) > 5 else ""
|
|
450
|
+
else:
|
|
451
|
+
schedule = " ".join(parts[:5])
|
|
452
|
+
command = parts[-1]
|
|
453
|
+
jobs.append(
|
|
454
|
+
{
|
|
455
|
+
"name": command.split()[0] if command else "system",
|
|
456
|
+
"schedule": schedule,
|
|
457
|
+
"command": command[:200],
|
|
458
|
+
"owner": "system",
|
|
459
|
+
"label": "system",
|
|
460
|
+
"category": "system",
|
|
461
|
+
"source": str(path),
|
|
462
|
+
"description": _cron_plain_english(schedule),
|
|
463
|
+
"enabled": True,
|
|
464
|
+
}
|
|
465
|
+
)
|
|
466
|
+
return jobs[:50]
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def cron_data(profile: str) -> dict[str, Any]:
|
|
470
|
+
hermes = _hermes_cron_jobs(profile)
|
|
471
|
+
system = _system_cron_jobs()
|
|
472
|
+
all_jobs = hermes + system
|
|
473
|
+
return {"jobs": all_jobs, "items": all_jobs, "count": len(all_jobs), "hermes": hermes, "system": system}
|