aicortexpy 0.1.0__py3-none-any.whl
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.
- aicortexpy/__init__.py +3 -0
- aicortexpy/__main__.py +5 -0
- aicortexpy/acp_client.py +559 -0
- aicortexpy/acp_conn.py +853 -0
- aicortexpy/acp_errors.py +107 -0
- aicortexpy/agent_env.py +88 -0
- aicortexpy/artifact_ws.py +233 -0
- aicortexpy/artifacts.py +67 -0
- aicortexpy/attachments.py +85 -0
- aicortexpy/auto_commit.py +106 -0
- aicortexpy/capabilities.py +92 -0
- aicortexpy/chat_acp_pool.py +321 -0
- aicortexpy/cli.py +133 -0
- aicortexpy/client.py +438 -0
- aicortexpy/codex_config.py +98 -0
- aicortexpy/config.py +298 -0
- aicortexpy/daemon.py +677 -0
- aicortexpy/data/default_skills/agent-memory/SKILL.md +67 -0
- aicortexpy/data/default_skills/aicorkit-atlassian/SKILL.md +144 -0
- aicortexpy/data/default_skills/aicorkit-graph/SKILL.md +145 -0
- aicortexpy/data/default_skills/aicorkit-outlook/SKILL.md +87 -0
- aicortexpy/data/default_skills/aicorkit-wxmsg/SKILL.md +96 -0
- aicortexpy/data/default_skills/aicortex-basics/SKILL.md +10 -0
- aicortexpy/data/default_skills/aicortex-bridge-cdp/SKILL.md +118 -0
- aicortexpy/data/default_skills/aicortex-cli-ops/SKILL.md +100 -0
- aicortexpy/data/default_skills/aicortex-graph/SKILL.md +30 -0
- aicortexpy/data/default_skills/graphify/SKILL.md +97 -0
- aicortexpy/data/default_skills/question-form/SKILL.md +162 -0
- aicortexpy/data/default_skills/test-report-generator/SKILL.md +179 -0
- aicortexpy/data/default_skills/test-report-generator/scripts/generate_cases.py +146 -0
- aicortexpy/data/default_skills/test-report-generator/scripts/generate_report.py +275 -0
- aicortexpy/design_seed.py +59 -0
- aicortexpy/diskusage.py +209 -0
- aicortexpy/execenv.py +264 -0
- aicortexpy/gc.py +286 -0
- aicortexpy/handoff_bundle.py +65 -0
- aicortexpy/health.py +178 -0
- aicortexpy/local_skills.py +157 -0
- aicortexpy/models.py +385 -0
- aicortexpy/openclaw_config.py +153 -0
- aicortexpy/pinned_workdir.py +60 -0
- aicortexpy/platform_info.py +61 -0
- aicortexpy/poisoned.py +44 -0
- aicortexpy/process.py +168 -0
- aicortexpy/procutil.py +61 -0
- aicortexpy/prompt.py +235 -0
- aicortexpy/repo.py +462 -0
- aicortexpy/runner.py +969 -0
- aicortexpy/runtime_config.py +540 -0
- aicortexpy/sandbox.py +264 -0
- aicortexpy/stream.py +422 -0
- aicortexpy/terminal.py +147 -0
- aicortexpy/tunnel.py +82 -0
- aicortexpy/wakeup.py +222 -0
- aicortexpy-0.1.0.dist-info/METADATA +106 -0
- aicortexpy-0.1.0.dist-info/RECORD +58 -0
- aicortexpy-0.1.0.dist-info/WHEEL +5 -0
- aicortexpy-0.1.0.dist-info/top_level.txt +1 -0
aicortexpy/execenv.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Per-task execution environment preparation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .client import Task
|
|
13
|
+
from .codex_config import ensure_codex_multi_agent_config, sanitize_copied_codex_config
|
|
14
|
+
from .openclaw_config import prepare_openclaw_config
|
|
15
|
+
from .pinned_workdir import prepare_pinned_workdir, project_uses_pinned_workdir
|
|
16
|
+
from .prompt import build_prompt
|
|
17
|
+
from .runtime_config import (
|
|
18
|
+
inject_runtime_config,
|
|
19
|
+
task_context_from_task,
|
|
20
|
+
write_project_sidecar,
|
|
21
|
+
)
|
|
22
|
+
from .sandbox import codex_sandbox_policy, prepare_provider_sandbox, write_codex_sandbox_config
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger("aicortexpy.execenv")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Environment:
|
|
29
|
+
root_dir: Path
|
|
30
|
+
work_dir: Path
|
|
31
|
+
codex_home: Path | None = None
|
|
32
|
+
pinned: bool = False
|
|
33
|
+
env_updates: dict[str, str] = field(default_factory=dict)
|
|
34
|
+
runtime_brief: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def provider_needs_inline_system_prompt(provider: str) -> bool:
|
|
38
|
+
"""Match Go daemon.providerNeedsInlineSystemPrompt (openclaw/kiro/kimi)."""
|
|
39
|
+
return provider in ("openclaw", "kiro", "kimi")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _workdir_accessible(path: Path) -> bool:
|
|
43
|
+
try:
|
|
44
|
+
return path.is_dir()
|
|
45
|
+
except OSError:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def prepare_environment(
|
|
50
|
+
workspaces_root: Path,
|
|
51
|
+
task: Task,
|
|
52
|
+
provider: str,
|
|
53
|
+
) -> Environment:
|
|
54
|
+
ws = task.workspace_id or "default"
|
|
55
|
+
env_updates: dict[str, str] = {}
|
|
56
|
+
|
|
57
|
+
if project_uses_pinned_workdir(task):
|
|
58
|
+
work_dir = prepare_pinned_workdir(workspaces_root, task)
|
|
59
|
+
root_dir = work_dir
|
|
60
|
+
pinned = True
|
|
61
|
+
else:
|
|
62
|
+
pinned = False
|
|
63
|
+
prior = (task.prior_work_dir or "").strip()
|
|
64
|
+
prior_path = Path(prior) if prior else None
|
|
65
|
+
if prior_path is not None and _workdir_accessible(prior_path):
|
|
66
|
+
work_dir = prior_path
|
|
67
|
+
root_dir = prior_path.parent
|
|
68
|
+
log.info("reusing prior workdir %s", work_dir)
|
|
69
|
+
else:
|
|
70
|
+
if prior:
|
|
71
|
+
log.warning(
|
|
72
|
+
"prior workdir unavailable, preparing fresh env path=%s",
|
|
73
|
+
prior,
|
|
74
|
+
)
|
|
75
|
+
root_dir = workspaces_root / ws / task.id
|
|
76
|
+
work_dir = root_dir / "workdir"
|
|
77
|
+
for d in (work_dir, root_dir / "output", root_dir / "logs"):
|
|
78
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
prompt = build_prompt(task, provider)
|
|
81
|
+
(work_dir / "PROMPT.md").write_text(prompt, encoding="utf-8")
|
|
82
|
+
|
|
83
|
+
agent = task.agent or {}
|
|
84
|
+
ctx = task_context_from_task(task)
|
|
85
|
+
runtime_brief = inject_runtime_config(work_dir, provider, ctx)
|
|
86
|
+
write_project_sidecar(work_dir, ctx)
|
|
87
|
+
|
|
88
|
+
if task.project_prompt:
|
|
89
|
+
(work_dir / "PROJECT.md").write_text(task.project_prompt, encoding="utf-8")
|
|
90
|
+
|
|
91
|
+
write_agent_skills(work_dir, provider, agent.get("skills"))
|
|
92
|
+
inject_default_skills(work_dir, provider)
|
|
93
|
+
|
|
94
|
+
codex_home: Path | None = None
|
|
95
|
+
if provider == "codex":
|
|
96
|
+
codex_home = root_dir / "codex-home" if not pinned else work_dir / ".codex-home"
|
|
97
|
+
policy = codex_sandbox_policy()
|
|
98
|
+
write_codex_sandbox_config(codex_home, policy)
|
|
99
|
+
hydrate_codex_home(codex_home, agent.get("skills"))
|
|
100
|
+
# Codex discovers skills from CODEX_HOME/skills — mirror default skills there too.
|
|
101
|
+
_copy_defaults_into(codex_home / "skills")
|
|
102
|
+
# prepare_provider_sandbox may rewrite config.toml — apply multi_agent last.
|
|
103
|
+
prepare_provider_sandbox(provider, work_dir)
|
|
104
|
+
ensure_codex_multi_agent_config(codex_home / "config.toml")
|
|
105
|
+
sanitize_copied_codex_config(codex_home / "config.toml")
|
|
106
|
+
|
|
107
|
+
if provider == "openclaw":
|
|
108
|
+
oc = prepare_openclaw_config(root_dir, work_dir)
|
|
109
|
+
if oc.env_updates:
|
|
110
|
+
env_updates.update(oc.env_updates)
|
|
111
|
+
|
|
112
|
+
return Environment(
|
|
113
|
+
root_dir=root_dir,
|
|
114
|
+
work_dir=work_dir,
|
|
115
|
+
codex_home=codex_home,
|
|
116
|
+
pinned=pinned,
|
|
117
|
+
env_updates=env_updates,
|
|
118
|
+
runtime_brief=runtime_brief,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def write_agent_skills(work_dir: Path, provider: str, skills: Any) -> None:
|
|
123
|
+
if not isinstance(skills, list):
|
|
124
|
+
return
|
|
125
|
+
base = _skills_root(work_dir, provider)
|
|
126
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
for sk in skills:
|
|
128
|
+
if not isinstance(sk, dict):
|
|
129
|
+
continue
|
|
130
|
+
name = str(sk.get("name") or "skill").strip() or "skill"
|
|
131
|
+
safe = "".join(c if c.isalnum() or c in "-_" else "-" for c in name)
|
|
132
|
+
skill_dir = base / safe
|
|
133
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
content = str(sk.get("content") or "")
|
|
135
|
+
(skill_dir / "SKILL.md").write_text(content, encoding="utf-8")
|
|
136
|
+
files = sk.get("files")
|
|
137
|
+
if isinstance(files, list):
|
|
138
|
+
for f in files:
|
|
139
|
+
if not isinstance(f, dict):
|
|
140
|
+
continue
|
|
141
|
+
rel = str(f.get("path") or "").lstrip("/\\")
|
|
142
|
+
if not rel or ".." in rel.split("/"):
|
|
143
|
+
continue
|
|
144
|
+
dest = skill_dir / rel
|
|
145
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
dest.write_text(str(f.get("content") or ""), encoding="utf-8")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def inject_default_skills(work_dir: Path, provider: str) -> None:
|
|
150
|
+
dest = _skills_root(work_dir, provider)
|
|
151
|
+
for src_root in _default_skill_roots():
|
|
152
|
+
_copy_default_skill_dirs(src_root, dest)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _copy_defaults_into(dest_root: Path) -> None:
|
|
156
|
+
for src_root in _default_skill_roots():
|
|
157
|
+
_copy_default_skill_dirs(src_root, dest_root)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _default_skill_roots() -> list[Path]:
|
|
161
|
+
roots: list[Path] = []
|
|
162
|
+
bundled = Path(__file__).resolve().parent / "data" / "default_skills"
|
|
163
|
+
if bundled.is_dir():
|
|
164
|
+
roots.append(bundled)
|
|
165
|
+
# Monorepo sibling (dev / source checkout): reuse Go daemon defaultskills.
|
|
166
|
+
mono = (
|
|
167
|
+
Path(__file__).resolve().parents[2]
|
|
168
|
+
/ "server"
|
|
169
|
+
/ "internal"
|
|
170
|
+
/ "daemon"
|
|
171
|
+
/ "defaultskills"
|
|
172
|
+
)
|
|
173
|
+
if mono.is_dir():
|
|
174
|
+
roots.append(mono)
|
|
175
|
+
return roots
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _copy_default_skill_dirs(src_root: Path, dest_root: Path) -> None:
|
|
179
|
+
dest_root.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
skip_heavy = (os.environ.get("AICORTEX_INJECT_TENCENT_DOCS") or "").strip().lower() not in (
|
|
181
|
+
"1",
|
|
182
|
+
"true",
|
|
183
|
+
"yes",
|
|
184
|
+
"on",
|
|
185
|
+
)
|
|
186
|
+
for child in src_root.iterdir():
|
|
187
|
+
if not child.is_dir():
|
|
188
|
+
continue
|
|
189
|
+
# Skip Go package helper dirs / tests and huge tencent bundle by default.
|
|
190
|
+
if child.name in ("testdata",):
|
|
191
|
+
continue
|
|
192
|
+
if skip_heavy and child.name == "tencent-docs":
|
|
193
|
+
# Still inject SKILL.md alone so the skill is discoverable.
|
|
194
|
+
skill_md = child / "SKILL.md"
|
|
195
|
+
if skill_md.is_file():
|
|
196
|
+
target = dest_root / child.name
|
|
197
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
dest_md = target / "SKILL.md"
|
|
199
|
+
if not dest_md.exists():
|
|
200
|
+
try:
|
|
201
|
+
shutil.copy2(skill_md, dest_md)
|
|
202
|
+
except OSError as e:
|
|
203
|
+
log.warning("copy tencent-docs SKILL.md failed: %s", e)
|
|
204
|
+
continue
|
|
205
|
+
if not (child / "SKILL.md").is_file():
|
|
206
|
+
continue
|
|
207
|
+
target = dest_root / child.name
|
|
208
|
+
if target.exists():
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
shutil.copytree(child, target)
|
|
212
|
+
except OSError as e:
|
|
213
|
+
log.warning("copy default skill %s failed: %s", child.name, e)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def hydrate_codex_home(codex_home: Path, skills: Any) -> None:
|
|
217
|
+
user = Path.home() / ".codex"
|
|
218
|
+
codex_home.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
for name in ("auth.json", "config.toml"):
|
|
220
|
+
src = user / name
|
|
221
|
+
dst = codex_home / name
|
|
222
|
+
if src.is_file() and not dst.exists():
|
|
223
|
+
try:
|
|
224
|
+
shutil.copy2(src, dst)
|
|
225
|
+
except OSError:
|
|
226
|
+
pass
|
|
227
|
+
cfg = codex_home / "config.toml"
|
|
228
|
+
sanitize_copied_codex_config(cfg)
|
|
229
|
+
|
|
230
|
+
skills_dir = codex_home / "skills"
|
|
231
|
+
skills_dir.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
if isinstance(skills, list):
|
|
233
|
+
for sk in skills:
|
|
234
|
+
if not isinstance(sk, dict):
|
|
235
|
+
continue
|
|
236
|
+
name = str(sk.get("name") or "skill")
|
|
237
|
+
safe = "".join(c if c.isalnum() or c in "-_" else "-" for c in name)
|
|
238
|
+
d = skills_dir / safe
|
|
239
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
(d / "SKILL.md").write_text(str(sk.get("content") or ""), encoding="utf-8")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _skills_root(work_dir: Path, provider: str) -> Path:
|
|
244
|
+
if provider == "claude":
|
|
245
|
+
return work_dir / ".claude" / "skills"
|
|
246
|
+
if provider == "cursor":
|
|
247
|
+
return work_dir / ".cursor" / "skills"
|
|
248
|
+
if provider == "copilot":
|
|
249
|
+
return work_dir / ".github" / "skills"
|
|
250
|
+
if provider == "opencode":
|
|
251
|
+
return work_dir / ".opencode" / "skills"
|
|
252
|
+
if provider == "openclaw":
|
|
253
|
+
return work_dir / "skills"
|
|
254
|
+
if provider == "pi":
|
|
255
|
+
return work_dir / ".pi" / "skills"
|
|
256
|
+
if provider == "kimi":
|
|
257
|
+
return work_dir / ".kimi" / "skills"
|
|
258
|
+
if provider == "kiro":
|
|
259
|
+
return work_dir / ".kiro" / "skills"
|
|
260
|
+
if provider == "dsh":
|
|
261
|
+
return work_dir / ".dsh" / "skills"
|
|
262
|
+
# codex / gemini / hermes / unknown → meta skill references .agent_context/skills
|
|
263
|
+
# (Codex also gets skills under per-task CODEX_HOME/skills via hydrate_codex_home)
|
|
264
|
+
return work_dir / ".agent_context" / "skills"
|
aicortexpy/gc.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Periodic workspace GC."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import shutil
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .client import APIError, DaemonClient, is_not_found
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("aicortexpy.gc")
|
|
17
|
+
|
|
18
|
+
META_NAME = ".gc_meta.json"
|
|
19
|
+
LEGACY_META_NAME = ".aicortex-gc.json"
|
|
20
|
+
ARTIFACT_PATTERNS = ("node_modules", ".next", ".turbo")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ActiveEnvRoots:
|
|
24
|
+
"""Refcounted set of env roots currently used by running tasks (Go activeEnvRoots)."""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self._mu = threading.Lock()
|
|
28
|
+
self._roots: dict[str, int] = {}
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def _key(path: str | Path) -> str:
|
|
32
|
+
try:
|
|
33
|
+
return str(Path(path).resolve())
|
|
34
|
+
except OSError:
|
|
35
|
+
return str(path)
|
|
36
|
+
|
|
37
|
+
def mark(self, path: str | Path) -> None:
|
|
38
|
+
key = self._key(path)
|
|
39
|
+
if not key:
|
|
40
|
+
return
|
|
41
|
+
with self._mu:
|
|
42
|
+
self._roots[key] = self._roots.get(key, 0) + 1
|
|
43
|
+
|
|
44
|
+
def unmark(self, path: str | Path) -> None:
|
|
45
|
+
key = self._key(path)
|
|
46
|
+
if not key:
|
|
47
|
+
return
|
|
48
|
+
with self._mu:
|
|
49
|
+
n = self._roots.get(key, 0)
|
|
50
|
+
if n <= 1:
|
|
51
|
+
self._roots.pop(key, None)
|
|
52
|
+
else:
|
|
53
|
+
self._roots[key] = n - 1
|
|
54
|
+
|
|
55
|
+
def is_active(self, path: str | Path) -> bool:
|
|
56
|
+
key = self._key(path)
|
|
57
|
+
with self._mu:
|
|
58
|
+
return self._roots.get(key, 0) > 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def write_gc_meta(env_root: Path, meta: dict[str, Any]) -> None:
|
|
62
|
+
try:
|
|
63
|
+
(env_root / META_NAME).write_text(
|
|
64
|
+
json.dumps(meta, ensure_ascii=False, indent=2),
|
|
65
|
+
encoding="utf-8",
|
|
66
|
+
)
|
|
67
|
+
legacy = env_root / LEGACY_META_NAME
|
|
68
|
+
if legacy.is_file():
|
|
69
|
+
try:
|
|
70
|
+
legacy.unlink()
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
except OSError as e:
|
|
74
|
+
log.warning("write gc meta failed: %s", e)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_gc_meta(task_dir: Path) -> dict[str, Any] | None:
|
|
78
|
+
for name in (META_NAME, LEGACY_META_NAME):
|
|
79
|
+
path = task_dir / name
|
|
80
|
+
if not path.is_file():
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
84
|
+
except Exception:
|
|
85
|
+
continue
|
|
86
|
+
if isinstance(data, dict):
|
|
87
|
+
return data
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def gc_meta_for_task(
|
|
92
|
+
task_id: str,
|
|
93
|
+
issue_id: str,
|
|
94
|
+
chat_id: str,
|
|
95
|
+
autopilot_id: str,
|
|
96
|
+
*,
|
|
97
|
+
workspace_id: str = "",
|
|
98
|
+
quick_create: bool = False,
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
"""Align with Go execenv.GCMeta field names (+ parent_id for local helpers)."""
|
|
101
|
+
completed = datetime.now(timezone.utc).isoformat()
|
|
102
|
+
if issue_id:
|
|
103
|
+
return {
|
|
104
|
+
"kind": "issue",
|
|
105
|
+
"issue_id": issue_id,
|
|
106
|
+
"parent_id": issue_id,
|
|
107
|
+
"task_id": task_id,
|
|
108
|
+
"workspace_id": workspace_id,
|
|
109
|
+
"completed_at": completed,
|
|
110
|
+
}
|
|
111
|
+
if chat_id:
|
|
112
|
+
return {
|
|
113
|
+
"kind": "chat",
|
|
114
|
+
"chat_session_id": chat_id,
|
|
115
|
+
"parent_id": chat_id,
|
|
116
|
+
"task_id": task_id,
|
|
117
|
+
"workspace_id": workspace_id,
|
|
118
|
+
"completed_at": completed,
|
|
119
|
+
}
|
|
120
|
+
if autopilot_id:
|
|
121
|
+
return {
|
|
122
|
+
"kind": "autopilot_run",
|
|
123
|
+
"autopilot_run_id": autopilot_id,
|
|
124
|
+
"parent_id": autopilot_id,
|
|
125
|
+
"task_id": task_id,
|
|
126
|
+
"workspace_id": workspace_id,
|
|
127
|
+
"completed_at": completed,
|
|
128
|
+
}
|
|
129
|
+
if quick_create:
|
|
130
|
+
return {
|
|
131
|
+
"kind": "quick_create",
|
|
132
|
+
"parent_id": task_id,
|
|
133
|
+
"task_id": task_id,
|
|
134
|
+
"workspace_id": workspace_id,
|
|
135
|
+
"completed_at": completed,
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
"kind": "task",
|
|
139
|
+
"parent_id": task_id,
|
|
140
|
+
"task_id": task_id,
|
|
141
|
+
"workspace_id": workspace_id,
|
|
142
|
+
"completed_at": completed,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _meta_parent(meta: dict[str, Any]) -> tuple[str, str]:
|
|
147
|
+
kind = str(meta.get("kind") or "")
|
|
148
|
+
if kind == "issue":
|
|
149
|
+
return kind, str(meta.get("issue_id") or meta.get("parent_id") or "")
|
|
150
|
+
if kind == "chat":
|
|
151
|
+
return kind, str(meta.get("chat_session_id") or meta.get("parent_id") or "")
|
|
152
|
+
if kind in ("autopilot", "autopilot_run"):
|
|
153
|
+
return "autopilot", str(meta.get("autopilot_run_id") or meta.get("parent_id") or "")
|
|
154
|
+
if kind == "quick_create":
|
|
155
|
+
return kind, str(meta.get("task_id") or meta.get("parent_id") or "")
|
|
156
|
+
return kind or "task", str(meta.get("parent_id") or meta.get("task_id") or "")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def run_gc_loop(
|
|
160
|
+
stop: threading.Event,
|
|
161
|
+
client: DaemonClient,
|
|
162
|
+
workspaces_root: Path,
|
|
163
|
+
*,
|
|
164
|
+
interval: float,
|
|
165
|
+
ttl: float,
|
|
166
|
+
orphan_ttl: float,
|
|
167
|
+
artifact_ttl: float,
|
|
168
|
+
enabled: bool,
|
|
169
|
+
active_roots: ActiveEnvRoots | None = None,
|
|
170
|
+
) -> None:
|
|
171
|
+
if not enabled:
|
|
172
|
+
log.info("gc: disabled")
|
|
173
|
+
return
|
|
174
|
+
log.info("gc: started interval=%ss ttl=%ss", interval, ttl)
|
|
175
|
+
stop.wait(30.0)
|
|
176
|
+
while not stop.is_set():
|
|
177
|
+
try:
|
|
178
|
+
run_gc_once(
|
|
179
|
+
client,
|
|
180
|
+
workspaces_root,
|
|
181
|
+
ttl,
|
|
182
|
+
orphan_ttl,
|
|
183
|
+
artifact_ttl,
|
|
184
|
+
active_roots=active_roots,
|
|
185
|
+
)
|
|
186
|
+
except Exception as e:
|
|
187
|
+
log.warning("gc cycle failed: %s", e)
|
|
188
|
+
stop.wait(interval)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def run_gc_once(
|
|
192
|
+
client: DaemonClient,
|
|
193
|
+
workspaces_root: Path,
|
|
194
|
+
ttl: float,
|
|
195
|
+
orphan_ttl: float,
|
|
196
|
+
artifact_ttl: float,
|
|
197
|
+
*,
|
|
198
|
+
active_roots: ActiveEnvRoots | None = None,
|
|
199
|
+
) -> None:
|
|
200
|
+
if not workspaces_root.is_dir():
|
|
201
|
+
return
|
|
202
|
+
now = time.time()
|
|
203
|
+
cleaned = orphaned = artifact_dirs = skipped_active = 0
|
|
204
|
+
for ws in workspaces_root.iterdir():
|
|
205
|
+
if not ws.is_dir() or ws.name in (".repos",):
|
|
206
|
+
continue
|
|
207
|
+
for child in ws.iterdir():
|
|
208
|
+
if not child.is_dir():
|
|
209
|
+
continue
|
|
210
|
+
if child.name == "projects":
|
|
211
|
+
continue # pinned project roots — never GC
|
|
212
|
+
if active_roots is not None and active_roots.is_active(child):
|
|
213
|
+
skipped_active += 1
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
age = now - child.stat().st_mtime
|
|
217
|
+
meta = read_gc_meta(child)
|
|
218
|
+
|
|
219
|
+
if meta is None:
|
|
220
|
+
if age >= orphan_ttl:
|
|
221
|
+
_rm(child)
|
|
222
|
+
orphaned += 1
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
kind, parent = _meta_parent(meta)
|
|
226
|
+
try:
|
|
227
|
+
status_info = _status_for(client, kind, parent)
|
|
228
|
+
except APIError as e:
|
|
229
|
+
if is_not_found(e, "not found") and age >= orphan_ttl:
|
|
230
|
+
_rm(child)
|
|
231
|
+
orphaned += 1
|
|
232
|
+
continue
|
|
233
|
+
except Exception:
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
status = str(status_info.get("status") or "").lower()
|
|
237
|
+
# Chat sessions that are still active must never be reclaimed.
|
|
238
|
+
if kind == "chat" and status in ("active", "open", ""):
|
|
239
|
+
continue
|
|
240
|
+
terminal = status in ("done", "cancelled", "completed", "failed", "archived")
|
|
241
|
+
if terminal and age >= ttl:
|
|
242
|
+
_rm(child)
|
|
243
|
+
cleaned += 1
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
# artifact reclaim on still-open parents
|
|
247
|
+
if artifact_ttl > 0 and age >= artifact_ttl:
|
|
248
|
+
if _reclaim_artifacts(child):
|
|
249
|
+
artifact_dirs += 1
|
|
250
|
+
|
|
251
|
+
if cleaned or orphaned or artifact_dirs or skipped_active:
|
|
252
|
+
log.info(
|
|
253
|
+
"gc: cleaned=%s orphaned=%s artifact_dirs=%s skipped_active=%s",
|
|
254
|
+
cleaned,
|
|
255
|
+
orphaned,
|
|
256
|
+
artifact_dirs,
|
|
257
|
+
skipped_active,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _status_for(client: DaemonClient, kind: str, parent: str) -> dict[str, Any]:
|
|
262
|
+
if kind == "issue":
|
|
263
|
+
return client.get_issue_gc_check(parent)
|
|
264
|
+
if kind == "chat":
|
|
265
|
+
return client.get_chat_session_gc_check(parent)
|
|
266
|
+
if kind == "autopilot":
|
|
267
|
+
return client.get_autopilot_run_gc_check(parent)
|
|
268
|
+
return client.get_task_gc_check(parent)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _reclaim_artifacts(root: Path) -> bool:
|
|
272
|
+
removed = False
|
|
273
|
+
for pat in ARTIFACT_PATTERNS:
|
|
274
|
+
for hit in root.rglob(pat):
|
|
275
|
+
if hit.is_dir() and hit.name == pat:
|
|
276
|
+
_rm(hit)
|
|
277
|
+
removed = True
|
|
278
|
+
return removed
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _rm(path: Path) -> None:
|
|
282
|
+
try:
|
|
283
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
284
|
+
log.info("gc: removed %s", path)
|
|
285
|
+
except OSError as e:
|
|
286
|
+
log.warning("gc: remove failed %s: %s", path, e)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Download + extract design→dev handoff zip bundles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import logging
|
|
7
|
+
import zipfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.request import Request, urlopen
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger("aicortexpy.handoff")
|
|
13
|
+
|
|
14
|
+
MAX_BYTES = 50 << 20
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def download_handoff_bundle(
|
|
18
|
+
url: str,
|
|
19
|
+
*,
|
|
20
|
+
token: str = "",
|
|
21
|
+
server_base: str = "",
|
|
22
|
+
timeout: float = 120.0,
|
|
23
|
+
) -> bytes:
|
|
24
|
+
url = (url or "").strip()
|
|
25
|
+
if not url:
|
|
26
|
+
raise ValueError("handoff bundle url is empty")
|
|
27
|
+
req = Request(url, method="GET")
|
|
28
|
+
if token and server_base and url.startswith(server_base.rstrip("/")):
|
|
29
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
30
|
+
req.add_header("X-Client-Platform", "daemon")
|
|
31
|
+
with urlopen(req, timeout=timeout) as resp: # noqa: S310 — intentional HTTP fetch
|
|
32
|
+
data = resp.read(MAX_BYTES + 1)
|
|
33
|
+
if len(data) > MAX_BYTES:
|
|
34
|
+
raise ValueError("handoff bundle exceeds size limit")
|
|
35
|
+
return data
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def extract_zip_archive(data: bytes, dest_dir: Path) -> None:
|
|
39
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
dest_root = dest_dir.resolve()
|
|
41
|
+
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
|
42
|
+
for info in zf.infolist():
|
|
43
|
+
if info.is_dir():
|
|
44
|
+
continue
|
|
45
|
+
name = info.filename.replace("\\", "/")
|
|
46
|
+
if name.startswith("/") or ".." in name.split("/"):
|
|
47
|
+
raise ValueError(f"unsafe zip path: {info.filename}")
|
|
48
|
+
target = (dest_dir / name).resolve()
|
|
49
|
+
if not str(target).startswith(str(dest_root)):
|
|
50
|
+
raise ValueError(f"zip slip blocked: {info.filename}")
|
|
51
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
target.write_bytes(zf.read(info))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def materialize_handoff_bundle(
|
|
56
|
+
work_dir: Path,
|
|
57
|
+
bundle_url: str,
|
|
58
|
+
*,
|
|
59
|
+
token: str = "",
|
|
60
|
+
server_base: str = "",
|
|
61
|
+
) -> None:
|
|
62
|
+
log.info("downloading handoff bundle url=%s", bundle_url)
|
|
63
|
+
data = download_handoff_bundle(bundle_url, token=token, server_base=server_base)
|
|
64
|
+
extract_zip_archive(data, work_dir)
|
|
65
|
+
log.info("handoff bundle extracted to %s", work_dir)
|