@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,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
|
+
]
|