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,747 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Crash-safe SQLite state and tamper-evident append-only workflow ledger."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import json
8
+ import os
9
+ import sqlite3
10
+ import uuid
11
+ from contextlib import contextmanager
12
+ from datetime import datetime, timedelta, timezone
13
+ from pathlib import Path
14
+ from typing import Any, Iterator
15
+
16
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
17
+
18
+ from .models import (
19
+ ActionResult,
20
+ TERMINAL_STATES,
21
+ WorkOrder,
22
+ WorkflowEvent,
23
+ WorkflowRecord,
24
+ WorkflowState,
25
+ canonical_json,
26
+ content_hash,
27
+ sanitize_ledger_payload,
28
+ utc_now,
29
+ )
30
+ from .state_machine import assert_transition
31
+
32
+
33
+ class WorkflowConflict(RuntimeError):
34
+ pass
35
+
36
+
37
+ class WorkflowNotFound(KeyError):
38
+ pass
39
+
40
+
41
+ class WorkflowStore:
42
+ """Durable local workflow store.
43
+
44
+ Each mutation uses ``BEGIN IMMEDIATE`` and ``synchronous=FULL``. The event
45
+ chain makes accidental history edits detectable while SQLite WAL provides
46
+ process-crash recovery.
47
+ """
48
+
49
+ _ENCRYPTED_PREFIX = "enc:v1:"
50
+ _MAX_RESULT_RETENTION_SECONDS = 30 * 24 * 60 * 60
51
+
52
+ def __init__(
53
+ self,
54
+ path: str | Path,
55
+ *,
56
+ encryption_key: bytes | str | None = None,
57
+ result_retention_seconds: int = 24 * 60 * 60,
58
+ ) -> None:
59
+ self.path = Path(path).expanduser().resolve()
60
+ self.path.parent.mkdir(parents=True, exist_ok=True)
61
+ if not 1 <= result_retention_seconds <= self._MAX_RESULT_RETENTION_SECONDS:
62
+ raise ValueError("result retention must be between 1 second and 30 days")
63
+ self.result_retention_seconds = result_retention_seconds
64
+ self._key = self._load_or_create_key(encryption_key)
65
+ self._aead = AESGCM(self._key)
66
+ migrated = self._initialize()
67
+ if migrated:
68
+ # Ensure legacy plaintext does not survive in free pages or the WAL.
69
+ self._compact_sensitive_storage()
70
+ self.purge_expired_results()
71
+
72
+ def _connect(self) -> sqlite3.Connection:
73
+ connection = sqlite3.connect(self.path, timeout=30, isolation_level=None)
74
+ connection.row_factory = sqlite3.Row
75
+ connection.execute("PRAGMA foreign_keys=ON")
76
+ connection.execute("PRAGMA journal_mode=WAL")
77
+ connection.execute("PRAGMA synchronous=FULL")
78
+ connection.execute("PRAGMA secure_delete=ON")
79
+ return connection
80
+
81
+ def _load_or_create_key(self, supplied: bytes | str | None) -> bytes:
82
+ if supplied is not None:
83
+ key = supplied if isinstance(supplied, bytes) else base64.urlsafe_b64decode(supplied)
84
+ if len(key) != 32:
85
+ raise ValueError("workflow encryption key must be exactly 32 bytes")
86
+ return key
87
+ configured = os.environ.get("HWPX_WORKFLOW_ENCRYPTION_KEY")
88
+ if configured:
89
+ try:
90
+ key = base64.urlsafe_b64decode(configured)
91
+ except Exception as exc:
92
+ raise ValueError("HWPX_WORKFLOW_ENCRYPTION_KEY must be URL-safe base64") from exc
93
+ if len(key) != 32:
94
+ raise ValueError("HWPX_WORKFLOW_ENCRYPTION_KEY must decode to exactly 32 bytes")
95
+ return key
96
+ key_path = self.path.with_name(f"{self.path.name}.key")
97
+ try:
98
+ key = key_path.read_bytes()
99
+ except FileNotFoundError:
100
+ key = AESGCM.generate_key(bit_length=256)
101
+ temporary = key_path.with_name(f".{key_path.name}.{uuid.uuid4().hex}.tmp")
102
+ descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
103
+ try:
104
+ with os.fdopen(descriptor, "wb") as stream:
105
+ stream.write(key)
106
+ stream.flush()
107
+ os.fsync(stream.fileno())
108
+ try:
109
+ os.link(temporary, key_path)
110
+ except FileExistsError:
111
+ key = key_path.read_bytes()
112
+ finally:
113
+ temporary.unlink(missing_ok=True)
114
+ if len(key) != 32:
115
+ raise WorkflowConflict(f"invalid workflow encryption key file: {key_path}")
116
+ return key
117
+
118
+ def _compact_sensitive_storage(self) -> None:
119
+ """Remove replaced encrypted payloads from the WAL and free pages."""
120
+
121
+ with self._connect() as connection:
122
+ connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
123
+ connection.execute("VACUUM")
124
+
125
+ def _encrypt_json(self, value: Any, *, associated_data: str) -> str:
126
+ nonce = os.urandom(12)
127
+ plaintext = canonical_json(value).encode("utf-8")
128
+ ciphertext = self._aead.encrypt(nonce, plaintext, associated_data.encode("utf-8"))
129
+ return self._ENCRYPTED_PREFIX + base64.urlsafe_b64encode(nonce + ciphertext).decode("ascii")
130
+
131
+ def _decrypt_json(self, envelope: str, *, associated_data: str) -> Any:
132
+ if not envelope.startswith(self._ENCRYPTED_PREFIX):
133
+ return json.loads(envelope)
134
+ try:
135
+ packed = base64.urlsafe_b64decode(envelope[len(self._ENCRYPTED_PREFIX) :])
136
+ plaintext = self._aead.decrypt(
137
+ packed[:12], packed[12:], associated_data.encode("utf-8")
138
+ )
139
+ return json.loads(plaintext)
140
+ except Exception as exc:
141
+ raise WorkflowConflict("encrypted workflow data failed authentication") from exc
142
+
143
+ @staticmethod
144
+ def _action_result_aad(
145
+ workflow_id: str,
146
+ action_hash: str,
147
+ result_hash: str,
148
+ size_bytes: int,
149
+ created_at: str,
150
+ expires_at: str,
151
+ ) -> str:
152
+ return canonical_json(
153
+ {
154
+ "kind": "action-result",
155
+ "workflowId": workflow_id,
156
+ "actionHash": action_hash,
157
+ "contentHash": result_hash,
158
+ "sizeBytes": size_bytes,
159
+ "createdAt": created_at,
160
+ "expiresAt": expires_at,
161
+ }
162
+ )
163
+
164
+ @contextmanager
165
+ def _transaction(self) -> Iterator[sqlite3.Connection]:
166
+ connection = self._connect()
167
+ try:
168
+ connection.execute("BEGIN IMMEDIATE")
169
+ yield connection
170
+ connection.commit()
171
+ except Exception:
172
+ connection.rollback()
173
+ raise
174
+ finally:
175
+ connection.close()
176
+
177
+ def _initialize(self) -> bool:
178
+ migrated = False
179
+ with self._transaction() as connection:
180
+ connection.executescript(
181
+ """
182
+ CREATE TABLE IF NOT EXISTS workflows (
183
+ workflow_id TEXT PRIMARY KEY,
184
+ idempotency_key TEXT NOT NULL UNIQUE,
185
+ work_order_json TEXT NOT NULL,
186
+ work_order_hash TEXT NOT NULL,
187
+ state TEXT NOT NULL,
188
+ state_version INTEGER NOT NULL,
189
+ created_at TEXT NOT NULL,
190
+ updated_at TEXT NOT NULL,
191
+ original_content_hash TEXT,
192
+ output_content_hash TEXT,
193
+ stop_reason TEXT
194
+ );
195
+ CREATE TABLE IF NOT EXISTS workflow_events (
196
+ workflow_id TEXT NOT NULL REFERENCES workflows(workflow_id),
197
+ event_index INTEGER NOT NULL,
198
+ event_type TEXT NOT NULL,
199
+ from_state TEXT,
200
+ to_state TEXT NOT NULL,
201
+ occurred_at TEXT NOT NULL,
202
+ payload_json TEXT NOT NULL,
203
+ previous_hash TEXT,
204
+ event_hash TEXT NOT NULL,
205
+ event_key TEXT,
206
+ PRIMARY KEY (workflow_id, event_index),
207
+ UNIQUE (workflow_id, event_hash),
208
+ UNIQUE (workflow_id, event_key)
209
+ );
210
+ CREATE INDEX IF NOT EXISTS workflow_events_lookup
211
+ ON workflow_events(workflow_id, event_index);
212
+ CREATE TABLE IF NOT EXISTS workflow_action_results (
213
+ workflow_id TEXT NOT NULL REFERENCES workflows(workflow_id) ON DELETE CASCADE,
214
+ action_hash TEXT NOT NULL,
215
+ result_ciphertext TEXT NOT NULL,
216
+ content_hash TEXT NOT NULL,
217
+ size_bytes INTEGER NOT NULL,
218
+ created_at TEXT NOT NULL,
219
+ expires_at TEXT NOT NULL,
220
+ PRIMARY KEY (workflow_id, action_hash)
221
+ );
222
+ CREATE INDEX IF NOT EXISTS workflow_action_results_expiry
223
+ ON workflow_action_results(expires_at);
224
+ """
225
+ )
226
+ columns = {
227
+ str(row["name"])
228
+ for row in connection.execute("PRAGMA table_info(workflow_events)").fetchall()
229
+ }
230
+ if "event_key" not in columns:
231
+ connection.execute("ALTER TABLE workflow_events ADD COLUMN event_key TEXT")
232
+ connection.execute(
233
+ "CREATE UNIQUE INDEX IF NOT EXISTS workflow_events_event_key "
234
+ "ON workflow_events(workflow_id, event_key) WHERE event_key IS NOT NULL"
235
+ )
236
+ rows = connection.execute(
237
+ "SELECT workflow_id, idempotency_key, work_order_json FROM workflows"
238
+ ).fetchall()
239
+ for row in rows:
240
+ idempotency_key = str(row["idempotency_key"])
241
+ if not idempotency_key.startswith("sha256:"):
242
+ connection.execute(
243
+ "UPDATE workflows SET idempotency_key = ? WHERE workflow_id = ?",
244
+ (content_hash(idempotency_key), row["workflow_id"]),
245
+ )
246
+ migrated = True
247
+ serialized = str(row["work_order_json"])
248
+ if serialized.startswith(self._ENCRYPTED_PREFIX):
249
+ continue
250
+ parsed = json.loads(serialized)
251
+ connection.execute(
252
+ "UPDATE workflows SET work_order_json = ? WHERE workflow_id = ?",
253
+ (
254
+ self._encrypt_json(
255
+ parsed,
256
+ associated_data=f"work-order:{row['workflow_id']}",
257
+ ),
258
+ row["workflow_id"],
259
+ ),
260
+ )
261
+ migrated = True
262
+ return migrated
263
+
264
+ def create(
265
+ self,
266
+ work_order: WorkOrder,
267
+ *,
268
+ original_content_hash: str | None = None,
269
+ workflow_id: str | None = None,
270
+ ) -> tuple[WorkflowRecord, bool]:
271
+ """Create or idempotently recover a workflow.
272
+
273
+ Returns ``(record, created)``. Reusing a key for different content fails
274
+ closed instead of silently returning the unrelated workflow.
275
+ """
276
+
277
+ workflow_id = workflow_id or f"wf_{uuid.uuid4().hex}"
278
+ now = utc_now()
279
+ idempotency_key_hash = content_hash(work_order.idempotency_key)
280
+ order_json = self._encrypt_json(
281
+ work_order.model_dump(mode="json"),
282
+ associated_data=f"work-order:{workflow_id}",
283
+ )
284
+ order_hash = work_order.fingerprint()
285
+ with self._transaction() as connection:
286
+ existing = connection.execute(
287
+ "SELECT * FROM workflows WHERE idempotency_key = ?", (idempotency_key_hash,)
288
+ ).fetchone()
289
+ if existing is not None:
290
+ if existing["work_order_hash"] != order_hash:
291
+ raise WorkflowConflict("idempotency key was already used for a different work order")
292
+ return self._record_from_row(existing), False
293
+
294
+ connection.execute(
295
+ """INSERT INTO workflows(
296
+ workflow_id, idempotency_key, work_order_json, work_order_hash,
297
+ state, state_version, created_at, updated_at, original_content_hash
298
+ ) VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)""",
299
+ (
300
+ workflow_id,
301
+ idempotency_key_hash,
302
+ order_json,
303
+ order_hash,
304
+ WorkflowState.INTAKE.value,
305
+ now.isoformat(),
306
+ now.isoformat(),
307
+ original_content_hash,
308
+ ),
309
+ )
310
+ self._append_event(
311
+ connection,
312
+ workflow_id=workflow_id,
313
+ event_type="workflow.created",
314
+ from_state=None,
315
+ to_state=WorkflowState.INTAKE,
316
+ payload=work_order.ledger_summary(),
317
+ occurred_at=now,
318
+ )
319
+ row = connection.execute("SELECT * FROM workflows WHERE workflow_id = ?", (workflow_id,)).fetchone()
320
+ assert row is not None
321
+ return self._record_from_row(row), True
322
+
323
+ def get(self, workflow_id: str) -> WorkflowRecord:
324
+ with self._connect() as connection:
325
+ row = connection.execute("SELECT * FROM workflows WHERE workflow_id = ?", (workflow_id,)).fetchone()
326
+ if row is None:
327
+ raise WorkflowNotFound(workflow_id)
328
+ return self._record_from_row(row)
329
+
330
+ def transition(
331
+ self,
332
+ workflow_id: str,
333
+ target: WorkflowState,
334
+ *,
335
+ expected_state: WorkflowState,
336
+ expected_version: int,
337
+ event_type: str = "workflow.transitioned",
338
+ payload: dict[str, Any] | None = None,
339
+ output_content_hash: str | None = None,
340
+ stop_reason: str | None = None,
341
+ ) -> WorkflowRecord:
342
+ with self._transaction() as connection:
343
+ row = connection.execute("SELECT * FROM workflows WHERE workflow_id = ?", (workflow_id,)).fetchone()
344
+ if row is None:
345
+ raise WorkflowNotFound(workflow_id)
346
+ current = WorkflowState(row["state"])
347
+ version = int(row["state_version"])
348
+ if event_type in {"decision.approved", "decision.rejected"}:
349
+ raise WorkflowConflict("decision receipts must use the same-state append_event policy path")
350
+ if current != expected_state or version != expected_version:
351
+ raise WorkflowConflict(
352
+ f"stale workflow state: expected {expected_state.value}@{expected_version}, "
353
+ f"actual {current.value}@{version}"
354
+ )
355
+ assert_transition(current, target)
356
+ now = utc_now()
357
+ work_order_json = row["work_order_json"]
358
+ if target in TERMINAL_STATES:
359
+ raw_order = self._decrypt_json(
360
+ str(work_order_json), associated_data=f"work-order:{workflow_id}"
361
+ )
362
+ raw_order["parameters"] = {}
363
+ work_order_json = self._encrypt_json(
364
+ raw_order, associated_data=f"work-order:{workflow_id}"
365
+ )
366
+ updated = connection.execute(
367
+ """UPDATE workflows
368
+ SET state = ?, state_version = state_version + 1, updated_at = ?,
369
+ output_content_hash = COALESCE(?, output_content_hash),
370
+ stop_reason = COALESCE(?, stop_reason), work_order_json = ?
371
+ WHERE workflow_id = ? AND state = ? AND state_version = ?""",
372
+ (
373
+ target.value,
374
+ now.isoformat(),
375
+ output_content_hash,
376
+ stop_reason,
377
+ work_order_json,
378
+ workflow_id,
379
+ current.value,
380
+ version,
381
+ ),
382
+ )
383
+ if updated.rowcount != 1:
384
+ raise WorkflowConflict("workflow changed concurrently")
385
+ self._append_event(
386
+ connection,
387
+ workflow_id=workflow_id,
388
+ event_type=event_type,
389
+ from_state=current,
390
+ to_state=target,
391
+ payload=payload or {},
392
+ occurred_at=now,
393
+ )
394
+ result = connection.execute("SELECT * FROM workflows WHERE workflow_id = ?", (workflow_id,)).fetchone()
395
+ assert result is not None
396
+ transitioned = self._record_from_row(result)
397
+ if target in TERMINAL_STATES:
398
+ self._compact_sensitive_storage()
399
+ return transitioned
400
+
401
+ def put_action_result(
402
+ self,
403
+ workflow_id: str,
404
+ action_hash: str,
405
+ result: Any,
406
+ *,
407
+ retention_seconds: int | None = None,
408
+ ) -> ActionResult:
409
+ """Persist a JSON result using authenticated encryption.
410
+
411
+ Repeating the same workflow/action/result is idempotent. Reusing an
412
+ action hash for different content fails closed.
413
+ """
414
+
415
+ retention = self.result_retention_seconds if retention_seconds is None else retention_seconds
416
+ if not 1 <= retention <= self._MAX_RESULT_RETENTION_SECONDS:
417
+ raise ValueError("result retention must be between 1 second and 30 days")
418
+ raw = canonical_json(result).encode("utf-8")
419
+ result_hash = content_hash(raw)
420
+ now = utc_now()
421
+ expires_at = now + timedelta(seconds=retention)
422
+ created_at_text = now.isoformat()
423
+ expires_at_text = expires_at.isoformat()
424
+ with self._transaction() as connection:
425
+ if connection.execute(
426
+ "SELECT 1 FROM workflows WHERE workflow_id = ?", (workflow_id,)
427
+ ).fetchone() is None:
428
+ raise WorkflowNotFound(workflow_id)
429
+ existing = connection.execute(
430
+ "SELECT * FROM workflow_action_results WHERE workflow_id = ? AND action_hash = ?",
431
+ (workflow_id, action_hash),
432
+ ).fetchone()
433
+ if existing is not None:
434
+ if existing["content_hash"] != result_hash:
435
+ raise WorkflowConflict("action hash was already used for a different result")
436
+ return self._action_result_from_row(existing, include_result=True)
437
+ ciphertext = self._encrypt_json(
438
+ result,
439
+ associated_data=self._action_result_aad(
440
+ workflow_id,
441
+ action_hash,
442
+ result_hash,
443
+ len(raw),
444
+ created_at_text,
445
+ expires_at_text,
446
+ ),
447
+ )
448
+ connection.execute(
449
+ """INSERT INTO workflow_action_results(
450
+ workflow_id, action_hash, result_ciphertext, content_hash,
451
+ size_bytes, created_at, expires_at
452
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)""",
453
+ (
454
+ workflow_id,
455
+ action_hash,
456
+ ciphertext,
457
+ result_hash,
458
+ len(raw),
459
+ created_at_text,
460
+ expires_at_text,
461
+ ),
462
+ )
463
+ row = connection.execute(
464
+ "SELECT * FROM workflow_action_results WHERE workflow_id = ? AND action_hash = ?",
465
+ (workflow_id, action_hash),
466
+ ).fetchone()
467
+ assert row is not None
468
+ return self._action_result_from_row(row, include_result=True)
469
+
470
+ def get_action_result(self, workflow_id: str, action_hash: str) -> Any:
471
+ """Return the decrypted result body, or raise ``KeyError`` if unavailable."""
472
+
473
+ with self._connect() as connection:
474
+ row = connection.execute(
475
+ """SELECT * FROM workflow_action_results
476
+ WHERE workflow_id = ? AND action_hash = ? AND expires_at > ?""",
477
+ (workflow_id, action_hash, utc_now().isoformat()),
478
+ ).fetchone()
479
+ if row is None:
480
+ raise KeyError((workflow_id, action_hash))
481
+ return self._action_result_from_row(row, include_result=True).result
482
+
483
+ def action_result_metadata(self, workflow_id: str, action_hash: str) -> dict[str, Any] | None:
484
+ with self._connect() as connection:
485
+ row = connection.execute(
486
+ """SELECT * FROM workflow_action_results
487
+ WHERE workflow_id = ? AND action_hash = ? AND expires_at > ?""",
488
+ (workflow_id, action_hash, utc_now().isoformat()),
489
+ ).fetchone()
490
+ if row is None:
491
+ return None
492
+ value = self._action_result_from_row(row, include_result=False)
493
+ return value.model_dump(mode="json", by_alias=True, exclude={"result"})
494
+
495
+ def list_action_results(self, workflow_id: str) -> list[dict[str, Any]]:
496
+ with self._connect() as connection:
497
+ rows = connection.execute(
498
+ """SELECT * FROM workflow_action_results
499
+ WHERE workflow_id = ? AND expires_at > ? ORDER BY created_at, action_hash""",
500
+ (workflow_id, utc_now().isoformat()),
501
+ ).fetchall()
502
+ if not rows and not self._exists(workflow_id):
503
+ raise WorkflowNotFound(workflow_id)
504
+ return [
505
+ self._action_result_from_row(row, include_result=False).model_dump(
506
+ mode="json", by_alias=True, exclude={"result"}
507
+ )
508
+ for row in rows
509
+ ]
510
+
511
+ def purge_expired_results(self, *, now: datetime | None = None) -> int:
512
+ cutoff = now or utc_now()
513
+ if cutoff.tzinfo is None:
514
+ cutoff = cutoff.replace(tzinfo=timezone.utc)
515
+ cutoff = cutoff.astimezone(timezone.utc)
516
+ with self._transaction() as connection:
517
+ deleted = connection.execute(
518
+ "DELETE FROM workflow_action_results WHERE expires_at <= ?", (cutoff.isoformat(),)
519
+ )
520
+ deleted_count = int(deleted.rowcount)
521
+ if deleted_count:
522
+ self._compact_sensitive_storage()
523
+ return deleted_count
524
+
525
+ def append_event(
526
+ self,
527
+ workflow_id: str,
528
+ event_type: str,
529
+ *,
530
+ expected_state: WorkflowState,
531
+ expected_version: int,
532
+ payload: dict[str, Any] | None = None,
533
+ event_key: str | None = None,
534
+ ) -> tuple[WorkflowRecord, WorkflowEvent, bool]:
535
+ """Append an idempotent same-state receipt under optimistic CAS.
536
+
537
+ Same-state actions are mutations too, so they advance ``state_version``.
538
+ An ``event_key`` makes retries return the original receipt without
539
+ creating a second event, while reuse for different content fails closed.
540
+ """
541
+
542
+ raw_payload = payload or {}
543
+ safe_payload = sanitize_ledger_payload(raw_payload, key="event")
544
+ with self._transaction() as connection:
545
+ row = connection.execute("SELECT * FROM workflows WHERE workflow_id = ?", (workflow_id,)).fetchone()
546
+ if row is None:
547
+ raise WorkflowNotFound(workflow_id)
548
+ current = WorkflowState(row["state"])
549
+ version = int(row["state_version"])
550
+ if event_type in {"decision.approved", "decision.rejected"}:
551
+ if current != WorkflowState.DECISION:
552
+ raise WorkflowConflict("decision receipts are only valid in decision state")
553
+ if not isinstance(raw_payload.get("actionHash"), str):
554
+ raise WorkflowConflict("decision receipt requires an action hash")
555
+ expected_approval = event_type == "decision.approved"
556
+ if raw_payload.get("approved") is not expected_approval:
557
+ raise WorkflowConflict("decision receipt type and approved value disagree")
558
+ if event_key is not None:
559
+ existing = connection.execute(
560
+ "SELECT * FROM workflow_events WHERE workflow_id = ? AND event_key = ?",
561
+ (workflow_id, event_key),
562
+ ).fetchone()
563
+ if existing is not None:
564
+ event = self._event_from_row(existing)
565
+ if event.event_type != event_type or event.payload != safe_payload:
566
+ raise WorkflowConflict("event key was already used for a different receipt")
567
+ return self._record_from_row(row), event, False
568
+ if current != expected_state or version != expected_version:
569
+ raise WorkflowConflict(
570
+ f"stale workflow state: expected {expected_state.value}@{expected_version}, "
571
+ f"actual {current.value}@{version}"
572
+ )
573
+ now = utc_now()
574
+ updated = connection.execute(
575
+ """UPDATE workflows SET state_version = state_version + 1, updated_at = ?
576
+ WHERE workflow_id = ? AND state = ? AND state_version = ?""",
577
+ (now.isoformat(), workflow_id, current.value, version),
578
+ )
579
+ if updated.rowcount != 1:
580
+ raise WorkflowConflict("workflow changed concurrently")
581
+ self._append_event(
582
+ connection,
583
+ workflow_id=workflow_id,
584
+ event_type=event_type,
585
+ from_state=current,
586
+ to_state=current,
587
+ payload=raw_payload,
588
+ occurred_at=now,
589
+ event_key=event_key,
590
+ )
591
+ result = connection.execute("SELECT * FROM workflows WHERE workflow_id = ?", (workflow_id,)).fetchone()
592
+ event_row = connection.execute(
593
+ "SELECT * FROM workflow_events WHERE workflow_id = ? ORDER BY event_index DESC LIMIT 1",
594
+ (workflow_id,),
595
+ ).fetchone()
596
+ assert result is not None and event_row is not None
597
+ return self._record_from_row(result), self._event_from_row(event_row), True
598
+
599
+ def events(self, workflow_id: str, *, verify_chain: bool = True) -> list[WorkflowEvent]:
600
+ with self._connect() as connection:
601
+ rows = connection.execute(
602
+ "SELECT * FROM workflow_events WHERE workflow_id = ? ORDER BY event_index", (workflow_id,)
603
+ ).fetchall()
604
+ if not rows and not self._exists(workflow_id):
605
+ raise WorkflowNotFound(workflow_id)
606
+ events = [self._event_from_row(row) for row in rows]
607
+ if verify_chain:
608
+ previous: str | None = None
609
+ for event in events:
610
+ if event.previous_hash != previous or self._calculate_event_hash(event) != event.event_hash:
611
+ raise WorkflowConflict(f"workflow event chain is invalid at index {event.index}")
612
+ previous = event.event_hash
613
+ return events
614
+
615
+ def _exists(self, workflow_id: str) -> bool:
616
+ with self._connect() as connection:
617
+ return connection.execute(
618
+ "SELECT 1 FROM workflows WHERE workflow_id = ?", (workflow_id,)
619
+ ).fetchone() is not None
620
+
621
+ def _append_event(
622
+ self,
623
+ connection: sqlite3.Connection,
624
+ *,
625
+ workflow_id: str,
626
+ event_type: str,
627
+ from_state: WorkflowState | None,
628
+ to_state: WorkflowState,
629
+ payload: dict[str, Any],
630
+ occurred_at: datetime,
631
+ event_key: str | None = None,
632
+ ) -> None:
633
+ last = connection.execute(
634
+ "SELECT event_index, event_hash FROM workflow_events WHERE workflow_id = ? ORDER BY event_index DESC LIMIT 1",
635
+ (workflow_id,),
636
+ ).fetchone()
637
+ index = int(last["event_index"]) + 1 if last else 0
638
+ previous_hash = str(last["event_hash"]) if last else None
639
+ safe_payload = sanitize_ledger_payload(payload, key="event")
640
+ event = WorkflowEvent(
641
+ workflow_id=workflow_id,
642
+ index=index,
643
+ event_type=event_type,
644
+ from_state=from_state,
645
+ to_state=to_state,
646
+ occurred_at=occurred_at,
647
+ payload=safe_payload,
648
+ previous_hash=previous_hash,
649
+ event_hash="pending",
650
+ event_key=event_key,
651
+ )
652
+ event_hash = self._calculate_event_hash(event)
653
+ connection.execute(
654
+ """INSERT INTO workflow_events(
655
+ workflow_id, event_index, event_type, from_state, to_state,
656
+ occurred_at, payload_json, previous_hash, event_hash
657
+ , event_key
658
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
659
+ (
660
+ workflow_id,
661
+ index,
662
+ event_type,
663
+ from_state.value if from_state else None,
664
+ to_state.value,
665
+ occurred_at.isoformat(),
666
+ canonical_json(safe_payload),
667
+ previous_hash,
668
+ event_hash,
669
+ event_key,
670
+ ),
671
+ )
672
+
673
+ @staticmethod
674
+ def _calculate_event_hash(event: WorkflowEvent) -> str:
675
+ hashed = {
676
+ "workflowId": event.workflow_id,
677
+ "index": event.index,
678
+ "eventType": event.event_type,
679
+ "fromState": event.from_state.value if event.from_state else None,
680
+ "toState": event.to_state.value,
681
+ "occurredAt": event.occurred_at.isoformat(),
682
+ "payload": event.payload,
683
+ "previousHash": event.previous_hash,
684
+ }
685
+ if event.event_key is not None:
686
+ hashed["eventKey"] = event.event_key
687
+ return content_hash(hashed)
688
+
689
+ def _record_from_row(self, row: sqlite3.Row) -> WorkflowRecord:
690
+ work_order = self._decrypt_json(
691
+ str(row["work_order_json"]), associated_data=f"work-order:{row['workflow_id']}"
692
+ )
693
+ return WorkflowRecord(
694
+ workflow_id=row["workflow_id"],
695
+ work_order=WorkOrder.model_validate(work_order),
696
+ state=WorkflowState(row["state"]),
697
+ state_version=int(row["state_version"]),
698
+ created_at=datetime.fromisoformat(row["created_at"]),
699
+ updated_at=datetime.fromisoformat(row["updated_at"]),
700
+ original_content_hash=row["original_content_hash"],
701
+ output_content_hash=row["output_content_hash"],
702
+ stop_reason=row["stop_reason"],
703
+ )
704
+
705
+ def _action_result_from_row(
706
+ self, row: sqlite3.Row, *, include_result: bool
707
+ ) -> ActionResult:
708
+ result = None
709
+ if include_result:
710
+ result = self._decrypt_json(
711
+ str(row["result_ciphertext"]),
712
+ associated_data=self._action_result_aad(
713
+ str(row["workflow_id"]),
714
+ str(row["action_hash"]),
715
+ str(row["content_hash"]),
716
+ int(row["size_bytes"]),
717
+ str(row["created_at"]),
718
+ str(row["expires_at"]),
719
+ ),
720
+ )
721
+ return ActionResult(
722
+ workflow_id=row["workflow_id"],
723
+ action_hash=row["action_hash"],
724
+ result=result,
725
+ content_hash=row["content_hash"],
726
+ size_bytes=int(row["size_bytes"]),
727
+ created_at=datetime.fromisoformat(row["created_at"]),
728
+ expires_at=datetime.fromisoformat(row["expires_at"]),
729
+ )
730
+
731
+ @staticmethod
732
+ def _event_from_row(row: sqlite3.Row) -> WorkflowEvent:
733
+ return WorkflowEvent(
734
+ workflow_id=row["workflow_id"],
735
+ index=int(row["event_index"]),
736
+ event_type=row["event_type"],
737
+ from_state=WorkflowState(row["from_state"]) if row["from_state"] else None,
738
+ to_state=WorkflowState(row["to_state"]),
739
+ occurred_at=datetime.fromisoformat(row["occurred_at"]),
740
+ payload=json.loads(row["payload_json"]),
741
+ previous_hash=row["previous_hash"],
742
+ event_hash=row["event_hash"],
743
+ event_key=row["event_key"],
744
+ )
745
+
746
+
747
+ __all__ = ["WorkflowConflict", "WorkflowNotFound", "WorkflowStore"]