@openlucaskaka/kagent 0.1.7
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.
- package/README.md +353 -0
- package/npm/bin/kagent-serve.js +6 -0
- package/npm/bin/kagent.js +6 -0
- package/npm/lib/App.js +524 -0
- package/npm/lib/app-state.js +224 -0
- package/npm/lib/approval-choice.js +25 -0
- package/npm/lib/commands.js +59 -0
- package/npm/lib/editor.js +188 -0
- package/npm/lib/ink-runner.js +41 -0
- package/npm/lib/kagent-home.js +39 -0
- package/npm/lib/launcher.js +221 -0
- package/npm/lib/protocol.js +33 -0
- package/npm/lib/provider-setup.js +139 -0
- package/npm/lib/python-runner.js +892 -0
- package/npm/lib/runtime-client.js +390 -0
- package/npm/lib/terminal-input.js +127 -0
- package/npm/lib/terminal-text.js +19 -0
- package/npm/lib/terminal-width.js +14 -0
- package/npm/lib/transcript.js +227 -0
- package/npm/lib/ui-components.js +247 -0
- package/npm/lib/update-manager.js +334 -0
- package/package.json +39 -0
- package/pyproject.toml +55 -0
- package/src/kagent/__init__.py +90 -0
- package/src/kagent/cli/__init__.py +5 -0
- package/src/kagent/cli/__main__.py +6 -0
- package/src/kagent/cli/commands.py +112 -0
- package/src/kagent/cli/conversation.py +127 -0
- package/src/kagent/cli/interactive.py +841 -0
- package/src/kagent/cli/main.py +685 -0
- package/src/kagent/cli/memory.py +460 -0
- package/src/kagent/cli/pending_approval.py +169 -0
- package/src/kagent/cli/provider.py +27 -0
- package/src/kagent/cli/session_commands.py +401 -0
- package/src/kagent/cli/stdio_runtime.py +784 -0
- package/src/kagent/cli/trace.py +67 -0
- package/src/kagent/cli/ui.py +931 -0
- package/src/kagent/core/__init__.py +0 -0
- package/src/kagent/core/agent.py +296 -0
- package/src/kagent/core/faults.py +11 -0
- package/src/kagent/core/invariants.py +47 -0
- package/src/kagent/core/normalization.py +81 -0
- package/src/kagent/core/planning.py +48 -0
- package/src/kagent/core/state.py +73 -0
- package/src/kagent/core/summary.py +64 -0
- package/src/kagent/core/tools.py +196 -0
- package/src/kagent/core/trace.py +57 -0
- package/src/kagent/eval/__init__.py +11 -0
- package/src/kagent/eval/cases.py +229 -0
- package/src/kagent/eval/evaluator.py +216 -0
- package/src/kagent/integrations/__init__.py +3 -0
- package/src/kagent/integrations/audit.py +95 -0
- package/src/kagent/integrations/backends.py +131 -0
- package/src/kagent/integrations/memory.py +301 -0
- package/src/kagent/ops/__init__.py +0 -0
- package/src/kagent/ops/batch.py +156 -0
- package/src/kagent/ops/doctor.py +255 -0
- package/src/kagent/ops/metrics.py +214 -0
- package/src/kagent/ops/release_evidence.py +877 -0
- package/src/kagent/ops/release_manifest.py +142 -0
- package/src/kagent/ops/trace_replay.py +285 -0
- package/src/kagent/providers/__init__.py +7 -0
- package/src/kagent/providers/embeddings.py +187 -0
- package/src/kagent/providers/llm.py +770 -0
- package/src/kagent/py.typed +1 -0
- package/src/kagent/runtime/__init__.py +28 -0
- package/src/kagent/runtime/action_graph.py +543 -0
- package/src/kagent/runtime/agent.py +2089 -0
- package/src/kagent/runtime/approval.py +64 -0
- package/src/kagent/runtime/cancellation.py +64 -0
- package/src/kagent/runtime/checkpoint_state.py +146 -0
- package/src/kagent/runtime/checkpoint_storage.py +270 -0
- package/src/kagent/runtime/context.py +65 -0
- package/src/kagent/runtime/file_transaction.py +195 -0
- package/src/kagent/runtime/hooks.py +74 -0
- package/src/kagent/runtime/metadata.py +116 -0
- package/src/kagent/runtime/patch_checkpoints.py +385 -0
- package/src/kagent/runtime/policy.py +50 -0
- package/src/kagent/runtime/presentation.py +205 -0
- package/src/kagent/runtime/redaction.py +130 -0
- package/src/kagent/runtime/sandbox.py +331 -0
- package/src/kagent/runtime/skills.py +66 -0
- package/src/kagent/runtime/steering.py +56 -0
- package/src/kagent/runtime/steps.py +255 -0
- package/src/kagent/runtime/task_state.py +40 -0
- package/src/kagent/runtime/tools.py +3532 -0
- package/src/kagent/runtime/types.py +240 -0
- package/src/kagent/runtime/workspace.py +597 -0
- package/src/kagent/service/__init__.py +26 -0
- package/src/kagent/service/__main__.py +6 -0
- package/src/kagent/service/active_runs.py +178 -0
- package/src/kagent/service/cli.py +737 -0
- package/src/kagent/service/contract.py +2571 -0
- package/src/kagent/service/errors.py +71 -0
- package/src/kagent/service/idempotency.py +584 -0
- package/src/kagent/service/router.py +884 -0
- package/src/kagent/service/run.py +150 -0
- package/src/kagent/service/runtime.py +1915 -0
- package/src/kagent/service/runtime_approval.py +20 -0
- package/src/kagent/service/runtime_cancel.py +192 -0
- package/src/kagent/service/runtime_lifecycle.py +229 -0
- package/src/kagent/service/runtime_metadata.py +21 -0
- package/src/kagent/service/runtime_policy.py +115 -0
- package/src/kagent/service/runtime_recovery.py +573 -0
- package/src/kagent/service/runtime_resume.py +382 -0
- package/src/kagent/service/runtime_resume_claim.py +116 -0
- package/src/kagent/service/runtime_run.py +350 -0
- package/src/kagent/service/runtime_status.py +2007 -0
- package/src/kagent/service/safety.py +139 -0
- package/src/kagent/service/server.py +114 -0
- package/src/kagent/service/status.py +233 -0
- package/src/kagent/service/trace_store.py +322 -0
- package/src/kagent/service/transport.py +24 -0
- package/src/kagent/utils/__init__.py +0 -0
- package/src/kagent/utils/config_validation.py +21 -0
- package/src/kagent/utils/json_output.py +23 -0
- package/src/kagent/utils/paths.py +473 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Dict
|
|
4
|
+
|
|
5
|
+
AGENT_RUN_FAILED = "agent_run_failed"
|
|
6
|
+
AGENT_RUN_INTERRUPTED = "agent_run_interrupted"
|
|
7
|
+
AGENT_RUN_TIMEOUT = "agent_run_timeout"
|
|
8
|
+
EXPECTATION_FAILED = "expectation_failed"
|
|
9
|
+
INVALID_AGENT_CONFIG = "invalid_agent_config"
|
|
10
|
+
INVALID_CONTENT_LENGTH = "invalid_content_length"
|
|
11
|
+
INVALID_TRANSFER_ENCODING = "invalid_transfer_encoding"
|
|
12
|
+
INCOMPLETE_REQUEST_BODY = "incomplete_request_body"
|
|
13
|
+
INVALID_JSON = "invalid_json"
|
|
14
|
+
INVALID_IDEMPOTENCY_KEY = "invalid_idempotency_key"
|
|
15
|
+
INVALID_REQUEST_BODY = "invalid_request_body"
|
|
16
|
+
FULL_TRACE_DISABLED = "full_trace_disabled"
|
|
17
|
+
GOAL_TOO_LARGE = "goal_too_large"
|
|
18
|
+
IDEMPOTENCY_KEY_CONFLICT = "idempotency_key_conflict"
|
|
19
|
+
IDEMPOTENCY_REQUEST_IN_PROGRESS = "idempotency_request_in_progress"
|
|
20
|
+
METHOD_NOT_ALLOWED = "method_not_allowed"
|
|
21
|
+
MISSING_GOAL = "missing_goal"
|
|
22
|
+
NOT_FOUND = "not_found"
|
|
23
|
+
RATE_LIMIT_EXCEEDED = "rate_limit_exceeded"
|
|
24
|
+
READINESS_FAILED = "readiness_failed"
|
|
25
|
+
REQUEST_BODY_TIMEOUT = "request_body_timeout"
|
|
26
|
+
REQUEST_TOO_LARGE = "request_too_large"
|
|
27
|
+
TOO_MANY_CONCURRENT_RUNS = "too_many_concurrent_runs"
|
|
28
|
+
TRACE_PERSISTENCE_FAILED = "trace_persistence_failed"
|
|
29
|
+
TRACE_READ_FAILED = "trace_read_failed"
|
|
30
|
+
UNAUTHORIZED = "unauthorized"
|
|
31
|
+
UNSUPPORTED_MEDIA_TYPE = "unsupported_media_type"
|
|
32
|
+
|
|
33
|
+
ERROR_CODES = (
|
|
34
|
+
AGENT_RUN_FAILED,
|
|
35
|
+
AGENT_RUN_INTERRUPTED,
|
|
36
|
+
AGENT_RUN_TIMEOUT,
|
|
37
|
+
EXPECTATION_FAILED,
|
|
38
|
+
INVALID_AGENT_CONFIG,
|
|
39
|
+
INVALID_CONTENT_LENGTH,
|
|
40
|
+
INVALID_TRANSFER_ENCODING,
|
|
41
|
+
INCOMPLETE_REQUEST_BODY,
|
|
42
|
+
INVALID_JSON,
|
|
43
|
+
INVALID_IDEMPOTENCY_KEY,
|
|
44
|
+
INVALID_REQUEST_BODY,
|
|
45
|
+
FULL_TRACE_DISABLED,
|
|
46
|
+
GOAL_TOO_LARGE,
|
|
47
|
+
IDEMPOTENCY_KEY_CONFLICT,
|
|
48
|
+
IDEMPOTENCY_REQUEST_IN_PROGRESS,
|
|
49
|
+
METHOD_NOT_ALLOWED,
|
|
50
|
+
MISSING_GOAL,
|
|
51
|
+
NOT_FOUND,
|
|
52
|
+
RATE_LIMIT_EXCEEDED,
|
|
53
|
+
READINESS_FAILED,
|
|
54
|
+
REQUEST_BODY_TIMEOUT,
|
|
55
|
+
REQUEST_TOO_LARGE,
|
|
56
|
+
TOO_MANY_CONCURRENT_RUNS,
|
|
57
|
+
TRACE_PERSISTENCE_FAILED,
|
|
58
|
+
TRACE_READ_FAILED,
|
|
59
|
+
UNAUTHORIZED,
|
|
60
|
+
UNSUPPORTED_MEDIA_TYPE,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def failure_payload(error_code: str, message: str) -> Dict[str, str]:
|
|
65
|
+
if error_code not in ERROR_CODES:
|
|
66
|
+
raise ValueError(f"unknown service error code: {error_code}")
|
|
67
|
+
return {
|
|
68
|
+
"status": "failed",
|
|
69
|
+
"error_code": error_code,
|
|
70
|
+
"error": message,
|
|
71
|
+
}
|
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import time
|
|
6
|
+
from collections import OrderedDict
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from hashlib import sha256
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from threading import Condition, Lock
|
|
12
|
+
from typing import Any, Dict, Optional, Protocol, Tuple
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
from kagent.utils.json_output import json_ready
|
|
16
|
+
|
|
17
|
+
IdempotencyResponse = Tuple[int, Any]
|
|
18
|
+
IdempotencyAcquireResult = Tuple[str, Optional[IdempotencyResponse], str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IdempotencyCache(Protocol):
|
|
22
|
+
def acquire(
|
|
23
|
+
self,
|
|
24
|
+
key: str,
|
|
25
|
+
body: bytes,
|
|
26
|
+
*,
|
|
27
|
+
lease_seconds: float,
|
|
28
|
+
wait_timeout_seconds: float,
|
|
29
|
+
) -> IdempotencyAcquireResult: ...
|
|
30
|
+
|
|
31
|
+
def complete(
|
|
32
|
+
self,
|
|
33
|
+
key: str,
|
|
34
|
+
body: bytes,
|
|
35
|
+
claim_token: str,
|
|
36
|
+
status_code: int,
|
|
37
|
+
payload: Any,
|
|
38
|
+
) -> bool: ...
|
|
39
|
+
|
|
40
|
+
def release(self, key: str, claim_token: str) -> bool: ...
|
|
41
|
+
|
|
42
|
+
def lookup(self, key: str, body: bytes) -> Tuple[str, Optional[IdempotencyResponse]]: ...
|
|
43
|
+
|
|
44
|
+
def store(self, key: str, body: bytes, status_code: int, payload: Any) -> None: ...
|
|
45
|
+
|
|
46
|
+
def snapshot(self) -> Dict[str, str]: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _IdempotencyMetrics:
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
self._hits = 0
|
|
52
|
+
self._misses = 0
|
|
53
|
+
self._conflicts = 0
|
|
54
|
+
self._stores = 0
|
|
55
|
+
self._evictions = 0
|
|
56
|
+
self._claims = 0
|
|
57
|
+
self._waits = 0
|
|
58
|
+
self._wait_timeouts = 0
|
|
59
|
+
self._takeovers = 0
|
|
60
|
+
|
|
61
|
+
def _metrics_snapshot(
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
entry_count: int,
|
|
65
|
+
max_entries: int,
|
|
66
|
+
backend: str,
|
|
67
|
+
) -> Dict[str, str]:
|
|
68
|
+
return {
|
|
69
|
+
"idempotency_cache_backend": backend,
|
|
70
|
+
"idempotency_cache_entries": str(entry_count),
|
|
71
|
+
"idempotency_cache_size": str(max_entries),
|
|
72
|
+
"idempotency_cache_hits": str(self._hits),
|
|
73
|
+
"idempotency_cache_misses": str(self._misses),
|
|
74
|
+
"idempotency_cache_conflicts": str(self._conflicts),
|
|
75
|
+
"idempotency_cache_stores": str(self._stores),
|
|
76
|
+
"idempotency_cache_evictions": str(self._evictions),
|
|
77
|
+
"idempotency_cache_claims": str(self._claims),
|
|
78
|
+
"idempotency_cache_waits": str(self._waits),
|
|
79
|
+
"idempotency_cache_wait_timeouts": str(self._wait_timeouts),
|
|
80
|
+
"idempotency_cache_takeovers": str(self._takeovers),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class _MemoryRecord:
|
|
86
|
+
fingerprint: str
|
|
87
|
+
state: str
|
|
88
|
+
status_code: int = 0
|
|
89
|
+
payload: Any = None
|
|
90
|
+
owner_token: str = ""
|
|
91
|
+
lease_expires_at: float = 0.0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ServiceIdempotencyCache(_IdempotencyMetrics):
|
|
95
|
+
def __init__(self, *, max_entries: int) -> None:
|
|
96
|
+
if max_entries < 0:
|
|
97
|
+
raise ValueError("max_entries must be non-negative")
|
|
98
|
+
super().__init__()
|
|
99
|
+
self._max_entries = max_entries
|
|
100
|
+
self._condition = Condition()
|
|
101
|
+
self._records: "OrderedDict[str, _MemoryRecord]" = OrderedDict()
|
|
102
|
+
|
|
103
|
+
def acquire(
|
|
104
|
+
self,
|
|
105
|
+
key: str,
|
|
106
|
+
body: bytes,
|
|
107
|
+
*,
|
|
108
|
+
lease_seconds: float,
|
|
109
|
+
wait_timeout_seconds: float,
|
|
110
|
+
) -> IdempotencyAcquireResult:
|
|
111
|
+
_validate_acquire_timeouts(lease_seconds, wait_timeout_seconds)
|
|
112
|
+
if self._max_entries == 0:
|
|
113
|
+
return "disabled", None, ""
|
|
114
|
+
fingerprint = _request_body_fingerprint(body)
|
|
115
|
+
claim_token = str(uuid4())
|
|
116
|
+
deadline = time.monotonic() + wait_timeout_seconds
|
|
117
|
+
recorded_wait = False
|
|
118
|
+
with self._condition:
|
|
119
|
+
while True:
|
|
120
|
+
now = time.monotonic()
|
|
121
|
+
record = self._records.get(key)
|
|
122
|
+
if record is None:
|
|
123
|
+
self._records[key] = _MemoryRecord(
|
|
124
|
+
fingerprint=fingerprint,
|
|
125
|
+
state="pending",
|
|
126
|
+
owner_token=claim_token,
|
|
127
|
+
lease_expires_at=now + lease_seconds,
|
|
128
|
+
)
|
|
129
|
+
self._records.move_to_end(key)
|
|
130
|
+
self._misses += 1
|
|
131
|
+
self._claims += 1
|
|
132
|
+
self._evict_completed_over_capacity()
|
|
133
|
+
return "claimed", None, claim_token
|
|
134
|
+
self._records.move_to_end(key)
|
|
135
|
+
if record.fingerprint != fingerprint:
|
|
136
|
+
self._conflicts += 1
|
|
137
|
+
return "conflict", None, ""
|
|
138
|
+
if record.state == "completed":
|
|
139
|
+
self._hits += 1
|
|
140
|
+
return "hit", (record.status_code, deepcopy(record.payload)), ""
|
|
141
|
+
if record.lease_expires_at <= now:
|
|
142
|
+
record.owner_token = claim_token
|
|
143
|
+
record.lease_expires_at = now + lease_seconds
|
|
144
|
+
self._claims += 1
|
|
145
|
+
self._takeovers += 1
|
|
146
|
+
return "claimed", None, claim_token
|
|
147
|
+
remaining = deadline - now
|
|
148
|
+
if remaining <= 0:
|
|
149
|
+
self._wait_timeouts += 1
|
|
150
|
+
return "in_progress", None, ""
|
|
151
|
+
if not recorded_wait:
|
|
152
|
+
self._waits += 1
|
|
153
|
+
recorded_wait = True
|
|
154
|
+
self._condition.wait(
|
|
155
|
+
timeout=min(remaining, max(0.001, record.lease_expires_at - now))
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def complete(
|
|
159
|
+
self,
|
|
160
|
+
key: str,
|
|
161
|
+
body: bytes,
|
|
162
|
+
claim_token: str,
|
|
163
|
+
status_code: int,
|
|
164
|
+
payload: Any,
|
|
165
|
+
) -> bool:
|
|
166
|
+
fingerprint = _request_body_fingerprint(body)
|
|
167
|
+
with self._condition:
|
|
168
|
+
record = self._records.get(key)
|
|
169
|
+
if (
|
|
170
|
+
record is None
|
|
171
|
+
or record.state != "pending"
|
|
172
|
+
or record.fingerprint != fingerprint
|
|
173
|
+
or record.owner_token != claim_token
|
|
174
|
+
):
|
|
175
|
+
return False
|
|
176
|
+
record.state = "completed"
|
|
177
|
+
record.status_code = int(status_code)
|
|
178
|
+
record.payload = deepcopy(payload)
|
|
179
|
+
record.owner_token = ""
|
|
180
|
+
record.lease_expires_at = 0.0
|
|
181
|
+
self._records.move_to_end(key)
|
|
182
|
+
self._stores += 1
|
|
183
|
+
self._evict_completed_over_capacity()
|
|
184
|
+
self._condition.notify_all()
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
def release(self, key: str, claim_token: str) -> bool:
|
|
188
|
+
with self._condition:
|
|
189
|
+
record = self._records.get(key)
|
|
190
|
+
if (
|
|
191
|
+
record is None
|
|
192
|
+
or record.state != "pending"
|
|
193
|
+
or record.owner_token != claim_token
|
|
194
|
+
):
|
|
195
|
+
return False
|
|
196
|
+
del self._records[key]
|
|
197
|
+
self._condition.notify_all()
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
def lookup(self, key: str, body: bytes) -> Tuple[str, Optional[IdempotencyResponse]]:
|
|
201
|
+
if self._max_entries == 0:
|
|
202
|
+
return "disabled", None
|
|
203
|
+
fingerprint = _request_body_fingerprint(body)
|
|
204
|
+
with self._condition:
|
|
205
|
+
record = self._records.get(key)
|
|
206
|
+
if record is None:
|
|
207
|
+
self._misses += 1
|
|
208
|
+
return "miss", None
|
|
209
|
+
self._records.move_to_end(key)
|
|
210
|
+
if record.fingerprint != fingerprint:
|
|
211
|
+
self._conflicts += 1
|
|
212
|
+
return "conflict", None
|
|
213
|
+
if record.state == "pending":
|
|
214
|
+
return "in_progress", None
|
|
215
|
+
self._hits += 1
|
|
216
|
+
return "hit", (record.status_code, deepcopy(record.payload))
|
|
217
|
+
|
|
218
|
+
def store(self, key: str, body: bytes, status_code: int, payload: Any) -> None:
|
|
219
|
+
if self._max_entries == 0:
|
|
220
|
+
return
|
|
221
|
+
with self._condition:
|
|
222
|
+
self._records[key] = _MemoryRecord(
|
|
223
|
+
fingerprint=_request_body_fingerprint(body),
|
|
224
|
+
state="completed",
|
|
225
|
+
status_code=int(status_code),
|
|
226
|
+
payload=deepcopy(payload),
|
|
227
|
+
)
|
|
228
|
+
self._records.move_to_end(key)
|
|
229
|
+
self._stores += 1
|
|
230
|
+
self._evict_completed_over_capacity()
|
|
231
|
+
self._condition.notify_all()
|
|
232
|
+
|
|
233
|
+
def snapshot(self) -> Dict[str, str]:
|
|
234
|
+
with self._condition:
|
|
235
|
+
return self._metrics_snapshot(
|
|
236
|
+
entry_count=len(self._records),
|
|
237
|
+
max_entries=self._max_entries,
|
|
238
|
+
backend="memory",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def _evict_completed_over_capacity(self) -> None:
|
|
242
|
+
while len(self._records) > self._max_entries:
|
|
243
|
+
completed_key = next(
|
|
244
|
+
(
|
|
245
|
+
cache_key
|
|
246
|
+
for cache_key, record in self._records.items()
|
|
247
|
+
if record.state == "completed"
|
|
248
|
+
),
|
|
249
|
+
None,
|
|
250
|
+
)
|
|
251
|
+
if completed_key is None:
|
|
252
|
+
return
|
|
253
|
+
del self._records[completed_key]
|
|
254
|
+
self._evictions += 1
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class SqliteServiceIdempotencyCache(_IdempotencyMetrics):
|
|
258
|
+
_POLL_SECONDS = 0.05
|
|
259
|
+
|
|
260
|
+
def __init__(self, *, max_entries: int, database_path: str) -> None:
|
|
261
|
+
if max_entries < 0:
|
|
262
|
+
raise ValueError("max_entries must be non-negative")
|
|
263
|
+
if not database_path:
|
|
264
|
+
raise ValueError("database_path is required")
|
|
265
|
+
super().__init__()
|
|
266
|
+
self._max_entries = max_entries
|
|
267
|
+
self._database_path = Path(database_path)
|
|
268
|
+
self._lock = Lock()
|
|
269
|
+
self._initialize_database()
|
|
270
|
+
|
|
271
|
+
def acquire(
|
|
272
|
+
self,
|
|
273
|
+
key: str,
|
|
274
|
+
body: bytes,
|
|
275
|
+
*,
|
|
276
|
+
lease_seconds: float,
|
|
277
|
+
wait_timeout_seconds: float,
|
|
278
|
+
) -> IdempotencyAcquireResult:
|
|
279
|
+
_validate_acquire_timeouts(lease_seconds, wait_timeout_seconds)
|
|
280
|
+
if self._max_entries == 0:
|
|
281
|
+
return "disabled", None, ""
|
|
282
|
+
fingerprint = _request_body_fingerprint(body)
|
|
283
|
+
claim_token = str(uuid4())
|
|
284
|
+
lease_ns = max(1, int(lease_seconds * 1_000_000_000))
|
|
285
|
+
deadline = time.monotonic() + wait_timeout_seconds
|
|
286
|
+
recorded_wait = False
|
|
287
|
+
while True:
|
|
288
|
+
status, response, wait_seconds = self._try_acquire(
|
|
289
|
+
key,
|
|
290
|
+
fingerprint,
|
|
291
|
+
claim_token,
|
|
292
|
+
lease_ns,
|
|
293
|
+
)
|
|
294
|
+
if status != "waiting":
|
|
295
|
+
return status, response, claim_token if status == "claimed" else ""
|
|
296
|
+
remaining = deadline - time.monotonic()
|
|
297
|
+
if remaining <= 0:
|
|
298
|
+
with self._lock:
|
|
299
|
+
self._wait_timeouts += 1
|
|
300
|
+
return "in_progress", None, ""
|
|
301
|
+
if not recorded_wait:
|
|
302
|
+
with self._lock:
|
|
303
|
+
self._waits += 1
|
|
304
|
+
recorded_wait = True
|
|
305
|
+
time.sleep(min(self._POLL_SECONDS, remaining, max(0.001, wait_seconds)))
|
|
306
|
+
|
|
307
|
+
def complete(
|
|
308
|
+
self,
|
|
309
|
+
key: str,
|
|
310
|
+
body: bytes,
|
|
311
|
+
claim_token: str,
|
|
312
|
+
status_code: int,
|
|
313
|
+
payload: Any,
|
|
314
|
+
) -> bool:
|
|
315
|
+
payload_json = json.dumps(json_ready(payload), sort_keys=True)
|
|
316
|
+
fingerprint = _request_body_fingerprint(body)
|
|
317
|
+
with self._lock:
|
|
318
|
+
with self._connect() as connection:
|
|
319
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
320
|
+
cursor = connection.execute(
|
|
321
|
+
(
|
|
322
|
+
"UPDATE idempotency_cache SET state = 'completed', "
|
|
323
|
+
"status_code = ?, payload_json = ?, owner_token = '', "
|
|
324
|
+
"lease_expires_ns = 0, updated_at_ns = ? "
|
|
325
|
+
"WHERE cache_key = ? AND fingerprint = ? "
|
|
326
|
+
"AND state = 'pending' AND owner_token = ?"
|
|
327
|
+
),
|
|
328
|
+
(
|
|
329
|
+
int(status_code),
|
|
330
|
+
payload_json,
|
|
331
|
+
time.time_ns(),
|
|
332
|
+
key,
|
|
333
|
+
fingerprint,
|
|
334
|
+
claim_token,
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
if cursor.rowcount != 1:
|
|
338
|
+
return False
|
|
339
|
+
self._stores += 1
|
|
340
|
+
self._evict_completed_over_capacity(connection)
|
|
341
|
+
return True
|
|
342
|
+
|
|
343
|
+
def release(self, key: str, claim_token: str) -> bool:
|
|
344
|
+
with self._lock:
|
|
345
|
+
with self._connect() as connection:
|
|
346
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
347
|
+
cursor = connection.execute(
|
|
348
|
+
(
|
|
349
|
+
"DELETE FROM idempotency_cache WHERE cache_key = ? "
|
|
350
|
+
"AND state = 'pending' AND owner_token = ?"
|
|
351
|
+
),
|
|
352
|
+
(key, claim_token),
|
|
353
|
+
)
|
|
354
|
+
return cursor.rowcount == 1
|
|
355
|
+
|
|
356
|
+
def lookup(self, key: str, body: bytes) -> Tuple[str, Optional[IdempotencyResponse]]:
|
|
357
|
+
if self._max_entries == 0:
|
|
358
|
+
return "disabled", None
|
|
359
|
+
fingerprint = _request_body_fingerprint(body)
|
|
360
|
+
with self._lock:
|
|
361
|
+
with self._connect() as connection:
|
|
362
|
+
row = connection.execute(
|
|
363
|
+
(
|
|
364
|
+
"SELECT fingerprint, state, status_code, payload_json "
|
|
365
|
+
"FROM idempotency_cache WHERE cache_key = ?"
|
|
366
|
+
),
|
|
367
|
+
(key,),
|
|
368
|
+
).fetchone()
|
|
369
|
+
if row is None:
|
|
370
|
+
self._misses += 1
|
|
371
|
+
return "miss", None
|
|
372
|
+
cached_fingerprint, state, status_code, payload_json = row
|
|
373
|
+
if cached_fingerprint != fingerprint:
|
|
374
|
+
self._conflicts += 1
|
|
375
|
+
return "conflict", None
|
|
376
|
+
if state == "pending":
|
|
377
|
+
return "in_progress", None
|
|
378
|
+
if state != "completed":
|
|
379
|
+
raise ValueError("idempotency cache record has invalid state")
|
|
380
|
+
connection.execute(
|
|
381
|
+
"UPDATE idempotency_cache SET updated_at_ns = ? WHERE cache_key = ?",
|
|
382
|
+
(time.time_ns(), key),
|
|
383
|
+
)
|
|
384
|
+
self._hits += 1
|
|
385
|
+
return "hit", (int(status_code), json.loads(str(payload_json)))
|
|
386
|
+
|
|
387
|
+
def store(self, key: str, body: bytes, status_code: int, payload: Any) -> None:
|
|
388
|
+
if self._max_entries == 0:
|
|
389
|
+
return
|
|
390
|
+
payload_json = json.dumps(json_ready(payload), sort_keys=True)
|
|
391
|
+
with self._lock:
|
|
392
|
+
with self._connect() as connection:
|
|
393
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
394
|
+
connection.execute(
|
|
395
|
+
(
|
|
396
|
+
"INSERT INTO idempotency_cache "
|
|
397
|
+
"(cache_key, fingerprint, state, owner_token, lease_expires_ns, "
|
|
398
|
+
"status_code, payload_json, updated_at_ns) "
|
|
399
|
+
"VALUES (?, ?, 'completed', '', 0, ?, ?, ?) "
|
|
400
|
+
"ON CONFLICT(cache_key) DO UPDATE SET "
|
|
401
|
+
"fingerprint = excluded.fingerprint, state = 'completed', "
|
|
402
|
+
"owner_token = '', lease_expires_ns = 0, "
|
|
403
|
+
"status_code = excluded.status_code, "
|
|
404
|
+
"payload_json = excluded.payload_json, "
|
|
405
|
+
"updated_at_ns = excluded.updated_at_ns"
|
|
406
|
+
),
|
|
407
|
+
(
|
|
408
|
+
key,
|
|
409
|
+
_request_body_fingerprint(body),
|
|
410
|
+
int(status_code),
|
|
411
|
+
payload_json,
|
|
412
|
+
time.time_ns(),
|
|
413
|
+
),
|
|
414
|
+
)
|
|
415
|
+
self._stores += 1
|
|
416
|
+
self._evict_completed_over_capacity(connection)
|
|
417
|
+
|
|
418
|
+
def snapshot(self) -> Dict[str, str]:
|
|
419
|
+
with self._lock:
|
|
420
|
+
with self._connect() as connection:
|
|
421
|
+
row = connection.execute(
|
|
422
|
+
"SELECT COUNT(*) FROM idempotency_cache",
|
|
423
|
+
).fetchone()
|
|
424
|
+
entry_count = int(row[0]) if row is not None else 0
|
|
425
|
+
return self._metrics_snapshot(
|
|
426
|
+
entry_count=entry_count,
|
|
427
|
+
max_entries=self._max_entries,
|
|
428
|
+
backend="sqlite",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
def _try_acquire(
|
|
432
|
+
self,
|
|
433
|
+
key: str,
|
|
434
|
+
fingerprint: str,
|
|
435
|
+
claim_token: str,
|
|
436
|
+
lease_ns: int,
|
|
437
|
+
) -> Tuple[str, Optional[IdempotencyResponse], float]:
|
|
438
|
+
now_ns = time.time_ns()
|
|
439
|
+
with self._lock:
|
|
440
|
+
with self._connect() as connection:
|
|
441
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
442
|
+
row = connection.execute(
|
|
443
|
+
(
|
|
444
|
+
"SELECT fingerprint, state, owner_token, lease_expires_ns, "
|
|
445
|
+
"status_code, payload_json FROM idempotency_cache "
|
|
446
|
+
"WHERE cache_key = ?"
|
|
447
|
+
),
|
|
448
|
+
(key,),
|
|
449
|
+
).fetchone()
|
|
450
|
+
if row is None:
|
|
451
|
+
connection.execute(
|
|
452
|
+
(
|
|
453
|
+
"INSERT INTO idempotency_cache "
|
|
454
|
+
"(cache_key, fingerprint, state, owner_token, lease_expires_ns, "
|
|
455
|
+
"status_code, payload_json, updated_at_ns) "
|
|
456
|
+
"VALUES (?, ?, 'pending', ?, ?, 0, 'null', ?)"
|
|
457
|
+
),
|
|
458
|
+
(key, fingerprint, claim_token, now_ns + lease_ns, now_ns),
|
|
459
|
+
)
|
|
460
|
+
self._misses += 1
|
|
461
|
+
self._claims += 1
|
|
462
|
+
self._evict_completed_over_capacity(connection)
|
|
463
|
+
return "claimed", None, 0.0
|
|
464
|
+
(
|
|
465
|
+
cached_fingerprint,
|
|
466
|
+
state,
|
|
467
|
+
_owner_token,
|
|
468
|
+
lease_expires_ns,
|
|
469
|
+
status_code,
|
|
470
|
+
payload_json,
|
|
471
|
+
) = row
|
|
472
|
+
if cached_fingerprint != fingerprint:
|
|
473
|
+
self._conflicts += 1
|
|
474
|
+
return "conflict", None, 0.0
|
|
475
|
+
if state == "completed":
|
|
476
|
+
connection.execute(
|
|
477
|
+
"UPDATE idempotency_cache SET updated_at_ns = ? WHERE cache_key = ?",
|
|
478
|
+
(now_ns, key),
|
|
479
|
+
)
|
|
480
|
+
self._hits += 1
|
|
481
|
+
return (
|
|
482
|
+
"hit",
|
|
483
|
+
(int(status_code), json.loads(str(payload_json))),
|
|
484
|
+
0.0,
|
|
485
|
+
)
|
|
486
|
+
if state != "pending":
|
|
487
|
+
raise ValueError("idempotency cache record has invalid state")
|
|
488
|
+
if int(lease_expires_ns) <= now_ns:
|
|
489
|
+
connection.execute(
|
|
490
|
+
(
|
|
491
|
+
"UPDATE idempotency_cache SET owner_token = ?, "
|
|
492
|
+
"lease_expires_ns = ?, updated_at_ns = ? WHERE cache_key = ?"
|
|
493
|
+
),
|
|
494
|
+
(claim_token, now_ns + lease_ns, now_ns, key),
|
|
495
|
+
)
|
|
496
|
+
self._claims += 1
|
|
497
|
+
self._takeovers += 1
|
|
498
|
+
return "claimed", None, 0.0
|
|
499
|
+
wait_seconds = max(0.001, (int(lease_expires_ns) - now_ns) / 1_000_000_000)
|
|
500
|
+
return "waiting", None, wait_seconds
|
|
501
|
+
|
|
502
|
+
def _initialize_database(self) -> None:
|
|
503
|
+
self._database_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
504
|
+
with self._connect() as connection:
|
|
505
|
+
connection.execute("PRAGMA journal_mode=WAL")
|
|
506
|
+
connection.execute("PRAGMA busy_timeout=5000")
|
|
507
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
508
|
+
connection.execute(
|
|
509
|
+
(
|
|
510
|
+
"CREATE TABLE IF NOT EXISTS idempotency_cache ("
|
|
511
|
+
"cache_key TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, "
|
|
512
|
+
"state TEXT NOT NULL DEFAULT 'completed', "
|
|
513
|
+
"owner_token TEXT NOT NULL DEFAULT '', "
|
|
514
|
+
"lease_expires_ns INTEGER NOT NULL DEFAULT 0, "
|
|
515
|
+
"status_code INTEGER NOT NULL DEFAULT 0, "
|
|
516
|
+
"payload_json TEXT NOT NULL DEFAULT 'null', "
|
|
517
|
+
"updated_at_ns INTEGER NOT NULL)"
|
|
518
|
+
)
|
|
519
|
+
)
|
|
520
|
+
columns = {
|
|
521
|
+
str(row[1])
|
|
522
|
+
for row in connection.execute("PRAGMA table_info(idempotency_cache)")
|
|
523
|
+
}
|
|
524
|
+
migrations = {
|
|
525
|
+
"state": (
|
|
526
|
+
"ALTER TABLE idempotency_cache ADD COLUMN state TEXT NOT NULL "
|
|
527
|
+
"DEFAULT 'completed'"
|
|
528
|
+
),
|
|
529
|
+
"owner_token": (
|
|
530
|
+
"ALTER TABLE idempotency_cache ADD COLUMN owner_token TEXT NOT NULL "
|
|
531
|
+
"DEFAULT ''"
|
|
532
|
+
),
|
|
533
|
+
"lease_expires_ns": (
|
|
534
|
+
"ALTER TABLE idempotency_cache ADD COLUMN lease_expires_ns "
|
|
535
|
+
"INTEGER NOT NULL DEFAULT 0"
|
|
536
|
+
),
|
|
537
|
+
}
|
|
538
|
+
for column, statement in migrations.items():
|
|
539
|
+
if column not in columns:
|
|
540
|
+
connection.execute(statement)
|
|
541
|
+
connection.execute(
|
|
542
|
+
(
|
|
543
|
+
"CREATE INDEX IF NOT EXISTS idx_idempotency_cache_updated_at "
|
|
544
|
+
"ON idempotency_cache(updated_at_ns, cache_key)"
|
|
545
|
+
)
|
|
546
|
+
)
|
|
547
|
+
self._database_path.chmod(0o600)
|
|
548
|
+
|
|
549
|
+
def _connect(self) -> sqlite3.Connection:
|
|
550
|
+
return sqlite3.connect(str(self._database_path), timeout=5.0)
|
|
551
|
+
|
|
552
|
+
def _evict_completed_over_capacity(self, connection: sqlite3.Connection) -> None:
|
|
553
|
+
row = connection.execute("SELECT COUNT(*) FROM idempotency_cache").fetchone()
|
|
554
|
+
entry_count = int(row[0]) if row is not None else 0
|
|
555
|
+
overflow = max(0, entry_count - self._max_entries)
|
|
556
|
+
if overflow == 0:
|
|
557
|
+
return
|
|
558
|
+
rows = connection.execute(
|
|
559
|
+
(
|
|
560
|
+
"SELECT cache_key FROM idempotency_cache WHERE state = 'completed' "
|
|
561
|
+
"ORDER BY updated_at_ns ASC, cache_key ASC LIMIT ?"
|
|
562
|
+
),
|
|
563
|
+
(overflow,),
|
|
564
|
+
).fetchall()
|
|
565
|
+
for (cache_key,) in rows:
|
|
566
|
+
connection.execute(
|
|
567
|
+
"DELETE FROM idempotency_cache WHERE cache_key = ?",
|
|
568
|
+
(cache_key,),
|
|
569
|
+
)
|
|
570
|
+
self._evictions += 1
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _validate_acquire_timeouts(
|
|
574
|
+
lease_seconds: float,
|
|
575
|
+
wait_timeout_seconds: float,
|
|
576
|
+
) -> None:
|
|
577
|
+
if lease_seconds <= 0:
|
|
578
|
+
raise ValueError("lease_seconds must be positive")
|
|
579
|
+
if wait_timeout_seconds < 0:
|
|
580
|
+
raise ValueError("wait_timeout_seconds must be non-negative")
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _request_body_fingerprint(body: bytes) -> str:
|
|
584
|
+
return sha256(body).hexdigest()
|