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,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"]
|