thinkstack-core 4.0.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.
- thinkstack_core/__init__.py +158 -0
- thinkstack_core/aggphi_textual.py +275 -0
- thinkstack_core/alerts/__init__.py +23 -0
- thinkstack_core/alerts/base.py +46 -0
- thinkstack_core/alerts/config.py +60 -0
- thinkstack_core/alerts/dispatcher.py +110 -0
- thinkstack_core/alerts/jira.py +96 -0
- thinkstack_core/alerts/linear.py +72 -0
- thinkstack_core/alerts/pagerduty.py +66 -0
- thinkstack_core/alerts/slack.py +81 -0
- thinkstack_core/alerts/teams.py +70 -0
- thinkstack_core/audit/__init__.py +43 -0
- thinkstack_core/audit/exporter.py +297 -0
- thinkstack_core/audit/privacy.py +101 -0
- thinkstack_core/audit/scrubber.py +149 -0
- thinkstack_core/audit/service.py +67 -0
- thinkstack_core/audit/signing.py +127 -0
- thinkstack_core/broadcast/__init__.py +4 -0
- thinkstack_core/broadcast/broadcaster.py +100 -0
- thinkstack_core/broadcast/watcher.py +71 -0
- thinkstack_core/capability.py +639 -0
- thinkstack_core/cloud/__init__.py +1 -0
- thinkstack_core/cloud/client_config.py +472 -0
- thinkstack_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
- thinkstack_core/cloud/client_configs/.claude-stdio.json +13 -0
- thinkstack_core/cloud/client_configs/.cursor-mcp.json +13 -0
- thinkstack_core/cloud/client_configs/.opencode-bridge.json +13 -0
- thinkstack_core/cloud/client_configs/.opencode.json +15 -0
- thinkstack_core/cloud/client_configs/.vscode-mcp.json +13 -0
- thinkstack_core/cloud/mcp_client.py +229 -0
- thinkstack_core/cloud/setup.py +144 -0
- thinkstack_core/cloud/sync.py +143 -0
- thinkstack_core/cloud/sync_bundle.py +639 -0
- thinkstack_core/cloud/sync_conflicts.py +183 -0
- thinkstack_core/cloud/sync_state.py +159 -0
- thinkstack_core/cloud/team_sync.py +337 -0
- thinkstack_core/cloud/thinkstack-mcp-bridge.js +357 -0
- thinkstack_core/codex/__init__.py +9 -0
- thinkstack_core/codex/__main__.py +97 -0
- thinkstack_core/codex/capture.py +208 -0
- thinkstack_core/codex/proxy.py +412 -0
- thinkstack_core/compat.py +103 -0
- thinkstack_core/concept_catalog.py +209 -0
- thinkstack_core/consolidation/__init__.py +3 -0
- thinkstack_core/consolidation/synthesizer.py +87 -0
- thinkstack_core/consolidation/workflow.py +175 -0
- thinkstack_core/daemon/__init__.py +27 -0
- thinkstack_core/daemon/supervisor.py +293 -0
- thinkstack_core/daemon/watcher.py +244 -0
- thinkstack_core/dashboard_api.py +2012 -0
- thinkstack_core/deltaf.py +97 -0
- thinkstack_core/disclosure.py +50 -0
- thinkstack_core/divergence/__init__.py +3 -0
- thinkstack_core/divergence/detector.py +166 -0
- thinkstack_core/gateway/__init__.py +32 -0
- thinkstack_core/gateway/key_manager.py +124 -0
- thinkstack_core/gateway/metrics_webhook.py +252 -0
- thinkstack_core/gateway/policy.py +262 -0
- thinkstack_core/gateway/server.py +727 -0
- thinkstack_core/gateway/sso.py +233 -0
- thinkstack_core/gcc.py +1246 -0
- thinkstack_core/github/__init__.py +35 -0
- thinkstack_core/github/app.py +240 -0
- thinkstack_core/github/comment_builder.py +113 -0
- thinkstack_core/github/pat.py +76 -0
- thinkstack_core/github/pr_parser.py +82 -0
- thinkstack_core/github/pr_reporter.py +555 -0
- thinkstack_core/gitlab/__init__.py +177 -0
- thinkstack_core/hitl/__init__.py +4 -0
- thinkstack_core/hitl/channels.py +129 -0
- thinkstack_core/hitl/orchestrator.py +95 -0
- thinkstack_core/hooks/__init__.py +17 -0
- thinkstack_core/hooks/claude_code.py +228 -0
- thinkstack_core/hooks/git_capture.py +341 -0
- thinkstack_core/hooks/git_commit.py +182 -0
- thinkstack_core/hooks/installer.py +850 -0
- thinkstack_core/hooks/pre_commit.py +157 -0
- thinkstack_core/hooks/runner.py +386 -0
- thinkstack_core/identity/__init__.py +4 -0
- thinkstack_core/identity/agent.py +86 -0
- thinkstack_core/identity/providers.py +85 -0
- thinkstack_core/invariants.py +182 -0
- thinkstack_core/mcp/__init__.py +10 -0
- thinkstack_core/mcp/auth.py +177 -0
- thinkstack_core/mcp/server.py +1215 -0
- thinkstack_core/metrics/__init__.py +35 -0
- thinkstack_core/metrics/aggregate.py +215 -0
- thinkstack_core/metrics/calibrate.py +198 -0
- thinkstack_core/metrics/calibration.py +125 -0
- thinkstack_core/metrics/credibility.py +288 -0
- thinkstack_core/metrics/delivery_time.py +70 -0
- thinkstack_core/metrics/dhs.py +126 -0
- thinkstack_core/metrics/mcs.py +96 -0
- thinkstack_core/metrics/roi.py +88 -0
- thinkstack_core/metrics/session_writer.py +81 -0
- thinkstack_core/metrics/shadow_ai.py +117 -0
- thinkstack_core/metrics/sprint_writer.py +243 -0
- thinkstack_core/observability/__init__.py +78 -0
- thinkstack_core/observability/datadog.py +157 -0
- thinkstack_core/observability/formatter.py +119 -0
- thinkstack_core/observability/report.py +264 -0
- thinkstack_core/observability/servicenow.py +147 -0
- thinkstack_core/observability/splunk.py +218 -0
- thinkstack_core/observability/webhook.py +227 -0
- thinkstack_core/parser/__init__.py +30 -0
- thinkstack_core/parser/blocks.py +216 -0
- thinkstack_core/parser/inference.py +159 -0
- thinkstack_core/parser/thinking.py +112 -0
- thinkstack_core/projects.py +169 -0
- thinkstack_core/prompt_artifact.py +76 -0
- thinkstack_core/proxy/__init__.py +9 -0
- thinkstack_core/proxy/routes/__init__.py +1 -0
- thinkstack_core/proxy/routes/anthropic.py +264 -0
- thinkstack_core/proxy/routes/azure_openai.py +336 -0
- thinkstack_core/proxy/routes/gemini.py +331 -0
- thinkstack_core/proxy/routes/groq.py +284 -0
- thinkstack_core/proxy/routes/ollama.py +279 -0
- thinkstack_core/proxy/routes/openai.py +287 -0
- thinkstack_core/proxy/server.py +356 -0
- thinkstack_core/query/__init__.py +15 -0
- thinkstack_core/query/grep.py +181 -0
- thinkstack_core/query/hybrid.py +86 -0
- thinkstack_core/query/semantic.py +157 -0
- thinkstack_core/rdp.py +105 -0
- thinkstack_core/reasoning/__init__.py +4 -0
- thinkstack_core/reasoning/entry.py +31 -0
- thinkstack_core/reasoning/store.py +122 -0
- thinkstack_core/reasoning_plus/__init__.py +70 -0
- thinkstack_core/reasoning_plus/augmenter.py +337 -0
- thinkstack_core/reasoning_plus/capture.py +51 -0
- thinkstack_core/reasoning_plus/config.py +313 -0
- thinkstack_core/reasoning_plus/context.py +262 -0
- thinkstack_core/reasoning_plus/learning/__init__.py +125 -0
- thinkstack_core/reasoning_plus/learning/analytics.py +141 -0
- thinkstack_core/reasoning_plus/learning/api.py +784 -0
- thinkstack_core/reasoning_plus/learning/chain.py +285 -0
- thinkstack_core/reasoning_plus/learning/composer.py +141 -0
- thinkstack_core/reasoning_plus/learning/conflicts.py +184 -0
- thinkstack_core/reasoning_plus/learning/context_collector.py +194 -0
- thinkstack_core/reasoning_plus/learning/cross_project.py +234 -0
- thinkstack_core/reasoning_plus/learning/deny_list.py +108 -0
- thinkstack_core/reasoning_plus/learning/embeddings.py +209 -0
- thinkstack_core/reasoning_plus/learning/evolution.py +119 -0
- thinkstack_core/reasoning_plus/learning/extractor.py +271 -0
- thinkstack_core/reasoning_plus/learning/filter_five_layer.py +95 -0
- thinkstack_core/reasoning_plus/learning/models.py +149 -0
- thinkstack_core/reasoning_plus/learning/org_store.py +156 -0
- thinkstack_core/reasoning_plus/learning/pii.py +142 -0
- thinkstack_core/reasoning_plus/learning/promotion.py +58 -0
- thinkstack_core/reasoning_plus/learning/provenance.py +126 -0
- thinkstack_core/reasoning_plus/learning/recorder.py +81 -0
- thinkstack_core/reasoning_plus/learning/relevance.py +122 -0
- thinkstack_core/reasoning_plus/learning/state.py +86 -0
- thinkstack_core/reasoning_plus/learning/store.py +178 -0
- thinkstack_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
- thinkstack_core/reasoning_plus/prompt.py +90 -0
- thinkstack_core/rep.py +134 -0
- thinkstack_core/rep_network/__init__.py +25 -0
- thinkstack_core/rep_network/merge.py +70 -0
- thinkstack_core/rep_network/node.py +137 -0
- thinkstack_core/rep_network/server.py +140 -0
- thinkstack_core/rep_network/sync.py +207 -0
- thinkstack_core/sensitivity.py +182 -0
- thinkstack_core/serve.py +258 -0
- thinkstack_core/session/__init__.py +39 -0
- thinkstack_core/session/disagreement.py +188 -0
- thinkstack_core/session/models.py +114 -0
- thinkstack_core/session/orchestrator.py +182 -0
- thinkstack_core/session/planner.py +169 -0
- thinkstack_core/session/simulator.py +132 -0
- thinkstack_core/signing.py +290 -0
- thinkstack_core/sis.py +197 -0
- thinkstack_core/skills/pr-reviewer/SKILL.md +204 -0
- thinkstack_core/skills/thinkstack-auto-sync/SKILL.md +175 -0
- thinkstack_core/skills/thinkstack-session-start/SKILL.md +136 -0
- thinkstack_core/storage.py +308 -0
- thinkstack_core/templates/__init__.py +6 -0
- thinkstack_core/templates/engine.py +122 -0
- thinkstack_core/templates/go.py +18 -0
- thinkstack_core/templates/infra.py +19 -0
- thinkstack_core/templates/library/__init__.py +18 -0
- thinkstack_core/templates/library/api_design.md +27 -0
- thinkstack_core/templates/library/bug_fix.md +27 -0
- thinkstack_core/templates/library/decision_record.md +27 -0
- thinkstack_core/templates/library/engine.py +228 -0
- thinkstack_core/templates/library/security_review.md +30 -0
- thinkstack_core/templates/python.py +19 -0
- thinkstack_core/templates/react.py +18 -0
- thinkstack_core/templates/typescript.py +18 -0
- thinkstack_core/theta.py +221 -0
- thinkstack_core/theta_synthesis.py +268 -0
- thinkstack_core/topics.py +320 -0
- thinkstack_core/variance.py +219 -0
- thinkstack_core/wrapper/__init__.py +52 -0
- thinkstack_core/wrapper/anthropic.py +487 -0
- thinkstack_core/wrapper/base.py +562 -0
- thinkstack_core/wrapper/bedrock.py +342 -0
- thinkstack_core/wrapper/gemini.py +422 -0
- thinkstack_core/wrapper/ollama.py +527 -0
- thinkstack_core/wrapper/openai.py +461 -0
- thinkstack_core-4.0.0.dist-info/METADATA +868 -0
- thinkstack_core-4.0.0.dist-info/RECORD +205 -0
- thinkstack_core-4.0.0.dist-info/WHEEL +5 -0
- thinkstack_core-4.0.0.dist-info/entry_points.txt +2 -0
- thinkstack_core-4.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Background daemon supervisor for the ThinkStack CLI (S26).
|
|
3
|
+
|
|
4
|
+
The daemon runs a filesystem watcher in the background and optionally the
|
|
5
|
+
unified ThinkStack server. It writes its PID to `.GCC/thinkstack_daemon.pid` and
|
|
6
|
+
logs to `.GCC/thinkstack_daemon.log`.
|
|
7
|
+
|
|
8
|
+
CLI commands:
|
|
9
|
+
thinkstack daemon start # idempotent
|
|
10
|
+
thinkstack daemon stop
|
|
11
|
+
thinkstack daemon status
|
|
12
|
+
thinkstack daemon logs
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import signal
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Callable, Optional
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
PID_FILE_NAME = "thinkstack_daemon.pid"
|
|
27
|
+
LOG_FILE_NAME = "thinkstack_daemon.log"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _gcc_dir(project_root: Path) -> Path:
|
|
31
|
+
"""Resolve the .GCC directory for a project root, ensuring it exists."""
|
|
32
|
+
from thinkstack_core import GCCRepository
|
|
33
|
+
|
|
34
|
+
repo = GCCRepository.at(project_root)
|
|
35
|
+
if not repo.is_initialized():
|
|
36
|
+
raise RuntimeError(f"No initialized .GCC/ found at {project_root}")
|
|
37
|
+
return repo.gcc_dir
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _read_pid(gcc_dir: Path) -> Optional[int]:
|
|
41
|
+
"""Read the daemon PID from the PID file, if present and valid."""
|
|
42
|
+
pid_path = gcc_dir / PID_FILE_NAME
|
|
43
|
+
if not pid_path.exists():
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
return int(pid_path.read_text(encoding="utf-8").strip())
|
|
47
|
+
except Exception:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_alive(pid: int) -> bool:
|
|
52
|
+
"""Check whether a process with the given PID is still running."""
|
|
53
|
+
try:
|
|
54
|
+
os.kill(pid, 0)
|
|
55
|
+
return True
|
|
56
|
+
except (OSError, ProcessLookupError):
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _wait_for_pid(
|
|
61
|
+
gcc_dir: Path,
|
|
62
|
+
timeout: float = 2.0,
|
|
63
|
+
interval: float = 0.1,
|
|
64
|
+
) -> Optional[int]:
|
|
65
|
+
"""Wait for a daemon PID file to appear and refer to a live process."""
|
|
66
|
+
deadline = time.time() + timeout
|
|
67
|
+
while time.time() < deadline:
|
|
68
|
+
pid = _read_pid(gcc_dir)
|
|
69
|
+
if pid is not None and _is_alive(pid):
|
|
70
|
+
return pid
|
|
71
|
+
time.sleep(interval)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# daemon process entry point
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _run_daemon(
|
|
81
|
+
project_root: Optional[str] = None,
|
|
82
|
+
run_server: bool = False,
|
|
83
|
+
host: str = "127.0.0.1",
|
|
84
|
+
port: int = 8765,
|
|
85
|
+
stop_event: Optional[threading.Event] = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Entry point for the daemon process.
|
|
88
|
+
|
|
89
|
+
This function is invoked by the subprocess spawned in `start_daemon`.
|
|
90
|
+
It writes the PID file, starts the project watcher, and optionally runs the
|
|
91
|
+
unified server. The process keeps running until it receives SIGTERM/SIGINT.
|
|
92
|
+
"""
|
|
93
|
+
if project_root is None:
|
|
94
|
+
project_root = os.environ.get("THINKSTACK_DAEMON_ROOT", str(Path.cwd()))
|
|
95
|
+
root = Path(project_root).resolve()
|
|
96
|
+
|
|
97
|
+
gcc_dir = _gcc_dir(root)
|
|
98
|
+
pid_path = gcc_dir / PID_FILE_NAME
|
|
99
|
+
pid_path.write_text(str(os.getpid()), encoding="utf-8")
|
|
100
|
+
|
|
101
|
+
if stop_event is None:
|
|
102
|
+
stop_event = threading.Event()
|
|
103
|
+
|
|
104
|
+
def _handle_signal(signum: int, frame: Any) -> None:
|
|
105
|
+
stop_event.set()
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
signal.signal(signal.SIGTERM, _handle_signal)
|
|
109
|
+
signal.signal(signal.SIGINT, _handle_signal)
|
|
110
|
+
except Exception:
|
|
111
|
+
# May fail in some test environments; continue without signal handling.
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
from thinkstack_core.daemon.watcher import ProjectWatcher
|
|
116
|
+
|
|
117
|
+
watcher = ProjectWatcher(root)
|
|
118
|
+
watcher.start()
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
print(f"Failed to start watcher: {exc}", file=sys.stderr)
|
|
121
|
+
pid_path.unlink(missing_ok=True)
|
|
122
|
+
raise
|
|
123
|
+
|
|
124
|
+
server_thread: Optional[threading.Thread] = None
|
|
125
|
+
if run_server:
|
|
126
|
+
try:
|
|
127
|
+
from thinkstack_core.serve import serve_all
|
|
128
|
+
|
|
129
|
+
def _serve() -> None:
|
|
130
|
+
try:
|
|
131
|
+
serve_all(host=host, port=port, gcc_path=str(root))
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
print(f"Unified server exited: {exc}", file=sys.stderr)
|
|
134
|
+
|
|
135
|
+
server_thread = threading.Thread(target=_serve, daemon=True)
|
|
136
|
+
server_thread.start()
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
print(f"Failed to start unified server: {exc}", file=sys.stderr)
|
|
139
|
+
|
|
140
|
+
print(f"ThinkStack daemon started (PID {os.getpid()}) watching {root}")
|
|
141
|
+
sys.stdout.flush()
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
while not stop_event.is_set():
|
|
145
|
+
stop_event.wait(1.0)
|
|
146
|
+
finally:
|
|
147
|
+
watcher.stop()
|
|
148
|
+
print(f"ThinkStack daemon stopped (PID {os.getpid()})")
|
|
149
|
+
sys.stdout.flush()
|
|
150
|
+
pid_path.unlink(missing_ok=True)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# process spawning
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_daemon_command(project_root: Path, run_server: bool, host: str, port: int) -> list[str]:
|
|
159
|
+
"""Build the command used to spawn the daemon subprocess."""
|
|
160
|
+
code = (
|
|
161
|
+
"from thinkstack_core.daemon.supervisor import _run_daemon; "
|
|
162
|
+
f"_run_daemon(project_root={str(project_root)!r}, run_server={run_server!r}, "
|
|
163
|
+
f"host={host!r}, port={port!r})"
|
|
164
|
+
)
|
|
165
|
+
return [sys.executable, "-c", code]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# public control functions
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def start_daemon(
|
|
174
|
+
project_root: Path,
|
|
175
|
+
run_server: bool = False,
|
|
176
|
+
host: str = "127.0.0.1",
|
|
177
|
+
port: int = 8765,
|
|
178
|
+
popen_factory: Optional[Callable[..., Any]] = None,
|
|
179
|
+
) -> tuple[bool, str]:
|
|
180
|
+
"""Start the daemon if it is not already running.
|
|
181
|
+
|
|
182
|
+
Returns (success, message). If the daemon is already running, reports
|
|
183
|
+
the existing PID without starting a new process (idempotent).
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
gcc_dir = _gcc_dir(project_root)
|
|
187
|
+
except RuntimeError as exc:
|
|
188
|
+
return False, str(exc)
|
|
189
|
+
|
|
190
|
+
pid = _read_pid(gcc_dir)
|
|
191
|
+
if pid is not None and _is_alive(pid):
|
|
192
|
+
return True, f"ThinkStack daemon already running (PID {pid})"
|
|
193
|
+
|
|
194
|
+
log_path = gcc_dir / LOG_FILE_NAME
|
|
195
|
+
cmd = _build_daemon_command(project_root, run_server, host, port)
|
|
196
|
+
|
|
197
|
+
env = dict(os.environ)
|
|
198
|
+
env["THINKSTACK_DAEMON_ROOT"] = str(project_root)
|
|
199
|
+
|
|
200
|
+
if popen_factory is None:
|
|
201
|
+
popen_factory = subprocess.Popen
|
|
202
|
+
|
|
203
|
+
with open(log_path, "a", encoding="utf-8") as log_f:
|
|
204
|
+
process = popen_factory(
|
|
205
|
+
cmd,
|
|
206
|
+
stdout=log_f,
|
|
207
|
+
stderr=subprocess.STDOUT,
|
|
208
|
+
stdin=subprocess.DEVNULL,
|
|
209
|
+
start_new_session=True,
|
|
210
|
+
env=env,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
new_pid = _wait_for_pid(gcc_dir)
|
|
214
|
+
if new_pid is not None:
|
|
215
|
+
return True, f"ThinkStack daemon started (PID {new_pid})"
|
|
216
|
+
return False, (
|
|
217
|
+
f"Daemon subprocess launched (PID {process.pid}) but PID file "
|
|
218
|
+
f"was not written to {gcc_dir / PID_FILE_NAME}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def stop_daemon(project_root: Path) -> tuple[bool, str]:
|
|
223
|
+
"""Stop the daemon and clean up its PID file.
|
|
224
|
+
|
|
225
|
+
Returns (success, message). If no daemon is running, returns a message
|
|
226
|
+
explaining that and cleans up any stale PID file.
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
gcc_dir = _gcc_dir(project_root)
|
|
230
|
+
except RuntimeError as exc:
|
|
231
|
+
return False, str(exc)
|
|
232
|
+
|
|
233
|
+
pid = _read_pid(gcc_dir)
|
|
234
|
+
pid_path = gcc_dir / PID_FILE_NAME
|
|
235
|
+
|
|
236
|
+
if pid is None:
|
|
237
|
+
return False, "ThinkStack daemon is not running"
|
|
238
|
+
|
|
239
|
+
if not _is_alive(pid):
|
|
240
|
+
pid_path.unlink(missing_ok=True)
|
|
241
|
+
return False, f"Daemon PID {pid} is not alive; cleaned up stale PID file"
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
os.kill(pid, signal.SIGTERM)
|
|
245
|
+
except (OSError, ProcessLookupError):
|
|
246
|
+
pid_path.unlink(missing_ok=True)
|
|
247
|
+
return False, f"Failed to signal daemon PID {pid}; cleaned up PID file"
|
|
248
|
+
|
|
249
|
+
for _ in range(20):
|
|
250
|
+
if not _is_alive(pid):
|
|
251
|
+
break
|
|
252
|
+
time.sleep(0.1)
|
|
253
|
+
|
|
254
|
+
if _is_alive(pid):
|
|
255
|
+
try:
|
|
256
|
+
os.kill(pid, signal.SIGKILL)
|
|
257
|
+
time.sleep(0.1)
|
|
258
|
+
except (OSError, ProcessLookupError):
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
pid_path.unlink(missing_ok=True)
|
|
262
|
+
return True, f"ThinkStack daemon stopped (PID {pid})"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def daemon_status(project_root: Path) -> dict[str, Any]:
|
|
266
|
+
"""Return a status dict for the daemon."""
|
|
267
|
+
try:
|
|
268
|
+
gcc_dir = _gcc_dir(project_root)
|
|
269
|
+
except RuntimeError as exc:
|
|
270
|
+
return {"running": False, "pid": None, "log_path": None, "error": str(exc)}
|
|
271
|
+
|
|
272
|
+
pid = _read_pid(gcc_dir)
|
|
273
|
+
log_path = gcc_dir / LOG_FILE_NAME
|
|
274
|
+
if pid is None:
|
|
275
|
+
return {"running": False, "pid": None, "log_path": str(log_path)}
|
|
276
|
+
alive = _is_alive(pid)
|
|
277
|
+
if not alive:
|
|
278
|
+
return {"running": False, "pid": pid, "log_path": str(log_path)}
|
|
279
|
+
return {"running": True, "pid": pid, "log_path": str(log_path)}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def daemon_logs(project_root: Path, lines: int = 50) -> list[str]:
|
|
283
|
+
"""Return the last N lines of the daemon log file."""
|
|
284
|
+
try:
|
|
285
|
+
gcc_dir = _gcc_dir(project_root)
|
|
286
|
+
except RuntimeError:
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
log_path = gcc_dir / LOG_FILE_NAME
|
|
290
|
+
if not log_path.exists():
|
|
291
|
+
return []
|
|
292
|
+
with log_path.open("r", encoding="utf-8") as f:
|
|
293
|
+
return f.read().splitlines()[-lines:]
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Filesystem watcher for the ThinkStack background daemon (S26).
|
|
3
|
+
|
|
4
|
+
Monitors project files for create/modify/delete events and emits them as
|
|
5
|
+
`fs_change` events to `.GCC/events.log.jsonl`.
|
|
6
|
+
|
|
7
|
+
Uses `watchdog` when available; otherwise falls back to a simple polling
|
|
8
|
+
implementation.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _emit_fs_change(project_root: Path, file_path: Path, event_kind: str) -> None:
|
|
20
|
+
"""Append a fs_change event to the project's event log.
|
|
21
|
+
|
|
22
|
+
Uses the GCCRepository._append_event helper so the hash chain and
|
|
23
|
+
optional signing remain intact.
|
|
24
|
+
"""
|
|
25
|
+
from thinkstack_core import GCCRepository
|
|
26
|
+
|
|
27
|
+
repo = GCCRepository.at(project_root)
|
|
28
|
+
if not repo.is_initialized():
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
mtime: Optional[float] = None
|
|
32
|
+
try:
|
|
33
|
+
if file_path.exists():
|
|
34
|
+
mtime = file_path.stat().st_mtime
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
payload = {
|
|
39
|
+
"path": str(file_path),
|
|
40
|
+
"event_kind": event_kind,
|
|
41
|
+
"mtime": mtime,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
repo._append_event("fs_change", payload)
|
|
46
|
+
except Exception:
|
|
47
|
+
# Last-resort fallback: write directly without hash chain.
|
|
48
|
+
# This should only happen if the repo backend is unavailable.
|
|
49
|
+
try:
|
|
50
|
+
import json
|
|
51
|
+
|
|
52
|
+
log_path = repo.gcc_dir / "events.log.jsonl"
|
|
53
|
+
entry = {
|
|
54
|
+
"timestamp": _now_iso(),
|
|
55
|
+
"actor": "thinkstack-daemon",
|
|
56
|
+
"event_type": "fs_change",
|
|
57
|
+
"payload": payload,
|
|
58
|
+
}
|
|
59
|
+
with log_path.open("a", encoding="utf-8") as f:
|
|
60
|
+
f.write(json.dumps(entry) + "\n")
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _now_iso() -> str:
|
|
66
|
+
import datetime as dt
|
|
67
|
+
return dt.datetime.now(tz=dt.timezone.utc).isoformat()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_inside_gcc(path: Path, gcc_dir: Path) -> bool:
|
|
71
|
+
"""Return True if path is inside or equal to the .GCC directory."""
|
|
72
|
+
try:
|
|
73
|
+
return str(path.resolve()).startswith(str(gcc_dir.resolve()))
|
|
74
|
+
except Exception:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# watchdog-based implementation
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
from watchdog.events import FileSystemEventHandler # type: ignore
|
|
84
|
+
from watchdog.observers import Observer # type: ignore
|
|
85
|
+
|
|
86
|
+
_HAS_WATCHDOG = True
|
|
87
|
+
|
|
88
|
+
class _WatchdogHandler(FileSystemEventHandler): # type: ignore[misc]
|
|
89
|
+
def __init__(self, watcher: "ProjectWatcher") -> None:
|
|
90
|
+
self._watcher = watcher
|
|
91
|
+
|
|
92
|
+
def on_created(self, event: Any) -> None:
|
|
93
|
+
self._watcher._emit(Path(event.src_path), "created") # noqa: SLF001
|
|
94
|
+
|
|
95
|
+
def on_modified(self, event: Any) -> None:
|
|
96
|
+
self._watcher._emit(Path(event.src_path), "modified") # noqa: SLF001
|
|
97
|
+
|
|
98
|
+
def on_deleted(self, event: Any) -> None:
|
|
99
|
+
self._watcher._emit(Path(event.src_path), "deleted") # noqa: SLF001
|
|
100
|
+
|
|
101
|
+
except Exception: # pragma: no cover
|
|
102
|
+
_HAS_WATCHDOG = False
|
|
103
|
+
Observer: Any = None # type: ignore[no-redef]
|
|
104
|
+
FileSystemEventHandler: Any = None # type: ignore[no-redef]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# polling-based implementation
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _PollingWatcher:
|
|
113
|
+
def __init__(self, project_root: Path, gcc_dir: Path, emit: Any, interval: float) -> None:
|
|
114
|
+
self.project_root = project_root
|
|
115
|
+
self.gcc_dir = gcc_dir
|
|
116
|
+
self.emit = emit
|
|
117
|
+
self.interval = interval
|
|
118
|
+
self._stop_event = threading.Event()
|
|
119
|
+
self._thread: Optional[threading.Thread] = None
|
|
120
|
+
|
|
121
|
+
def _build_snapshot(self) -> dict[str, float]:
|
|
122
|
+
snap: dict[str, float] = {}
|
|
123
|
+
for root, dirs, files in os.walk(self.project_root):
|
|
124
|
+
root_path = Path(root)
|
|
125
|
+
if _is_inside_gcc(root_path, self.gcc_dir):
|
|
126
|
+
dirs[:] = []
|
|
127
|
+
continue
|
|
128
|
+
for name in files:
|
|
129
|
+
p = root_path / name
|
|
130
|
+
if _is_inside_gcc(p, self.gcc_dir):
|
|
131
|
+
continue
|
|
132
|
+
try:
|
|
133
|
+
snap[str(p.resolve())] = p.stat().st_mtime
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
return snap
|
|
137
|
+
|
|
138
|
+
def _detect_changes(self, old: dict[str, float], new: dict[str, float]) -> None:
|
|
139
|
+
for p, mtime in new.items():
|
|
140
|
+
if p not in old:
|
|
141
|
+
self.emit(Path(p), "created")
|
|
142
|
+
elif old[p] != mtime:
|
|
143
|
+
self.emit(Path(p), "modified")
|
|
144
|
+
for p in old:
|
|
145
|
+
if p not in new:
|
|
146
|
+
self.emit(Path(p), "deleted")
|
|
147
|
+
|
|
148
|
+
def _run(self) -> None:
|
|
149
|
+
snapshot = self._build_snapshot()
|
|
150
|
+
while not self._stop_event.is_set():
|
|
151
|
+
self._stop_event.wait(self.interval)
|
|
152
|
+
if self._stop_event.is_set():
|
|
153
|
+
break
|
|
154
|
+
new_snapshot = self._build_snapshot()
|
|
155
|
+
self._detect_changes(snapshot, new_snapshot)
|
|
156
|
+
snapshot = new_snapshot
|
|
157
|
+
|
|
158
|
+
def start(self) -> None:
|
|
159
|
+
self._stop_event.clear()
|
|
160
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
161
|
+
self._thread.start()
|
|
162
|
+
|
|
163
|
+
def stop(self) -> None:
|
|
164
|
+
self._stop_event.set()
|
|
165
|
+
if self._thread is not None:
|
|
166
|
+
self._thread.join(timeout=2.0)
|
|
167
|
+
self._thread = None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# public watcher
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class ProjectWatcher:
|
|
176
|
+
"""Watch a project root for file changes and emit `fs_change` events."""
|
|
177
|
+
|
|
178
|
+
def __init__(self, project_root: Path, poll_interval: float = 1.0, use_watchdog: bool = True) -> None:
|
|
179
|
+
self.project_root = Path(project_root).resolve()
|
|
180
|
+
self.gcc_dir = self.project_root / ".GCC"
|
|
181
|
+
self.poll_interval = poll_interval
|
|
182
|
+
self.use_watchdog = use_watchdog
|
|
183
|
+
self._observer: Optional[Any] = None
|
|
184
|
+
self._polling: Optional[_PollingWatcher] = None
|
|
185
|
+
|
|
186
|
+
def _is_gcc_path(self, path: Path) -> bool:
|
|
187
|
+
return _is_inside_gcc(path, self.gcc_dir)
|
|
188
|
+
|
|
189
|
+
def _emit(self, path: Path, event_kind: str) -> None:
|
|
190
|
+
if self._is_gcc_path(path):
|
|
191
|
+
return
|
|
192
|
+
_emit_fs_change(self.project_root, path, event_kind)
|
|
193
|
+
|
|
194
|
+
def _start_watchdog(self) -> bool:
|
|
195
|
+
if not self.use_watchdog or not _HAS_WATCHDOG:
|
|
196
|
+
return False
|
|
197
|
+
observer = Observer()
|
|
198
|
+
handler = _WatchdogHandler(self)
|
|
199
|
+
observer.schedule(handler, str(self.project_root), recursive=True)
|
|
200
|
+
observer.start()
|
|
201
|
+
self._observer = observer
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
def _start_polling(self) -> None:
|
|
205
|
+
polling = _PollingWatcher(
|
|
206
|
+
self.project_root,
|
|
207
|
+
self.gcc_dir,
|
|
208
|
+
self._emit,
|
|
209
|
+
self.poll_interval,
|
|
210
|
+
)
|
|
211
|
+
polling.start()
|
|
212
|
+
self._polling = polling
|
|
213
|
+
|
|
214
|
+
def start(self) -> None:
|
|
215
|
+
"""Start watching the project root.
|
|
216
|
+
|
|
217
|
+
Tries to use watchdog if installed; otherwise falls back to polling.
|
|
218
|
+
"""
|
|
219
|
+
if not self._start_watchdog():
|
|
220
|
+
self._start_polling()
|
|
221
|
+
|
|
222
|
+
def stop(self) -> None:
|
|
223
|
+
"""Stop watching the project root."""
|
|
224
|
+
if self._observer is not None:
|
|
225
|
+
try:
|
|
226
|
+
self._observer.stop()
|
|
227
|
+
self._observer.join(timeout=2.0)
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
self._observer = None
|
|
231
|
+
if self._polling is not None:
|
|
232
|
+
self._polling.stop()
|
|
233
|
+
self._polling = None
|
|
234
|
+
|
|
235
|
+
def is_alive(self) -> bool:
|
|
236
|
+
"""Return True if the watcher is still running."""
|
|
237
|
+
if self._observer is not None:
|
|
238
|
+
try:
|
|
239
|
+
return self._observer.is_alive()
|
|
240
|
+
except Exception:
|
|
241
|
+
return False
|
|
242
|
+
if self._polling is not None:
|
|
243
|
+
return self._polling._thread is not None and self._polling._thread.is_alive()
|
|
244
|
+
return False
|