gdmcode 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.
Files changed (131) hide show
  1. gdmcode-0.1.0.dist-info/METADATA +240 -0
  2. gdmcode-0.1.0.dist-info/RECORD +131 -0
  3. gdmcode-0.1.0.dist-info/WHEEL +4 -0
  4. gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. src/__init__.py +1 -0
  6. src/_internal/__init__.py +0 -0
  7. src/_internal/constants.py +244 -0
  8. src/_internal/domain_skills.py +339 -0
  9. src/agent/__init__.py +0 -0
  10. src/agent/commit_classifier.py +91 -0
  11. src/agent/context_budget.py +391 -0
  12. src/agent/daemon.py +681 -0
  13. src/agent/dag_validator.py +153 -0
  14. src/agent/debug_loop.py +473 -0
  15. src/agent/impact_analyzer.py +149 -0
  16. src/agent/impact_graph.py +117 -0
  17. src/agent/loop.py +1410 -0
  18. src/agent/orchestrator.py +141 -0
  19. src/agent/regression_guard.py +251 -0
  20. src/agent/review_gate.py +648 -0
  21. src/agent/risk_scorer.py +169 -0
  22. src/agent/self_healing.py +145 -0
  23. src/agent/smart_test_selector.py +89 -0
  24. src/agent/system_prompt.py +226 -0
  25. src/agent/task_tracker.py +320 -0
  26. src/agent/test_validator.py +210 -0
  27. src/agent/tool_orchestrator.py +402 -0
  28. src/agent/transcript.py +230 -0
  29. src/agent/verification_loop.py +133 -0
  30. src/agent/work_director.py +136 -0
  31. src/agent/worktree_manager.py +53 -0
  32. src/artifacts/__init__.py +16 -0
  33. src/artifacts/artifact_store.py +456 -0
  34. src/artifacts/verification_graph.py +75 -0
  35. src/auth.py +411 -0
  36. src/cli.py +1290 -0
  37. src/commands.py +1398 -0
  38. src/config.py +762 -0
  39. src/cost_tracker.py +348 -0
  40. src/db/__init__.py +4 -0
  41. src/db/migrations.py +337 -0
  42. src/enterprise/__init__.py +3 -0
  43. src/enterprise/audit_log.py +182 -0
  44. src/enterprise/identity.py +90 -0
  45. src/enterprise/rbac.py +100 -0
  46. src/enterprise/team_config.py +125 -0
  47. src/enterprise/usage_analytics.py +261 -0
  48. src/exceptions.py +207 -0
  49. src/git_workflow.py +651 -0
  50. src/integrations/__init__.py +6 -0
  51. src/integrations/github_actions.py +106 -0
  52. src/integrations/mcp_server.py +333 -0
  53. src/integrations/sentry_integration.py +100 -0
  54. src/integrations/sentry_server.py +82 -0
  55. src/integrations/webhook_security.py +19 -0
  56. src/main.py +27 -0
  57. src/memory/__init__.py +0 -0
  58. src/memory/code_index.py +376 -0
  59. src/memory/compressor.py +378 -0
  60. src/memory/context_memory.py +135 -0
  61. src/memory/continuous_memory.py +234 -0
  62. src/memory/conventions.py +495 -0
  63. src/memory/db.py +1119 -0
  64. src/memory/document_index.py +205 -0
  65. src/memory/file_cache.py +128 -0
  66. src/memory/project_scanner.py +178 -0
  67. src/memory/session_store.py +201 -0
  68. src/models/__init__.py +0 -0
  69. src/models/client.py +715 -0
  70. src/models/definitions.py +459 -0
  71. src/models/router.py +418 -0
  72. src/models/schemas.py +389 -0
  73. src/permissions.py +294 -0
  74. src/remote/__init__.py +5 -0
  75. src/remote/command_filter.py +33 -0
  76. src/remote/models.py +31 -0
  77. src/remote/permission_handler.py +79 -0
  78. src/remote/phone_ui.py +48 -0
  79. src/remote/protocol.py +59 -0
  80. src/remote/qr.py +65 -0
  81. src/remote/server.py +586 -0
  82. src/remote/token_manager.py +61 -0
  83. src/remote/tunnel.py +212 -0
  84. src/repl.py +475 -0
  85. src/runtime/__init__.py +1 -0
  86. src/runtime/branch_farm.py +372 -0
  87. src/runtime/replay.py +351 -0
  88. src/sandbox/__init__.py +2 -0
  89. src/sandbox/hermetic.py +214 -0
  90. src/sandbox/policy.py +44 -0
  91. src/sdk/__init__.py +3 -0
  92. src/sdk/plugin_base.py +39 -0
  93. src/sdk/plugin_host.py +100 -0
  94. src/sdk/plugin_loader.py +101 -0
  95. src/security.py +409 -0
  96. src/server/__init__.py +7 -0
  97. src/server/bridge.py +427 -0
  98. src/server/bridge_cli.py +103 -0
  99. src/server/bridge_client.py +170 -0
  100. src/server/protocol_version.py +103 -0
  101. src/session/__init__.py +10 -0
  102. src/session/event_fanout.py +46 -0
  103. src/session/input_broker.py +38 -0
  104. src/session/permission_bridge.py +100 -0
  105. src/tools/__init__.py +160 -0
  106. src/tools/_atomic.py +72 -0
  107. src/tools/agent_tools.py +423 -0
  108. src/tools/ask_user_tool.py +83 -0
  109. src/tools/bash_tool.py +384 -0
  110. src/tools/browser_tool.py +352 -0
  111. src/tools/browser_tools.py +179 -0
  112. src/tools/dep_tools.py +210 -0
  113. src/tools/document_reader.py +167 -0
  114. src/tools/document_tool.py +240 -0
  115. src/tools/document_writer.py +171 -0
  116. src/tools/impact_tools.py +240 -0
  117. src/tools/playwright_tool.py +172 -0
  118. src/tools/quality_tools.py +366 -0
  119. src/tools/read_tools.py +318 -0
  120. src/tools/result_cache.py +157 -0
  121. src/tools/search_tools.py +310 -0
  122. src/tools/shell_tools.py +311 -0
  123. src/tools/write_tools.py +337 -0
  124. src/voice/__init__.py +25 -0
  125. src/voice/audio_capture.py +92 -0
  126. src/voice/audio_playback.py +68 -0
  127. src/voice/errors.py +14 -0
  128. src/voice/models.py +35 -0
  129. src/voice/providers.py +143 -0
  130. src/voice/vad.py +55 -0
  131. src/voice/voice_loop.py +156 -0
