@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,473 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import secrets
|
|
5
|
+
import stat
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Mapping, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
KAGENT_HOME_ENV_VAR = "KAGENT_HOME"
|
|
11
|
+
_MIGRATION_MARKER = ".migration-v1-complete"
|
|
12
|
+
_OWNER_DIRECTORY_MODE = 0o700
|
|
13
|
+
_OWNER_FILE_MODE = 0o600
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _absolute_user_path(value: str, env: Mapping[str, str]) -> Path:
|
|
17
|
+
if value == "~" or value.startswith("~/"):
|
|
18
|
+
home = env.get("HOME", "").strip()
|
|
19
|
+
if not home:
|
|
20
|
+
home = str(Path.home())
|
|
21
|
+
suffix = value[2:] if value != "~" else ""
|
|
22
|
+
path = Path(home) / suffix
|
|
23
|
+
else:
|
|
24
|
+
path = Path(value)
|
|
25
|
+
return Path(os.path.abspath(path))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def kagent_home(env: Optional[Mapping[str, str]] = None) -> Path:
|
|
29
|
+
environment = os.environ if env is None else env
|
|
30
|
+
if KAGENT_HOME_ENV_VAR in environment:
|
|
31
|
+
configured = environment[KAGENT_HOME_ENV_VAR]
|
|
32
|
+
if not configured.strip():
|
|
33
|
+
raise ValueError("KAGENT_HOME must not be empty")
|
|
34
|
+
if (
|
|
35
|
+
not (configured == "~" or configured.startswith("~/"))
|
|
36
|
+
and not Path(configured).is_absolute()
|
|
37
|
+
):
|
|
38
|
+
raise ValueError("KAGENT_HOME must be an absolute or tilde-prefixed path")
|
|
39
|
+
return _absolute_user_path(configured, environment)
|
|
40
|
+
|
|
41
|
+
home = environment.get("HOME", "").strip()
|
|
42
|
+
if not home:
|
|
43
|
+
home = str(Path.home())
|
|
44
|
+
return _absolute_user_path(home, environment) / ".kagent"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def kagent_config_dir(env: Optional[Mapping[str, str]] = None) -> Path:
|
|
48
|
+
return kagent_home(env) / "config"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def kagent_state_dir(env: Optional[Mapping[str, str]] = None) -> Path:
|
|
52
|
+
return kagent_home(env) / "state"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def kagent_cache_dir(env: Optional[Mapping[str, str]] = None) -> Path:
|
|
56
|
+
return kagent_home(env) / "cache"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _directory_open_flags() -> int:
|
|
60
|
+
return (
|
|
61
|
+
os.O_RDONLY
|
|
62
|
+
| getattr(os, "O_DIRECTORY", 0)
|
|
63
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
64
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _open_directory(path: Path, *, create: bool = False) -> int:
|
|
69
|
+
if not path.is_absolute():
|
|
70
|
+
raise ValueError(f"migration directory must be absolute: {path}")
|
|
71
|
+
flags = _directory_open_flags()
|
|
72
|
+
current_fd = os.open(path.anchor, flags)
|
|
73
|
+
try:
|
|
74
|
+
for part in path.parts[1:]:
|
|
75
|
+
try:
|
|
76
|
+
next_fd = os.open(part, flags, dir_fd=current_fd)
|
|
77
|
+
except FileNotFoundError:
|
|
78
|
+
if not create:
|
|
79
|
+
raise
|
|
80
|
+
try:
|
|
81
|
+
os.mkdir(part, _OWNER_DIRECTORY_MODE, dir_fd=current_fd)
|
|
82
|
+
except FileExistsError:
|
|
83
|
+
pass
|
|
84
|
+
try:
|
|
85
|
+
next_fd = os.open(part, flags, dir_fd=current_fd)
|
|
86
|
+
except OSError as exc:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"migration path must not contain symlinks or non-directories: {path}"
|
|
89
|
+
) from exc
|
|
90
|
+
except OSError as exc:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"migration path must not contain symlinks or non-directories: {path}"
|
|
93
|
+
) from exc
|
|
94
|
+
os.close(current_fd)
|
|
95
|
+
current_fd = next_fd
|
|
96
|
+
return current_fd
|
|
97
|
+
except BaseException:
|
|
98
|
+
os.close(current_fd)
|
|
99
|
+
raise
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _ensure_directory_fd_matches_path(path: Path, directory_fd: int) -> None:
|
|
103
|
+
try:
|
|
104
|
+
path_stat = os.stat(path, follow_symlinks=False)
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
raise ValueError(f"migration directory changed during operation: {path}") from exc
|
|
107
|
+
descriptor_stat = os.fstat(directory_fd)
|
|
108
|
+
if (
|
|
109
|
+
not stat.S_ISDIR(path_stat.st_mode)
|
|
110
|
+
or path_stat.st_dev != descriptor_stat.st_dev
|
|
111
|
+
or path_stat.st_ino != descriptor_stat.st_ino
|
|
112
|
+
):
|
|
113
|
+
raise ValueError(f"migration directory changed or became a symlink: {path}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _ensure_private_directory(path: Path) -> None:
|
|
117
|
+
directory_fd = _open_directory(path, create=True)
|
|
118
|
+
try:
|
|
119
|
+
os.fchmod(directory_fd, _OWNER_DIRECTORY_MODE)
|
|
120
|
+
_ensure_directory_fd_matches_path(path, directory_fd)
|
|
121
|
+
finally:
|
|
122
|
+
os.close(directory_fd)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _legacy_root(
|
|
126
|
+
environment: Mapping[str, str], variable: str, default_suffix: Tuple[str, ...]
|
|
127
|
+
) -> Path:
|
|
128
|
+
configured = environment.get(variable, "").strip()
|
|
129
|
+
if configured:
|
|
130
|
+
return _absolute_user_path(configured, environment) / "kagent"
|
|
131
|
+
home = environment.get("HOME", "").strip()
|
|
132
|
+
if not home:
|
|
133
|
+
raise ValueError(f"HOME must be set when {variable} is not configured")
|
|
134
|
+
return _absolute_user_path(home, environment).joinpath(*default_suffix, "kagent")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _path_kind(path: Path) -> Optional[str]:
|
|
138
|
+
try:
|
|
139
|
+
parent_fd = _open_directory(path.parent)
|
|
140
|
+
except FileNotFoundError:
|
|
141
|
+
return None
|
|
142
|
+
try:
|
|
143
|
+
try:
|
|
144
|
+
mode = os.stat(path.name, dir_fd=parent_fd, follow_symlinks=False).st_mode
|
|
145
|
+
except FileNotFoundError:
|
|
146
|
+
return None
|
|
147
|
+
_ensure_directory_fd_matches_path(path.parent, parent_fd)
|
|
148
|
+
finally:
|
|
149
|
+
os.close(parent_fd)
|
|
150
|
+
if stat.S_ISREG(mode):
|
|
151
|
+
return "file"
|
|
152
|
+
if stat.S_ISDIR(mode):
|
|
153
|
+
return "directory"
|
|
154
|
+
if stat.S_ISLNK(mode):
|
|
155
|
+
raise ValueError(f"migration path must not contain symlinks: {path}")
|
|
156
|
+
return "other"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _open_source_file(path: Path) -> int:
|
|
160
|
+
parent_fd = _open_directory(path.parent)
|
|
161
|
+
try:
|
|
162
|
+
flags = (
|
|
163
|
+
os.O_RDONLY
|
|
164
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
165
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
166
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
167
|
+
)
|
|
168
|
+
try:
|
|
169
|
+
source_fd = os.open(path.name, flags, dir_fd=parent_fd)
|
|
170
|
+
except OSError as exc:
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"migration source must be a regular file without symlinks: {path}"
|
|
173
|
+
) from exc
|
|
174
|
+
try:
|
|
175
|
+
if not stat.S_ISREG(os.fstat(source_fd).st_mode):
|
|
176
|
+
raise ValueError(f"migration source must be a regular file: {path}")
|
|
177
|
+
_ensure_directory_fd_matches_path(path.parent, parent_fd)
|
|
178
|
+
return source_fd
|
|
179
|
+
except BaseException:
|
|
180
|
+
os.close(source_fd)
|
|
181
|
+
raise
|
|
182
|
+
finally:
|
|
183
|
+
os.close(parent_fd)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _open_temporary_file(parent_fd: int, destination_name: str) -> Tuple[int, str]:
|
|
187
|
+
flags = (
|
|
188
|
+
os.O_WRONLY
|
|
189
|
+
| os.O_CREAT
|
|
190
|
+
| os.O_EXCL
|
|
191
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
192
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
193
|
+
)
|
|
194
|
+
for _ in range(128):
|
|
195
|
+
name = f".{destination_name}.{secrets.token_hex(8)}"
|
|
196
|
+
try:
|
|
197
|
+
return os.open(name, flags, _OWNER_FILE_MODE, dir_fd=parent_fd), name
|
|
198
|
+
except FileExistsError:
|
|
199
|
+
continue
|
|
200
|
+
raise OSError("unable to allocate an atomic migration temporary file")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@contextmanager
|
|
204
|
+
def _fdopen_stream(file_descriptor: int, mode: str, *, closefd: bool = True):
|
|
205
|
+
try:
|
|
206
|
+
stream = os.fdopen(file_descriptor, mode, closefd=closefd)
|
|
207
|
+
except BaseException:
|
|
208
|
+
if closefd:
|
|
209
|
+
os.close(file_descriptor)
|
|
210
|
+
raise
|
|
211
|
+
try:
|
|
212
|
+
yield stream
|
|
213
|
+
finally:
|
|
214
|
+
stream.close()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _destination_entry_exists(parent_fd: int, name: str, path: Path) -> bool:
|
|
218
|
+
try:
|
|
219
|
+
mode = os.stat(name, dir_fd=parent_fd, follow_symlinks=False).st_mode
|
|
220
|
+
except FileNotFoundError:
|
|
221
|
+
return False
|
|
222
|
+
if stat.S_ISLNK(mode):
|
|
223
|
+
raise ValueError(f"migration path must not contain symlinks: {path}")
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _tighten_existing_destination(parent_fd: int, name: str, path: Path) -> bool:
|
|
228
|
+
flags = (
|
|
229
|
+
os.O_RDONLY
|
|
230
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
231
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
232
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
233
|
+
)
|
|
234
|
+
try:
|
|
235
|
+
destination_fd = os.open(name, flags, dir_fd=parent_fd)
|
|
236
|
+
except FileNotFoundError:
|
|
237
|
+
return False
|
|
238
|
+
except OSError as exc:
|
|
239
|
+
raise ValueError(f"migration path must not contain symlinks: {path}") from exc
|
|
240
|
+
try:
|
|
241
|
+
descriptor_stat = os.fstat(destination_fd)
|
|
242
|
+
if stat.S_ISREG(descriptor_stat.st_mode):
|
|
243
|
+
os.fchmod(destination_fd, _OWNER_FILE_MODE)
|
|
244
|
+
entry_stat = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
245
|
+
if stat.S_ISLNK(entry_stat.st_mode):
|
|
246
|
+
raise ValueError(f"migration path must not contain symlinks: {path}")
|
|
247
|
+
if (
|
|
248
|
+
entry_stat.st_dev != descriptor_stat.st_dev
|
|
249
|
+
or entry_stat.st_ino != descriptor_stat.st_ino
|
|
250
|
+
):
|
|
251
|
+
raise ValueError(f"migration destination changed during operation: {path}")
|
|
252
|
+
_ensure_directory_fd_matches_path(path.parent, parent_fd)
|
|
253
|
+
return True
|
|
254
|
+
finally:
|
|
255
|
+
os.close(destination_fd)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _atomic_copy_file(source: Path, destination: Path) -> None:
|
|
259
|
+
_ensure_private_directory(destination.parent)
|
|
260
|
+
destination_parent_fd = _open_directory(destination.parent)
|
|
261
|
+
try:
|
|
262
|
+
_ensure_directory_fd_matches_path(destination.parent, destination_parent_fd)
|
|
263
|
+
if _destination_entry_exists(destination_parent_fd, destination.name, destination):
|
|
264
|
+
if _tighten_existing_destination(
|
|
265
|
+
destination_parent_fd, destination.name, destination
|
|
266
|
+
):
|
|
267
|
+
return
|
|
268
|
+
source_fd = _open_source_file(source)
|
|
269
|
+
try:
|
|
270
|
+
with _fdopen_stream(source_fd, "rb", closefd=False) as source_stream:
|
|
271
|
+
temporary_fd, temporary_name = _open_temporary_file(
|
|
272
|
+
destination_parent_fd, destination.name
|
|
273
|
+
)
|
|
274
|
+
try:
|
|
275
|
+
with _fdopen_stream(temporary_fd, "wb") as destination_stream:
|
|
276
|
+
while True:
|
|
277
|
+
chunk = source_stream.read(1024 * 1024)
|
|
278
|
+
if not chunk:
|
|
279
|
+
break
|
|
280
|
+
destination_stream.write(chunk)
|
|
281
|
+
destination_stream.flush()
|
|
282
|
+
os.fsync(destination_stream.fileno())
|
|
283
|
+
try:
|
|
284
|
+
os.link(
|
|
285
|
+
temporary_name,
|
|
286
|
+
destination.name,
|
|
287
|
+
src_dir_fd=destination_parent_fd,
|
|
288
|
+
dst_dir_fd=destination_parent_fd,
|
|
289
|
+
follow_symlinks=False,
|
|
290
|
+
)
|
|
291
|
+
except FileExistsError:
|
|
292
|
+
if not _tighten_existing_destination(
|
|
293
|
+
destination_parent_fd, destination.name, destination
|
|
294
|
+
):
|
|
295
|
+
raise ValueError(
|
|
296
|
+
f"migration destination changed during operation: {destination}"
|
|
297
|
+
)
|
|
298
|
+
_ensure_directory_fd_matches_path(destination.parent, destination_parent_fd)
|
|
299
|
+
finally:
|
|
300
|
+
try:
|
|
301
|
+
os.unlink(temporary_name, dir_fd=destination_parent_fd)
|
|
302
|
+
except FileNotFoundError:
|
|
303
|
+
pass
|
|
304
|
+
finally:
|
|
305
|
+
os.close(source_fd)
|
|
306
|
+
finally:
|
|
307
|
+
os.close(destination_parent_fd)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _scan_directory(source: Path) -> Tuple[List[Path], List[Path]]:
|
|
311
|
+
directories: List[Path] = []
|
|
312
|
+
files: List[Path] = []
|
|
313
|
+
root_fd = _open_directory(source)
|
|
314
|
+
|
|
315
|
+
def scan(directory_fd: int, directory: Path) -> None:
|
|
316
|
+
_ensure_directory_fd_matches_path(directory, directory_fd)
|
|
317
|
+
with os.scandir(directory_fd) as entries:
|
|
318
|
+
for entry in entries:
|
|
319
|
+
entry_path = directory / entry.name
|
|
320
|
+
mode = entry.stat(follow_symlinks=False).st_mode
|
|
321
|
+
if stat.S_ISLNK(mode):
|
|
322
|
+
raise ValueError(
|
|
323
|
+
f"migration directories must contain only regular files and directories, "
|
|
324
|
+
f"not symlinks: {entry_path}"
|
|
325
|
+
)
|
|
326
|
+
if stat.S_ISDIR(mode):
|
|
327
|
+
directories.append(entry_path)
|
|
328
|
+
try:
|
|
329
|
+
child_fd = os.open(entry.name, _directory_open_flags(), dir_fd=directory_fd)
|
|
330
|
+
except OSError as exc:
|
|
331
|
+
raise ValueError(
|
|
332
|
+
f"migration directory changed or became a symlink: {entry_path}"
|
|
333
|
+
) from exc
|
|
334
|
+
try:
|
|
335
|
+
scan(child_fd, entry_path)
|
|
336
|
+
finally:
|
|
337
|
+
os.close(child_fd)
|
|
338
|
+
elif stat.S_ISREG(mode):
|
|
339
|
+
files.append(entry_path)
|
|
340
|
+
else:
|
|
341
|
+
raise ValueError(
|
|
342
|
+
"migration directories must contain only regular files and directories: "
|
|
343
|
+
f"{entry_path}"
|
|
344
|
+
)
|
|
345
|
+
_ensure_directory_fd_matches_path(directory, directory_fd)
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
scan(root_fd, source)
|
|
349
|
+
finally:
|
|
350
|
+
os.close(root_fd)
|
|
351
|
+
return sorted(directories), sorted(files)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _copy_directory(source: Path, destination: Path) -> None:
|
|
355
|
+
directories, files = _scan_directory(source)
|
|
356
|
+
destination_kind = _path_kind(destination)
|
|
357
|
+
if destination_kind is not None and destination_kind != "directory":
|
|
358
|
+
return
|
|
359
|
+
_ensure_private_directory(destination)
|
|
360
|
+
blocked_prefixes: List[Path] = []
|
|
361
|
+
for directory in directories:
|
|
362
|
+
relative = directory.relative_to(source)
|
|
363
|
+
if any(prefix == relative or prefix in relative.parents for prefix in blocked_prefixes):
|
|
364
|
+
continue
|
|
365
|
+
target = destination / relative
|
|
366
|
+
target_kind = _path_kind(target)
|
|
367
|
+
if target_kind is not None and target_kind != "directory":
|
|
368
|
+
blocked_prefixes.append(relative)
|
|
369
|
+
continue
|
|
370
|
+
_ensure_private_directory(target)
|
|
371
|
+
for source_file in files:
|
|
372
|
+
relative = source_file.relative_to(source)
|
|
373
|
+
if any(prefix == relative or prefix in relative.parents for prefix in blocked_prefixes):
|
|
374
|
+
continue
|
|
375
|
+
_atomic_copy_file(source_file, destination / relative)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _write_marker(marker: Path) -> None:
|
|
379
|
+
marker_kind = _path_kind(marker)
|
|
380
|
+
if marker_kind is not None:
|
|
381
|
+
if marker_kind != "file":
|
|
382
|
+
raise ValueError(f"migration marker must be a regular file: {marker}")
|
|
383
|
+
parent_fd = _open_directory(marker.parent)
|
|
384
|
+
try:
|
|
385
|
+
flags = (
|
|
386
|
+
os.O_RDONLY
|
|
387
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
388
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
389
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
390
|
+
)
|
|
391
|
+
marker_fd = os.open(marker.name, flags, dir_fd=parent_fd)
|
|
392
|
+
try:
|
|
393
|
+
if not stat.S_ISREG(os.fstat(marker_fd).st_mode):
|
|
394
|
+
raise ValueError(f"migration marker must be a regular file: {marker}")
|
|
395
|
+
os.fchmod(marker_fd, _OWNER_FILE_MODE)
|
|
396
|
+
_ensure_directory_fd_matches_path(marker.parent, parent_fd)
|
|
397
|
+
finally:
|
|
398
|
+
os.close(marker_fd)
|
|
399
|
+
finally:
|
|
400
|
+
os.close(parent_fd)
|
|
401
|
+
return
|
|
402
|
+
_ensure_private_directory(marker.parent)
|
|
403
|
+
parent_fd = _open_directory(marker.parent)
|
|
404
|
+
try:
|
|
405
|
+
temporary_fd, temporary_name = _open_temporary_file(parent_fd, marker.name)
|
|
406
|
+
try:
|
|
407
|
+
with _fdopen_stream(temporary_fd, "wb") as stream:
|
|
408
|
+
stream.write(b"complete\n")
|
|
409
|
+
stream.flush()
|
|
410
|
+
os.fsync(stream.fileno())
|
|
411
|
+
try:
|
|
412
|
+
os.link(
|
|
413
|
+
temporary_name,
|
|
414
|
+
marker.name,
|
|
415
|
+
src_dir_fd=parent_fd,
|
|
416
|
+
dst_dir_fd=parent_fd,
|
|
417
|
+
follow_symlinks=False,
|
|
418
|
+
)
|
|
419
|
+
except FileExistsError:
|
|
420
|
+
if _path_kind(marker) != "file":
|
|
421
|
+
raise ValueError(f"migration marker must be a regular file: {marker}")
|
|
422
|
+
_ensure_directory_fd_matches_path(marker.parent, parent_fd)
|
|
423
|
+
finally:
|
|
424
|
+
try:
|
|
425
|
+
os.unlink(temporary_name, dir_fd=parent_fd)
|
|
426
|
+
except FileNotFoundError:
|
|
427
|
+
pass
|
|
428
|
+
finally:
|
|
429
|
+
os.close(parent_fd)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def migrate_legacy_kagent_state(env: Optional[Mapping[str, str]] = None) -> Path:
|
|
433
|
+
environment = os.environ if env is None else env
|
|
434
|
+
root = kagent_home(environment)
|
|
435
|
+
marker = root / _MIGRATION_MARKER
|
|
436
|
+
if KAGENT_HOME_ENV_VAR in environment:
|
|
437
|
+
return marker
|
|
438
|
+
|
|
439
|
+
marker_kind = _path_kind(marker)
|
|
440
|
+
if marker_kind is not None:
|
|
441
|
+
if marker_kind != "file":
|
|
442
|
+
raise ValueError(f"migration marker must be a regular file: {marker}")
|
|
443
|
+
_ensure_private_directory(root)
|
|
444
|
+
_write_marker(marker)
|
|
445
|
+
return marker
|
|
446
|
+
|
|
447
|
+
legacy_config = _legacy_root(environment, "XDG_CONFIG_HOME", (".config",))
|
|
448
|
+
legacy_state = _legacy_root(environment, "XDG_STATE_HOME", (".local", "state"))
|
|
449
|
+
migrations = [
|
|
450
|
+
(legacy_config / "provider.json", root / "config" / "provider.json", "file"),
|
|
451
|
+
(legacy_state / "session-memory.json", root / "state" / "session-memory.json", "file"),
|
|
452
|
+
(legacy_state / "history", root / "state" / "history", "file"),
|
|
453
|
+
(
|
|
454
|
+
legacy_state / "pending-approvals",
|
|
455
|
+
root / "state" / "pending-approvals",
|
|
456
|
+
"directory",
|
|
457
|
+
),
|
|
458
|
+
(legacy_state / "patches", root / "state" / "patches", "directory"),
|
|
459
|
+
]
|
|
460
|
+
|
|
461
|
+
for source, destination, expected_kind in migrations:
|
|
462
|
+
kind = _path_kind(source)
|
|
463
|
+
if kind is None:
|
|
464
|
+
continue
|
|
465
|
+
if kind != expected_kind:
|
|
466
|
+
raise ValueError(f"unexpected legacy migration source type: {source}")
|
|
467
|
+
if kind == "file":
|
|
468
|
+
_atomic_copy_file(source, destination)
|
|
469
|
+
else:
|
|
470
|
+
_copy_directory(source, destination)
|
|
471
|
+
|
|
472
|
+
_write_marker(marker)
|
|
473
|
+
return marker
|