python-hwpx-automation 6.0.3__py3-none-any.whl

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 (217) hide show
  1. hwpx_automation/__init__.py +61 -0
  2. hwpx_automation/__init__.pyi +27 -0
  3. hwpx_automation/__main__.py +8 -0
  4. hwpx_automation/agent_document.py +392 -0
  5. hwpx_automation/api.py +136 -0
  6. hwpx_automation/blind_eval.py +407 -0
  7. hwpx_automation/capabilities.py +110 -0
  8. hwpx_automation/compat.py +48 -0
  9. hwpx_automation/configuration.py +60 -0
  10. hwpx_automation/core/__init__.py +2 -0
  11. hwpx_automation/core/content.py +762 -0
  12. hwpx_automation/core/context.py +111 -0
  13. hwpx_automation/core/diff.py +53 -0
  14. hwpx_automation/core/document.py +37 -0
  15. hwpx_automation/core/formatting.py +513 -0
  16. hwpx_automation/core/handles.py +24 -0
  17. hwpx_automation/core/locations.py +205 -0
  18. hwpx_automation/core/locator.py +162 -0
  19. hwpx_automation/core/plan.py +680 -0
  20. hwpx_automation/core/resources.py +42 -0
  21. hwpx_automation/core/search.py +296 -0
  22. hwpx_automation/core/transactions.py +434 -0
  23. hwpx_automation/core/txn.py +48 -0
  24. hwpx_automation/document_state.py +95 -0
  25. hwpx_automation/errors.py +174 -0
  26. hwpx_automation/execution_lock.py +15 -0
  27. hwpx_automation/fastmcp_adapter.py +672 -0
  28. hwpx_automation/form_fill.py +1177 -0
  29. hwpx_automation/form_output_models.py +223 -0
  30. hwpx_automation/handlers/__init__.py +2 -0
  31. hwpx_automation/handlers/_shared.py +377 -0
  32. hwpx_automation/handlers/agent_document.py +257 -0
  33. hwpx_automation/handlers/authoring.py +750 -0
  34. hwpx_automation/handlers/content_edit.py +1078 -0
  35. hwpx_automation/handlers/form_fill.py +607 -0
  36. hwpx_automation/handlers/layout_style.py +660 -0
  37. hwpx_automation/handlers/quality_render.py +566 -0
  38. hwpx_automation/handlers/read_export.py +1295 -0
  39. hwpx_automation/handlers/specialized.py +624 -0
  40. hwpx_automation/handlers/tracked_changes.py +589 -0
  41. hwpx_automation/handlers/workflow.py +105 -0
  42. hwpx_automation/hwp_converter.py +227 -0
  43. hwpx_automation/hwp_support.py +94 -0
  44. hwpx_automation/hwpx_ops.py +1439 -0
  45. hwpx_automation/identity.json +263 -0
  46. hwpx_automation/identity.py +18 -0
  47. hwpx_automation/ingest_adapters.py +85 -0
  48. hwpx_automation/markdown_plan.py +216 -0
  49. hwpx_automation/mcp_cli.py +29 -0
  50. hwpx_automation/metadata/tools_meta.py +40 -0
  51. hwpx_automation/mixed_form.py +3007 -0
  52. hwpx_automation/mutation_models.py +401 -0
  53. hwpx_automation/network_policy.py +232 -0
  54. hwpx_automation/office/__init__.py +14 -0
  55. hwpx_automation/office/agent/__init__.py +125 -0
  56. hwpx_automation/office/agent/_batch_verification.py +383 -0
  57. hwpx_automation/office/agent/blueprint/__init__.py +58 -0
  58. hwpx_automation/office/agent/blueprint/bundle.py +282 -0
  59. hwpx_automation/office/agent/blueprint/catalog.py +136 -0
  60. hwpx_automation/office/agent/blueprint/dump.py +520 -0
  61. hwpx_automation/office/agent/blueprint/mapping.py +312 -0
  62. hwpx_automation/office/agent/blueprint/model.py +722 -0
  63. hwpx_automation/office/agent/blueprint/native.py +621 -0
  64. hwpx_automation/office/agent/blueprint/replay.py +622 -0
  65. hwpx_automation/office/agent/catalog.py +252 -0
  66. hwpx_automation/office/agent/cli.py +647 -0
  67. hwpx_automation/office/agent/commands.py +1383 -0
  68. hwpx_automation/office/agent/document.py +801 -0
  69. hwpx_automation/office/agent/form_plan.py +1760 -0
  70. hwpx_automation/office/agent/model.py +808 -0
  71. hwpx_automation/office/agent/path.py +155 -0
  72. hwpx_automation/office/agent/query.py +230 -0
  73. hwpx_automation/office/agent/story.py +207 -0
  74. hwpx_automation/office/authoring/__init__.py +3542 -0
  75. hwpx_automation/office/authoring/advanced_generators.py +154 -0
  76. hwpx_automation/office/authoring/builder/__init__.py +52 -0
  77. hwpx_automation/office/authoring/builder/core.py +996 -0
  78. hwpx_automation/office/authoring/builder/report.py +195 -0
  79. hwpx_automation/office/authoring/design/__init__.py +30 -0
  80. hwpx_automation/office/authoring/design/_support.py +144 -0
  81. hwpx_automation/office/authoring/design/composer.py +282 -0
  82. hwpx_automation/office/authoring/design/harvest.py +305 -0
  83. hwpx_automation/office/authoring/design/plan.py +69 -0
  84. hwpx_automation/office/authoring/design/profile.py +88 -0
  85. hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
  86. hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
  87. hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
  88. hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
  89. hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
  90. hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
  91. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
  92. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
  93. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
  94. hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
  95. hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
  96. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
  97. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
  98. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
  99. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
  100. hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
  101. hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
  102. hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
  103. hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
  104. hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
  105. hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
  106. hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
  107. hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
  108. hwpx_automation/office/authoring/design/validator.py +107 -0
  109. hwpx_automation/office/authoring/presets/__init__.py +22 -0
  110. hwpx_automation/office/authoring/presets/proposal.py +538 -0
  111. hwpx_automation/office/authoring/report_parser.py +141 -0
  112. hwpx_automation/office/authoring/style_profile.py +437 -0
  113. hwpx_automation/office/authoring/template_analyzer.py +657 -0
  114. hwpx_automation/office/compliance/__init__.py +38 -0
  115. hwpx_automation/office/compliance/official_lint.py +478 -0
  116. hwpx_automation/office/compliance/pii.py +388 -0
  117. hwpx_automation/office/document_ops/__init__.py +13 -0
  118. hwpx_automation/office/document_ops/comparison.py +62 -0
  119. hwpx_automation/office/document_ops/mail_merge.py +73 -0
  120. hwpx_automation/office/document_ops/redline.py +35 -0
  121. hwpx_automation/office/evalplan/__init__.py +36 -0
  122. hwpx_automation/office/evalplan/runtime.py +2762 -0
  123. hwpx_automation/office/exam/__init__.py +44 -0
  124. hwpx_automation/office/exam/compose.py +282 -0
  125. hwpx_automation/office/exam/ir.py +44 -0
  126. hwpx_automation/office/exam/measure.py +163 -0
  127. hwpx_automation/office/exam/parser.py +150 -0
  128. hwpx_automation/office/exam/profile.py +123 -0
  129. hwpx_automation/office/form_fill/__init__.py +66 -0
  130. hwpx_automation/office/form_fill/classification.py +108 -0
  131. hwpx_automation/office/form_fill/fill_residue.py +242 -0
  132. hwpx_automation/office/form_fill/fit/__init__.py +36 -0
  133. hwpx_automation/office/form_fill/fit/apply.py +24 -0
  134. hwpx_automation/office/form_fill/fit/engine.py +24 -0
  135. hwpx_automation/office/form_fill/fit/measure.py +50 -0
  136. hwpx_automation/office/form_fill/fit/policy.py +28 -0
  137. hwpx_automation/office/form_fill/fit/report.py +28 -0
  138. hwpx_automation/office/form_fill/fit/seal.py +457 -0
  139. hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
  140. hwpx_automation/office/form_fill/guidance.py +704 -0
  141. hwpx_automation/office/form_fill/quality.py +961 -0
  142. hwpx_automation/office/form_fill/split_run.py +333 -0
  143. hwpx_automation/office/form_fill/template_formfit.py +656 -0
  144. hwpx_automation/office/house_style/__init__.py +196 -0
  145. hwpx_automation/office/house_style/composition.py +68 -0
  146. hwpx_automation/office/house_style/data/bank.json +625 -0
  147. hwpx_automation/office/house_style/data/genres.json +43 -0
  148. hwpx_automation/office/quality/__init__.py +14 -0
  149. hwpx_automation/office/quality/page_guard.py +277 -0
  150. hwpx_automation/office/rendering/__init__.py +145 -0
  151. hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
  152. hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
  153. hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
  154. hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
  155. hwpx_automation/office/rendering/block_splits.py +76 -0
  156. hwpx_automation/office/rendering/detectors.py +151 -0
  157. hwpx_automation/office/rendering/diff.py +153 -0
  158. hwpx_automation/office/rendering/fixture_corpus.py +215 -0
  159. hwpx_automation/office/rendering/oracle.py +909 -0
  160. hwpx_automation/office/rendering/page_qa.py +245 -0
  161. hwpx_automation/office/rendering/qa_contracts.py +293 -0
  162. hwpx_automation/office/rendering/qa_metrics.py +241 -0
  163. hwpx_automation/office/rendering/worker.py +290 -0
  164. hwpx_automation/office/utilities/__init__.py +12 -0
  165. hwpx_automation/office/utilities/table_compute.py +477 -0
  166. hwpx_automation/ops_services/__init__.py +1 -0
  167. hwpx_automation/ops_services/_border_fill.py +283 -0
  168. hwpx_automation/ops_services/composition.py +55 -0
  169. hwpx_automation/ops_services/content_layout.py +322 -0
  170. hwpx_automation/ops_services/context.py +213 -0
  171. hwpx_automation/ops_services/form_fields.py +557 -0
  172. hwpx_automation/ops_services/media.py +178 -0
  173. hwpx_automation/ops_services/memo_style.py +477 -0
  174. hwpx_automation/ops_services/package_validation.py +166 -0
  175. hwpx_automation/ops_services/planning.py +201 -0
  176. hwpx_automation/ops_services/preview_export.py +585 -0
  177. hwpx_automation/ops_services/read_query.py +601 -0
  178. hwpx_automation/ops_services/save_policy.py +604 -0
  179. hwpx_automation/ops_services/tables.py +539 -0
  180. hwpx_automation/ops_services/transactions.py +616 -0
  181. hwpx_automation/preview_output_models.py +69 -0
  182. hwpx_automation/public-modules.json +206 -0
  183. hwpx_automation/py.typed +1 -0
  184. hwpx_automation/quality.py +351 -0
  185. hwpx_automation/quality_generation.py +725 -0
  186. hwpx_automation/runtime.py +321 -0
  187. hwpx_automation/runtime_services.py +100 -0
  188. hwpx_automation/server.py +259 -0
  189. hwpx_automation/storage.py +747 -0
  190. hwpx_automation/tool_bindings.py +170 -0
  191. hwpx_automation/tool_contract.py +982 -0
  192. hwpx_automation/upstream.py +755 -0
  193. hwpx_automation/utils/__init__.py +2 -0
  194. hwpx_automation/utils/helpers.py +29 -0
  195. hwpx_automation/visual_qa.py +667 -0
  196. hwpx_automation/workflow/__init__.py +55 -0
  197. hwpx_automation/workflow/adapters.py +482 -0
  198. hwpx_automation/workflow/dispatcher.py +213 -0
  199. hwpx_automation/workflow/models.py +243 -0
  200. hwpx_automation/workflow/policy.py +198 -0
  201. hwpx_automation/workflow/render_contracts.py +173 -0
  202. hwpx_automation/workflow/render_metrics.py +196 -0
  203. hwpx_automation/workflow/render_queue.py +482 -0
  204. hwpx_automation/workflow/render_security.py +172 -0
  205. hwpx_automation/workflow/render_transport.py +369 -0
  206. hwpx_automation/workflow/rendering.py +206 -0
  207. hwpx_automation/workflow/service.py +758 -0
  208. hwpx_automation/workflow/state_machine.py +65 -0
  209. hwpx_automation/workflow/store.py +747 -0
  210. hwpx_automation/workspace.py +1694 -0
  211. python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
  212. python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
  213. python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
  214. python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
  215. python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
  216. python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
  217. python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,758 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """High-level durable orchestration over ToolSpec primitive functions."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import hashlib
8
+ from importlib.metadata import PackageNotFoundError, version
9
+ from pathlib import Path
10
+ from typing import Any, Mapping
11
+
12
+ from hwpx_automation.document_state import document_revision
13
+ from hwpx_automation.tool_contract import RELEASED_CONTRACT_HASH
14
+
15
+ from .adapters import ADAPTERS, AdapterAbstention
16
+ from .dispatcher import AllowlistedDispatcher
17
+ from .models import (
18
+ TERMINAL_STATES,
19
+ WORKFLOW_SCHEMA_VERSION,
20
+ WorkFamily,
21
+ WorkOrder,
22
+ WorkflowEvent,
23
+ WorkflowRecord,
24
+ WorkflowState,
25
+ canonical_json,
26
+ )
27
+ from .policy import ActionRequest, PolicyViolation, WorkflowPolicyEngine
28
+ from .store import WorkflowStore
29
+ from .rendering import NullRenderClientV2, RenderClientV2, RenderJobV2, RenderStatus
30
+
31
+
32
+ def default_workflow_store_path() -> Path:
33
+ configured = os.environ.get("HWPX_AUTOMATION_WORKFLOW_STORE")
34
+ if configured:
35
+ return Path(configured).expanduser().resolve()
36
+ configured = os.environ.get("HWPX_WORKFLOW_STORE")
37
+ if configured:
38
+ return Path(configured).expanduser().resolve()
39
+ state_home = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
40
+ # 배포 이름이 python-hwpx-automation으로 바뀌어도 이 경로는 그대로다.
41
+ # 디스크에 이미 있는 사용자의 workflow DB가 여기 있고, 경로를 바꾸면
42
+ # 진행 중이던 workflow가 통째로 사라진 것처럼 보인다.
43
+ return (state_home / "hwpx-mcp-server" / "workflows.sqlite3").resolve()
44
+
45
+
46
+ def _package_version(name: str) -> str:
47
+ try:
48
+ return version(name)
49
+ except PackageNotFoundError:
50
+ return "uninstalled"
51
+
52
+
53
+ class WorkflowService:
54
+ def __init__(
55
+ self,
56
+ namespace: Mapping[str, Any],
57
+ *,
58
+ store: WorkflowStore | None = None,
59
+ capability_ok: bool = True,
60
+ render_client: RenderClientV2 | None = None,
61
+ tool_spec_hash: str = RELEASED_CONTRACT_HASH,
62
+ ) -> None:
63
+ self.store = store or WorkflowStore(default_workflow_store_path())
64
+ self.dispatcher = AllowlistedDispatcher(namespace)
65
+ self.policy = WorkflowPolicyEngine()
66
+ self.capability_ok = capability_ok
67
+ self.render_client = render_client or NullRenderClientV2()
68
+ self.tool_spec_hash = tool_spec_hash
69
+
70
+ def start(
71
+ self,
72
+ *,
73
+ family: str,
74
+ idempotency_key: str,
75
+ source_path: str | None = None,
76
+ output_path: str | None = None,
77
+ expected_revision: str | None = None,
78
+ parameters: dict[str, Any] | None = None,
79
+ budget: dict[str, Any] | None = None,
80
+ policy: dict[str, Any] | None = None,
81
+ ) -> dict[str, Any]:
82
+ try:
83
+ typed_family = WorkFamily(family)
84
+ except ValueError:
85
+ return self._abstention_receipt(family, "UNSUPPORTED_INTENT")
86
+ if expected_revision is None and source_path:
87
+ expected_revision = document_revision(source_path)
88
+ values: dict[str, Any] = {
89
+ "family": typed_family,
90
+ "idempotency_key": idempotency_key,
91
+ "source_path": source_path,
92
+ "output_path": output_path,
93
+ "expected_revision": expected_revision,
94
+ "parameters": parameters or {},
95
+ }
96
+ if budget is not None:
97
+ values["budget"] = budget
98
+ if policy is not None:
99
+ values["policy"] = policy
100
+ order = WorkOrder.model_validate(values)
101
+ record, _ = self.store.create(
102
+ order,
103
+ original_content_hash=document_revision(source_path) if source_path else None,
104
+ )
105
+ if typed_family == WorkFamily.MUST_ABSTAIN and not record.terminal:
106
+ # Persist a real no-mutation workflow identity and a closed,
107
+ # authenticated-by-ledger decision gate. Replays recover the same
108
+ # terminal record instead of returning a synthetic no-ID receipt.
109
+ if record.state == WorkflowState.INTAKE:
110
+ record = self.store.transition(
111
+ record.workflow_id,
112
+ WorkflowState.RECON,
113
+ expected_state=record.state,
114
+ expected_version=record.state_version,
115
+ event_type="abstention.recognized",
116
+ payload={"noMutation": True},
117
+ )
118
+ if record.state == WorkflowState.RECON:
119
+ record = self.store.transition(
120
+ record.workflow_id,
121
+ WorkflowState.NEEDS_REVIEW,
122
+ expected_state=record.state,
123
+ expected_version=record.state_version,
124
+ event_type="decision_gate.abstained",
125
+ payload={"reasonCode": "UNSUPPORTED_INTENT", "noMutation": True},
126
+ stop_reason="UNSUPPORTED_INTENT",
127
+ )
128
+ return self.receipt(record)
129
+ try:
130
+ self.policy.validate_intake(record)
131
+ except PolicyViolation as error:
132
+ record = self.store.transition(
133
+ record.workflow_id,
134
+ WorkflowState.FAILED,
135
+ expected_state=record.state,
136
+ expected_version=record.state_version,
137
+ event_type="workflow.rejected",
138
+ payload={"code": error.code},
139
+ stop_reason=error.code,
140
+ )
141
+ return self.receipt(record)
142
+
143
+ def get(self, workflow_id: str) -> dict[str, Any]:
144
+ return self.receipt(self.store.get(workflow_id))
145
+
146
+ def continue_workflow(self, workflow_id: str) -> dict[str, Any]:
147
+ record = self.store.get(workflow_id)
148
+ if record.terminal or record.state == WorkflowState.DECISION:
149
+ return self.receipt(record)
150
+ adapter = ADAPTERS[record.work_order.family]
151
+ try:
152
+ if record.state == WorkflowState.INTAKE:
153
+ self.policy.validate_intake(record)
154
+ record = self.store.transition(
155
+ workflow_id,
156
+ WorkflowState.RECON,
157
+ expected_state=record.state,
158
+ expected_version=record.state_version,
159
+ )
160
+ elif record.state == WorkflowState.RECON:
161
+ action = adapter.recon_action(record)
162
+ outcome = self.dispatcher.dispatch_durable(
163
+ self.store,
164
+ workflow_id,
165
+ action,
166
+ expected_version=record.state_version,
167
+ capability_ok=self.capability_ok,
168
+ policy=self.policy,
169
+ )
170
+ evidence = outcome.result if outcome.result is not None else outcome.receipt.payload
171
+ target = WorkflowState.PLAN if adapter.recon_ok(evidence) else WorkflowState.NEEDS_REVIEW
172
+ record = self.store.transition(
173
+ workflow_id,
174
+ target,
175
+ expected_state=outcome.receipt.to_state,
176
+ expected_version=self.store.get(workflow_id).state_version,
177
+ event_type="recon.completed" if target == WorkflowState.PLAN else "workflow.abstained",
178
+ payload={"actionHash": action.action_hash, "ok": target == WorkflowState.PLAN},
179
+ stop_reason=None if target == WorkflowState.PLAN else "RECON_NOT_ACTIONABLE",
180
+ )
181
+ elif record.state == WorkflowState.PLAN:
182
+ action = adapter.execution_action(record)
183
+ if action is None:
184
+ record = self.store.transition(
185
+ workflow_id,
186
+ WorkflowState.EXECUTE,
187
+ expected_state=record.state,
188
+ expected_version=record.state_version,
189
+ event_type="plan.accepted",
190
+ payload={"readOnly": True},
191
+ )
192
+ else:
193
+ planned, _, _ = self.store.append_event(
194
+ workflow_id,
195
+ "action.planned",
196
+ expected_state=record.state,
197
+ expected_version=record.state_version,
198
+ payload={
199
+ "actionHash": action.action_hash,
200
+ "tool": action.tool_name,
201
+ "destructive": action.destructive,
202
+ },
203
+ event_key=f"plan:{action.action_hash}",
204
+ )
205
+ target = WorkflowState.DECISION if action.destructive else WorkflowState.EXECUTE
206
+ record = self.store.transition(
207
+ workflow_id,
208
+ target,
209
+ expected_state=planned.state,
210
+ expected_version=planned.state_version,
211
+ event_type="decision.requested" if action.destructive else "plan.accepted",
212
+ payload={"actionHash": action.action_hash},
213
+ )
214
+ elif record.state == WorkflowState.EXECUTE:
215
+ action = adapter.execution_action(record)
216
+ if action is None:
217
+ record = self.store.transition(
218
+ workflow_id,
219
+ WorkflowState.VERIFY,
220
+ expected_state=record.state,
221
+ expected_version=record.state_version,
222
+ event_type="execution.completed",
223
+ payload={"ok": True, "openSafetyOk": True, "readOnly": True},
224
+ )
225
+ else:
226
+ prior = self._action_events(record.workflow_id, action)
227
+ if not any(event.event_type == "dispatch.started" for event in prior):
228
+ adapter.prepare_execution(record)
229
+ outcome = self.dispatcher.dispatch_durable(
230
+ self.store,
231
+ workflow_id,
232
+ action,
233
+ expected_version=record.state_version,
234
+ capability_ok=self.capability_ok,
235
+ policy=self.policy,
236
+ )
237
+ evidence = {
238
+ "actionHash": action.action_hash,
239
+ "ok": outcome.receipt.payload.get("ok") is True,
240
+ "openSafetyOk": outcome.receipt.payload.get("openSafetyOk"),
241
+ }
242
+ record = self.store.transition(
243
+ workflow_id,
244
+ WorkflowState.VERIFY,
245
+ expected_state=outcome.receipt.to_state,
246
+ expected_version=self.store.get(workflow_id).state_version,
247
+ event_type="execution.completed",
248
+ payload=evidence,
249
+ )
250
+ elif record.state == WorkflowState.VERIFY:
251
+ evidence = self._execution_evidence(workflow_id)
252
+ read_only = record.work_order.family == WorkFamily.READ_EXTRACT
253
+ verification_actions = adapter.verification_actions(record)
254
+ for action in verification_actions:
255
+ result = self._stored_action_result(workflow_id, action)
256
+ if result is None:
257
+ try:
258
+ outcome = self.dispatcher.dispatch_durable(
259
+ self.store,
260
+ workflow_id,
261
+ action,
262
+ expected_version=record.state_version,
263
+ capability_ok=self.capability_ok,
264
+ policy=self.policy,
265
+ )
266
+ except PolicyViolation:
267
+ record, _, _ = self.store.append_event(
268
+ workflow_id,
269
+ "verification.checked",
270
+ expected_state=record.state,
271
+ expected_version=record.state_version,
272
+ payload={
273
+ "actionHash": action.action_hash,
274
+ "tool": action.tool_name,
275
+ "ok": False,
276
+ },
277
+ event_key=f"verification:{action.action_hash}",
278
+ )
279
+ raise
280
+ result = outcome.result
281
+ record = self.store.get(workflow_id)
282
+ already_checked = any(
283
+ event.event_type == "verification.checked"
284
+ and event.payload.get("actionHash") == action.action_hash
285
+ for event in self.store.events(workflow_id)
286
+ )
287
+ if not already_checked:
288
+ record, _, _ = self.store.append_event(
289
+ workflow_id,
290
+ "verification.checked",
291
+ expected_state=record.state,
292
+ expected_version=record.state_version,
293
+ payload={
294
+ "actionHash": action.action_hash,
295
+ "tool": action.tool_name,
296
+ "ok": isinstance(result, Mapping)
297
+ and result.get("ok") is not False
298
+ and result.get("pass") is not False,
299
+ },
300
+ event_key=f"verification:{action.action_hash}",
301
+ )
302
+ record = self.store.get(workflow_id)
303
+ if result is None:
304
+ break
305
+ primary_action_hash = self._primary_action_hash(record)
306
+ try:
307
+ primary_result = (
308
+ self.store.get_action_result(workflow_id, primary_action_hash)
309
+ if primary_action_hash is not None
310
+ else None
311
+ )
312
+ except KeyError:
313
+ primary_result = None
314
+ verifier_results = tuple(
315
+ result
316
+ for action in verification_actions
317
+ if (result := self._stored_action_result(workflow_id, action)) is not None
318
+ )
319
+ domain_verified = (
320
+ adapter.execution_evidence_ok(record, primary_result)
321
+ and adapter.verification_ok(record, verifier_results)
322
+ )
323
+ verification = {
324
+ "verified": evidence.get("ok") is True,
325
+ "openSafetyOk": True if read_only else evidence.get("openSafetyOk") is True,
326
+ "domainVerified": domain_verified,
327
+ "verifierCount": len(verifier_results),
328
+ "requiredVerifierCount": len(verification_actions),
329
+ "renderChecked": False,
330
+ }
331
+ if (
332
+ verification["verified"]
333
+ and verification["openSafetyOk"]
334
+ and record.work_order.policy.require_real_hancom_render
335
+ and not read_only
336
+ ):
337
+ record = self._advance_render_verification(record)
338
+ elif verification["verified"] and verification["openSafetyOk"] and domain_verified:
339
+ record = self.policy.complete(
340
+ self.store,
341
+ record,
342
+ verification,
343
+ output_content_hash=self._output_hash(record),
344
+ )
345
+ else:
346
+ record = self.store.transition(
347
+ workflow_id,
348
+ WorkflowState.NEEDS_REVIEW,
349
+ expected_state=record.state,
350
+ expected_version=record.state_version,
351
+ event_type="verification.incomplete",
352
+ payload=verification,
353
+ stop_reason="VERIFICATION_EVIDENCE_REQUIRED",
354
+ )
355
+ elif record.state == WorkflowState.REPAIR:
356
+ record = self.store.transition(
357
+ workflow_id,
358
+ WorkflowState.NEEDS_REVIEW,
359
+ expected_state=record.state,
360
+ expected_version=record.state_version,
361
+ event_type="workflow.abstained",
362
+ stop_reason="AUTOMATIC_REPAIR_NOT_AVAILABLE",
363
+ )
364
+ except AdapterAbstention as error:
365
+ record = self._abstain_existing(record, error.code)
366
+ except PolicyViolation as error:
367
+ current = self.store.get(workflow_id)
368
+ if current.state != WorkflowState.VERIFY:
369
+ raise
370
+ record = self._abstain_existing(current, error.code)
371
+ return self.receipt(record)
372
+
373
+ def approve_decision(self, workflow_id: str, *, approved: bool, action_hash: str | None = None) -> dict[str, Any]:
374
+ record = self.store.get(workflow_id)
375
+ if record.state != WorkflowState.DECISION:
376
+ raise PolicyViolation("DECISION_STATE_REQUIRED", "workflow is not waiting for a decision")
377
+ action = ADAPTERS[record.work_order.family].execution_action(record)
378
+ if action is None:
379
+ raise PolicyViolation("DECISION_ACTION_MISSING", "workflow has no decision action")
380
+ if action_hash is not None and action_hash != action.action_hash:
381
+ raise PolicyViolation("DECISION_ACTION_MISMATCH", "decision action hash does not match the plan")
382
+ record = self.policy.record_decision(self.store, record, action, approved=approved)
383
+ target = WorkflowState.EXECUTE if approved else WorkflowState.NEEDS_REVIEW
384
+ record = self.store.transition(
385
+ workflow_id,
386
+ target,
387
+ expected_state=record.state,
388
+ expected_version=record.state_version,
389
+ event_type="decision.applied",
390
+ payload={"actionHash": action.action_hash, "approved": approved},
391
+ stop_reason=None if approved else "DECISION_REJECTED",
392
+ )
393
+ return self.receipt(record)
394
+
395
+ def cancel(self, workflow_id: str, *, reason: str = "CLIENT_CANCELLED") -> dict[str, Any]:
396
+ record = self.store.get(workflow_id)
397
+ if record.terminal:
398
+ return self.receipt(record)
399
+ render_receipt = self._render_receipt(record)
400
+ if render_receipt and render_receipt.status in {RenderStatus.QUEUED, RenderStatus.RUNNING}:
401
+ self.render_client.cancel(render_receipt.job_id)
402
+ record = self.store.transition(
403
+ workflow_id,
404
+ WorkflowState.CANCELLED,
405
+ expected_state=record.state,
406
+ expected_version=record.state_version,
407
+ event_type="workflow.cancelled",
408
+ payload={"reasonCode": reason},
409
+ stop_reason=reason,
410
+ )
411
+ return self.receipt(record)
412
+
413
+ def resume(self, workflow_id: str) -> dict[str, Any]:
414
+ try:
415
+ return self.continue_workflow(workflow_id)
416
+ except PolicyViolation as error:
417
+ record = self.store.get(workflow_id)
418
+ if error.code != "DISPATCH_IN_DOUBT" or record.state not in {
419
+ WorkflowState.EXECUTE,
420
+ WorkflowState.RECON,
421
+ }:
422
+ raise
423
+ record = self._abstain_existing(record, error.code)
424
+ return self.receipt(record)
425
+
426
+ def receipt(self, record: WorkflowRecord) -> dict[str, Any]:
427
+ events = self.store.events(record.workflow_id)
428
+ decisions = [
429
+ {
430
+ "status": "approved" if event.event_type == "decision.approved" else "rejected",
431
+ "actionHash": event.payload.get("actionHash"),
432
+ }
433
+ for event in events
434
+ if event.event_type in {"decision.approved", "decision.rejected"}
435
+ ]
436
+ evidence = self._execution_evidence(record.workflow_id)
437
+ render_receipt = self._render_receipt(record)
438
+ try:
439
+ render_job = self._render_job(record) if render_receipt else None
440
+ except PolicyViolation:
441
+ render_job = None
442
+ render_checked = bool(
443
+ render_receipt and render_job
444
+ and render_receipt.status == RenderStatus.SUCCEEDED
445
+ and render_receipt.render_checked and render_receipt.binds(render_job)
446
+ )
447
+ read_only = record.work_order.family == WorkFamily.READ_EXTRACT
448
+ if record.state == WorkflowState.COMPLETED:
449
+ verification_status = (
450
+ "verified_read_only" if read_only else
451
+ ("real_hancom_verified" if render_checked else "structurally_verified_render_unverified")
452
+ )
453
+ elif record.state == WorkflowState.NEEDS_REVIEW:
454
+ verification_status = "needs_review"
455
+ else:
456
+ verification_status = "pending"
457
+ stop_reason = record.stop_reason
458
+ if record.state == WorkflowState.DECISION:
459
+ stop_reason = "DECISION_REQUIRED"
460
+ elif record.state == WorkflowState.COMPLETED:
461
+ stop_reason = "VERIFIED_COMPLETION"
462
+ result_envelope = self._result_envelope(record)
463
+ return {
464
+ "schemaVersion": WORKFLOW_SCHEMA_VERSION,
465
+ "workflowId": record.workflow_id,
466
+ "family": record.work_order.family.value,
467
+ "state": record.state.value,
468
+ "stateVersion": record.state_version,
469
+ "terminal": record.state in TERMINAL_STATES,
470
+ "artifacts": self._artifacts(record),
471
+ "decisions": decisions,
472
+ "result": result_envelope.get("inline"),
473
+ "resultRef": result_envelope.get("reference"),
474
+ "semanticDiff": self._semantic_diff(record),
475
+ "domainVerification": self._domain_verification(record),
476
+ "openSafety": {
477
+ "ok": True if read_only and record.state == WorkflowState.COMPLETED else evidence.get("openSafetyOk"),
478
+ "renderChecked": render_checked,
479
+ },
480
+ "render": render_receipt.model_dump(mode="json") if render_receipt else None,
481
+ "verificationStatus": verification_status,
482
+ "unresolvedFindings": [] if record.state == WorkflowState.COMPLETED else self._findings(stop_reason),
483
+ "versions": {
484
+ "workflow": WORKFLOW_SCHEMA_VERSION,
485
+ "automation": _package_version("python-hwpx-automation"),
486
+ # 6.x compatibility alias.
487
+ "mcp": _package_version("python-hwpx-automation"),
488
+ "pythonHwpx": _package_version("python-hwpx"),
489
+ },
490
+ "toolSpecHash": self.tool_spec_hash,
491
+ "stopReason": stop_reason,
492
+ }
493
+
494
+ def _action_events(self, workflow_id: str, action: ActionRequest) -> list[WorkflowEvent]:
495
+ return [
496
+ event
497
+ for event in self.store.events(workflow_id)
498
+ if event.payload.get("actionHash") == action.action_hash
499
+ ]
500
+
501
+ def _stored_action_result(self, workflow_id: str, action: ActionRequest) -> Any | None:
502
+ try:
503
+ return self.store.get_action_result(workflow_id, action.action_hash)
504
+ except KeyError:
505
+ return None
506
+
507
+ def _primary_action_hash(self, record: WorkflowRecord) -> str | None:
508
+ events = self.store.events(record.workflow_id)
509
+ event_type = "recon.completed" if record.work_order.family == WorkFamily.READ_EXTRACT else "execution.completed"
510
+ event = next((item for item in reversed(events) if item.event_type == event_type), None)
511
+ return str(event.payload.get("actionHash")) if event and event.payload.get("actionHash") else None
512
+
513
+ def _result_envelope(self, record: WorkflowRecord) -> dict[str, Any]:
514
+ action_hash = self._primary_action_hash(record)
515
+ if action_hash is None:
516
+ return {"inline": None, "reference": None}
517
+ try:
518
+ result = self.store.get_action_result(record.workflow_id, action_hash)
519
+ except KeyError:
520
+ return {"inline": None, "reference": None}
521
+ metadata = self.store.action_result_metadata(record.workflow_id, action_hash)
522
+ encoded_size = len(canonical_json(result).encode("utf-8"))
523
+ if encoded_size <= 32 * 1024:
524
+ return {"inline": result, "reference": None}
525
+ if metadata is None:
526
+ # get_action_result above already proved the row exists and is
527
+ # unexpired; action_result_metadata queries the same row, so this
528
+ # branch is unreachable in practice. Fail closed to the same empty
529
+ # envelope the missing-result paths return instead of subscripting.
530
+ return {"inline": None, "reference": None}
531
+ return {
532
+ "inline": None,
533
+ "reference": {
534
+ "workflowId": record.workflow_id,
535
+ "actionHash": action_hash,
536
+ "contentHash": metadata["contentHash"],
537
+ "sizeBytes": metadata["sizeBytes"],
538
+ "expiresAt": metadata["expiresAt"],
539
+ "retrievalTool": "get_workflow_result",
540
+ },
541
+ }
542
+
543
+ def workflow_result(self, workflow_id: str, *, action_hash: str | None = None) -> dict[str, Any]:
544
+ record = self.store.get(workflow_id)
545
+ if action_hash is None:
546
+ action_hash = self._primary_action_hash(record)
547
+ if action_hash is None:
548
+ raise KeyError("workflow has no primary result")
549
+ result = self.store.get_action_result(workflow_id, action_hash)
550
+ metadata = self.store.action_result_metadata(workflow_id, action_hash)
551
+ if metadata is None:
552
+ # Same row as get_action_result above (already unexpired), so this
553
+ # is unreachable in practice; raise the same KeyError the store uses
554
+ # for an unavailable result rather than subscript None.
555
+ raise KeyError((workflow_id, action_hash))
556
+ return {
557
+ "workflowId": workflow_id,
558
+ "actionHash": action_hash,
559
+ "contentHash": metadata["contentHash"],
560
+ "sizeBytes": metadata["sizeBytes"],
561
+ "expiresAt": metadata["expiresAt"],
562
+ "result": result,
563
+ }
564
+
565
+ def _semantic_diff(self, record: WorkflowRecord) -> dict[str, Any]:
566
+ if record.work_order.family != WorkFamily.TRANSACTIONAL_EDIT:
567
+ return {"status": "not_applicable", "available": False}
568
+ envelope = self._result_envelope(record)
569
+ result = envelope.get("inline")
570
+ if result is None:
571
+ try:
572
+ action_hash = self._primary_action_hash(record)
573
+ result = self.store.get_action_result(record.workflow_id, action_hash) if action_hash else None
574
+ except KeyError:
575
+ result = None
576
+ semantic_diff = result.get("semanticDiff") if isinstance(result, Mapping) else None
577
+ if isinstance(semantic_diff, Mapping):
578
+ return {**dict(semantic_diff), "status": "computed", "available": True}
579
+ return {"status": "missing", "available": False}
580
+
581
+ def _domain_verification(self, record: WorkflowRecord) -> dict[str, Any]:
582
+ rows = []
583
+ for event in self.store.events(record.workflow_id):
584
+ if event.event_type != "verification.checked":
585
+ continue
586
+ action_hash = str(event.payload.get("actionHash"))
587
+ try:
588
+ result = self.store.get_action_result(record.workflow_id, action_hash)
589
+ except KeyError:
590
+ result = None
591
+ metadata = None
592
+ if result is not None:
593
+ metadata = self.store.action_result_metadata(record.workflow_id, action_hash)
594
+ rows.append(
595
+ {
596
+ "tool": event.payload.get("tool"),
597
+ "actionHash": action_hash,
598
+ "ok": isinstance(result, Mapping)
599
+ and result.get("ok") is not False
600
+ and result.get("pass") is not False,
601
+ "contentHash": metadata["contentHash"] if metadata else None,
602
+ }
603
+ )
604
+ return {
605
+ "required": bool(rows),
606
+ "complete": all(row["contentHash"] for row in rows),
607
+ "ok": all(row["ok"] for row in rows),
608
+ "verifiers": rows,
609
+ }
610
+
611
+ def _execution_evidence(self, workflow_id: str) -> dict[str, Any]:
612
+ for event in reversed(self.store.events(workflow_id)):
613
+ if event.event_type == "execution.completed":
614
+ return dict(event.payload)
615
+ return {}
616
+
617
+ def _advance_render_verification(self, record: WorkflowRecord) -> WorkflowRecord:
618
+ job = self._render_job(record)
619
+ submitted = next(
620
+ (event for event in self.store.events(record.workflow_id) if event.event_type == "render.submitted"),
621
+ None,
622
+ )
623
+ if submitted is None:
624
+ receipt = self.render_client.submit(job, Path(record.work_order.output_path or ""))
625
+ record, _, _ = self.store.append_event(
626
+ record.workflow_id, "render.submitted", expected_state=record.state,
627
+ expected_version=record.state_version,
628
+ payload={"jobId": job.job_id, "inputHash": job.source_content_hash, "status": receipt.status.value},
629
+ event_key=f"render-submit:{job.job_id}",
630
+ )
631
+ else:
632
+ try:
633
+ receipt = self.render_client.get(job.job_id)
634
+ except (KeyError, RuntimeError):
635
+ return self.store.transition(
636
+ record.workflow_id, WorkflowState.NEEDS_REVIEW,
637
+ expected_state=record.state, expected_version=record.state_version,
638
+ event_type="render.unverified", payload={"jobId": job.job_id},
639
+ stop_reason="RENDER_RECEIPT_UNAVAILABLE",
640
+ )
641
+ if receipt.status in {RenderStatus.QUEUED, RenderStatus.RUNNING}:
642
+ return record
643
+ if receipt.status == RenderStatus.SUCCEEDED and receipt.render_checked and receipt.binds(job):
644
+ record, _, _ = self.store.append_event(
645
+ record.workflow_id, "render.completed", expected_state=record.state,
646
+ expected_version=record.state_version,
647
+ payload={"jobId": job.job_id, "inputHash": job.source_content_hash, "status": receipt.status.value},
648
+ event_key=f"render-complete:{job.job_id}",
649
+ )
650
+ return self.policy.complete(
651
+ self.store, record,
652
+ {
653
+ "verified": True,
654
+ "openSafetyOk": True,
655
+ "domainVerified": self._domain_verification(record)["ok"],
656
+ "renderChecked": True,
657
+ },
658
+ output_content_hash=job.source_content_hash,
659
+ )
660
+ return self.store.transition(
661
+ record.workflow_id, WorkflowState.NEEDS_REVIEW,
662
+ expected_state=record.state, expected_version=record.state_version,
663
+ event_type="render.unverified",
664
+ payload={"jobId": job.job_id, "status": receipt.status.value},
665
+ stop_reason=receipt.terminal_reason or "REAL_HANCOM_RENDER_UNVERIFIED",
666
+ )
667
+
668
+ def _render_job(self, record: WorkflowRecord) -> RenderJobV2:
669
+ output = Path(record.work_order.output_path or "")
670
+ if not output.is_file():
671
+ raise PolicyViolation("RENDER_OUTPUT_MISSING", "workflow output is unavailable for rendering")
672
+ digest = "sha256:" + hashlib.sha256(output.read_bytes()).hexdigest()
673
+ stable = hashlib.sha256(record.workflow_id.encode("utf-8")).hexdigest()[:24]
674
+ return RenderJobV2(
675
+ job_id=f"render-{stable}", workflow_id=record.workflow_id,
676
+ idempotency_key=f"render-{stable}-{digest[7:23]}",
677
+ source_content_hash=digest, source_size_bytes=output.stat().st_size,
678
+ submitted_at=record.created_at,
679
+ )
680
+
681
+ def _render_receipt(self, record: WorkflowRecord):
682
+ if not record.work_order.policy.require_real_hancom_render:
683
+ return None
684
+ try:
685
+ job = self._render_job(record)
686
+ except PolicyViolation:
687
+ return None
688
+ if not any(event.event_type == "render.submitted" for event in self.store.events(record.workflow_id)):
689
+ return None
690
+ try:
691
+ return self.render_client.get(job.job_id)
692
+ except (KeyError, RuntimeError):
693
+ return None
694
+
695
+ def _abstain_existing(self, record: WorkflowRecord, reason: str) -> WorkflowRecord:
696
+ if record.state == WorkflowState.INTAKE:
697
+ target = WorkflowState.FAILED
698
+ else:
699
+ target = WorkflowState.NEEDS_REVIEW
700
+ return self.store.transition(
701
+ record.workflow_id,
702
+ target,
703
+ expected_state=record.state,
704
+ expected_version=record.state_version,
705
+ event_type="workflow.abstained",
706
+ payload={"reasonCode": reason},
707
+ stop_reason=reason,
708
+ )
709
+
710
+ @staticmethod
711
+ def _output_hash(record: WorkflowRecord) -> str | None:
712
+ output = record.work_order.output_path
713
+ return document_revision(output) if output and Path(output).is_file() else None
714
+
715
+ def _artifacts(self, record: WorkflowRecord) -> list[dict[str, Any]]:
716
+ artifacts = []
717
+ for role, path in (("source", record.work_order.source_path), ("output", record.work_order.output_path)):
718
+ if path:
719
+ artifacts.append(
720
+ {
721
+ "role": role,
722
+ "path": path,
723
+ "contentHash": document_revision(path) if Path(path).is_file() else None,
724
+ }
725
+ )
726
+ return artifacts
727
+
728
+ @staticmethod
729
+ def _findings(stop_reason: str | None) -> list[dict[str, str]]:
730
+ return [{"code": stop_reason, "severity": "review"}] if stop_reason else []
731
+
732
+ def _abstention_receipt(self, family: str, reason: str) -> dict[str, Any]:
733
+ return {
734
+ "schemaVersion": WORKFLOW_SCHEMA_VERSION,
735
+ "workflowId": None,
736
+ "family": family,
737
+ "state": WorkflowState.NEEDS_REVIEW.value,
738
+ "stateVersion": 0,
739
+ "terminal": True,
740
+ "artifacts": [],
741
+ "decisions": [],
742
+ "semanticDiff": {"status": "not_computed", "available": False},
743
+ "openSafety": {"ok": None, "renderChecked": False},
744
+ "verificationStatus": "needs_review",
745
+ "unresolvedFindings": [{"code": reason, "severity": "review"}],
746
+ "versions": {
747
+ "workflow": WORKFLOW_SCHEMA_VERSION,
748
+ "automation": _package_version("python-hwpx-automation"),
749
+ # 6.x compatibility alias.
750
+ "mcp": _package_version("python-hwpx-automation"),
751
+ "pythonHwpx": _package_version("python-hwpx"),
752
+ },
753
+ "toolSpecHash": self.tool_spec_hash,
754
+ "stopReason": reason,
755
+ }
756
+
757
+
758
+ __all__ = ["WorkflowService", "default_workflow_store_path"]