@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,597 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ import fcntl
5
+ import hashlib
6
+ import os
7
+ import tempfile
8
+ from contextlib import contextmanager
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path, PurePath
11
+ from typing import Any, Dict
12
+
13
+ VIRTUAL_WORKSPACE_KINDS = ("workspace", "reports", "logs", "policies", "memories")
14
+ _VERSION_DIRECTORY_NAME = ".versions"
15
+ _OWNER_ONLY_DIRECTORY_MODE = 0o700
16
+ _OWNER_ONLY_FILE_MODE = 0o600
17
+ _DEFAULT_MAX_READ_BYTES = 65536
18
+ _DEFAULT_MAX_LIST_DEPTH = 5
19
+ _DEFAULT_LIST_LIMIT = 500
20
+
21
+
22
+ class RuntimeWorkspace:
23
+ def __init__(self, root: str | Path) -> None:
24
+ self.root = Path(root)
25
+
26
+ def ensure_layout(self) -> Dict[str, Any]:
27
+ root = self.root
28
+ if root.is_symlink():
29
+ raise ValueError("workspace root must not be a symlink")
30
+ root.mkdir(parents=True, exist_ok=True)
31
+ root.chmod(_OWNER_ONLY_DIRECTORY_MODE)
32
+ versions = root / _VERSION_DIRECTORY_NAME
33
+ if versions.is_symlink():
34
+ raise ValueError("workspace versions path must not contain symlinks")
35
+ versions.mkdir(parents=True, exist_ok=True)
36
+ versions.chmod(_OWNER_ONLY_DIRECTORY_MODE)
37
+ for kind in VIRTUAL_WORKSPACE_KINDS:
38
+ directory = root / kind
39
+ if directory.is_symlink():
40
+ raise ValueError("workspace kind path must not contain symlinks")
41
+ directory.mkdir(parents=True, exist_ok=True)
42
+ directory.chmod(_OWNER_ONLY_DIRECTORY_MODE)
43
+ version_directory = versions / kind
44
+ if version_directory.is_symlink():
45
+ raise ValueError("workspace versions path must not contain symlinks")
46
+ version_directory.mkdir(parents=True, exist_ok=True)
47
+ version_directory.chmod(_OWNER_ONLY_DIRECTORY_MODE)
48
+ return {
49
+ "root": str(root),
50
+ "kinds": list(VIRTUAL_WORKSPACE_KINDS),
51
+ "directory_permissions": "0700",
52
+ "file_permissions": "0600",
53
+ }
54
+
55
+ def resolve(self, kind: str, relative_path: str | Path = ".") -> Path:
56
+ base = self._kind_directory(kind)
57
+ relative_parts = _safe_relative_parts(relative_path)
58
+ target = base.joinpath(*relative_parts) if relative_parts else base
59
+ _reject_symlink_traversal(base, target)
60
+ try:
61
+ target.resolve(strict=False).relative_to(base.resolve(strict=True))
62
+ except ValueError as exc:
63
+ raise ValueError("path must stay inside the virtual directory") from exc
64
+ return target
65
+
66
+ def write_text(
67
+ self,
68
+ kind: str,
69
+ relative_path: str | Path,
70
+ content: str,
71
+ *,
72
+ metadata: Dict[str, Any] | None = None,
73
+ ) -> Dict[str, Any]:
74
+ if not isinstance(content, str):
75
+ raise ValueError("content must be a string")
76
+ self.ensure_layout()
77
+ target = self.resolve(kind, relative_path)
78
+ if target.exists() and target.is_dir():
79
+ raise ValueError("path is a directory")
80
+ target.parent.mkdir(parents=True, exist_ok=True)
81
+ _chmod_created_directories(self._kind_directory(kind), target.parent)
82
+ encoded = content.encode("utf-8")
83
+ with self._asset_lock(kind, target):
84
+ _reject_symlink_traversal(self._kind_directory(kind), target)
85
+ if target.exists():
86
+ self._record_revision(kind, target)
87
+ _write_owner_only_text_file(target, content)
88
+ return _asset_metadata(
89
+ root=self._kind_directory(kind),
90
+ path=target,
91
+ kind=kind,
92
+ content_bytes=encoded,
93
+ metadata=metadata or {},
94
+ )
95
+
96
+ def read_text(
97
+ self,
98
+ kind: str,
99
+ relative_path: str | Path,
100
+ *,
101
+ max_bytes: int = _DEFAULT_MAX_READ_BYTES,
102
+ ) -> Dict[str, Any]:
103
+ if max_bytes < 1:
104
+ raise ValueError("max_bytes must be positive")
105
+ self.ensure_layout()
106
+ target = self.resolve(kind, relative_path)
107
+ if not target.exists():
108
+ raise ValueError("file does not exist")
109
+ if target.is_dir():
110
+ raise ValueError("path is a directory")
111
+ body = target.read_bytes()
112
+ visible = body[:max_bytes]
113
+ return {
114
+ **_asset_metadata(
115
+ root=self._kind_directory(kind),
116
+ path=target,
117
+ kind=kind,
118
+ content_bytes=body,
119
+ metadata={},
120
+ ),
121
+ "content": visible.decode("utf-8", errors="replace"),
122
+ "bytes": len(visible),
123
+ "truncated": len(body) > max_bytes,
124
+ }
125
+
126
+ def list(
127
+ self,
128
+ kind: str,
129
+ relative_path: str | Path = ".",
130
+ *,
131
+ max_depth: int = _DEFAULT_MAX_LIST_DEPTH,
132
+ limit: int = _DEFAULT_LIST_LIMIT,
133
+ ) -> Dict[str, Any]:
134
+ if max_depth < 0:
135
+ raise ValueError("max_depth must be non-negative")
136
+ if limit < 1:
137
+ raise ValueError("limit must be positive")
138
+ self.ensure_layout()
139
+ root = self.resolve(kind, relative_path)
140
+ if not root.exists():
141
+ raise ValueError("path does not exist")
142
+ entries = []
143
+ truncated = False
144
+ for path in _iter_entries(root, max_depth=max_depth):
145
+ if len(entries) >= limit:
146
+ truncated = True
147
+ break
148
+ if path.is_symlink():
149
+ continue
150
+ entries.append(_list_entry(self._kind_directory(kind), path))
151
+ return {
152
+ "kind": kind,
153
+ "root": _relative_asset_path(self._kind_directory(kind), root)
154
+ if root != self._kind_directory(kind)
155
+ else kind,
156
+ "entries": entries,
157
+ "file_count": len(entries),
158
+ "truncated": truncated,
159
+ }
160
+
161
+ def history(
162
+ self,
163
+ kind: str,
164
+ relative_path: str | Path,
165
+ *,
166
+ limit: int = 20,
167
+ max_bytes: int = _DEFAULT_MAX_READ_BYTES,
168
+ ) -> Dict[str, Any]:
169
+ if limit < 1:
170
+ raise ValueError("limit must be positive")
171
+ if max_bytes < 1:
172
+ raise ValueError("max_bytes must be positive")
173
+ self.ensure_layout()
174
+ target = self.resolve(kind, relative_path)
175
+ revision_directory = self._revision_directory(kind, target)
176
+ revisions = []
177
+ truncated = False
178
+ if revision_directory.exists():
179
+ files = sorted(
180
+ (
181
+ path
182
+ for path in revision_directory.iterdir()
183
+ if path.is_file() and not path.is_symlink()
184
+ ),
185
+ key=lambda item: item.name,
186
+ )
187
+ for path in files:
188
+ if len(revisions) >= limit:
189
+ truncated = True
190
+ break
191
+ body = path.read_bytes()
192
+ visible = body[:max_bytes]
193
+ stat_result = path.stat()
194
+ revisions.append(
195
+ {
196
+ "revision_id": path.stem,
197
+ "bytes": len(body),
198
+ "sha256": hashlib.sha256(body).hexdigest(),
199
+ "created_at": _timestamp(stat_result.st_mtime),
200
+ "content": visible.decode("utf-8", errors="replace"),
201
+ "content_truncated": len(body) > max_bytes,
202
+ }
203
+ )
204
+ return {
205
+ "kind": kind,
206
+ "path": _relative_asset_path(self._kind_directory(kind), target),
207
+ "revisions": revisions,
208
+ "revision_count": len(revisions),
209
+ "truncated": truncated,
210
+ }
211
+
212
+ def diff(
213
+ self,
214
+ kind: str,
215
+ relative_path: str | Path,
216
+ *,
217
+ revision_id: str = "",
218
+ context_lines: int = 3,
219
+ max_bytes: int = _DEFAULT_MAX_READ_BYTES,
220
+ ) -> Dict[str, Any]:
221
+ if context_lines < 0:
222
+ raise ValueError("context_lines must be non-negative")
223
+ if max_bytes < 1:
224
+ raise ValueError("max_bytes must be positive")
225
+ self.ensure_layout()
226
+ target = self.resolve(kind, relative_path)
227
+ if not target.exists():
228
+ raise ValueError("file does not exist")
229
+ if target.is_dir():
230
+ raise ValueError("path is a directory")
231
+ revision = self._select_revision(kind, target, revision_id=revision_id)
232
+ revision_body = revision.read_bytes()
233
+ current_body = target.read_bytes()
234
+ resolved_revision_id = revision.stem
235
+ relative_asset_path = _relative_asset_path(self._kind_directory(kind), target)
236
+ diff_text = _unified_diff(
237
+ revision_body.decode("utf-8", errors="replace"),
238
+ current_body.decode("utf-8", errors="replace"),
239
+ fromfile=f"{kind}/{relative_asset_path}@{resolved_revision_id}",
240
+ tofile=f"{kind}/{relative_asset_path}",
241
+ context_lines=context_lines,
242
+ )
243
+ encoded = diff_text.encode("utf-8")
244
+ visible = encoded[:max_bytes]
245
+ return {
246
+ "kind": kind,
247
+ "path": relative_asset_path,
248
+ "revision_id": resolved_revision_id,
249
+ "from_sha256": hashlib.sha256(revision_body).hexdigest(),
250
+ "to_sha256": hashlib.sha256(current_body).hexdigest(),
251
+ "diff": visible.decode("utf-8", errors="replace"),
252
+ "bytes": len(visible),
253
+ "truncated": len(encoded) > max_bytes,
254
+ }
255
+
256
+ def restore(
257
+ self,
258
+ kind: str,
259
+ relative_path: str | Path,
260
+ *,
261
+ revision_id: str,
262
+ expected_current_sha256: str,
263
+ expected_revision_sha256: str,
264
+ ) -> Dict[str, Any]:
265
+ if not isinstance(revision_id, str) or not revision_id:
266
+ raise ValueError("revision does not exist")
267
+ if not isinstance(expected_current_sha256, str) or (
268
+ len(expected_current_sha256) != 64
269
+ or any(
270
+ character not in "0123456789abcdefABCDEF"
271
+ for character in expected_current_sha256
272
+ )
273
+ ):
274
+ raise ValueError("expected_current_sha256 must be 64 hexadecimal characters")
275
+ if not isinstance(expected_revision_sha256, str) or (
276
+ len(expected_revision_sha256) != 64
277
+ or any(
278
+ character not in "0123456789abcdefABCDEF"
279
+ for character in expected_revision_sha256
280
+ )
281
+ ):
282
+ raise ValueError("expected_revision_sha256 must be 64 hexadecimal characters")
283
+ self.ensure_layout()
284
+ target = self.resolve(kind, relative_path)
285
+ if not target.exists():
286
+ raise ValueError("file does not exist")
287
+ if not target.is_file():
288
+ raise ValueError("path is not a regular file")
289
+ with self._asset_lock(kind, target):
290
+ _reject_symlink_traversal(self._kind_directory(kind), target)
291
+ current_body = target.read_bytes()
292
+ try:
293
+ current_body.decode("utf-8")
294
+ except UnicodeDecodeError as exc:
295
+ raise ValueError("file must be UTF-8 encoded") from exc
296
+ current_sha256 = hashlib.sha256(current_body).hexdigest()
297
+ if expected_current_sha256.lower() != current_sha256:
298
+ raise ValueError(
299
+ "current SHA-256 does not match expected_current_sha256"
300
+ )
301
+
302
+ revision = self._select_revision(kind, target, revision_id=revision_id)
303
+ revision_body = revision.read_bytes()
304
+ try:
305
+ revision_content = revision_body.decode("utf-8")
306
+ except UnicodeDecodeError as exc:
307
+ raise ValueError("revision must be UTF-8 encoded") from exc
308
+ restored_sha256 = hashlib.sha256(revision_body).hexdigest()
309
+ if expected_revision_sha256.lower() != restored_sha256:
310
+ raise ValueError(
311
+ "revision SHA-256 does not match expected_revision_sha256"
312
+ )
313
+ if revision_body == current_body:
314
+ raise ValueError("revision matches current content")
315
+
316
+ self._record_revision(kind, target)
317
+ _write_owner_only_text_file(target, revision_content)
318
+ stat_result = target.stat()
319
+ return {
320
+ "kind": kind,
321
+ "path": _relative_asset_path(self._kind_directory(kind), target),
322
+ "restored_revision_id": revision.stem,
323
+ "previous_sha256": current_sha256,
324
+ "sha256": restored_sha256,
325
+ "bytes": len(revision_body),
326
+ "updated_at": _timestamp(stat_result.st_mtime),
327
+ }
328
+
329
+ def search(
330
+ self,
331
+ kind: str,
332
+ query: str,
333
+ relative_path: str | Path = ".",
334
+ *,
335
+ max_depth: int = _DEFAULT_MAX_LIST_DEPTH,
336
+ limit: int = 50,
337
+ max_bytes: int = _DEFAULT_MAX_READ_BYTES,
338
+ ) -> Dict[str, Any]:
339
+ normalized_query = str(query)
340
+ if not normalized_query:
341
+ raise ValueError("query must be non-empty")
342
+ if max_depth < 0:
343
+ raise ValueError("max_depth must be non-negative")
344
+ if limit < 1:
345
+ raise ValueError("limit must be positive")
346
+ if max_bytes < 1:
347
+ raise ValueError("max_bytes must be positive")
348
+ self.ensure_layout()
349
+ root = self.resolve(kind, relative_path)
350
+ if not root.exists():
351
+ raise ValueError("path does not exist")
352
+ matches = []
353
+ truncated = False
354
+ for path in _iter_entries(root, max_depth=max_depth):
355
+ if path.is_symlink() or not path.is_file():
356
+ continue
357
+ body = path.read_bytes()
358
+ visible = body[:max_bytes]
359
+ text = visible.decode("utf-8", errors="replace")
360
+ lines = text.splitlines()
361
+ for line_number, line in enumerate(lines, start=1):
362
+ column = line.find(normalized_query)
363
+ if column < 0:
364
+ continue
365
+ if len(matches) >= limit:
366
+ truncated = True
367
+ break
368
+ previous_text = "\n".join(lines[: line_number - 1])
369
+ if previous_text:
370
+ previous_text += "\n"
371
+ byte_offset = len((previous_text + line[:column]).encode("utf-8"))
372
+ matches.append(
373
+ {
374
+ "path": _relative_asset_path(self._kind_directory(kind), path),
375
+ "line_number": line_number,
376
+ "line": line,
377
+ "byte_offset": byte_offset,
378
+ "sha256": hashlib.sha256(body).hexdigest(),
379
+ }
380
+ )
381
+ if truncated:
382
+ break
383
+ return {
384
+ "kind": kind,
385
+ "root": _relative_asset_path(self._kind_directory(kind), root)
386
+ if root != self._kind_directory(kind)
387
+ else kind,
388
+ "query": normalized_query,
389
+ "matches": matches,
390
+ "match_count": len(matches),
391
+ "truncated": truncated,
392
+ }
393
+
394
+ def _kind_directory(self, kind: str) -> Path:
395
+ if kind not in VIRTUAL_WORKSPACE_KINDS:
396
+ raise ValueError("unknown virtual directory kind")
397
+ return self.root / kind
398
+
399
+ def _revision_directory(self, kind: str, target: Path) -> Path:
400
+ relative = target.relative_to(self._kind_directory(kind))
401
+ version_root = self.root / _VERSION_DIRECTORY_NAME / kind
402
+ if version_root.is_symlink():
403
+ raise ValueError("workspace versions path must not contain symlinks")
404
+ directory = version_root
405
+ for part in relative.parts:
406
+ directory = directory / part
407
+ if directory.exists() and directory.is_symlink():
408
+ raise ValueError("workspace versions path must not contain symlinks")
409
+ return directory
410
+
411
+ @contextmanager
412
+ def _asset_lock(self, kind: str, _target: Path):
413
+ lock_directory = self._kind_directory(kind)
414
+ if lock_directory.is_symlink():
415
+ raise ValueError("workspace kind path must not contain symlinks")
416
+ flags = os.O_RDONLY
417
+ if hasattr(os, "O_DIRECTORY"):
418
+ flags |= os.O_DIRECTORY
419
+ if hasattr(os, "O_NOFOLLOW"):
420
+ flags |= os.O_NOFOLLOW
421
+ descriptor = os.open(lock_directory, flags)
422
+ try:
423
+ fcntl.flock(descriptor, fcntl.LOCK_EX)
424
+ yield
425
+ finally:
426
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
427
+ os.close(descriptor)
428
+
429
+ def _select_revision(self, kind: str, target: Path, *, revision_id: str) -> Path:
430
+ revision_directory = self._revision_directory(kind, target)
431
+ if not revision_directory.exists():
432
+ raise ValueError("revision does not exist")
433
+ revisions = sorted(
434
+ path
435
+ for path in revision_directory.iterdir()
436
+ if path.is_file() and not path.is_symlink()
437
+ )
438
+ if not revisions:
439
+ raise ValueError("revision does not exist")
440
+ if revision_id:
441
+ for revision in revisions:
442
+ if revision.stem == revision_id:
443
+ return revision
444
+ raise ValueError("revision does not exist")
445
+ return revisions[-1]
446
+
447
+ def _record_revision(self, kind: str, target: Path) -> None:
448
+ if target.is_symlink():
449
+ raise ValueError("path must not traverse symlinks")
450
+ body = target.read_bytes()
451
+ revision_directory = self._revision_directory(kind, target)
452
+ revision_directory.mkdir(parents=True, exist_ok=True)
453
+ _chmod_created_directories(self.root / _VERSION_DIRECTORY_NAME / kind, revision_directory)
454
+ revision_id = (
455
+ datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ")
456
+ + "-"
457
+ + hashlib.sha256(body).hexdigest()[:12]
458
+ )
459
+ _write_owner_only_text_file(
460
+ revision_directory / f"{revision_id}.txt",
461
+ body.decode("utf-8", errors="replace"),
462
+ )
463
+
464
+
465
+ def _unified_diff(
466
+ previous: str,
467
+ current: str,
468
+ *,
469
+ fromfile: str,
470
+ tofile: str,
471
+ context_lines: int,
472
+ ) -> str:
473
+ lines = difflib.unified_diff(
474
+ previous.splitlines(),
475
+ current.splitlines(),
476
+ fromfile=fromfile,
477
+ tofile=tofile,
478
+ n=context_lines,
479
+ lineterm="",
480
+ )
481
+ body = "\n".join(lines)
482
+ if body:
483
+ return body + "\n"
484
+ return ""
485
+
486
+
487
+ def _safe_relative_parts(relative_path: str | Path) -> tuple[str, ...]:
488
+ raw_path = PurePath(relative_path)
489
+ if raw_path.is_absolute():
490
+ raise ValueError("path must be relative")
491
+ parts = tuple(part for part in raw_path.parts if part not in {"", "."})
492
+ if any(part == ".." for part in parts):
493
+ raise ValueError("path must stay inside the virtual directory")
494
+ return parts
495
+
496
+
497
+ def _reject_symlink_traversal(base: Path, target: Path) -> None:
498
+ current = base
499
+ if current.exists() and current.is_symlink():
500
+ raise ValueError("path must not traverse symlinks")
501
+ try:
502
+ relative_parts = target.relative_to(base).parts
503
+ except ValueError as exc:
504
+ raise ValueError("path must stay inside the virtual directory") from exc
505
+ for part in relative_parts:
506
+ current = current / part
507
+ if current.exists() and current.is_symlink():
508
+ raise ValueError("path must not traverse symlinks")
509
+
510
+
511
+ def _chmod_created_directories(base: Path, target_parent: Path) -> None:
512
+ current = base
513
+ for part in target_parent.relative_to(base).parts:
514
+ current = current / part
515
+ current.chmod(_OWNER_ONLY_DIRECTORY_MODE)
516
+
517
+
518
+ def _write_owner_only_text_file(target: Path, content: str) -> None:
519
+ fd, temporary_name = tempfile.mkstemp(
520
+ prefix=f".{target.name}.",
521
+ suffix=".tmp",
522
+ dir=target.parent,
523
+ text=True,
524
+ )
525
+ temporary_path = Path(temporary_name)
526
+ try:
527
+ os.chmod(temporary_path, _OWNER_ONLY_FILE_MODE)
528
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
529
+ fd = -1
530
+ handle.write(content)
531
+ handle.flush()
532
+ os.fsync(handle.fileno())
533
+ temporary_path.replace(target)
534
+ target.chmod(_OWNER_ONLY_FILE_MODE)
535
+ except Exception:
536
+ if fd != -1:
537
+ os.close(fd)
538
+ temporary_path.unlink(missing_ok=True)
539
+ raise
540
+
541
+
542
+ def _asset_metadata(
543
+ *,
544
+ root: Path,
545
+ path: Path,
546
+ kind: str,
547
+ content_bytes: bytes,
548
+ metadata: Dict[str, Any],
549
+ ) -> Dict[str, Any]:
550
+ stat_result = path.stat()
551
+ timestamp = _timestamp(stat_result.st_mtime)
552
+ return {
553
+ "kind": kind,
554
+ "path": _relative_asset_path(root, path),
555
+ "bytes": len(content_bytes),
556
+ "sha256": hashlib.sha256(content_bytes).hexdigest(),
557
+ "created_at": timestamp,
558
+ "updated_at": timestamp,
559
+ "metadata": metadata,
560
+ }
561
+
562
+
563
+ def _relative_asset_path(root: Path, path: Path) -> str:
564
+ relative = path.relative_to(root)
565
+ return "." if not relative.parts else relative.as_posix()
566
+
567
+
568
+ def _timestamp(value: float) -> str:
569
+ return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
570
+
571
+
572
+ def _iter_entries(root: Path, *, max_depth: int) -> list[Path]:
573
+ if root.is_file():
574
+ return [root]
575
+ entries = []
576
+ for path in sorted(root.rglob("*"), key=lambda item: item.relative_to(root).as_posix()):
577
+ if len(path.relative_to(root).parts) > max_depth:
578
+ continue
579
+ entries.append(path)
580
+ return entries
581
+
582
+
583
+ def _list_entry(root: Path, path: Path) -> Dict[str, Any]:
584
+ if path.is_dir():
585
+ return {
586
+ "path": _relative_asset_path(root, path),
587
+ "type": "directory",
588
+ "bytes": 0,
589
+ "sha256": "",
590
+ }
591
+ body = path.read_bytes()
592
+ return {
593
+ "path": _relative_asset_path(root, path),
594
+ "type": "file",
595
+ "bytes": len(body),
596
+ "sha256": hashlib.sha256(body).hexdigest(),
597
+ }
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from kagent.service.cli import create_server, main
4
+ from kagent.service.router import handle_request
5
+ from kagent.service.runtime import (
6
+ ServiceConcurrencyLimiter,
7
+ ServiceConfig,
8
+ ServiceIdempotencyCache,
9
+ ServiceMetrics,
10
+ ServiceRateLimiter,
11
+ access_log_record,
12
+ )
13
+ from kagent.service.status import readiness_payload
14
+
15
+ __all__ = [
16
+ "ServiceConcurrencyLimiter",
17
+ "ServiceConfig",
18
+ "ServiceIdempotencyCache",
19
+ "ServiceMetrics",
20
+ "ServiceRateLimiter",
21
+ "access_log_record",
22
+ "create_server",
23
+ "handle_request",
24
+ "main",
25
+ "readiness_payload",
26
+ ]
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from kagent.service.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()