ci-fleet 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.
ci_fleet/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """ci_fleet — self-hosted GitHub Actions runner fleet management.
2
+
3
+ Extracted from ``charlie_work`` so runner development and orchestrator
4
+ development stop colliding. Import concrete modules directly; this package
5
+ intentionally exposes no barrel re-exports.
6
+
7
+ The one exception is :mod:`ci_fleet.charlie_work_adapter`, which exists solely
8
+ to hold the legacy import surface stable during the migration. Nothing inside
9
+ this package may import from it.
10
+ """
11
+
12
+ __version__ = "0.1.0"
@@ -0,0 +1,16 @@
1
+ """Vendored support code copied from ``charlie_work``.
2
+
3
+ Every module here carries a provenance header naming the source module and the
4
+ date it was copied. These are *copies of specific named functions and classes*,
5
+ not whole-file mirrors: only the surface the fleet code actually calls comes
6
+ across.
7
+
8
+ Hard rule for this package: **nothing under ``ci_fleet`` may import
9
+ ``charlie_work``.** Vendoring exists precisely so the dependency arrow points
10
+ one way. A ``charlie_work`` import here would create the cycle the extraction
11
+ was meant to break.
12
+
13
+ Two readings of "vendor" were considered and rejected in the plan:
14
+ - having ``ci_fleet`` depend on ``charlie_work`` (creates the cycle), and
15
+ - extracting a third shared package (out of scope for this migration).
16
+ """
@@ -0,0 +1,119 @@
1
+ # vendored from charlie_work.fleet_paths on 2026-07-30;
2
+ # do not import charlie_work from this package.
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def _paths_equal(a: Path, b: Path) -> bool:
14
+ """Compare two paths for equality using the OS's own normalization.
15
+
16
+ ``PurePath.__eq__`` is case-sensitive even on Windows, so ``C:\\Foo`` and
17
+ ``c:\\foo`` (the same file) would falsely report virtualization. The probe
18
+ keys on a real divergence between literal and resolved paths, not on a
19
+ case-only spelling difference, so normalize before comparing.
20
+ """
21
+ return os.path.normcase(os.fspath(a)) == os.path.normcase(os.fspath(b))
22
+
23
+
24
+ def detect_path_virtualization(literal: Path) -> tuple[Path, Path] | None:
25
+ """Detect per-process virtualization of a path (issue #624).
26
+
27
+ MSIX/container copy-on-write redirection sits *below* the environment
28
+ variable: the literal path string is identical in both the container and
29
+ the host, but ``Path.resolve()`` follows the reparse point to the
30
+ redirected location. Reads pass through to the real file; the first write
31
+ forks a private copy that daemons reading the same path string never see.
32
+
33
+ The load-bearing signal is that the literal path and its resolved form
34
+ disagree — never a hardcoded package moniker (which would rot on the next
35
+ app update and only cover one container). Returns ``(literal, resolved)``
36
+ when they diverge, else ``None``.
37
+
38
+ Resolution errors are treated as "not virtualized" rather than raising: a
39
+ probe that crashes the caller is worse than one that stays silent on an
40
+ unreadable path. ``Path.resolve(strict=False)`` (the default) does not
41
+ require the path to exist, so an absent fleet dir still resolves cleanly
42
+ and only a real reparse-point divergence fires.
43
+ """
44
+ try:
45
+ resolved = literal.resolve()
46
+ except OSError:
47
+ return None
48
+ if not _paths_equal(resolved, literal):
49
+ return (literal, resolved)
50
+ return None
51
+
52
+
53
+ def fleet_dir_virtualization(*, override: str | None = None) -> tuple[Path, Path] | None:
54
+ """Detect per-process virtualization of the fleet directory (issue #624).
55
+
56
+ Thin wrapper over :func:`detect_path_virtualization` keyed on the fleet
57
+ dir. Repo-agnostic by construction: the fleet dir is a host-wide
58
+ per-process property, not a project layout, so the same probe fires for
59
+ every registered repo on this host.
60
+ """
61
+ return detect_path_virtualization(fleet_dir(override=override))
62
+
63
+
64
+ def warn_fleet_dir_virtualization_on_write(literal: Path, *, context: str) -> None:
65
+ """Log a warning when a host-wide write lands in a virtualized copy.
66
+
67
+ Called from fleet-dir state writers so "I deployed it" cannot be reported
68
+ when the write forked a private copy that daemons reading the same path
69
+ string will never see (issue #624; this is the exact shape of the #590
70
+ failure). Never raises and never blocks the write — the operator already
71
+ asked for it; this only names where it actually landed.
72
+ """
73
+ diverged = detect_path_virtualization(literal)
74
+ if diverged is None:
75
+ return
76
+ _literal, resolved = diverged
77
+ logger.warning(
78
+ "Fleet dir virtualization detected while %s: %s resolves to %s — "
79
+ "this write lands in a private copy invisible to scheduled tasks and "
80
+ "daemons reading the same path string (issue #624; see also #590). "
81
+ "Daemon-visible state must be written via a non-redirected route "
82
+ "(e.g. a UNC path).",
83
+ context,
84
+ literal,
85
+ resolved,
86
+ )
87
+
88
+
89
+ def fleet_dir(*, override: str | None = None) -> Path:
90
+ """Return the user-level fleet directory for charlie-work.
91
+
92
+ Platform-specific defaults:
93
+ - Windows (win32): %LOCALAPPDATA%\\charlie-work\\
94
+ - POSIX: ${XDG_STATE_HOME:-~/.local/state}/charlie-work/
95
+
96
+ The override parameter (or CHARLIE_WORK_FLEET_DIR env var) allows
97
+ test isolation without hardcoding platform-specific paths in fixtures.
98
+
99
+ Args:
100
+ override: Optional path string to use instead of the platform default.
101
+ If None, checks CHARLIE_WORK_FLEET_DIR env var, then uses
102
+ the platform default.
103
+
104
+ Returns:
105
+ Path to the fleet directory.
106
+ """
107
+ if override is not None:
108
+ return Path(override)
109
+
110
+ env_override = os.environ.get("CHARLIE_WORK_FLEET_DIR")
111
+ if env_override:
112
+ return Path(env_override)
113
+
114
+ if sys.platform == "win32":
115
+ base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
116
+ else:
117
+ base = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
118
+
119
+ return base / "charlie-work"
@@ -0,0 +1,73 @@
1
+ # vendored from charlie_work.safe_path on 2026-07-30; do not import charlie_work from this package.
2
+ """Single point of enforcement for path-containment checks.
3
+
4
+ Consolidates the ad hoc ``.resolve()`` + ``is_relative_to()`` pairs previously
5
+ duplicated across five independent call sites (``runner_slots.py``,
6
+ ``worktree.py`` x3, ``supervise.py``) with no single source of truth for what
7
+ "safely contained" means. None of them resolved symlinks/junctions
8
+ defensively on *both* sides before comparing, and one (``worktree.py``'s
9
+ ``_materialize_directory``) compared unresolved paths, which does not detect
10
+ a `..` segment escaping the base directory (``Path.is_relative_to`` is a
11
+ purely lexical part-prefix check; it does not collapse ``..``).
12
+
13
+ The two-function shape (a predicate plus a raising variant) is ported from a
14
+ prior Node implementation -- the design, not the code, since ``fs.realpathSync``
15
+ and ``pathlib`` differ in exactly the nonexistent-path handling that matters
16
+ here.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+
23
+
24
+ def _resolve_defensive(path: Path) -> Path:
25
+ """Resolve ``path``, collapsing symlinks/junctions on every existing segment.
26
+
27
+ ``Path.resolve()`` does not require ``path`` to exist: it resolves every
28
+ existing leading segment (following symlinks/junctions along the way) and
29
+ leaves only trailing nonexistent components as literal names. That already
30
+ matches the ``realpathSync``-with-existing-parent-fallback design, so a
31
+ candidate path that hasn't been created yet can still be checked for
32
+ containment before it's materialized.
33
+ """
34
+ return path.resolve()
35
+
36
+
37
+ def contains(base: Path, candidate: Path) -> bool:
38
+ """Return ``True`` when ``candidate`` resolves to a path inside ``base``.
39
+
40
+ Both sides are resolved before comparing, so this catches a reparse point
41
+ or symlink that makes ``candidate`` *look* contained lexically but
42
+ actually escapes ``base`` on disk (the CLAUDE.md-declared ``managed_root``
43
+ invariant this guards). Use at any site that can simply skip a
44
+ non-contained entry (e.g. a discovery loop); use ``require_contained``
45
+ where skipping isn't an option.
46
+ """
47
+ resolved_base = _resolve_defensive(base)
48
+ resolved_candidate = _resolve_defensive(candidate)
49
+ return resolved_candidate == resolved_base or resolved_candidate.is_relative_to(resolved_base)
50
+
51
+
52
+ def require_contained(base: Path, candidate: Path, *, context: str) -> Path:
53
+ """Return the resolved ``candidate``, raising ``ValueError`` if it escapes ``base``.
54
+
55
+ For boundaries where an externally-influenced path (config, JSON state, a
56
+ subprocess's stdout) is about to be read from or written to, and silently
57
+ skipping would hide the problem rather than surface it. ``context`` is
58
+ included in the error message to identify the call site without needing a
59
+ traceback.
60
+ """
61
+ resolved_base = _resolve_defensive(base)
62
+ resolved_candidate = _resolve_defensive(candidate)
63
+ if resolved_candidate != resolved_base and not resolved_candidate.is_relative_to(
64
+ resolved_base
65
+ ):
66
+ raise ValueError(
67
+ f"{context}: {candidate} resolves to {resolved_candidate}, which "
68
+ f"escapes {base} (resolves to {resolved_base})"
69
+ )
70
+ return resolved_candidate
71
+
72
+
73
+ __all__ = ["contains", "require_contained"]
@@ -0,0 +1,227 @@
1
+ # vendored from charlie_work.subprocess_runner on 2026-07-30;
2
+ # do not import charlie_work from this package.
3
+ """One subprocess runner for every adapter and cross-family invocation.
4
+
5
+ Centralizes the Windows-safe capture contract: text mode with explicit UTF-8
6
+ decoding and ``errors="replace"`` (never the cp1252 default), and bytes-safe
7
+ handling of ``TimeoutExpired`` partial output. Callers get a plain result and
8
+ never an encoding crash.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Any, TypedDict
21
+
22
+ _CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
23
+ _DETACHED_PROCESS = getattr(subprocess, "DETACHED_PROCESS", 0)
24
+ _CREATE_NEW_CONSOLE = getattr(subprocess, "CREATE_NEW_CONSOLE", 0)
25
+ _STARTF_USESHOWWINDOW = getattr(subprocess, "STARTF_USESHOWWINDOW", 0)
26
+ _SW_HIDE = getattr(subprocess, "SW_HIDE", 0)
27
+
28
+
29
+ class _SpawnKwargs(TypedDict, total=False):
30
+ creationflags: int
31
+ startupinfo: Any
32
+
33
+
34
+ def no_console_window_kwargs(extra_creationflags: int = 0) -> _SpawnKwargs:
35
+ """Return the ``creationflags`` kwargs that suppress the transient console
36
+ window Windows allocates for a spawned child when the parent has no
37
+ console/window of its own (e.g. an orchestrator running headless or from
38
+ a service). This is the single point of enforcement for
39
+ ``CREATE_NO_WINDOW`` — every ``subprocess.run``/``Popen`` call site in
40
+ this codebase should route its creationflags through this helper rather
41
+ than hard-coding the flag itself.
42
+
43
+ ``extra_creationflags`` composes in whatever flags the call site already
44
+ needs (e.g. ``CREATE_NEW_PROCESS_GROUP`` so a launched worker can still be
45
+ killed by process group). ``CREATE_NO_WINDOW`` is never combined with
46
+ ``DETACHED_PROCESS`` — that combination is invalid/contradictory on
47
+ Windows, since ``DETACHED_PROCESS`` already fully detaches the child from
48
+ any console. Callers passing ``DETACHED_PROCESS`` get their flags back
49
+ unchanged.
50
+
51
+ On POSIX platforms (no ``creationflags`` concept), returns an empty dict
52
+ so callers can unconditionally do
53
+ ``subprocess.Popen(..., **no_console_window_kwargs(...))`` cross-platform.
54
+ """
55
+ if sys.platform != "win32" or not _CREATE_NO_WINDOW:
56
+ return {}
57
+ if extra_creationflags & _DETACHED_PROCESS:
58
+ return {"creationflags": extra_creationflags}
59
+ return {"creationflags": extra_creationflags | _CREATE_NO_WINDOW}
60
+
61
+
62
+ def hidden_console_kwargs(extra_creationflags: int = 0) -> _SpawnKwargs:
63
+ """Return the ``creationflags`` and ``startupinfo`` kwargs that allocate a
64
+ hidden console for a long-lived worker spawn (e.g. ``devin`` or ``claude``).
65
+
66
+ A ``CREATE_NEW_CONSOLE`` process is created with ``STARTF_USESHOWWINDOW`` and
67
+ ``wShowWindow=SW_HIDE``. The worker and all of its console-subsystem
68
+ descendants (pytest, git, gh, bash) inherit that hidden console, so they
69
+ do not each allocate their own visible console window. This is the worker-
70
+ spawn boundary; short-lived leaf spawns continue to use
71
+ ``no_console_window_kwargs()``.
72
+
73
+ ``extra_creationflags`` composes in whatever flags the call site already
74
+ needs (e.g. ``CREATE_NEW_PROCESS_GROUP`` so a launched worker can still be
75
+ killed by process group). ``CREATE_NEW_CONSOLE`` is never combined with
76
+ ``CREATE_NO_WINDOW`` or ``DETACHED_PROCESS`` — those console modes are
77
+ mutually exclusive on Windows. A ``ValueError`` is raised if the caller
78
+ tries to combine them.
79
+
80
+ On POSIX platforms (no ``creationflags`` concept), returns an empty dict so
81
+ callers can unconditionally do
82
+ ``subprocess.Popen(..., **hidden_console_kwargs(...))`` cross-platform.
83
+ """
84
+ if sys.platform != "win32" or not _CREATE_NEW_CONSOLE:
85
+ return {}
86
+ forbidden = extra_creationflags & (_CREATE_NO_WINDOW | _DETACHED_PROCESS)
87
+ if forbidden:
88
+ raise ValueError(
89
+ f"CREATE_NEW_CONSOLE cannot be combined with mutually-exclusive "
90
+ f"console modes (CREATE_NO_WINDOW, DETACHED_PROCESS); got {forbidden:#x}"
91
+ )
92
+ startupinfo = subprocess.STARTUPINFO()
93
+ startupinfo.dwFlags |= _STARTF_USESHOWWINDOW
94
+ startupinfo.wShowWindow = _SW_HIDE
95
+ return {
96
+ "creationflags": extra_creationflags | _CREATE_NEW_CONSOLE,
97
+ "startupinfo": startupinfo,
98
+ }
99
+
100
+
101
+ _NPM_SHIM_EXE_PATTERN = re.compile(r'"%dp0%\\([^"]+\.exe)"', re.IGNORECASE)
102
+
103
+
104
+ def resolve_cli_binary(name: str) -> str:
105
+ """Resolve an npm-installed CLI tool name to its underlying ``.exe`` on Windows.
106
+
107
+ npm-published shims (``claude.CMD``, ``gemini.CMD``, ...) are batch-file
108
+ wrappers around the real ``.exe``. Two distinct problems follow from that,
109
+ and this is the single point of enforcement that fixes both:
110
+
111
+ 1. ``subprocess.Popen``/``subprocess.run`` with ``shell=False`` and a bare
112
+ name (e.g. ``"claude"``) goes straight to Windows' ``CreateProcessW``,
113
+ which does **not** perform the ``PATHEXT``-based extension search that
114
+ ``cmd.exe`` does — so it cannot find ``claude.CMD`` at all and fails
115
+ with ``OSError: [WinError 2] The system cannot find the file
116
+ specified``, even though ``claude`` is on ``PATH`` and works fine when
117
+ typed at a shell prompt (see charlie-work issue #487).
118
+ 2. Pre-resolving via ``shutil.which()`` alone (which *does* honor
119
+ ``PATHEXT`` and returns the full ``.CMD`` path) fixes (1) but trades it
120
+ for a subtler bug: ``CreateProcessW`` on a ``.CMD`` file implicitly
121
+ routes the child through ``cmd.exe``, whose argv parser uses
122
+ caret-escaping rather than the C-runtime backslash-escaping that
123
+ Python's ``subprocess.list2cmdline`` emits. A literal ``|`` in an
124
+ argument value (e.g. a prompt containing ``"small|mid|large"``) is
125
+ then interpreted as a ``cmd.exe`` pipeline separator, breaking the
126
+ invocation.
127
+
128
+ The real fix is to skip ``cmd.exe`` entirely: parse the ``.CMD`` shim to
129
+ find the underlying ``.exe`` it wraps (the npm shim pattern is stable —
130
+ ``"%dp0%\\node_modules\\<pkg>\\bin\\<name>.exe" %*``) and use that ``.exe``
131
+ path directly as ``argv[0]``. ``CreateProcessW`` then invokes it without
132
+ any shell interposed. Ported from a sibling repo's
133
+ ``claude_client._resolve_cli_binary`` (commit fe0cdde7), which fixed the
134
+ identical class of bug for the same npm-installed CLI shims.
135
+
136
+ On POSIX, or when ``shutil.which`` resolves to something other than a
137
+ ``.cmd``/``.bat`` file, the resolved path is returned unchanged (already
138
+ directly executable). If the binary cannot be found on ``PATH`` at all,
139
+ or the shim cannot be parsed/its target ``.exe`` does not exist, the
140
+ original (or ``shutil.which``-resolved) value is returned unchanged — a
141
+ deliberately conservative fallback that preserves the caller's existing
142
+ "binary not found" error handling rather than masking a missing install.
143
+
144
+ Args:
145
+ name: CLI tool name or path, e.g. ``"claude"``.
146
+
147
+ Returns:
148
+ A path safe to pass as ``argv[0]`` to ``subprocess.Popen``/``.run``
149
+ with ``shell=False``.
150
+ """
151
+ path = shutil.which(name)
152
+ if path is None:
153
+ return name
154
+ if os.name != "nt" or not path.lower().endswith((".cmd", ".bat")):
155
+ return path
156
+ try:
157
+ shim_text = Path(path).read_text(encoding="utf-8", errors="replace")
158
+ except OSError:
159
+ return path
160
+ match = _NPM_SHIM_EXE_PATTERN.search(shim_text)
161
+ if match is None:
162
+ return path
163
+ exe_path = Path(os.path.normpath(Path(path).parent / match.group(1)))
164
+ return str(exe_path) if exe_path.exists() else path
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class RunResult:
169
+ returncode: int | None
170
+ stdout: str
171
+ stderr: str
172
+ timed_out: bool = False
173
+ error: str | None = None
174
+
175
+ @property
176
+ def ok(self) -> bool:
177
+ return self.returncode == 0 and self.error is None
178
+
179
+
180
+ def _as_text(value: object) -> str:
181
+ if isinstance(value, bytes):
182
+ return value.decode("utf-8", "replace")
183
+ return str(value) if value else ""
184
+
185
+
186
+ def run_captured(
187
+ command: list[str] | str,
188
+ *,
189
+ cwd: Path | str,
190
+ timeout_seconds: int,
191
+ shell: bool = False,
192
+ stdin: str | None = None,
193
+ ) -> RunResult:
194
+ """Run ``command`` and capture output. Never raises for runtime failures —
195
+ timeouts, missing binaries, and non-zero exits all come back as a result."""
196
+ try:
197
+ completed = subprocess.run(
198
+ command,
199
+ cwd=str(cwd),
200
+ text=True,
201
+ encoding="utf-8",
202
+ errors="replace",
203
+ capture_output=True,
204
+ timeout=timeout_seconds,
205
+ shell=shell,
206
+ check=False,
207
+ input=stdin,
208
+ **hidden_console_kwargs(),
209
+ )
210
+ except subprocess.TimeoutExpired as exc:
211
+ return RunResult(
212
+ returncode=None,
213
+ stdout=_as_text(exc.stdout),
214
+ stderr=_as_text(exc.stderr),
215
+ timed_out=True,
216
+ error=f"command timed out after {timeout_seconds}s",
217
+ )
218
+ except OSError as exc:
219
+ return RunResult(returncode=None, stdout="", stderr="", error=str(exc))
220
+ except subprocess.SubprocessError as exc:
221
+ return RunResult(returncode=None, stdout="", stderr="", error=str(exc))
222
+ return RunResult(
223
+ returncode=completed.returncode,
224
+ stdout=completed.stdout or "",
225
+ stderr=completed.stderr or "",
226
+ error=None if completed.returncode == 0 else f"command exited {completed.returncode}",
227
+ )