graphite-code 0.3.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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
graphite/windows_job.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Race-free Windows process launch inside a kill-on-close Job Object."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ctypes
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
from ctypes import wintypes
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import BinaryIO, Mapping
|
|
11
|
+
|
|
12
|
+
from .process_contracts import WINDOWS_PROCESS_CREATION_LOCK, build_windows_environment_block
|
|
13
|
+
|
|
14
|
+
CREATE_SUSPENDED = 0x00000004
|
|
15
|
+
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
16
|
+
CREATE_UNICODE_ENVIRONMENT = 0x00000400
|
|
17
|
+
EXTENDED_STARTUPINFO_PRESENT = 0x00080000
|
|
18
|
+
STARTF_USESTDHANDLES = 0x00000100
|
|
19
|
+
HANDLE_FLAG_INHERIT = 0x00000001
|
|
20
|
+
PROC_THREAD_ATTRIBUTE_HANDLE_LIST = 0x00020002
|
|
21
|
+
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
|
|
22
|
+
JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
|
|
23
|
+
WAIT_OBJECT_0 = 0
|
|
24
|
+
WAIT_TIMEOUT = 258
|
|
25
|
+
STILL_ACTIVE = 259
|
|
26
|
+
|
|
27
|
+
ULONG_PTR = wintypes.WPARAM
|
|
28
|
+
SIZE_T = ctypes.c_size_t
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SECURITY_ATTRIBUTES(ctypes.Structure):
|
|
32
|
+
_fields_ = (("nLength", wintypes.DWORD), ("lpSecurityDescriptor", wintypes.LPVOID), ("bInheritHandle", wintypes.BOOL))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class STARTUPINFOW(ctypes.Structure):
|
|
36
|
+
_fields_ = (
|
|
37
|
+
("cb", wintypes.DWORD), ("lpReserved", wintypes.LPWSTR), ("lpDesktop", wintypes.LPWSTR),
|
|
38
|
+
("lpTitle", wintypes.LPWSTR), ("dwX", wintypes.DWORD), ("dwY", wintypes.DWORD),
|
|
39
|
+
("dwXSize", wintypes.DWORD), ("dwYSize", wintypes.DWORD), ("dwXCountChars", wintypes.DWORD),
|
|
40
|
+
("dwYCountChars", wintypes.DWORD), ("dwFillAttribute", wintypes.DWORD), ("dwFlags", wintypes.DWORD),
|
|
41
|
+
("wShowWindow", wintypes.WORD), ("cbReserved2", wintypes.WORD), ("lpReserved2", ctypes.POINTER(wintypes.BYTE)),
|
|
42
|
+
("hStdInput", wintypes.HANDLE), ("hStdOutput", wintypes.HANDLE), ("hStdError", wintypes.HANDLE),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class STARTUPINFOEXW(ctypes.Structure):
|
|
47
|
+
_fields_ = (("StartupInfo", STARTUPINFOW), ("lpAttributeList", wintypes.LPVOID))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PROCESS_INFORMATION(ctypes.Structure):
|
|
51
|
+
_fields_ = (("hProcess", wintypes.HANDLE), ("hThread", wintypes.HANDLE), ("dwProcessId", wintypes.DWORD), ("dwThreadId", wintypes.DWORD))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class IO_COUNTERS(ctypes.Structure):
|
|
55
|
+
_fields_ = tuple((name, ctypes.c_ulonglong) for name in (
|
|
56
|
+
"ReadOperationCount", "WriteOperationCount", "OtherOperationCount", "ReadTransferCount",
|
|
57
|
+
"WriteTransferCount", "OtherTransferCount",
|
|
58
|
+
))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
|
|
62
|
+
_fields_ = (
|
|
63
|
+
("PerProcessUserTimeLimit", ctypes.c_longlong), ("PerJobUserTimeLimit", ctypes.c_longlong),
|
|
64
|
+
("LimitFlags", wintypes.DWORD), ("MinimumWorkingSetSize", SIZE_T), ("MaximumWorkingSetSize", SIZE_T),
|
|
65
|
+
("ActiveProcessLimit", wintypes.DWORD), ("Affinity", ULONG_PTR),
|
|
66
|
+
("PriorityClass", wintypes.DWORD), ("SchedulingClass", wintypes.DWORD),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
|
|
71
|
+
_fields_ = (
|
|
72
|
+
("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION), ("IoInfo", IO_COUNTERS),
|
|
73
|
+
("ProcessMemoryLimit", SIZE_T), ("JobMemoryLimit", SIZE_T),
|
|
74
|
+
("PeakProcessMemoryUsed", SIZE_T), ("PeakJobMemoryUsed", SIZE_T),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _kernel32() -> ctypes.WinDLL:
|
|
79
|
+
api = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
80
|
+
api.CreateJobObjectW.argtypes = (ctypes.POINTER(SECURITY_ATTRIBUTES), wintypes.LPCWSTR)
|
|
81
|
+
api.CreateJobObjectW.restype = wintypes.HANDLE
|
|
82
|
+
api.SetInformationJobObject.argtypes = (wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD)
|
|
83
|
+
api.SetInformationJobObject.restype = wintypes.BOOL
|
|
84
|
+
api.CreatePipe.argtypes = (ctypes.POINTER(wintypes.HANDLE), ctypes.POINTER(wintypes.HANDLE), ctypes.POINTER(SECURITY_ATTRIBUTES), wintypes.DWORD)
|
|
85
|
+
api.CreatePipe.restype = wintypes.BOOL
|
|
86
|
+
api.SetHandleInformation.argtypes = (wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD)
|
|
87
|
+
api.SetHandleInformation.restype = wintypes.BOOL
|
|
88
|
+
api.InitializeProcThreadAttributeList.argtypes = (wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(SIZE_T))
|
|
89
|
+
api.InitializeProcThreadAttributeList.restype = wintypes.BOOL
|
|
90
|
+
api.UpdateProcThreadAttribute.argtypes = (wintypes.LPVOID, wintypes.DWORD, SIZE_T, wintypes.LPVOID, SIZE_T, wintypes.LPVOID, ctypes.POINTER(SIZE_T))
|
|
91
|
+
api.UpdateProcThreadAttribute.restype = wintypes.BOOL
|
|
92
|
+
api.DeleteProcThreadAttributeList.argtypes = (wintypes.LPVOID,)
|
|
93
|
+
api.DeleteProcThreadAttributeList.restype = None
|
|
94
|
+
api.CreateProcessW.argtypes = (
|
|
95
|
+
wintypes.LPCWSTR, wintypes.LPWSTR, ctypes.POINTER(SECURITY_ATTRIBUTES), ctypes.POINTER(SECURITY_ATTRIBUTES),
|
|
96
|
+
wintypes.BOOL, wintypes.DWORD, wintypes.LPVOID, wintypes.LPCWSTR,
|
|
97
|
+
ctypes.POINTER(STARTUPINFOW), ctypes.POINTER(PROCESS_INFORMATION),
|
|
98
|
+
)
|
|
99
|
+
api.CreateProcessW.restype = wintypes.BOOL
|
|
100
|
+
api.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
|
|
101
|
+
api.AssignProcessToJobObject.restype = wintypes.BOOL
|
|
102
|
+
api.ResumeThread.argtypes = (wintypes.HANDLE,)
|
|
103
|
+
api.ResumeThread.restype = wintypes.DWORD
|
|
104
|
+
api.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD)
|
|
105
|
+
api.WaitForSingleObject.restype = wintypes.DWORD
|
|
106
|
+
api.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
|
|
107
|
+
api.GetExitCodeProcess.restype = wintypes.BOOL
|
|
108
|
+
api.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
|
|
109
|
+
api.TerminateJobObject.restype = wintypes.BOOL
|
|
110
|
+
api.TerminateProcess.argtypes = (wintypes.HANDLE, wintypes.UINT)
|
|
111
|
+
api.TerminateProcess.restype = wintypes.BOOL
|
|
112
|
+
api.CloseHandle.argtypes = (wintypes.HANDLE,)
|
|
113
|
+
api.CloseHandle.restype = wintypes.BOOL
|
|
114
|
+
return api
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class JobProcess:
|
|
118
|
+
def __init__(self, api: ctypes.WinDLL, process_handle: int, job_handle: int, pid: int,
|
|
119
|
+
stdin: BinaryIO | None, stdout: BinaryIO, stderr: BinaryIO) -> None:
|
|
120
|
+
self._api = api
|
|
121
|
+
self._process_handle = process_handle
|
|
122
|
+
self._job_handle = job_handle
|
|
123
|
+
self.pid = pid
|
|
124
|
+
self.stdin = stdin
|
|
125
|
+
self.stdout = stdout
|
|
126
|
+
self.stderr = stderr
|
|
127
|
+
self.returncode: int | None = None
|
|
128
|
+
|
|
129
|
+
def poll(self) -> int | None:
|
|
130
|
+
if self.returncode is not None:
|
|
131
|
+
return self.returncode
|
|
132
|
+
code = wintypes.DWORD()
|
|
133
|
+
if not self._api.GetExitCodeProcess(self._process_handle, ctypes.byref(code)):
|
|
134
|
+
raise OSError("process query failed")
|
|
135
|
+
if code.value != STILL_ACTIVE:
|
|
136
|
+
self.returncode = code.value
|
|
137
|
+
return self.returncode
|
|
138
|
+
|
|
139
|
+
def wait(self, timeout: float | None = None) -> int:
|
|
140
|
+
import subprocess
|
|
141
|
+
|
|
142
|
+
milliseconds = 0xFFFFFFFF if timeout is None else max(0, min(0xFFFFFFFE, int(timeout * 1000 + 0.999)))
|
|
143
|
+
result = self._api.WaitForSingleObject(self._process_handle, milliseconds)
|
|
144
|
+
if result == WAIT_TIMEOUT:
|
|
145
|
+
raise subprocess.TimeoutExpired([], timeout)
|
|
146
|
+
if result != WAIT_OBJECT_0:
|
|
147
|
+
raise OSError("process wait failed")
|
|
148
|
+
value = self.poll()
|
|
149
|
+
if value is None:
|
|
150
|
+
raise OSError("process state unavailable")
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
def send_signal(self, signal_number: int) -> None:
|
|
154
|
+
if signal_number != signal.CTRL_BREAK_EVENT:
|
|
155
|
+
raise ValueError("unsupported signal")
|
|
156
|
+
os.kill(self.pid, signal_number)
|
|
157
|
+
|
|
158
|
+
def kill(self) -> bool:
|
|
159
|
+
if self._job_handle:
|
|
160
|
+
return bool(self._api.TerminateJobObject(self._job_handle, 1))
|
|
161
|
+
elif self._process_handle:
|
|
162
|
+
return bool(self._api.TerminateProcess(self._process_handle, 1))
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
def terminate_tree(self) -> bool:
|
|
166
|
+
return self.kill()
|
|
167
|
+
|
|
168
|
+
def close_handles(self) -> bool:
|
|
169
|
+
closed = True
|
|
170
|
+
if self._job_handle:
|
|
171
|
+
closed = bool(self._api.CloseHandle(self._job_handle)) and closed
|
|
172
|
+
self._job_handle = 0
|
|
173
|
+
if self._process_handle:
|
|
174
|
+
closed = bool(self._api.CloseHandle(self._process_handle)) and closed
|
|
175
|
+
self._process_handle = 0
|
|
176
|
+
return closed
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def launch(argv: list[str], *, cwd: Path, environment: Mapping[str, str], with_stdin: bool) -> JobProcess:
|
|
180
|
+
"""Launch suspended, assign to a preconfigured Job, then resume."""
|
|
181
|
+
import msvcrt
|
|
182
|
+
import subprocess
|
|
183
|
+
|
|
184
|
+
if not argv or any("\0" in part for part in argv) or "\0" in str(cwd):
|
|
185
|
+
raise ValueError("invalid launch data")
|
|
186
|
+
api = _kernel32()
|
|
187
|
+
job = process_handle = thread_handle = 0
|
|
188
|
+
stdin_read = stdin_write = stdout_read = stdout_write = stderr_read = stderr_write = 0
|
|
189
|
+
streams: list[BinaryIO] = []
|
|
190
|
+
attribute_initialized = False
|
|
191
|
+
attribute_buffer = None
|
|
192
|
+
try:
|
|
193
|
+
job = api.CreateJobObjectW(None, None)
|
|
194
|
+
if not job:
|
|
195
|
+
raise OSError("job create failed")
|
|
196
|
+
limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
|
|
197
|
+
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
198
|
+
if not api.SetInformationJobObject(job, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, ctypes.byref(limits), ctypes.sizeof(limits)):
|
|
199
|
+
raise OSError("job configure failed")
|
|
200
|
+
|
|
201
|
+
security = SECURITY_ATTRIBUTES(ctypes.sizeof(SECURITY_ATTRIBUTES), None, False)
|
|
202
|
+
with WINDOWS_PROCESS_CREATION_LOCK:
|
|
203
|
+
for pipe_index in range(3):
|
|
204
|
+
read_handle, write_handle = wintypes.HANDLE(), wintypes.HANDLE()
|
|
205
|
+
if not api.CreatePipe(ctypes.byref(read_handle), ctypes.byref(write_handle), ctypes.byref(security), 0):
|
|
206
|
+
raise OSError("pipe create failed")
|
|
207
|
+
if pipe_index == 0:
|
|
208
|
+
stdin_read, stdin_write = read_handle.value, write_handle.value
|
|
209
|
+
elif pipe_index == 1:
|
|
210
|
+
stdout_read, stdout_write = read_handle.value, write_handle.value
|
|
211
|
+
else:
|
|
212
|
+
stderr_read, stderr_write = read_handle.value, write_handle.value
|
|
213
|
+
for parent_handle in (stdin_write, stdout_read, stderr_read):
|
|
214
|
+
if not api.SetHandleInformation(parent_handle, HANDLE_FLAG_INHERIT, 0):
|
|
215
|
+
raise OSError("pipe configure failed")
|
|
216
|
+
|
|
217
|
+
inherited = (wintypes.HANDLE * 3)(stdin_read, stdout_write, stderr_write)
|
|
218
|
+
attribute_size = SIZE_T()
|
|
219
|
+
api.InitializeProcThreadAttributeList(None, 1, 0, ctypes.byref(attribute_size))
|
|
220
|
+
if not attribute_size.value:
|
|
221
|
+
raise OSError("attribute sizing failed")
|
|
222
|
+
attribute_buffer = ctypes.create_string_buffer(attribute_size.value)
|
|
223
|
+
attribute_pointer = ctypes.cast(attribute_buffer, wintypes.LPVOID)
|
|
224
|
+
if not api.InitializeProcThreadAttributeList(attribute_pointer, 1, 0, ctypes.byref(attribute_size)):
|
|
225
|
+
raise OSError("attribute initialize failed")
|
|
226
|
+
attribute_initialized = True
|
|
227
|
+
if not api.UpdateProcThreadAttribute(
|
|
228
|
+
attribute_pointer, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, ctypes.cast(inherited, wintypes.LPVOID),
|
|
229
|
+
ctypes.sizeof(inherited), None, None,
|
|
230
|
+
):
|
|
231
|
+
raise OSError("attribute update failed")
|
|
232
|
+
|
|
233
|
+
startup = STARTUPINFOEXW()
|
|
234
|
+
startup.StartupInfo.cb = ctypes.sizeof(startup)
|
|
235
|
+
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES
|
|
236
|
+
startup.StartupInfo.hStdInput = stdin_read
|
|
237
|
+
startup.StartupInfo.hStdOutput = stdout_write
|
|
238
|
+
startup.StartupInfo.hStdError = stderr_write
|
|
239
|
+
startup.lpAttributeList = attribute_pointer
|
|
240
|
+
process_info = PROCESS_INFORMATION()
|
|
241
|
+
command_line = ctypes.create_unicode_buffer(subprocess.list2cmdline(argv))
|
|
242
|
+
environment_block = ctypes.create_unicode_buffer(build_windows_environment_block(environment))
|
|
243
|
+
flags = CREATE_SUSPENDED | CREATE_NEW_PROCESS_GROUP | CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT
|
|
244
|
+
child_handles = (stdin_read, stdout_write, stderr_write)
|
|
245
|
+
enabled_handles: list[int] = []
|
|
246
|
+
failed_restores: list[int] = []
|
|
247
|
+
created = False
|
|
248
|
+
assigned = False
|
|
249
|
+
recovery_failed = False
|
|
250
|
+
try:
|
|
251
|
+
for child_handle in child_handles:
|
|
252
|
+
if not api.SetHandleInformation(child_handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT):
|
|
253
|
+
raise OSError("child handle configure failed")
|
|
254
|
+
enabled_handles.append(child_handle)
|
|
255
|
+
created = api.CreateProcessW(
|
|
256
|
+
None, command_line, None, None, True, flags, environment_block, str(cwd),
|
|
257
|
+
ctypes.cast(ctypes.byref(startup), ctypes.POINTER(STARTUPINFOW)), ctypes.byref(process_info),
|
|
258
|
+
)
|
|
259
|
+
finally:
|
|
260
|
+
restored = True
|
|
261
|
+
for child_handle in enabled_handles:
|
|
262
|
+
try:
|
|
263
|
+
handle_restored = bool(api.SetHandleInformation(child_handle, HANDLE_FLAG_INHERIT, 0))
|
|
264
|
+
except Exception:
|
|
265
|
+
handle_restored = False
|
|
266
|
+
restored = handle_restored and restored
|
|
267
|
+
if not handle_restored:
|
|
268
|
+
failed_restores.append(child_handle)
|
|
269
|
+
if created:
|
|
270
|
+
process_handle, thread_handle = process_info.hProcess, process_info.hThread
|
|
271
|
+
if failed_restores:
|
|
272
|
+
try:
|
|
273
|
+
assigned = bool(api.AssignProcessToJobObject(job, process_handle))
|
|
274
|
+
except Exception:
|
|
275
|
+
assigned = False
|
|
276
|
+
recovery_failed = not assigned
|
|
277
|
+
for failed_handle in failed_restores:
|
|
278
|
+
try:
|
|
279
|
+
closed = bool(api.CloseHandle(failed_handle))
|
|
280
|
+
except Exception:
|
|
281
|
+
closed = False
|
|
282
|
+
inheritance_cleared = False
|
|
283
|
+
if not closed:
|
|
284
|
+
try:
|
|
285
|
+
inheritance_cleared = bool(
|
|
286
|
+
api.SetHandleInformation(failed_handle, HANDLE_FLAG_INHERIT, 0)
|
|
287
|
+
)
|
|
288
|
+
except Exception:
|
|
289
|
+
inheritance_cleared = False
|
|
290
|
+
try:
|
|
291
|
+
closed = bool(api.CloseHandle(failed_handle))
|
|
292
|
+
except Exception:
|
|
293
|
+
closed = False
|
|
294
|
+
recovery_failed = recovery_failed or (not closed and not inheritance_cleared)
|
|
295
|
+
if closed:
|
|
296
|
+
if failed_handle == stdin_read:
|
|
297
|
+
stdin_read = 0
|
|
298
|
+
elif failed_handle == stdout_write:
|
|
299
|
+
stdout_write = 0
|
|
300
|
+
elif failed_handle == stderr_write:
|
|
301
|
+
stderr_write = 0
|
|
302
|
+
if not created:
|
|
303
|
+
raise OSError("process create failed")
|
|
304
|
+
if not restored:
|
|
305
|
+
raise OSError("child handle restore failed")
|
|
306
|
+
if recovery_failed:
|
|
307
|
+
raise OSError("child handle recovery failed")
|
|
308
|
+
if not assigned and not api.AssignProcessToJobObject(job, process_handle):
|
|
309
|
+
raise OSError("job assignment failed")
|
|
310
|
+
if api.ResumeThread(thread_handle) == 0xFFFFFFFF:
|
|
311
|
+
raise OSError("thread resume failed")
|
|
312
|
+
if not api.CloseHandle(thread_handle):
|
|
313
|
+
raise OSError("thread handle close failed")
|
|
314
|
+
thread_handle = 0
|
|
315
|
+
for handle_name in ("stdin_read", "stdout_write", "stderr_write"):
|
|
316
|
+
handle = locals()[handle_name]
|
|
317
|
+
if not api.CloseHandle(handle):
|
|
318
|
+
raise OSError("child handle close failed")
|
|
319
|
+
if handle_name == "stdin_read":
|
|
320
|
+
stdin_read = 0
|
|
321
|
+
elif handle_name == "stdout_write":
|
|
322
|
+
stdout_write = 0
|
|
323
|
+
else:
|
|
324
|
+
stderr_write = 0
|
|
325
|
+
|
|
326
|
+
def stream_from_descriptor(descriptor: int, mode: str) -> BinaryIO:
|
|
327
|
+
try:
|
|
328
|
+
return io.FileIO(descriptor, mode, closefd=True)
|
|
329
|
+
except Exception:
|
|
330
|
+
os.close(descriptor)
|
|
331
|
+
raise
|
|
332
|
+
|
|
333
|
+
stdin_descriptor = msvcrt.open_osfhandle(stdin_write, os.O_WRONLY | os.O_BINARY)
|
|
334
|
+
stdin_write = 0
|
|
335
|
+
stdin_stream = stream_from_descriptor(stdin_descriptor, "wb")
|
|
336
|
+
streams.append(stdin_stream)
|
|
337
|
+
stdout_descriptor = msvcrt.open_osfhandle(stdout_read, os.O_RDONLY | os.O_BINARY)
|
|
338
|
+
stdout_read = 0
|
|
339
|
+
stdout_stream = stream_from_descriptor(stdout_descriptor, "rb")
|
|
340
|
+
streams.append(stdout_stream)
|
|
341
|
+
stderr_descriptor = msvcrt.open_osfhandle(stderr_read, os.O_RDONLY | os.O_BINARY)
|
|
342
|
+
stderr_read = 0
|
|
343
|
+
stderr_stream = stream_from_descriptor(stderr_descriptor, "rb")
|
|
344
|
+
streams.append(stderr_stream)
|
|
345
|
+
if not with_stdin:
|
|
346
|
+
stdin_stream.close()
|
|
347
|
+
streams.remove(stdin_stream)
|
|
348
|
+
stdin_value = None
|
|
349
|
+
else:
|
|
350
|
+
stdin_value = stdin_stream
|
|
351
|
+
return JobProcess(api, process_handle, job, process_info.dwProcessId, stdin_value, stdout_stream, stderr_stream)
|
|
352
|
+
except Exception:
|
|
353
|
+
if process_handle:
|
|
354
|
+
api.TerminateProcess(process_handle, 1)
|
|
355
|
+
if job:
|
|
356
|
+
api.TerminateJobObject(job, 1)
|
|
357
|
+
for stream in streams:
|
|
358
|
+
try:
|
|
359
|
+
stream.close()
|
|
360
|
+
except OSError:
|
|
361
|
+
pass
|
|
362
|
+
for handle in (thread_handle, process_handle, stdin_read, stdin_write, stdout_read, stdout_write, stderr_read, stderr_write, job):
|
|
363
|
+
if handle:
|
|
364
|
+
api.CloseHandle(handle)
|
|
365
|
+
raise
|
|
366
|
+
finally:
|
|
367
|
+
if attribute_initialized:
|
|
368
|
+
api.DeleteProcThreadAttributeList(ctypes.cast(attribute_buffer, wintypes.LPVOID))
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Windows Startup-folder fallback for launching the Graphite daemon at user logon."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .windows_task import DEFAULT_TASK_NAME, daemon_task_command
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class StartupInstall:
|
|
14
|
+
name: str
|
|
15
|
+
base_path: Path
|
|
16
|
+
state_dir: Path
|
|
17
|
+
script_path: Path
|
|
18
|
+
launcher_path: Path
|
|
19
|
+
command_line: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def require_windows() -> None:
|
|
23
|
+
if platform.system().lower() != "windows":
|
|
24
|
+
raise RuntimeError("Windows startup integration is only available on Windows")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def startup_folder() -> Path:
|
|
28
|
+
appdata = os.environ.get("APPDATA")
|
|
29
|
+
if not appdata:
|
|
30
|
+
raise RuntimeError("APPDATA is not set; cannot locate Windows Startup folder")
|
|
31
|
+
return Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def startup_paths(name: str, base_path: Path) -> tuple[Path, Path, Path]:
|
|
35
|
+
base = base_path.resolve()
|
|
36
|
+
state_dir = base / ".graphite-daemon"
|
|
37
|
+
script_path = state_dir / f"{name}.ps1"
|
|
38
|
+
launcher_path = startup_folder() / f"{name}.vbs"
|
|
39
|
+
return state_dir, script_path, launcher_path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def install_startup_launcher(
|
|
43
|
+
base_path: Path,
|
|
44
|
+
*,
|
|
45
|
+
name: str = DEFAULT_TASK_NAME,
|
|
46
|
+
graphite_executable: str | None = None,
|
|
47
|
+
scan_interval: float = 15.0,
|
|
48
|
+
discover_interval: float = 90.0,
|
|
49
|
+
max_projects: int = 128,
|
|
50
|
+
max_depth: int = 6,
|
|
51
|
+
max_builds_per_cycle: int = 1,
|
|
52
|
+
build_timeout: float = 240.0,
|
|
53
|
+
debounce: float = 1.0,
|
|
54
|
+
) -> StartupInstall:
|
|
55
|
+
require_windows()
|
|
56
|
+
base = base_path.resolve()
|
|
57
|
+
command = daemon_task_command(
|
|
58
|
+
base,
|
|
59
|
+
graphite_executable=graphite_executable,
|
|
60
|
+
scan_interval=scan_interval,
|
|
61
|
+
discover_interval=discover_interval,
|
|
62
|
+
max_projects=max_projects,
|
|
63
|
+
max_depth=max_depth,
|
|
64
|
+
max_builds_per_cycle=max_builds_per_cycle,
|
|
65
|
+
build_timeout=build_timeout,
|
|
66
|
+
debounce=debounce,
|
|
67
|
+
)
|
|
68
|
+
state_dir, script_path, launcher_path = startup_paths(name, base)
|
|
69
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
launcher_path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
|
|
72
|
+
script = _powershell_script(command.executable, command.arguments, command.working_dir)
|
|
73
|
+
script_path.write_text(script, encoding="utf-8")
|
|
74
|
+
|
|
75
|
+
vbs = _vbs_launcher(script_path)
|
|
76
|
+
launcher_path.write_text(vbs, encoding="utf-8")
|
|
77
|
+
|
|
78
|
+
return StartupInstall(
|
|
79
|
+
name=name,
|
|
80
|
+
base_path=base,
|
|
81
|
+
state_dir=state_dir,
|
|
82
|
+
script_path=script_path,
|
|
83
|
+
launcher_path=launcher_path,
|
|
84
|
+
command_line=command.task_run,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def startup_status(base_path: Path, *, name: str = DEFAULT_TASK_NAME) -> dict[str, object]:
|
|
89
|
+
require_windows()
|
|
90
|
+
state_dir, script_path, launcher_path = startup_paths(name, base_path)
|
|
91
|
+
return {
|
|
92
|
+
"name": name,
|
|
93
|
+
"installed": script_path.exists() and launcher_path.exists(),
|
|
94
|
+
"script_path": str(script_path),
|
|
95
|
+
"launcher_path": str(launcher_path),
|
|
96
|
+
"state_dir": str(state_dir),
|
|
97
|
+
"script_exists": script_path.exists(),
|
|
98
|
+
"launcher_exists": launcher_path.exists(),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def uninstall_startup_launcher(base_path: Path, *, name: str = DEFAULT_TASK_NAME) -> dict[str, object]:
|
|
103
|
+
require_windows()
|
|
104
|
+
_, script_path, launcher_path = startup_paths(name, base_path)
|
|
105
|
+
removed: list[str] = []
|
|
106
|
+
for path in (launcher_path, script_path):
|
|
107
|
+
if path.exists():
|
|
108
|
+
path.unlink()
|
|
109
|
+
removed.append(str(path))
|
|
110
|
+
return {
|
|
111
|
+
"name": name,
|
|
112
|
+
"ok": True,
|
|
113
|
+
"removed": removed,
|
|
114
|
+
"script_path": str(script_path),
|
|
115
|
+
"launcher_path": str(launcher_path),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _powershell_script(executable: Path, arguments: tuple[str, ...], working_dir: Path) -> str:
|
|
120
|
+
args_literal = ", ".join(_ps_quote(arg) for arg in arguments)
|
|
121
|
+
escaped_marker = str(working_dir)
|
|
122
|
+
# The already-running guard only inspects processes that can actually host
|
|
123
|
+
# the daemon (python/cmd). Matching every process command line caused false
|
|
124
|
+
# positives: a PowerShell command that merely mentioned
|
|
125
|
+
# "graphite ... daemon ... <base>" (e.g. a restart command) matched itself
|
|
126
|
+
# and the launcher exited without starting anything.
|
|
127
|
+
return f"""$ErrorActionPreference = 'Stop'
|
|
128
|
+
$hosts = @('python.exe', 'pythonw.exe', 'cmd.exe', 'graphite.exe')
|
|
129
|
+
$existing = Get-CimInstance Win32_Process | Where-Object {{ $hosts -contains $_.Name -and $_.CommandLine -like '*graphite*daemon*{escaped_marker}*' }}
|
|
130
|
+
if ($existing) {{ exit 0 }}
|
|
131
|
+
Start-Process -FilePath {_ps_quote(str(executable))} -ArgumentList @({args_literal}) -WorkingDirectory {_ps_quote(str(working_dir))} -WindowStyle Hidden
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _vbs_launcher(script_path: Path) -> str:
|
|
136
|
+
ps = f'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "{script_path}"'
|
|
137
|
+
escaped = ps.replace('"', '""')
|
|
138
|
+
return f'Set WshShell = CreateObject("WScript.Shell")\nWshShell.Run "{escaped}", 0, False\n'
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _ps_quote(value: str) -> str:
|
|
142
|
+
return "'" + value.replace("'", "''") + "'"
|
|
143
|
+
|
|
144
|
+
|