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.
Files changed (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -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
+