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.
- hwpx_automation/__init__.py +61 -0
- hwpx_automation/__init__.pyi +27 -0
- hwpx_automation/__main__.py +8 -0
- hwpx_automation/agent_document.py +392 -0
- hwpx_automation/api.py +136 -0
- hwpx_automation/blind_eval.py +407 -0
- hwpx_automation/capabilities.py +110 -0
- hwpx_automation/compat.py +48 -0
- hwpx_automation/configuration.py +60 -0
- hwpx_automation/core/__init__.py +2 -0
- hwpx_automation/core/content.py +762 -0
- hwpx_automation/core/context.py +111 -0
- hwpx_automation/core/diff.py +53 -0
- hwpx_automation/core/document.py +37 -0
- hwpx_automation/core/formatting.py +513 -0
- hwpx_automation/core/handles.py +24 -0
- hwpx_automation/core/locations.py +205 -0
- hwpx_automation/core/locator.py +162 -0
- hwpx_automation/core/plan.py +680 -0
- hwpx_automation/core/resources.py +42 -0
- hwpx_automation/core/search.py +296 -0
- hwpx_automation/core/transactions.py +434 -0
- hwpx_automation/core/txn.py +48 -0
- hwpx_automation/document_state.py +95 -0
- hwpx_automation/errors.py +174 -0
- hwpx_automation/execution_lock.py +15 -0
- hwpx_automation/fastmcp_adapter.py +672 -0
- hwpx_automation/form_fill.py +1177 -0
- hwpx_automation/form_output_models.py +223 -0
- hwpx_automation/handlers/__init__.py +2 -0
- hwpx_automation/handlers/_shared.py +377 -0
- hwpx_automation/handlers/agent_document.py +257 -0
- hwpx_automation/handlers/authoring.py +750 -0
- hwpx_automation/handlers/content_edit.py +1078 -0
- hwpx_automation/handlers/form_fill.py +607 -0
- hwpx_automation/handlers/layout_style.py +660 -0
- hwpx_automation/handlers/quality_render.py +566 -0
- hwpx_automation/handlers/read_export.py +1295 -0
- hwpx_automation/handlers/specialized.py +624 -0
- hwpx_automation/handlers/tracked_changes.py +589 -0
- hwpx_automation/handlers/workflow.py +105 -0
- hwpx_automation/hwp_converter.py +227 -0
- hwpx_automation/hwp_support.py +94 -0
- hwpx_automation/hwpx_ops.py +1439 -0
- hwpx_automation/identity.json +263 -0
- hwpx_automation/identity.py +18 -0
- hwpx_automation/ingest_adapters.py +85 -0
- hwpx_automation/markdown_plan.py +216 -0
- hwpx_automation/mcp_cli.py +29 -0
- hwpx_automation/metadata/tools_meta.py +40 -0
- hwpx_automation/mixed_form.py +3007 -0
- hwpx_automation/mutation_models.py +401 -0
- hwpx_automation/network_policy.py +232 -0
- hwpx_automation/office/__init__.py +14 -0
- hwpx_automation/office/agent/__init__.py +125 -0
- hwpx_automation/office/agent/_batch_verification.py +383 -0
- hwpx_automation/office/agent/blueprint/__init__.py +58 -0
- hwpx_automation/office/agent/blueprint/bundle.py +282 -0
- hwpx_automation/office/agent/blueprint/catalog.py +136 -0
- hwpx_automation/office/agent/blueprint/dump.py +520 -0
- hwpx_automation/office/agent/blueprint/mapping.py +312 -0
- hwpx_automation/office/agent/blueprint/model.py +722 -0
- hwpx_automation/office/agent/blueprint/native.py +621 -0
- hwpx_automation/office/agent/blueprint/replay.py +622 -0
- hwpx_automation/office/agent/catalog.py +252 -0
- hwpx_automation/office/agent/cli.py +647 -0
- hwpx_automation/office/agent/commands.py +1383 -0
- hwpx_automation/office/agent/document.py +801 -0
- hwpx_automation/office/agent/form_plan.py +1760 -0
- hwpx_automation/office/agent/model.py +808 -0
- hwpx_automation/office/agent/path.py +155 -0
- hwpx_automation/office/agent/query.py +230 -0
- hwpx_automation/office/agent/story.py +207 -0
- hwpx_automation/office/authoring/__init__.py +3542 -0
- hwpx_automation/office/authoring/advanced_generators.py +154 -0
- hwpx_automation/office/authoring/builder/__init__.py +52 -0
- hwpx_automation/office/authoring/builder/core.py +996 -0
- hwpx_automation/office/authoring/builder/report.py +195 -0
- hwpx_automation/office/authoring/design/__init__.py +30 -0
- hwpx_automation/office/authoring/design/_support.py +144 -0
- hwpx_automation/office/authoring/design/composer.py +282 -0
- hwpx_automation/office/authoring/design/harvest.py +305 -0
- hwpx_automation/office/authoring/design/plan.py +69 -0
- hwpx_automation/office/authoring/design/profile.py +88 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/validator.py +107 -0
- hwpx_automation/office/authoring/presets/__init__.py +22 -0
- hwpx_automation/office/authoring/presets/proposal.py +538 -0
- hwpx_automation/office/authoring/report_parser.py +141 -0
- hwpx_automation/office/authoring/style_profile.py +437 -0
- hwpx_automation/office/authoring/template_analyzer.py +657 -0
- hwpx_automation/office/compliance/__init__.py +38 -0
- hwpx_automation/office/compliance/official_lint.py +478 -0
- hwpx_automation/office/compliance/pii.py +388 -0
- hwpx_automation/office/document_ops/__init__.py +13 -0
- hwpx_automation/office/document_ops/comparison.py +62 -0
- hwpx_automation/office/document_ops/mail_merge.py +73 -0
- hwpx_automation/office/document_ops/redline.py +35 -0
- hwpx_automation/office/evalplan/__init__.py +36 -0
- hwpx_automation/office/evalplan/runtime.py +2762 -0
- hwpx_automation/office/exam/__init__.py +44 -0
- hwpx_automation/office/exam/compose.py +282 -0
- hwpx_automation/office/exam/ir.py +44 -0
- hwpx_automation/office/exam/measure.py +163 -0
- hwpx_automation/office/exam/parser.py +150 -0
- hwpx_automation/office/exam/profile.py +123 -0
- hwpx_automation/office/form_fill/__init__.py +66 -0
- hwpx_automation/office/form_fill/classification.py +108 -0
- hwpx_automation/office/form_fill/fill_residue.py +242 -0
- hwpx_automation/office/form_fill/fit/__init__.py +36 -0
- hwpx_automation/office/form_fill/fit/apply.py +24 -0
- hwpx_automation/office/form_fill/fit/engine.py +24 -0
- hwpx_automation/office/form_fill/fit/measure.py +50 -0
- hwpx_automation/office/form_fill/fit/policy.py +28 -0
- hwpx_automation/office/form_fill/fit/report.py +28 -0
- hwpx_automation/office/form_fill/fit/seal.py +457 -0
- hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
- hwpx_automation/office/form_fill/guidance.py +704 -0
- hwpx_automation/office/form_fill/quality.py +961 -0
- hwpx_automation/office/form_fill/split_run.py +333 -0
- hwpx_automation/office/form_fill/template_formfit.py +656 -0
- hwpx_automation/office/house_style/__init__.py +196 -0
- hwpx_automation/office/house_style/composition.py +68 -0
- hwpx_automation/office/house_style/data/bank.json +625 -0
- hwpx_automation/office/house_style/data/genres.json +43 -0
- hwpx_automation/office/quality/__init__.py +14 -0
- hwpx_automation/office/quality/page_guard.py +277 -0
- hwpx_automation/office/rendering/__init__.py +145 -0
- hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
- hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
- hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
- hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
- hwpx_automation/office/rendering/block_splits.py +76 -0
- hwpx_automation/office/rendering/detectors.py +151 -0
- hwpx_automation/office/rendering/diff.py +153 -0
- hwpx_automation/office/rendering/fixture_corpus.py +215 -0
- hwpx_automation/office/rendering/oracle.py +909 -0
- hwpx_automation/office/rendering/page_qa.py +245 -0
- hwpx_automation/office/rendering/qa_contracts.py +293 -0
- hwpx_automation/office/rendering/qa_metrics.py +241 -0
- hwpx_automation/office/rendering/worker.py +290 -0
- hwpx_automation/office/utilities/__init__.py +12 -0
- hwpx_automation/office/utilities/table_compute.py +477 -0
- hwpx_automation/ops_services/__init__.py +1 -0
- hwpx_automation/ops_services/_border_fill.py +283 -0
- hwpx_automation/ops_services/composition.py +55 -0
- hwpx_automation/ops_services/content_layout.py +322 -0
- hwpx_automation/ops_services/context.py +213 -0
- hwpx_automation/ops_services/form_fields.py +557 -0
- hwpx_automation/ops_services/media.py +178 -0
- hwpx_automation/ops_services/memo_style.py +477 -0
- hwpx_automation/ops_services/package_validation.py +166 -0
- hwpx_automation/ops_services/planning.py +201 -0
- hwpx_automation/ops_services/preview_export.py +585 -0
- hwpx_automation/ops_services/read_query.py +601 -0
- hwpx_automation/ops_services/save_policy.py +604 -0
- hwpx_automation/ops_services/tables.py +539 -0
- hwpx_automation/ops_services/transactions.py +616 -0
- hwpx_automation/preview_output_models.py +69 -0
- hwpx_automation/public-modules.json +206 -0
- hwpx_automation/py.typed +1 -0
- hwpx_automation/quality.py +351 -0
- hwpx_automation/quality_generation.py +725 -0
- hwpx_automation/runtime.py +321 -0
- hwpx_automation/runtime_services.py +100 -0
- hwpx_automation/server.py +259 -0
- hwpx_automation/storage.py +747 -0
- hwpx_automation/tool_bindings.py +170 -0
- hwpx_automation/tool_contract.py +982 -0
- hwpx_automation/upstream.py +755 -0
- hwpx_automation/utils/__init__.py +2 -0
- hwpx_automation/utils/helpers.py +29 -0
- hwpx_automation/visual_qa.py +667 -0
- hwpx_automation/workflow/__init__.py +55 -0
- hwpx_automation/workflow/adapters.py +482 -0
- hwpx_automation/workflow/dispatcher.py +213 -0
- hwpx_automation/workflow/models.py +243 -0
- hwpx_automation/workflow/policy.py +198 -0
- hwpx_automation/workflow/render_contracts.py +173 -0
- hwpx_automation/workflow/render_metrics.py +196 -0
- hwpx_automation/workflow/render_queue.py +482 -0
- hwpx_automation/workflow/render_security.py +172 -0
- hwpx_automation/workflow/render_transport.py +369 -0
- hwpx_automation/workflow/rendering.py +206 -0
- hwpx_automation/workflow/service.py +758 -0
- hwpx_automation/workflow/state_machine.py +65 -0
- hwpx_automation/workflow/store.py +747 -0
- hwpx_automation/workspace.py +1694 -0
- python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
- python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
- python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
- python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
- 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"]
|