pilot-workers 0.2.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.
- pilot_workers/__init__.py +1 -0
- pilot_workers/__main__.py +3 -0
- pilot_workers/cli/__init__.py +0 -0
- pilot_workers/cli/dispatch.py +577 -0
- pilot_workers/cli/install.py +259 -0
- pilot_workers/cli/main.py +115 -0
- pilot_workers/cli/run.py +191 -0
- pilot_workers/credentials.py +102 -0
- pilot_workers/data/permissions/README.md +47 -0
- pilot_workers/data/permissions/relaxed.yaml +10 -0
- pilot_workers/data/permissions/strict.yaml +9 -0
- pilot_workers/data/providers/README.md +24 -0
- pilot_workers/data/providers/ds.yaml +11 -0
- pilot_workers/data/providers/glm.yaml +11 -0
- pilot_workers/data/providers/kimi-k3.yaml +11 -0
- pilot_workers/data/templates/code.md +28 -0
- pilot_workers/data/templates/explore.md +17 -0
- pilot_workers/data/templates/review.md +18 -0
- pilot_workers/data/templates/test.md +17 -0
- pilot_workers/fmt_events.py +218 -0
- pilot_workers/integrations/README.md +25 -0
- pilot_workers/integrations/claude-host/agents/ds-coder.md +66 -0
- pilot_workers/integrations/claude-host/agents/ds-explorer.md +56 -0
- pilot_workers/integrations/claude-host/agents/ds-reviewer.md +50 -0
- pilot_workers/integrations/claude-host/agents/ds-tester.md +46 -0
- pilot_workers/integrations/claude-host/agents/glm-coder.md +66 -0
- pilot_workers/integrations/claude-host/agents/glm-explorer.md +56 -0
- pilot_workers/integrations/claude-host/agents/glm-reviewer.md +50 -0
- pilot_workers/integrations/claude-host/agents/glm-tester.md +46 -0
- pilot_workers/integrations/claude-host/agents/kimi-coder.md +66 -0
- pilot_workers/integrations/claude-host/agents/kimi-explorer.md +56 -0
- pilot_workers/integrations/claude-host/agents/kimi-reviewer.md +50 -0
- pilot_workers/integrations/claude-host/agents/kimi-tester.md +50 -0
- pilot_workers/integrations/claude-host/commands/glm/code.md +48 -0
- pilot_workers/integrations/claude-host/commands/glm/explore.md +38 -0
- pilot_workers/integrations/claude-host/commands/glm/review.md +38 -0
- pilot_workers/integrations/claude-host/commands/glm/test.md +30 -0
- pilot_workers/integrations/claude-host/commands/kimi/code.md +47 -0
- pilot_workers/integrations/claude-host/commands/kimi/explore.md +38 -0
- pilot_workers/integrations/claude-host/commands/kimi/review.md +38 -0
- pilot_workers/integrations/claude-host/commands/kimi/test.md +32 -0
- pilot_workers/integrations/codex-host/ds/SKILL.md +15 -0
- pilot_workers/integrations/codex-host/ds/openai.yaml +4 -0
- pilot_workers/integrations/codex-host/glm/SKILL.md +15 -0
- pilot_workers/integrations/codex-host/glm/openai.yaml +4 -0
- pilot_workers/integrations/codex-host/kimi/SKILL.md +15 -0
- pilot_workers/integrations/codex-host/kimi/openai.yaml +4 -0
- pilot_workers/maintain.py +170 -0
- pilot_workers/policy.py +295 -0
- pilot_workers/prompts/code.md +6 -0
- pilot_workers/prompts/common.md +24 -0
- pilot_workers/prompts/explore.md +7 -0
- pilot_workers/prompts/review.md +5 -0
- pilot_workers/prompts/test.md +6 -0
- pilot_workers/providers.py +137 -0
- pilot_workers/runners/__init__.py +28 -0
- pilot_workers/runners/base.py +123 -0
- pilot_workers/runners/opencode_runner.py +377 -0
- pilot_workers/runtime.py +314 -0
- pilot_workers/scripts/install_runtime.sh +51 -0
- pilot_workers-0.2.0.dist-info/METADATA +84 -0
- pilot_workers-0.2.0.dist-info/RECORD +66 -0
- pilot_workers-0.2.0.dist-info/WHEEL +5 -0
- pilot_workers-0.2.0.dist-info/entry_points.txt +2 -0
- pilot_workers-0.2.0.dist-info/licenses/LICENSE +21 -0
- pilot_workers-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Install integration files (agents, commands, skills) to host config directories."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import importlib.metadata
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from pilot_workers import providers
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
INTEGRATIONS_DIR = Path(__file__).resolve().parent.parent / "integrations"
|
|
20
|
+
|
|
21
|
+
MANIFEST_SCHEMA_VERSION = 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _manifest_path() -> Path:
|
|
25
|
+
return providers.pilot_home() / "install-manifest.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _package_version() -> str:
|
|
29
|
+
try:
|
|
30
|
+
return importlib.metadata.version("pilot-workers")
|
|
31
|
+
except importlib.metadata.PackageNotFoundError:
|
|
32
|
+
return "unknown"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_manifest(path: Path) -> dict:
|
|
36
|
+
if not path.exists():
|
|
37
|
+
return {"schema_version": MANIFEST_SCHEMA_VERSION, "hosts": {}}
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
40
|
+
except json.JSONDecodeError as exc:
|
|
41
|
+
raise RuntimeError(f"corrupt install manifest {path}: {exc}") from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write_manifest(path: Path, data: dict) -> None:
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
temporary_name: str | None = None
|
|
47
|
+
try:
|
|
48
|
+
with tempfile.NamedTemporaryFile(
|
|
49
|
+
mode="w", encoding="utf-8", dir=path.parent,
|
|
50
|
+
prefix=".install-manifest.", suffix=".tmp", delete=False,
|
|
51
|
+
) as temporary:
|
|
52
|
+
temporary_name = temporary.name
|
|
53
|
+
json.dump(data, temporary, indent=2)
|
|
54
|
+
temporary.write("\n")
|
|
55
|
+
temporary.flush()
|
|
56
|
+
os.fsync(temporary.fileno())
|
|
57
|
+
os.replace(temporary_name, path)
|
|
58
|
+
finally:
|
|
59
|
+
if temporary_name and os.path.exists(temporary_name):
|
|
60
|
+
os.unlink(temporary_name)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _purge_previous(host: str, manifest: dict) -> None:
|
|
64
|
+
"""Remove files recorded by a previous install of the same host."""
|
|
65
|
+
entry = manifest.get("hosts", {}).get(host)
|
|
66
|
+
if not entry:
|
|
67
|
+
return
|
|
68
|
+
removed = 0
|
|
69
|
+
for name in entry.get("files", []):
|
|
70
|
+
try:
|
|
71
|
+
os.unlink(name)
|
|
72
|
+
removed += 1
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
# Drop directories the previous install created, if now empty
|
|
76
|
+
# (deepest first, so nested dirs like dispatch/references/ go before dispatch/).
|
|
77
|
+
# Only use created_dirs — do not try to rmdir file parents that the user may own.
|
|
78
|
+
candidates = {Path(d) for d in entry.get("created_dirs", [])}
|
|
79
|
+
for directory in sorted(candidates, key=lambda p: len(p.parts), reverse=True):
|
|
80
|
+
try:
|
|
81
|
+
directory.rmdir()
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
print(f" removed {removed} stale file(s) from previous {host} install")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def install_claude(target: Path | None = None) -> dict:
|
|
88
|
+
"""Copy Claude Code agent and command definitions."""
|
|
89
|
+
base = (target or Path.home() / ".claude").resolve()
|
|
90
|
+
src = INTEGRATIONS_DIR / "claude-host"
|
|
91
|
+
if not src.is_dir():
|
|
92
|
+
raise RuntimeError(f"integration source not found: {src}")
|
|
93
|
+
|
|
94
|
+
files: list[str] = []
|
|
95
|
+
created_dirs: list[str] = []
|
|
96
|
+
|
|
97
|
+
def _mkdir(path: Path) -> None:
|
|
98
|
+
if not path.exists():
|
|
99
|
+
created_dirs.append(str(path))
|
|
100
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
|
|
102
|
+
agents_src = src / "agents"
|
|
103
|
+
commands_src = src / "commands"
|
|
104
|
+
|
|
105
|
+
if agents_src.is_dir():
|
|
106
|
+
agents_dst = base / "agents"
|
|
107
|
+
_mkdir(agents_dst)
|
|
108
|
+
for f in agents_src.glob("*.md"):
|
|
109
|
+
shutil.copy2(f, agents_dst / f.name)
|
|
110
|
+
files.append(str(agents_dst / f.name))
|
|
111
|
+
print(f" installed agent: {f.name}")
|
|
112
|
+
|
|
113
|
+
if commands_src.is_dir():
|
|
114
|
+
for provider_dir in commands_src.iterdir():
|
|
115
|
+
if not provider_dir.is_dir():
|
|
116
|
+
continue
|
|
117
|
+
dst = base / "commands" / provider_dir.name
|
|
118
|
+
_mkdir(dst)
|
|
119
|
+
for f in provider_dir.glob("*.md"):
|
|
120
|
+
shutil.copy2(f, dst / f.name)
|
|
121
|
+
files.append(str(dst / f.name))
|
|
122
|
+
print(f" installed command: {provider_dir.name}/{f.name}")
|
|
123
|
+
|
|
124
|
+
return {"files": files, "created_dirs": created_dirs}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def install_codex(target: Path | None = None) -> dict:
|
|
128
|
+
"""Copy Codex skill definitions."""
|
|
129
|
+
codex_home = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
|
|
130
|
+
base = (target or codex_home / "skills").resolve()
|
|
131
|
+
src = INTEGRATIONS_DIR / "codex-host"
|
|
132
|
+
if not src.is_dir():
|
|
133
|
+
raise RuntimeError(f"integration source not found: {src}")
|
|
134
|
+
|
|
135
|
+
files: list[str] = []
|
|
136
|
+
created_dirs: list[str] = []
|
|
137
|
+
|
|
138
|
+
for skill_dir in src.iterdir():
|
|
139
|
+
if not skill_dir.is_dir():
|
|
140
|
+
continue
|
|
141
|
+
dst = base / skill_dir.name
|
|
142
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
# Copy files individually (do not rmtree — that would delete user content)
|
|
144
|
+
for src_file in sorted(skill_dir.rglob("*")):
|
|
145
|
+
if src_file.is_dir():
|
|
146
|
+
continue
|
|
147
|
+
rel = src_file.relative_to(skill_dir)
|
|
148
|
+
dest_file = dst / rel
|
|
149
|
+
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
shutil.copy2(src_file, dest_file)
|
|
151
|
+
created_dirs.append(str(dst))
|
|
152
|
+
for f in sorted(dst.rglob("*")):
|
|
153
|
+
if f.is_dir():
|
|
154
|
+
created_dirs.append(str(f))
|
|
155
|
+
elif f.is_file():
|
|
156
|
+
files.append(str(f))
|
|
157
|
+
print(f" installed skill: {skill_dir.name}/")
|
|
158
|
+
|
|
159
|
+
return {"files": files, "created_dirs": created_dirs}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
163
|
+
parser = argparse.ArgumentParser(description="Install pilot-workers integrations.")
|
|
164
|
+
parser.add_argument("host", choices=["claude", "codex", "all"], help="Target host.")
|
|
165
|
+
parser.add_argument("--target", type=Path, help="Override target directory.")
|
|
166
|
+
return parser.parse_args(argv)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main(argv: list[str] | None = None) -> int:
|
|
170
|
+
args = parse_args(argv)
|
|
171
|
+
try:
|
|
172
|
+
manifest_path = _manifest_path()
|
|
173
|
+
manifest = _load_manifest(manifest_path)
|
|
174
|
+
manifest["schema_version"] = MANIFEST_SCHEMA_VERSION
|
|
175
|
+
manifest.setdefault("hosts", {})
|
|
176
|
+
for host, label, installer in (
|
|
177
|
+
("claude", "Claude Code", install_claude),
|
|
178
|
+
("codex", "Codex", install_codex),
|
|
179
|
+
):
|
|
180
|
+
if args.host not in (host, "all"):
|
|
181
|
+
continue
|
|
182
|
+
print(f"Installing {label} integrations...")
|
|
183
|
+
_purge_previous(host, manifest)
|
|
184
|
+
result = installer(args.target)
|
|
185
|
+
manifest["hosts"][host] = {
|
|
186
|
+
"installed_at": datetime.now(timezone.utc).isoformat(),
|
|
187
|
+
"package_version": _package_version(),
|
|
188
|
+
"files": result["files"],
|
|
189
|
+
"created_dirs": result["created_dirs"],
|
|
190
|
+
}
|
|
191
|
+
_write_manifest(manifest_path, manifest)
|
|
192
|
+
print("Done.")
|
|
193
|
+
return 0
|
|
194
|
+
except (OSError, RuntimeError) as exc:
|
|
195
|
+
print(f"error: {exc}")
|
|
196
|
+
return 1
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _uninstall_host(entry: dict) -> None:
|
|
200
|
+
for name in entry.get("files", []):
|
|
201
|
+
path = Path(name)
|
|
202
|
+
if not path.exists():
|
|
203
|
+
print(f"note: already gone: {name}")
|
|
204
|
+
continue
|
|
205
|
+
path.unlink()
|
|
206
|
+
print(f"removed: {name}")
|
|
207
|
+
dirs = sorted(entry.get("created_dirs", []),
|
|
208
|
+
key=lambda p: len(Path(p).parts), reverse=True)
|
|
209
|
+
for name in dirs:
|
|
210
|
+
try:
|
|
211
|
+
os.rmdir(name)
|
|
212
|
+
print(f"removed: {name}")
|
|
213
|
+
except OSError:
|
|
214
|
+
print(f"note: kept non-empty directory: {name}")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def uninstall_main(argv: list[str] | None = None) -> int:
|
|
218
|
+
parser = argparse.ArgumentParser(description="Uninstall pilot-workers integrations.")
|
|
219
|
+
parser.add_argument("host", choices=["claude", "codex", "all"], help="Target host.")
|
|
220
|
+
args = parser.parse_args(argv)
|
|
221
|
+
|
|
222
|
+
hosts = ["claude", "codex"] if args.host == "all" else [args.host]
|
|
223
|
+
manifest_path = _manifest_path()
|
|
224
|
+
if not manifest_path.exists():
|
|
225
|
+
print(f"error: no install manifest found at {manifest_path}", file=sys.stderr)
|
|
226
|
+
return 1
|
|
227
|
+
try:
|
|
228
|
+
manifest = _load_manifest(manifest_path)
|
|
229
|
+
except (OSError, RuntimeError) as exc:
|
|
230
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
231
|
+
return 1
|
|
232
|
+
entries = manifest.get("hosts", {})
|
|
233
|
+
present = [h for h in hosts if h in entries]
|
|
234
|
+
missing = [h for h in hosts if h not in entries]
|
|
235
|
+
if missing and not present:
|
|
236
|
+
print(f"error: no manifest entry for host(s): {', '.join(missing)}",
|
|
237
|
+
file=sys.stderr)
|
|
238
|
+
return 1
|
|
239
|
+
if missing:
|
|
240
|
+
for h in missing:
|
|
241
|
+
print(f"note: no manifest entry for {h}, skipping", file=sys.stderr)
|
|
242
|
+
try:
|
|
243
|
+
for host in present:
|
|
244
|
+
print(f"Uninstalling {host} integrations...")
|
|
245
|
+
_uninstall_host(entries.pop(host))
|
|
246
|
+
if entries:
|
|
247
|
+
manifest["hosts"] = entries
|
|
248
|
+
_write_manifest(manifest_path, manifest)
|
|
249
|
+
else:
|
|
250
|
+
manifest_path.unlink()
|
|
251
|
+
print("Done.")
|
|
252
|
+
return 0
|
|
253
|
+
except (OSError, RuntimeError) as exc:
|
|
254
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
255
|
+
return 1
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
if __name__ == "__main__":
|
|
259
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Unified CLI entry: pilot-workers <subcommand> ..."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
USAGE = """usage: pilot-workers <subcommand> [args]
|
|
12
|
+
|
|
13
|
+
subcommands:
|
|
14
|
+
run Dispatch a bounded task to an isolated LLM worker.
|
|
15
|
+
dispatch Deterministic outer shell around run (started + verdict JSON).
|
|
16
|
+
template Print the task template for a mode (code|explore|test|review).
|
|
17
|
+
install Install worker integration assets.
|
|
18
|
+
uninstall Remove worker integration assets.
|
|
19
|
+
credentials Configure isolated worker credentials.
|
|
20
|
+
maintain Worker log and worktree lifecycle tools.
|
|
21
|
+
runtime Manage the worker runtime (runtime install).
|
|
22
|
+
|
|
23
|
+
Use 'pilot-workers <subcommand> --help' for subcommand-specific help.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _with_argv(program: str, rest: list[str], fn) -> int:
|
|
28
|
+
"""Call a no-arg main() that parses sys.argv, with a temporary argv."""
|
|
29
|
+
original = sys.argv
|
|
30
|
+
sys.argv = [program] + rest
|
|
31
|
+
try:
|
|
32
|
+
return fn()
|
|
33
|
+
finally:
|
|
34
|
+
sys.argv = original
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv: list[str] | None = None) -> int:
|
|
38
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
39
|
+
if not args or args[0] in ("-h", "--help"):
|
|
40
|
+
print(USAGE, end="")
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
subcommand, rest = args[0], args[1:]
|
|
44
|
+
|
|
45
|
+
if subcommand == "run":
|
|
46
|
+
from pilot_workers.cli.run import main as run_main
|
|
47
|
+
|
|
48
|
+
return run_main(rest)
|
|
49
|
+
|
|
50
|
+
if subcommand == "dispatch":
|
|
51
|
+
from pilot_workers.cli.dispatch import main as dispatch_main
|
|
52
|
+
|
|
53
|
+
return dispatch_main(rest)
|
|
54
|
+
|
|
55
|
+
if subcommand == "template":
|
|
56
|
+
import pilot_workers
|
|
57
|
+
|
|
58
|
+
modes = ("code", "explore", "test", "review")
|
|
59
|
+
if len(rest) != 1 or rest[0] not in modes:
|
|
60
|
+
print(f"usage: pilot-workers template {{{'|'.join(modes)}}}", file=sys.stderr)
|
|
61
|
+
return 2
|
|
62
|
+
path = (
|
|
63
|
+
Path(pilot_workers.__file__).resolve().parent
|
|
64
|
+
/ "data" / "templates" / f"{rest[0]}.md"
|
|
65
|
+
)
|
|
66
|
+
if not path.is_file():
|
|
67
|
+
print(f"error: template missing from package: {path}", file=sys.stderr)
|
|
68
|
+
return 1
|
|
69
|
+
sys.stdout.write(path.read_text(encoding="utf-8"))
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
if subcommand == "install":
|
|
73
|
+
from pilot_workers.cli.install import main as install_main
|
|
74
|
+
|
|
75
|
+
return install_main(rest)
|
|
76
|
+
|
|
77
|
+
if subcommand == "uninstall":
|
|
78
|
+
from pilot_workers.cli import install as install_mod
|
|
79
|
+
|
|
80
|
+
fn = getattr(install_mod, "uninstall_main", None)
|
|
81
|
+
if fn is None:
|
|
82
|
+
print("error: uninstall not available in this build", file=sys.stderr)
|
|
83
|
+
return 1
|
|
84
|
+
return fn(rest)
|
|
85
|
+
|
|
86
|
+
if subcommand == "credentials":
|
|
87
|
+
from pilot_workers.credentials import main as credentials_main
|
|
88
|
+
|
|
89
|
+
return _with_argv("pilot-workers credentials", rest, credentials_main)
|
|
90
|
+
|
|
91
|
+
if subcommand == "maintain":
|
|
92
|
+
from pilot_workers.maintain import main as maintain_main
|
|
93
|
+
|
|
94
|
+
return _with_argv("pilot-workers maintain", rest, maintain_main)
|
|
95
|
+
|
|
96
|
+
if subcommand == "runtime":
|
|
97
|
+
if rest != ["install"]:
|
|
98
|
+
print("usage: pilot-workers runtime install", file=sys.stderr)
|
|
99
|
+
return 2
|
|
100
|
+
import pilot_workers
|
|
101
|
+
|
|
102
|
+
script = (
|
|
103
|
+
Path(pilot_workers.__file__).resolve().parent
|
|
104
|
+
/ "scripts"
|
|
105
|
+
/ "install_runtime.sh"
|
|
106
|
+
)
|
|
107
|
+
return subprocess.run(["bash", str(script)]).returncode
|
|
108
|
+
|
|
109
|
+
print(f"error: unknown subcommand: {subcommand}", file=sys.stderr)
|
|
110
|
+
print(USAGE, end="", file=sys.stderr)
|
|
111
|
+
return 2
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
raise SystemExit(main())
|
pilot_workers/cli/run.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CLI entry: pilot-workers run — dispatch a worker task."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import secrets
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from pilot_workers import fmt_events, policy, providers, runtime
|
|
15
|
+
from pilot_workers.runners import get_runner
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DEFAULT_TIMEOUT_S = 3600
|
|
19
|
+
DEFAULT_IDLE_TIMEOUT_S = 900
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_task(args: argparse.Namespace) -> str:
|
|
23
|
+
if args.task is not None:
|
|
24
|
+
task = args.task
|
|
25
|
+
else:
|
|
26
|
+
path = Path(args.task_file).expanduser().resolve()
|
|
27
|
+
if not path.is_file():
|
|
28
|
+
raise RuntimeError(f"task file does not exist: {path}")
|
|
29
|
+
if path.stat().st_size > providers.MAX_TASK_BYTES:
|
|
30
|
+
raise RuntimeError(f"task file exceeds {providers.MAX_TASK_BYTES} bytes: {path}")
|
|
31
|
+
task = path.read_text(encoding="utf-8")
|
|
32
|
+
if not task.strip():
|
|
33
|
+
raise RuntimeError("task must not be empty")
|
|
34
|
+
if len(task.encode("utf-8")) > providers.MAX_TASK_BYTES:
|
|
35
|
+
raise RuntimeError(f"task exceeds {providers.MAX_TASK_BYTES} bytes")
|
|
36
|
+
return task.strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_mode_arguments(args: argparse.Namespace) -> None:
|
|
40
|
+
if args.mode == "resume" and not args.session:
|
|
41
|
+
raise RuntimeError("--session is required when --mode resume is used")
|
|
42
|
+
if args.mode != "resume" and args.session:
|
|
43
|
+
raise RuntimeError("--session is only valid with --mode resume")
|
|
44
|
+
if args.mode == "resume" and args.worktree:
|
|
45
|
+
raise RuntimeError("resume the previously reported work directory; do not create a new worktree")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def dry_run_summary(provider: providers.Provider, mode: str, workdir: Path, *, permission_profile: str | None = None) -> dict:
|
|
49
|
+
runner = get_runner(provider.runner)
|
|
50
|
+
config = runner.build_config(provider, mode, permission_profile=permission_profile)
|
|
51
|
+
paths = providers.profile_paths(provider)
|
|
52
|
+
effective_profile = permission_profile or provider.permissions
|
|
53
|
+
bp = runner.binary_path()
|
|
54
|
+
return {
|
|
55
|
+
"type": "worker_runner.dry_run",
|
|
56
|
+
"provider": provider.key,
|
|
57
|
+
"runner": provider.runner,
|
|
58
|
+
"provider_id": provider.provider_id,
|
|
59
|
+
"endpoint": provider.base_url,
|
|
60
|
+
"model": provider.model,
|
|
61
|
+
"agent": policy.MODE_TO_AGENT[mode],
|
|
62
|
+
"mode": mode,
|
|
63
|
+
"workdir": str(workdir),
|
|
64
|
+
"sharing": config["share"],
|
|
65
|
+
"enabled_providers": config["enabled_providers"],
|
|
66
|
+
"permission_profile": effective_profile,
|
|
67
|
+
"profile": str(paths["root"]),
|
|
68
|
+
"credential": runtime.credential_metadata(provider, runner),
|
|
69
|
+
"runtime": str(bp) if bp else None,
|
|
70
|
+
"runtime_present": bp.is_file() if bp else False,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
75
|
+
parser = argparse.ArgumentParser(description="Dispatch a bounded task to an isolated LLM worker.")
|
|
76
|
+
parser.add_argument("--provider", required=True, choices=sorted(providers.PROVIDERS))
|
|
77
|
+
parser.add_argument("--mode", required=True, choices=sorted(policy.MODE_TO_AGENT))
|
|
78
|
+
parser.add_argument("--workdir", required=True, help="Existing project directory.")
|
|
79
|
+
task_group = parser.add_mutually_exclusive_group(required=True)
|
|
80
|
+
task_group.add_argument("--task", help="Short task contract as a string.")
|
|
81
|
+
task_group.add_argument("--task-file", help="UTF-8 file containing the task contract.")
|
|
82
|
+
parser.add_argument("--session", help="Session ID for resume mode.")
|
|
83
|
+
parser.add_argument("--worktree", action="store_true", help="Create a detached worktree from HEAD.")
|
|
84
|
+
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_S)
|
|
85
|
+
parser.add_argument("--idle-timeout", type=int, default=DEFAULT_IDLE_TIMEOUT_S)
|
|
86
|
+
parser.add_argument("--permissions", help="Permission profile name (overrides provider YAML).")
|
|
87
|
+
parser.add_argument("--dry-run", action="store_true", help="Show routing metadata without invoking a model.")
|
|
88
|
+
return parser.parse_args(argv)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main(argv: list[str] | None = None) -> int:
|
|
92
|
+
args = parse_args(argv)
|
|
93
|
+
try:
|
|
94
|
+
validate_mode_arguments(args)
|
|
95
|
+
if args.timeout < 0 or args.idle_timeout < 0:
|
|
96
|
+
raise RuntimeError("--timeout and --idle-timeout must be >= 0")
|
|
97
|
+
provider = providers.PROVIDERS[args.provider]
|
|
98
|
+
workdir = Path(args.workdir).expanduser().resolve()
|
|
99
|
+
if not workdir.is_dir():
|
|
100
|
+
raise RuntimeError(f"work directory does not exist: {workdir}")
|
|
101
|
+
task = load_task(args)
|
|
102
|
+
|
|
103
|
+
if args.dry_run:
|
|
104
|
+
print(json.dumps(dry_run_summary(provider, args.mode, workdir, permission_profile=args.permissions), indent=2))
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
runner = get_runner(provider.runner)
|
|
108
|
+
binary = runner.resolve_binary()
|
|
109
|
+
secret = runtime.credential_key(provider, runner)
|
|
110
|
+
if args.worktree:
|
|
111
|
+
workdir = runtime.create_detached_worktree(workdir, providers.worktrees_root())
|
|
112
|
+
|
|
113
|
+
config = runner.build_config(provider, args.mode, permission_profile=args.permissions)
|
|
114
|
+
env = runtime.build_environment(provider, runner.runner_environment(provider, config))
|
|
115
|
+
logs = providers.logs_root(provider)
|
|
116
|
+
runtime.ensure_private_directory(logs)
|
|
117
|
+
run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{secrets.token_hex(4)}"
|
|
118
|
+
log_path = logs / f"{run_id}.jsonl"
|
|
119
|
+
stderr_path = logs / f"{run_id}.stderr.log"
|
|
120
|
+
agent = policy.MODE_TO_AGENT[args.mode]
|
|
121
|
+
prompt = runner.format_task_input(task, args.mode)
|
|
122
|
+
command = runner.build_command(binary, provider, args.mode, workdir, run_id, args.session)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
renderer = fmt_events.FmtWriter(logs, provider.key, run_id, os.getpid())
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
print(f"note: live log rendering unavailable ({exc})", file=sys.stderr)
|
|
128
|
+
renderer = None
|
|
129
|
+
|
|
130
|
+
started = {
|
|
131
|
+
"type": "worker_runner.started",
|
|
132
|
+
"provider": provider.key,
|
|
133
|
+
"runner": provider.runner,
|
|
134
|
+
"model": provider.model,
|
|
135
|
+
"mode": args.mode,
|
|
136
|
+
"agent": agent,
|
|
137
|
+
"run_id": run_id,
|
|
138
|
+
"workdir": str(workdir),
|
|
139
|
+
"log": str(log_path),
|
|
140
|
+
"stderr_log": str(stderr_path),
|
|
141
|
+
"rendered_log": str(logs / "latest.log") if renderer else None,
|
|
142
|
+
"timeout_s": args.timeout,
|
|
143
|
+
"idle_timeout_s": args.idle_timeout,
|
|
144
|
+
}
|
|
145
|
+
print(json.dumps(started), flush=True)
|
|
146
|
+
if renderer is not None:
|
|
147
|
+
try:
|
|
148
|
+
renderer.write_event(started)
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
print(f"note: live log rendering disabled ({exc})", file=sys.stderr)
|
|
151
|
+
renderer = None
|
|
152
|
+
|
|
153
|
+
result = runtime.run_process(
|
|
154
|
+
command, env, prompt, log_path, stderr_path, secret,
|
|
155
|
+
renderer=renderer, timeout_s=args.timeout, idle_timeout_s=args.idle_timeout,
|
|
156
|
+
runner=runner,
|
|
157
|
+
)
|
|
158
|
+
secret = ""
|
|
159
|
+
summary = {
|
|
160
|
+
"type": "worker_runner.summary",
|
|
161
|
+
"provider": provider.key,
|
|
162
|
+
"runner": provider.runner,
|
|
163
|
+
"model": provider.model,
|
|
164
|
+
"mode": args.mode,
|
|
165
|
+
"agent": agent,
|
|
166
|
+
"run_id": run_id,
|
|
167
|
+
"session_id": result.session_id or args.session,
|
|
168
|
+
"workdir": str(workdir),
|
|
169
|
+
"log": str(log_path),
|
|
170
|
+
"stderr_log": str(stderr_path),
|
|
171
|
+
"rendered_log": started["rendered_log"],
|
|
172
|
+
"timed_out": result.timed_out,
|
|
173
|
+
"idle_timed_out": result.idle_timed_out,
|
|
174
|
+
"interrupted": result.interrupted,
|
|
175
|
+
"exit_code": result.exit_code,
|
|
176
|
+
}
|
|
177
|
+
print(json.dumps(summary))
|
|
178
|
+
if renderer is not None:
|
|
179
|
+
try:
|
|
180
|
+
renderer.write_event(summary)
|
|
181
|
+
renderer.finalize()
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
print(f"note: live log rendering disabled ({exc})", file=sys.stderr)
|
|
184
|
+
return result.exit_code
|
|
185
|
+
except (OSError, RuntimeError, UnicodeError) as exc:
|
|
186
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
187
|
+
return 1
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Store worker credentials in provider-isolated auth files."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import getpass
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
|
|
12
|
+
from pilot_workers.providers import PROVIDERS, workers_root
|
|
13
|
+
from pilot_workers.runners import get_runner
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def credential_status(provider_key: str) -> dict[str, object]:
|
|
17
|
+
provider = PROVIDERS[provider_key]
|
|
18
|
+
runner = get_runner(provider.runner)
|
|
19
|
+
path = runner.credential_path(provider)
|
|
20
|
+
configured = False
|
|
21
|
+
secure_mode = False
|
|
22
|
+
error: str | None = None
|
|
23
|
+
if path.is_file():
|
|
24
|
+
secure_mode = (path.stat().st_mode & 0o077) == 0
|
|
25
|
+
try:
|
|
26
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
27
|
+
try:
|
|
28
|
+
key = runner.parse_credential(provider, payload)
|
|
29
|
+
except (RuntimeError, TypeError, AttributeError) as exc:
|
|
30
|
+
error = str(exc)
|
|
31
|
+
key = ""
|
|
32
|
+
configured = bool(key and key.strip())
|
|
33
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
34
|
+
error = str(exc)
|
|
35
|
+
return {
|
|
36
|
+
"provider": provider_key,
|
|
37
|
+
"runner": provider.runner,
|
|
38
|
+
"provider_id": provider.provider_id,
|
|
39
|
+
"configured": configured,
|
|
40
|
+
"secure_mode": secure_mode,
|
|
41
|
+
"path": str(path),
|
|
42
|
+
"error": error,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ensure_private_directories(destination) -> None:
|
|
47
|
+
current = workers_root()
|
|
48
|
+
target = destination.parent
|
|
49
|
+
while True:
|
|
50
|
+
current.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
51
|
+
current.chmod(0o700)
|
|
52
|
+
if current == target:
|
|
53
|
+
return
|
|
54
|
+
current = current / target.relative_to(current).parts[0]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def configure(provider_key: str) -> None:
|
|
58
|
+
provider = PROVIDERS[provider_key]
|
|
59
|
+
runner = get_runner(provider.runner)
|
|
60
|
+
key = getpass.getpass(f"{provider_key} API key (input hidden): ").strip()
|
|
61
|
+
if not key:
|
|
62
|
+
raise SystemExit(f"error: empty {provider_key} API key; no file was changed")
|
|
63
|
+
destination = runner.credential_path(provider)
|
|
64
|
+
ensure_private_directories(destination)
|
|
65
|
+
payload = runner.credential_payload(provider, key)
|
|
66
|
+
temporary_name: str | None = None
|
|
67
|
+
try:
|
|
68
|
+
with tempfile.NamedTemporaryFile(
|
|
69
|
+
mode="w", encoding="utf-8", dir=destination.parent,
|
|
70
|
+
prefix=".auth.", suffix=".tmp", delete=False,
|
|
71
|
+
) as temporary:
|
|
72
|
+
temporary_name = temporary.name
|
|
73
|
+
os.chmod(temporary_name, 0o600)
|
|
74
|
+
json.dump(payload, temporary, separators=(",", ":"))
|
|
75
|
+
temporary.write("\n")
|
|
76
|
+
temporary.flush()
|
|
77
|
+
os.fsync(temporary.fileno())
|
|
78
|
+
os.replace(temporary_name, destination)
|
|
79
|
+
os.chmod(destination, 0o600)
|
|
80
|
+
finally:
|
|
81
|
+
key = ""
|
|
82
|
+
if temporary_name and os.path.exists(temporary_name):
|
|
83
|
+
os.unlink(temporary_name)
|
|
84
|
+
print(f"Configured {provider_key} in {destination} (mode 0600); key not displayed")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main() -> int:
|
|
88
|
+
parser = argparse.ArgumentParser(description="Configure isolated worker credentials.")
|
|
89
|
+
parser.add_argument("provider", choices=[*sorted(PROVIDERS), "all"])
|
|
90
|
+
parser.add_argument("--status", action="store_true", help="Show metadata only; never show keys.")
|
|
91
|
+
args = parser.parse_args()
|
|
92
|
+
keys = sorted(PROVIDERS) if args.provider == "all" else [args.provider]
|
|
93
|
+
if args.status:
|
|
94
|
+
print(json.dumps([credential_status(item) for item in keys], indent=2))
|
|
95
|
+
return 0
|
|
96
|
+
for provider_key in keys:
|
|
97
|
+
configure(provider_key)
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
raise SystemExit(main())
|