@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.
Files changed (117) hide show
  1. package/README.md +353 -0
  2. package/npm/bin/kagent-serve.js +6 -0
  3. package/npm/bin/kagent.js +6 -0
  4. package/npm/lib/App.js +524 -0
  5. package/npm/lib/app-state.js +224 -0
  6. package/npm/lib/approval-choice.js +25 -0
  7. package/npm/lib/commands.js +59 -0
  8. package/npm/lib/editor.js +188 -0
  9. package/npm/lib/ink-runner.js +41 -0
  10. package/npm/lib/kagent-home.js +39 -0
  11. package/npm/lib/launcher.js +221 -0
  12. package/npm/lib/protocol.js +33 -0
  13. package/npm/lib/provider-setup.js +139 -0
  14. package/npm/lib/python-runner.js +892 -0
  15. package/npm/lib/runtime-client.js +390 -0
  16. package/npm/lib/terminal-input.js +127 -0
  17. package/npm/lib/terminal-text.js +19 -0
  18. package/npm/lib/terminal-width.js +14 -0
  19. package/npm/lib/transcript.js +227 -0
  20. package/npm/lib/ui-components.js +247 -0
  21. package/npm/lib/update-manager.js +334 -0
  22. package/package.json +39 -0
  23. package/pyproject.toml +55 -0
  24. package/src/kagent/__init__.py +90 -0
  25. package/src/kagent/cli/__init__.py +5 -0
  26. package/src/kagent/cli/__main__.py +6 -0
  27. package/src/kagent/cli/commands.py +112 -0
  28. package/src/kagent/cli/conversation.py +127 -0
  29. package/src/kagent/cli/interactive.py +841 -0
  30. package/src/kagent/cli/main.py +685 -0
  31. package/src/kagent/cli/memory.py +460 -0
  32. package/src/kagent/cli/pending_approval.py +169 -0
  33. package/src/kagent/cli/provider.py +27 -0
  34. package/src/kagent/cli/session_commands.py +401 -0
  35. package/src/kagent/cli/stdio_runtime.py +784 -0
  36. package/src/kagent/cli/trace.py +67 -0
  37. package/src/kagent/cli/ui.py +931 -0
  38. package/src/kagent/core/__init__.py +0 -0
  39. package/src/kagent/core/agent.py +296 -0
  40. package/src/kagent/core/faults.py +11 -0
  41. package/src/kagent/core/invariants.py +47 -0
  42. package/src/kagent/core/normalization.py +81 -0
  43. package/src/kagent/core/planning.py +48 -0
  44. package/src/kagent/core/state.py +73 -0
  45. package/src/kagent/core/summary.py +64 -0
  46. package/src/kagent/core/tools.py +196 -0
  47. package/src/kagent/core/trace.py +57 -0
  48. package/src/kagent/eval/__init__.py +11 -0
  49. package/src/kagent/eval/cases.py +229 -0
  50. package/src/kagent/eval/evaluator.py +216 -0
  51. package/src/kagent/integrations/__init__.py +3 -0
  52. package/src/kagent/integrations/audit.py +95 -0
  53. package/src/kagent/integrations/backends.py +131 -0
  54. package/src/kagent/integrations/memory.py +301 -0
  55. package/src/kagent/ops/__init__.py +0 -0
  56. package/src/kagent/ops/batch.py +156 -0
  57. package/src/kagent/ops/doctor.py +255 -0
  58. package/src/kagent/ops/metrics.py +214 -0
  59. package/src/kagent/ops/release_evidence.py +877 -0
  60. package/src/kagent/ops/release_manifest.py +142 -0
  61. package/src/kagent/ops/trace_replay.py +285 -0
  62. package/src/kagent/providers/__init__.py +7 -0
  63. package/src/kagent/providers/embeddings.py +187 -0
  64. package/src/kagent/providers/llm.py +770 -0
  65. package/src/kagent/py.typed +1 -0
  66. package/src/kagent/runtime/__init__.py +28 -0
  67. package/src/kagent/runtime/action_graph.py +543 -0
  68. package/src/kagent/runtime/agent.py +2089 -0
  69. package/src/kagent/runtime/approval.py +64 -0
  70. package/src/kagent/runtime/cancellation.py +64 -0
  71. package/src/kagent/runtime/checkpoint_state.py +146 -0
  72. package/src/kagent/runtime/checkpoint_storage.py +270 -0
  73. package/src/kagent/runtime/context.py +65 -0
  74. package/src/kagent/runtime/file_transaction.py +195 -0
  75. package/src/kagent/runtime/hooks.py +74 -0
  76. package/src/kagent/runtime/metadata.py +116 -0
  77. package/src/kagent/runtime/patch_checkpoints.py +385 -0
  78. package/src/kagent/runtime/policy.py +50 -0
  79. package/src/kagent/runtime/presentation.py +205 -0
  80. package/src/kagent/runtime/redaction.py +130 -0
  81. package/src/kagent/runtime/sandbox.py +331 -0
  82. package/src/kagent/runtime/skills.py +66 -0
  83. package/src/kagent/runtime/steering.py +56 -0
  84. package/src/kagent/runtime/steps.py +255 -0
  85. package/src/kagent/runtime/task_state.py +40 -0
  86. package/src/kagent/runtime/tools.py +3532 -0
  87. package/src/kagent/runtime/types.py +240 -0
  88. package/src/kagent/runtime/workspace.py +597 -0
  89. package/src/kagent/service/__init__.py +26 -0
  90. package/src/kagent/service/__main__.py +6 -0
  91. package/src/kagent/service/active_runs.py +178 -0
  92. package/src/kagent/service/cli.py +737 -0
  93. package/src/kagent/service/contract.py +2571 -0
  94. package/src/kagent/service/errors.py +71 -0
  95. package/src/kagent/service/idempotency.py +584 -0
  96. package/src/kagent/service/router.py +884 -0
  97. package/src/kagent/service/run.py +150 -0
  98. package/src/kagent/service/runtime.py +1915 -0
  99. package/src/kagent/service/runtime_approval.py +20 -0
  100. package/src/kagent/service/runtime_cancel.py +192 -0
  101. package/src/kagent/service/runtime_lifecycle.py +229 -0
  102. package/src/kagent/service/runtime_metadata.py +21 -0
  103. package/src/kagent/service/runtime_policy.py +115 -0
  104. package/src/kagent/service/runtime_recovery.py +573 -0
  105. package/src/kagent/service/runtime_resume.py +382 -0
  106. package/src/kagent/service/runtime_resume_claim.py +116 -0
  107. package/src/kagent/service/runtime_run.py +350 -0
  108. package/src/kagent/service/runtime_status.py +2007 -0
  109. package/src/kagent/service/safety.py +139 -0
  110. package/src/kagent/service/server.py +114 -0
  111. package/src/kagent/service/status.py +233 -0
  112. package/src/kagent/service/trace_store.py +322 -0
  113. package/src/kagent/service/transport.py +24 -0
  114. package/src/kagent/utils/__init__.py +0 -0
  115. package/src/kagent/utils/config_validation.py +21 -0
  116. package/src/kagent/utils/json_output.py +23 -0
  117. 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()