create-workframe 0.1.12 → 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.
Files changed (188) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +2 -2
  3. package/bin/workframe.js +130 -2
  4. package/docs/security.md +2 -2
  5. package/package.json +1 -1
  6. package/scripts/bundle-workframe-ui.mjs +9 -0
  7. package/scripts/sync-canonical-to-package.mjs +1 -0
  8. package/scripts/verify-public-deploy.sh +65 -3
  9. package/workframe-api/action_proxy.py +23 -17
  10. package/workframe-api/activity_feed.py +798 -0
  11. package/workframe-api/api_meta.py +157 -0
  12. package/workframe-api/auth_gate.py +566 -0
  13. package/workframe-api/avatar_registry.py +343 -0
  14. package/workframe-api/broker_audit.py +164 -0
  15. package/workframe-api/cell_authority.py +231 -0
  16. package/workframe-api/chat_bind.py +319 -0
  17. package/workframe-api/chat_sessions.py +509 -0
  18. package/workframe-api/chat_stream.py +694 -0
  19. package/workframe-api/cleanup_dogfood_smoke.py +252 -0
  20. package/workframe-api/credential_broker.py +162 -0
  21. package/workframe-api/credential_resolve.py +202 -0
  22. package/workframe-api/credential_store.py +245 -0
  23. package/workframe-api/crew_registry.py +250 -0
  24. package/workframe-api/db_schema.py +755 -0
  25. package/workframe-api/docker_gateway.py +166 -0
  26. package/workframe-api/doctor_runtime.py +109 -0
  27. package/workframe-api/domain/__init__.py +47 -0
  28. package/workframe-api/domain/entities.py +252 -0
  29. package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
  30. package/workframe-api/egress_policy.py +42 -0
  31. package/workframe-api/handler_modules/__init__.py +17 -0
  32. package/workframe-api/handler_modules/handler_admin.py +542 -0
  33. package/workframe-api/handler_modules/handler_auth.py +512 -0
  34. package/workframe-api/handler_modules/handler_chat.py +641 -0
  35. package/workframe-api/handler_modules/handler_install.py +178 -0
  36. package/workframe-api/handler_modules/handler_provider.py +325 -0
  37. package/workframe-api/handler_modules/handler_workspace.py +1420 -0
  38. package/workframe-api/health_monitor.py +80 -0
  39. package/workframe-api/hermes_admin.py +756 -0
  40. package/workframe-api/hermes_profiles.py +1328 -0
  41. package/workframe-api/install_api.py +123 -0
  42. package/workframe-api/kanban_cron.py +473 -0
  43. package/workframe-api/lane_bindings.py +423 -0
  44. package/workframe-api/llm_proxy.py +17 -43
  45. package/workframe-api/mention_helpers.py +180 -0
  46. package/workframe-api/mention_invoke.py +431 -0
  47. package/workframe-api/model_surface.py +1139 -0
  48. package/workframe-api/oauth_pending.py +85 -0
  49. package/workframe-api/oauth_redirect.py +381 -0
  50. package/workframe-api/package.json +2 -2
  51. package/workframe-api/profile_api_lifecycle.py +66 -0
  52. package/workframe-api/profile_gateway.py +436 -0
  53. package/workframe-api/provider_bindings.py +673 -0
  54. package/workframe-api/provider_bootstrap.py +347 -0
  55. package/workframe-api/provider_catalog.py +180 -0
  56. package/workframe-api/rooms.py +1613 -0
  57. package/workframe-api/route_registry.py +331 -191
  58. package/workframe-api/run-typecheck.mjs +2 -1
  59. package/workframe-api/run_authority.py +287 -0
  60. package/workframe-api/run_ledger.py +470 -0
  61. package/workframe-api/run_surface_wiring.py +344 -0
  62. package/workframe-api/runtime_cohort.py +752 -0
  63. package/workframe-api/runtime_tokens.py +127 -0
  64. package/workframe-api/server.py +1453 -19513
  65. package/workframe-api/snapshot_feed.py +49 -0
  66. package/workframe-api/stack_config.py +33 -1
  67. package/workframe-api/supervisor_client.py +154 -0
  68. package/workframe-api/test_api_meta_build_stamp.py +34 -0
  69. package/workframe-api/test_cell_authority.py +79 -0
  70. package/workframe-api/test_chat_bind.py +48 -0
  71. package/workframe-api/test_credential_lease_matrix.py +171 -0
  72. package/workframe-api/test_credential_resolve.py +51 -0
  73. package/workframe-api/test_credential_store.py +27 -0
  74. package/workframe-api/test_dogfood_flows.py +346 -0
  75. package/workframe-api/test_domain_entities.py +152 -0
  76. package/workframe-api/test_exception_hygiene.py +12 -0
  77. package/workframe-api/test_hermes_admin.py +72 -0
  78. package/workframe-api/test_install_wizard_resume.py +78 -0
  79. package/workframe-api/test_local_bootstrap.py +67 -0
  80. package/workframe-api/test_mention_helpers.py +35 -0
  81. package/workframe-api/test_mention_invoke.py +26 -0
  82. package/workframe-api/test_model_surface_consistency.py +1 -0
  83. package/workframe-api/test_oauth_pending.py +41 -0
  84. package/workframe-api/test_provider_bindings.py +52 -0
  85. package/workframe-api/test_provider_catalog.py +28 -0
  86. package/workframe-api/test_route_registry.py +171 -35
  87. package/workframe-api/test_run_authority.py +112 -0
  88. package/workframe-api/test_run_ledger.py +157 -0
  89. package/workframe-api/test_run_surface_wiring.py +130 -0
  90. package/workframe-api/test_runtime_cohort.py +85 -0
  91. package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
  92. package/workframe-api/test_server_reexports.py +99 -0
  93. package/workframe-api/test_stack_config_install_smtp.py +7 -0
  94. package/workframe-api/turn_credentials.py +28 -13
  95. package/workframe-api/turn_overlay.py +715 -0
  96. package/workframe-api/updates.py +16 -1
  97. package/workframe-api/user_prefs.py +387 -0
  98. package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
  99. package/workframe-api/workspace_bootstrap.py +536 -0
  100. package/workframe-api/workspace_files.py +344 -0
  101. package/workframe-api/workspace_messaging.py +206 -0
  102. package/workframe-api/zk_auth.py +3 -2
  103. package/workframe-supervisor/server.py +10 -1
  104. package/workframe-supervisor/test_supervisor_negative.py +75 -0
  105. package/workframe-ui/public/assets/{arc-B0OFRGmJ.js → arc-aUUffclm.js} +1 -1
  106. package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
  107. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DeBNltHS.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
  108. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BDhCHu7I.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
  109. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-olcDYvtI.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
  110. package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
  111. package/workframe-ui/public/assets/{chunk-2J33WTMH-D0obCBn_.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
  112. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DX93f-ZZ.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
  113. package/workframe-ui/public/assets/{chunk-4BX2VUAB-BX9B5wUs.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
  114. package/workframe-ui/public/assets/{chunk-55IACEB6-C4DGc6RO.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
  115. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Crlja9Lf.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
  116. package/workframe-ui/public/assets/{chunk-727SXJPM-JYSm3szI.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
  117. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-_KslxVEl.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
  118. package/workframe-ui/public/assets/{chunk-BSJP7CBP-CpTO-jU8.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
  119. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-JGUkCmbI.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
  120. package/workframe-ui/public/assets/{chunk-FMBD7UC4-BscBwGaC.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-KSCS5N6A-5ZTZ0bpX.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
  122. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DxvitYbW.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
  123. package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
  124. package/workframe-ui/public/assets/{chunk-ND2GUHAM-Cayl0iV9.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
  125. package/workframe-ui/public/assets/{chunk-NZK2D7GU-_7dyBR5G.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
  126. package/workframe-ui/public/assets/{chunk-O5CBEL6O--xTpD0yi.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
  127. package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
  128. package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
  129. package/workframe-ui/public/assets/{chunk-XPW4576I-CwxJQaeK.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
  130. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
  131. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
  132. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CCspENYv.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
  133. package/workframe-ui/public/assets/{dagre-BM42HDAG-Dv8V6R60.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
  134. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Da48hFPT.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
  135. package/workframe-ui/public/assets/{diagram-5GNKFQAL-dTihc7-3.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
  136. package/workframe-ui/public/assets/{diagram-KO2AKTUF-D_Jqu699.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
  137. package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt8PtEaZ.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
  138. package/workframe-ui/public/assets/{diagram-OG6HWLK6-BtmPjlh0.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
  139. package/workframe-ui/public/assets/{dist-Cz2turIT.js → dist-mQWmB3z6.js} +1 -1
  140. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-BxMGCflX.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
  141. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
  142. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-ivyroIZt.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
  143. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-B9llwShx.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
  144. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BFQHk4bw.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
  145. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-ursegLbc.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
  146. package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
  147. package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
  148. package/workframe-ui/public/assets/{info-J43DQDTF-DQpifAsB.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
  149. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-C16XP2Q7.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
  150. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-CiJoz7rP.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
  151. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CVwEvvUL.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
  152. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CWQ6hX5i.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
  153. package/workframe-ui/public/assets/{line-CgQsmU35.js → line-BTCEHb7l.js} +1 -1
  154. package/workframe-ui/public/assets/{linear-Bxbc77dL.js → linear-CkbydpnK.js} +1 -1
  155. package/workframe-ui/public/assets/{mermaid-parser.core-C_jMeF0a.js → mermaid-parser.core-DvrtArpk.js} +2 -2
  156. package/workframe-ui/public/assets/{mermaid.core-4RKV9MZP.js → mermaid.core-yj_1x_qj.js} +3 -3
  157. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-DZ7y7Sz0.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
  158. package/workframe-ui/public/assets/{packet-YPE3B663-B9h2I7Vq.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
  159. package/workframe-ui/public/assets/{pie-LRSECV5Y-B2mP5uHm.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
  160. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyYKyKiP.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
  161. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CL_6nej-.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
  162. package/workframe-ui/public/assets/{radar-GUYGQ44K-CNKNDQ7S.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
  163. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-eWU_mhFa.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
  164. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DrjcXvEQ.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
  165. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BHWR1xTJ.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
  166. package/workframe-ui/public/assets/{src-DfJB8kV1.js → src-D6mvlP96.js} +1 -1
  167. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BGJbSd3I.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
  168. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
  169. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DkwoCjiZ.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
  170. package/workframe-ui/public/assets/{treeView-BLDUP644-GxfhiqoW.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
  171. package/workframe-ui/public/assets/{treemap-LRROVOQU-DxrOsars.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
  172. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BsWhpUfr.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
  173. package/workframe-ui/public/assets/{wardley-L42UT6IY-CrFZWMHS.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
  174. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DV8Xpf5I.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
  175. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DqMqnwdZ.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
  176. package/workframe-ui/public/index.html +7 -5
  177. package/workframe-ui/public/workframe-build.json +5 -0
  178. package/workframe-ui/public/assets/architecture-7EHR7CIX-Dwwi9yA0.js +0 -1
  179. package/workframe-ui/public/assets/channel-fNHbDpV4.js +0 -1
  180. package/workframe-ui/public/assets/chunk-LZXEDZCA-DMfdMUwz.js +0 -2
  181. package/workframe-ui/public/assets/chunk-QZHKN3VN-ewTgEQK8.js +0 -1
  182. package/workframe-ui/public/assets/chunk-WU5MYG2G-D4Tg-A0l.js +0 -1
  183. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CFAgBpEl.js +0 -1
  184. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CFAgBpEl.js +0 -1
  185. package/workframe-ui/public/assets/eventmodeling-FCH6USID-BIJTP5k-.js +0 -1
  186. package/workframe-ui/public/assets/index-8CuZDEIG.css +0 -1
  187. package/workframe-ui/public/assets/index-xS9lFekI.js +0 -129
  188. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CMLuNyeC.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}