@@ -0,0 +1,103 @@
1
+ """WebSocket protocol version constants and compatibility checks.
2
+
3
+ Every WebSocket connection (bridge, remote, IDE, Chrome extension) must begin
4
+ with a 'hello' handshake. The server sends its version and capability set;
5
+ the client checks compatibility before sending commands.
6
+
7
+ Versioning policy:
8
+ - Major version bump = breaking change; both sides must update
9
+ - Minor version bump = additive only; backward-compatible
10
+ - Client major != server major → close with code 4000 + error message
11
+ - Client minor > server minor → log warning, continue (server may lack new features)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+
17
+ __all__ = [
18
+ "PROTOCOL_VERSION",
19
+ "PROTOCOL_MAJOR",
20
+ "PROTOCOL_MINOR",
21
+ "CapabilitySet",
22
+ "is_compatible",
23
+ "make_hello_message",
24
+ "make_version_mismatch_error",
25
+ ]
26
+
27
+ PROTOCOL_VERSION: str = "1.0"
28
+ PROTOCOL_MAJOR: int = 1
29
+ PROTOCOL_MINOR: int = 0
30
+
31
+
32
+ @dataclass
33
+ class CapabilitySet:
34
+ """Server capability advertisement sent in the hello message."""
35
+
36
+ dom_actions: bool = True
37
+ screenshot: bool = True
38
+ navigation: bool = True
39
+ mobile: bool = False
40
+ voice: bool = False
41
+ remote: bool = False
42
+
43
+ def to_dict(self) -> dict[str, bool]:
44
+ return {
45
+ "dom_actions": self.dom_actions,
46
+ "screenshot": self.screenshot,
47
+ "navigation": self.navigation,
48
+ "mobile": self.mobile,
49
+ "voice": self.voice,
50
+ "remote": self.remote,
51
+ }
52
+
53
+
54
+ def is_compatible(client_version: str) -> tuple[bool, str]:
55
+ """Check if client_version is compatible with this server.
56
+
57
+ Returns (compatible: bool, reason: str).
58
+ - compatible=True, reason="" if versions match exactly
59
+ - compatible=True, reason=warning if minor version mismatch (client ahead)
60
+ - compatible=False, reason=error if major version mismatch or invalid format
61
+ """
62
+ try:
63
+ parts = client_version.split(".")
64
+ client_major = int(parts[0])
65
+ client_minor = int(parts[1]) if len(parts) > 1 else 0
66
+ except (ValueError, IndexError):
67
+ return False, f"Invalid version format: {client_version!r}"
68
+
69
+ if client_major != PROTOCOL_MAJOR:
70
+ return False, (
71
+ f"Client v{client_version} required, got server v{PROTOCOL_VERSION}. "
72
+ "Please update the gdm extension."
73
+ )
74
+ if client_minor > PROTOCOL_MINOR:
75
+ return True, (
76
+ f"Client v{client_version} is ahead of server v{PROTOCOL_VERSION}"
77
+ " — some client features may not be supported"
78
+ )
79
+ return True, ""
80
+
81
+
82
+ def make_hello_message(
83
+ server_name: str = "gdm-bridge",
84
+ capabilities: CapabilitySet | None = None,
85
+ ) -> dict:
86
+ """Build the hello message the server sends on WebSocket open."""
87
+ caps = capabilities or CapabilitySet()
88
+ return {
89
+ "type": "hello",
90
+ "protocol_version": PROTOCOL_VERSION,
91
+ "capabilities": caps.to_dict(),
92
+ "server": server_name,
93
+ }
94
+
95
+
96
+ def make_version_mismatch_error(client_version: str) -> dict:
97
+ """Build the error message sent before closing on major version mismatch."""
98
+ _, reason = is_compatible(client_version)
99
+ return {
100
+ "type": "error",
101
+ "code": "version_mismatch",
102
+ "message": reason,
103
+ }
@@ -0,0 +1,10 @@
1
+ """Session management — multiplexed I/O and permission bridge."""
2
+ from src.session.input_broker import InputBroker, InputMessage
3
+ from src.session.event_fanout import EventFanout, SessionEvent
4
+ from src.session.permission_bridge import PermissionBridge
5
+
6
+ __all__ = [
7
+ "InputBroker", "InputMessage",
8
+ "EventFanout", "SessionEvent",
9
+ "PermissionBridge",
10
+ ]
@@ -0,0 +1,46 @@
1
+ """EventFanout — bounded pub/sub event distribution."""
2
+ from __future__ import annotations
3
+ import logging
4
+ import queue
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+ __all__ = ["EventFanout", "SessionEvent"]
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ @dataclass
14
+ class SessionEvent:
15
+ type: str
16
+ payload: Any = None
17
+ timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
18
+
19
+ class EventFanout:
20
+ """Bounded per-subscriber queues with put_nowait drop (no blocking)."""
21
+
22
+ def __init__(self) -> None:
23
+ self._subscribers: dict[str, queue.Queue[SessionEvent]] = {}
24
+
25
+ def subscribe(self, name: str, maxsize: int = 100) -> queue.Queue[SessionEvent]:
26
+ """Register a subscriber. Returns the queue to read from."""
27
+ q: queue.Queue[SessionEvent] = queue.Queue(maxsize=maxsize)
28
+ self._subscribers[name] = q
29
+ log.debug("EventFanout: subscribed %s", name)
30
+ return q
31
+
32
+ def publish(self, event: SessionEvent) -> None:
33
+ """Publish event to all subscribers. put_nowait — drops on full queue."""
34
+ for name, q in list(self._subscribers.items()):
35
+ try:
36
+ q.put_nowait(event)
37
+ except queue.Full:
38
+ log.warning("EventFanout: subscriber %s queue full — event dropped", name)
39
+
40
+ def unsubscribe(self, name: str) -> None:
41
+ """Remove subscriber."""
42
+ self._subscribers.pop(name, None)
43
+ log.debug("EventFanout: unsubscribed %s", name)
44
+
45
+ def subscriber_count(self) -> int:
46
+ return len(self._subscribers)
@@ -0,0 +1,38 @@
1
+ """InputBroker — thread-safe multiplexed input queue."""
2
+ from __future__ import annotations
3
+ import logging
4
+ import queue
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+ from typing import Optional
8
+
9
+ __all__ = ["InputBroker", "InputMessage"]
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ @dataclass
14
+ class InputMessage:
15
+ source: str # "terminal" | "remote" | "voice"
16
+ text: str
17
+ timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
18
+
19
+ class InputBroker:
20
+ """Thread-safe queue that multiplexes terminal + remote + voice input."""
21
+
22
+ def __init__(self, maxsize: int = 0) -> None:
23
+ self._queue: queue.Queue[InputMessage] = queue.Queue(maxsize=maxsize)
24
+
25
+ def put(self, source: str, message: str) -> None:
26
+ """Enqueue input from any source. Non-blocking."""
27
+ self._queue.put(InputMessage(source=source, text=message))
28
+ log.debug("InputBroker.put source=%s len=%d", source, len(message))
29
+
30
+ def get(self, timeout: float | None = None) -> InputMessage | None:
31
+ """Blocking dequeue. Returns None on timeout."""
32
+ try:
33
+ return self._queue.get(timeout=timeout)
34
+ except queue.Empty:
35
+ return None
36
+
37
+ def qsize(self) -> int:
38
+ return self._queue.qsize()
@@ -0,0 +1,100 @@
1
+ """PermissionBridge — sync↔async permission request gateway with UUID tracking."""
2
+ from __future__ import annotations
3
+ import logging
4
+ import threading
5
+ import uuid
6
+ from dataclasses import dataclass
7
+
8
+ __all__ = ["PermissionBridge", "PermissionBridgeConflict"]
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ class PermissionBridgeConflict(Exception):
14
+ """Raised when a second request arrives while one is already pending."""
15
+
16
+
17
+ @dataclass
18
+ class _PendingRequest:
19
+ request_id: str
20
+ prompt: str
21
+ event: threading.Event
22
+ result: bool = False
23
+
24
+
25
+ class PermissionBridge:
26
+ """Bridges sync PermissionContext._prompt_user() to async remote/voice handlers.
27
+
28
+ v1 constraint: only ONE outstanding permission request per bridge instance.
29
+ Concurrent requests raise PermissionBridgeConflict.
30
+
31
+ Usage::
32
+ bridge = PermissionBridge()
33
+
34
+ # In sync caller thread:
35
+ approved = bridge.request("Delete /etc/hosts?", timeout=60.0)
36
+
37
+ # In async handler thread:
38
+ bridge.respond(request_id, True)
39
+ """
40
+
41
+ def __init__(self, fanout: "EventFanout | None" = None) -> None:
42
+ self._fanout = fanout
43
+ self._lock = threading.Lock()
44
+ self._pending: _PendingRequest | None = None
45
+
46
+ def request(self, prompt: str, timeout: float = 60.0) -> bool:
47
+ """Block until approved/denied or timeout.
48
+
49
+ Returns True if approved, False on denial or timeout.
50
+ Raises PermissionBridgeConflict if a request is already pending.
51
+ """
52
+ request_id = str(uuid.uuid4())
53
+ with self._lock:
54
+ if self._pending is not None:
55
+ raise PermissionBridgeConflict(
56
+ f"Permission request already pending: {self._pending.prompt!r}"
57
+ )
58
+ event = threading.Event()
59
+ self._pending = _PendingRequest(
60
+ request_id=request_id, prompt=prompt, event=event
61
+ )
62
+
63
+ # Publish event so remote/voice handlers see it
64
+ if self._fanout is not None:
65
+ from src.session.event_fanout import SessionEvent
66
+ self._fanout.publish(SessionEvent(
67
+ type="permission_request",
68
+ payload={"request_id": request_id, "prompt": prompt},
69
+ ))
70
+
71
+ log.info("PermissionBridge: awaiting decision for request_id=%s", request_id)
72
+ approved = event.wait(timeout=timeout)
73
+
74
+ with self._lock:
75
+ result = self._pending.result if approved else False
76
+ self._pending = None
77
+
78
+ if not approved:
79
+ log.warning("PermissionBridge: request %s timed out → DENY", request_id)
80
+ return result
81
+
82
+ def respond(self, request_id: str, approved: bool) -> bool:
83
+ """Called from async handler. Returns False if request_id not found."""
84
+ with self._lock:
85
+ if self._pending is None or self._pending.request_id != request_id:
86
+ log.warning(
87
+ "PermissionBridge.respond: unknown request_id=%s (pending=%s)",
88
+ request_id,
89
+ self._pending.request_id if self._pending else None,
90
+ )
91
+ return False
92
+ self._pending.result = approved
93
+ self._pending.event.set()
94
+ log.info("PermissionBridge: responded request_id=%s approved=%s", request_id, approved)
95
+ return True
96
+
97
+ def pending_request_id(self) -> str | None:
98
+ """Returns the current pending request_id, or None."""
99
+ with self._lock:
100
+ return self._pending.request_id if self._pending else None
src/tools/__init__.py ADDED
@@ -0,0 +1,160 @@
1
+ """Tool registry and base class for all gdm tools.
2
+
3
+ Every concrete tool:
4
+ 1. Subclasses ToolBase
5
+ 2. Declares `name`, `description`, `input_schema`
6
+ 3. Implements `execute(params) -> ToolResult`
7
+ 4. Calls ToolRegistry.register(cls) at module level
8
+
9
+ The registry is a module-level singleton — import the module, tools are registered.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from abc import ABC, abstractmethod
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, ClassVar
17
+
18
+ __all__ = [
19
+ "ToolBase",
20
+ "ToolResult",
21
+ "ToolRegistry",
22
+ "REGISTRY",
23
+ "BrowserTools",
24
+ "BrowserToolResult",
25
+ ]
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # ToolResult
32
+ # ---------------------------------------------------------------------------
33
+
34
+ @dataclass
35
+ class ToolResult:
36
+ """Structured output from a tool invocation."""
37
+
38
+ output: str
39
+ """Human-readable output shown to the model and user."""
40
+
41
+ error: str | None = None
42
+ """If set, the tool failed. The agent will decide whether to retry."""
43
+
44
+ exit_code: int | None = None
45
+ """For shell tools — the process exit code."""
46
+
47
+ truncated: bool = False
48
+ """True when output was truncated to fit the context window."""
49
+
50
+ metadata: dict[str, Any] = field(default_factory=dict)
51
+ """Optional extra data (file paths modified, bytes read, etc.)."""
52
+
53
+ @property
54
+ def ok(self) -> bool:
55
+ """True when the tool succeeded (error is None or empty string)."""
56
+ return not self.error
57
+
58
+ def as_message_content(self) -> str:
59
+ """Render for inclusion in an LLM tool-result message."""
60
+ if self.error:
61
+ lines = [f"ERROR: {self.error}"]
62
+ if self.exit_code is not None:
63
+ lines.append(f"Exit code: {self.exit_code}")
64
+ return "\n".join(lines)
65
+ parts = [self.output]
66
+ if self.truncated:
67
+ parts.append("\n[Output truncated — request the next chunk if needed]")
68
+ return "\n".join(parts)
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # ToolBase
73
+ # ---------------------------------------------------------------------------
74
+
75
+ class ToolBase(ABC):
76
+ """Abstract base for every gdm tool."""
77
+
78
+ name: ClassVar[str]
79
+ """Unique snake_case identifier. Sent verbatim to the model."""
80
+
81
+ description: ClassVar[str]
82
+ """One- or two-sentence description the model uses to decide when to call this tool."""
83
+
84
+ input_schema: ClassVar[dict[str, Any]]
85
+ """JSON Schema object for the tool's parameters."""
86
+
87
+ @abstractmethod
88
+ def execute(self, params: dict[str, Any]) -> ToolResult:
89
+ """Run the tool and return a result.
90
+
91
+ Must never raise — catch all exceptions and return ToolResult(error=...).
92
+ """
93
+
94
+ def to_openai_spec(self) -> dict[str, Any]:
95
+ """Return the tool definition dict for the OpenAI tools array."""
96
+ return {
97
+ "type": "function",
98
+ "function": {
99
+ "name": self.name,
100
+ "description": self.description,
101
+ "parameters": self.input_schema,
102
+ },
103
+ }
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # ToolRegistry
108
+ # ---------------------------------------------------------------------------
109
+
110
+ class ToolRegistry:
111
+ """Global registry of all available tool instances.
112
+
113
+ Usage::
114
+
115
+ from src.tools import REGISTRY
116
+ spec_list = REGISTRY.openai_specs() # pass to client.chat.completions
117
+ result = REGISTRY.call("bash", {"command": "ls"})
118
+ """
119
+
120
+ def __init__(self) -> None:
121
+ self._tools: dict[str, ToolBase] = {}
122
+
123
+ def register(self, tool: ToolBase) -> None:
124
+ """Register a tool instance. Called once per tool at import time."""
125
+ if tool.name in self._tools:
126
+ log.warning("Tool %r already registered — overwriting", tool.name)
127
+ self._tools[tool.name] = tool
128
+
129
+ def get(self, name: str) -> ToolBase | None:
130
+ """Return a tool by name, or None if not found."""
131
+ return self._tools.get(name)
132
+
133
+ def all_tools(self) -> list[ToolBase]:
134
+ """Return all registered tools."""
135
+ return list(self._tools.values())
136
+
137
+ def openai_specs(self, exclude: frozenset[str] | None = None) -> list[dict[str, Any]]:
138
+ """Return OpenAI tool-spec dicts, optionally excluding denied tools."""
139
+ if exclude is None:
140
+ exclude = frozenset()
141
+ return [t.to_openai_spec() for t in self._tools.values() if t.name not in exclude]
142
+
143
+ def call(self, name: str, params: dict[str, Any]) -> ToolResult:
144
+ """Dispatch a tool call by name. Returns an error result if not found."""
145
+ tool = self._tools.get(name)
146
+ if tool is None:
147
+ return ToolResult(output="", error=f"Unknown tool: {name!r}")
148
+ try:
149
+ return tool.execute(params)
150
+ except Exception as exc: # noqa: BLE001
151
+ log.exception("Tool %r raised unexpectedly", name)
152
+ return ToolResult(output="", error=f"Tool raised: {exc}")
153
+
154
+
155
+ # Module-level singleton — all concrete tool modules import and extend this.
156
+ REGISTRY: ToolRegistry = ToolRegistry()
157
+
158
+ # Re-export browser tools so callers can do `from src.tools import BrowserTools`.
159
+ # Import after REGISTRY is defined to avoid circular imports.
160
+ from src.tools.browser_tools import BrowserToolResult, BrowserTools, BrowserToolsSync # noqa: E402, F401
src/tools/_atomic.py ADDED
@@ -0,0 +1,72 @@
1
+ """Atomic file-write helpers for gdm tools.
2
+
3
+ Uses write-to-temp + fsync + os.replace() to ensure a SIGTERM or power failure
4
+ during a write never leaves a half-written (corrupted) file.
5
+
6
+ Each temp file has a UUID component so concurrent writes to the same path
7
+ (from multiple ThreadPoolExecutor workers) cannot collide.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import uuid
13
+ from pathlib import Path
14
+
15
+ __all__ = ["_atomic_write", "sweep_orphans"]
16
+
17
+ _TMP_SUFFIX = ".gdm_tmp"
18
+
19
+
20
+ def _atomic_write(path: str | Path, content: str | bytes, *, encoding: str = "utf-8") -> None:
21
+ """Write content to path atomically via a uniquely-named temp file.
22
+
23
+ Steps: open unique temp → write → fsync → os.replace() → fsync parent dir.
24
+ On any failure the temp file is deleted and the original is untouched.
25
+
26
+ Raises:
27
+ ValueError: if path is a symlink (refusing to replace a symlink target).
28
+ """
29
+ path = Path(path)
30
+ if path.is_symlink():
31
+ raise ValueError(f"Refusing to atomically replace symlink: {path}")
32
+ tmp = path.with_name(path.name + f".{uuid.uuid4().hex}{_TMP_SUFFIX}")
33
+ try:
34
+ mode = "wb" if isinstance(content, bytes) else "w"
35
+ kwargs: dict = {} if isinstance(content, bytes) else {"encoding": encoding}
36
+ with open(tmp, mode, **kwargs) as fh:
37
+ fh.write(content)
38
+ fh.flush()
39
+ os.fsync(fh.fileno())
40
+ os.replace(tmp, path)
41
+ # fsync parent directory so the directory entry is durable (POSIX only)
42
+ try:
43
+ dir_fd = os.open(str(path.parent), os.O_RDONLY)
44
+ try:
45
+ os.fsync(dir_fd)
46
+ finally:
47
+ os.close(dir_fd)
48
+ except (AttributeError, OSError):
49
+ pass # Windows does not support O_RDONLY on directories — best-effort
50
+ except BaseException:
51
+ try:
52
+ tmp.unlink(missing_ok=True)
53
+ except OSError:
54
+ pass
55
+ raise
56
+
57
+
58
+ def sweep_orphans(root: str | Path) -> int:
59
+ """Delete any leftover *.gdm_tmp files under root. Returns count deleted.
60
+
61
+ Called once at startup (background thread) to clean up temp files left by
62
+ previous crashes.
63
+ """
64
+ root = Path(root)
65
+ count = 0
66
+ for p in root.rglob(f"*{_TMP_SUFFIX}"):
67
+ try:
68
+ p.unlink()
69
+ count += 1
70
+ except OSError:
71
+ pass
72
+ return count