alter-runtime 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.
- alter_runtime/__init__.py +11 -0
- alter_runtime/adapters/__init__.py +19 -0
- alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
- alter_runtime/adapters/git_watcher.py +457 -0
- alter_runtime/adapters/household/__init__.py +29 -0
- alter_runtime/adapters/household/_base.py +138 -0
- alter_runtime/adapters/household/compost/__init__.py +17 -0
- alter_runtime/adapters/household/compost/adapter.py +81 -0
- alter_runtime/adapters/household/compost/storage.py +75 -0
- alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
- alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
- alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
- alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
- alter_runtime/adapters/household/compost/traits.py +79 -0
- alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
- alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
- alter_runtime/adapters/household/self_hoster/storage.py +83 -0
- alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
- alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
- alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
- alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
- alter_runtime/adapters/household/self_hoster/traits.py +105 -0
- alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
- alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
- alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
- alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
- alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
- alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
- alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
- alter_runtime/adapters/worktree_watcher.py +378 -0
- alter_runtime/atlas/__init__.py +48 -0
- alter_runtime/atlas/base.py +102 -0
- alter_runtime/atlas/ledger.py +196 -0
- alter_runtime/atlas/observations.py +136 -0
- alter_runtime/atlas/schema.py +106 -0
- alter_runtime/cap_cache.py +392 -0
- alter_runtime/cli.py +517 -0
- alter_runtime/clients/__init__.py +0 -0
- alter_runtime/clients/token_usage_client.py +273 -0
- alter_runtime/config.py +648 -0
- alter_runtime/consent.py +425 -0
- alter_runtime/daemon.py +518 -0
- alter_runtime/floor_loop.py +335 -0
- alter_runtime/floor_preflight.py +734 -0
- alter_runtime/http_auth.py +173 -0
- alter_runtime/notifiers/__init__.py +18 -0
- alter_runtime/notifiers/desktop.py +321 -0
- alter_runtime/sdk/__init__.py +12 -0
- alter_runtime/sdk/client.py +399 -0
- alter_runtime/service_install.py +616 -0
- alter_runtime/services/__init__.py +59 -0
- alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
- alter_runtime/services/systemd/alter-runtime.service.in +74 -0
- alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
- alter_runtime/sockets/__init__.py +20 -0
- alter_runtime/sockets/dbus.py +272 -0
- alter_runtime/sockets/unix.py +702 -0
- alter_runtime/subscribers/__init__.py +58 -0
- alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
- alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
- alter_runtime/subscribers/active_sessions_gc.py +432 -0
- alter_runtime/subscribers/active_sessions_writer.py +446 -0
- alter_runtime/subscribers/adapters_writer.py +415 -0
- alter_runtime/subscribers/agent_frames.py +461 -0
- alter_runtime/subscribers/bus.py +188 -0
- alter_runtime/subscribers/cache_writer.py +347 -0
- alter_runtime/subscribers/ceremony_echo.py +290 -0
- alter_runtime/subscribers/do_sse.py +864 -0
- alter_runtime/subscribers/ebpf.py +506 -0
- alter_runtime/subscribers/inbox_writer.py +469 -0
- alter_runtime/subscribers/mcp_fallback.py +391 -0
- alter_runtime/subscribers/presence_writer.py +426 -0
- alter_runtime/subscribers/session_presence.py +467 -0
- alter_runtime/subscribers/sse.py +125 -0
- alter_runtime/subscribers/weave_intent_writer.py +608 -0
- alter_runtime/update_loop.py +519 -0
- alter_runtime/weave/__init__.py +21 -0
- alter_runtime/weave/resolver.py +544 -0
- alter_runtime-0.3.0.dist-info/METADATA +289 -0
- alter_runtime-0.3.0.dist-info/RECORD +92 -0
- alter_runtime-0.3.0.dist-info/WHEEL +4 -0
- alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
- alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
"""WeaveIntentWriter - projects intent signals into the weave-intent.jsonl stream.
|
|
2
|
+
|
|
3
|
+
D-WEAVE-VC-2 §8 items 1 + 2 (S1 + S2).
|
|
4
|
+
|
|
5
|
+
Owns ``~/.local/share/alter-runtime/weave-intent.jsonl``, writing ``intent.*``
|
|
6
|
+
records with explicit ``ttl_seconds`` + ``expires_at`` fields.
|
|
7
|
+
|
|
8
|
+
Two producers feed this writer:
|
|
9
|
+
|
|
10
|
+
(a) **cc-intent.sh hook** (built by a separate agent, NOT this module).
|
|
11
|
+
The hook runs on PreToolUse Edit|Write inside Claude Code and appends
|
|
12
|
+
JSONL records of kind ``intent.edit`` directly to ``weave-intent.jsonl``
|
|
13
|
+
with fields::
|
|
14
|
+
|
|
15
|
+
{kind, source, session_id, handle, file_path, anchor, ts (ISO-8601 string), ttl_seconds}
|
|
16
|
+
|
|
17
|
+
The writer tails this file for records it did NOT write itself, then
|
|
18
|
+
enriches them with a ``semantic_unit`` field via the tree-sitter resolver
|
|
19
|
+
(S2) and re-appends the enriched record.
|
|
20
|
+
|
|
21
|
+
(b) **WorktreeWatcher bus events** (kind ``"worktree_edit"`` on ``local.signal``).
|
|
22
|
+
Plain-editor saves with no ``intent.edit`` partner emit a coarse
|
|
23
|
+
``worktree_edit`` record — modelled as a dangling edge per D-WEAVE-VC-2
|
|
24
|
+
§3 degradation.
|
|
25
|
+
|
|
26
|
+
Ingestion design decision
|
|
27
|
+
--------------------------
|
|
28
|
+
|
|
29
|
+
The writer must NOT circularly tail the same file it appends to. The design
|
|
30
|
+
chosen is a ``source`` field sentinel:
|
|
31
|
+
|
|
32
|
+
1. Records written by THIS writer carry ``"source": "weave_intent_writer"``.
|
|
33
|
+
2. The tailer skips any record whose ``source`` matches this sentinel, so
|
|
34
|
+
enriched re-appended records are not re-processed.
|
|
35
|
+
3. Hook-originated records carry ``"source": "cc_intent_hook"`` (or omit
|
|
36
|
+
``source`` entirely) — the tailer picks those up for enrichment.
|
|
37
|
+
|
|
38
|
+
This means a single file carries both raw hook records and enriched records.
|
|
39
|
+
Consumers that want only enriched records filter on ``semantic_unit`` presence;
|
|
40
|
+
consumers that want the full audit trail read everything.
|
|
41
|
+
|
|
42
|
+
Advisory-only invariant
|
|
43
|
+
------------------------
|
|
44
|
+
|
|
45
|
+
Nothing in this module blocks, leases, or gates a write. The writer records;
|
|
46
|
+
it never arbitrates. See D-WEAVE-VC-2 §2 advisory doctrine.
|
|
47
|
+
|
|
48
|
+
TTL
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
Default TTL is 900 seconds (15 minutes), configurable via
|
|
52
|
+
``ALTER_RUNTIME_WEAVE_INTENT_TTL`` env var or constructor argument. Every
|
|
53
|
+
record carries ``ttl_seconds`` (integer) and ``expires_at`` (ISO-8601 UTC
|
|
54
|
+
datetime string) for consumer convenience.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import asyncio
|
|
60
|
+
import contextlib
|
|
61
|
+
import errno
|
|
62
|
+
import fcntl
|
|
63
|
+
import json
|
|
64
|
+
import logging
|
|
65
|
+
import os
|
|
66
|
+
from datetime import datetime, timedelta, timezone
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
|
|
69
|
+
# TYPE_CHECKING import for EventBus avoids circular dependency at runtime
|
|
70
|
+
from typing import TYPE_CHECKING, Any
|
|
71
|
+
|
|
72
|
+
from alter_runtime.config import DaemonConfig, data_dir
|
|
73
|
+
from alter_runtime.daemon import Component
|
|
74
|
+
|
|
75
|
+
if TYPE_CHECKING:
|
|
76
|
+
from alter_runtime.subscribers.bus import EventBus
|
|
77
|
+
|
|
78
|
+
__all__ = [
|
|
79
|
+
"WEAVE_INTENT_FILENAME",
|
|
80
|
+
"DEFAULT_TTL_SECONDS",
|
|
81
|
+
"WRITER_SOURCE_SENTINEL",
|
|
82
|
+
"WeaveIntentWriter",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
logger = logging.getLogger("alter_runtime.subscribers.weave_intent_writer")
|
|
86
|
+
|
|
87
|
+
#: Filename for the weave-intent JSONL (within ``data_dir()``).
|
|
88
|
+
WEAVE_INTENT_FILENAME: str = "weave-intent.jsonl"
|
|
89
|
+
|
|
90
|
+
#: Default intent TTL in seconds — 15-minute hypothesis per D-WEAVE-VC-2 §10 Q1.
|
|
91
|
+
#: Override via ALTER_RUNTIME_WEAVE_INTENT_TTL env var.
|
|
92
|
+
DEFAULT_TTL_SECONDS: int = 900
|
|
93
|
+
|
|
94
|
+
#: Source sentinel written on every record this module appends.
|
|
95
|
+
#: The tailer skips records with this sentinel to avoid circular re-processing.
|
|
96
|
+
WRITER_SOURCE_SENTINEL: str = "weave_intent_writer"
|
|
97
|
+
|
|
98
|
+
#: How often (seconds) the tailer polls the intent file for new hook records.
|
|
99
|
+
_TAIL_POLL_INTERVAL: float = 0.5
|
|
100
|
+
|
|
101
|
+
#: Rotation threshold: 5 MiB
|
|
102
|
+
ROTATION_THRESHOLD_BYTES: int = 5 * 1024 * 1024
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class WeaveIntentWriter(Component):
|
|
106
|
+
"""Subscribes to local.signal worktree_edit events and tails weave-intent.jsonl
|
|
107
|
+
for cc-intent hook records, writing enriched intent.* records.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
config:
|
|
112
|
+
Loaded :class:`DaemonConfig`.
|
|
113
|
+
bus:
|
|
114
|
+
Shared :class:`EventBus`. Subscribes to ``local.signal``.
|
|
115
|
+
intent_path:
|
|
116
|
+
Override the JSONL path. Tests redirect writes to ``tmp_path``.
|
|
117
|
+
ttl_seconds:
|
|
118
|
+
Intent TTL in seconds. Defaults to ``DEFAULT_TTL_SECONDS`` (900s).
|
|
119
|
+
tail_poll_interval:
|
|
120
|
+
How often (seconds) to poll the intent file for new hook records.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
name = "weave_intent_writer"
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
config: DaemonConfig,
|
|
128
|
+
bus: EventBus,
|
|
129
|
+
*,
|
|
130
|
+
intent_path: Path | None = None,
|
|
131
|
+
ttl_seconds: int | None = None,
|
|
132
|
+
tail_poll_interval: float = _TAIL_POLL_INTERVAL,
|
|
133
|
+
) -> None:
|
|
134
|
+
self._config = config
|
|
135
|
+
self._bus = bus
|
|
136
|
+
|
|
137
|
+
self._intent_path: Path = (
|
|
138
|
+
intent_path if intent_path is not None else data_dir() / WEAVE_INTENT_FILENAME
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# TTL: constructor arg > env var > default
|
|
142
|
+
if ttl_seconds is not None:
|
|
143
|
+
self._ttl_seconds = int(ttl_seconds)
|
|
144
|
+
else:
|
|
145
|
+
env_val = os.environ.get("ALTER_RUNTIME_WEAVE_INTENT_TTL")
|
|
146
|
+
if env_val:
|
|
147
|
+
try:
|
|
148
|
+
self._ttl_seconds = int(env_val)
|
|
149
|
+
except (ValueError, TypeError):
|
|
150
|
+
logger.warning(
|
|
151
|
+
"weave_intent_writer: invalid ALTER_RUNTIME_WEAVE_INTENT_TTL=%r — "
|
|
152
|
+
"using default %ds",
|
|
153
|
+
env_val,
|
|
154
|
+
DEFAULT_TTL_SECONDS,
|
|
155
|
+
)
|
|
156
|
+
self._ttl_seconds = DEFAULT_TTL_SECONDS
|
|
157
|
+
else:
|
|
158
|
+
self._ttl_seconds = DEFAULT_TTL_SECONDS
|
|
159
|
+
|
|
160
|
+
self._tail_poll_interval = tail_poll_interval
|
|
161
|
+
self._shutdown_event = asyncio.Event()
|
|
162
|
+
self._write_lock = asyncio.Lock()
|
|
163
|
+
#: Byte offset into the intent file up to which we have already processed
|
|
164
|
+
#: hook-originated records.
|
|
165
|
+
self._tail_offset: int = 0
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
# Component lifecycle
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
async def run(self) -> None:
|
|
172
|
+
self._bus.subscribe("local.signal", self._handle_bus_event)
|
|
173
|
+
logger.info(
|
|
174
|
+
"weave_intent_writer started path=%s ttl=%ds",
|
|
175
|
+
self._intent_path,
|
|
176
|
+
self._ttl_seconds,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Seed the tail offset to the current end-of-file so we don't
|
|
180
|
+
# re-process records that were written before this daemon started.
|
|
181
|
+
self._tail_offset = self._current_file_size()
|
|
182
|
+
|
|
183
|
+
tail_task = asyncio.create_task(self._tail_loop())
|
|
184
|
+
try:
|
|
185
|
+
await self._shutdown_event.wait()
|
|
186
|
+
except asyncio.CancelledError:
|
|
187
|
+
raise
|
|
188
|
+
finally:
|
|
189
|
+
with contextlib.suppress(Exception):
|
|
190
|
+
self._bus.unsubscribe("local.signal", self._handle_bus_event)
|
|
191
|
+
tail_task.cancel()
|
|
192
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
193
|
+
await tail_task
|
|
194
|
+
logger.info("weave_intent_writer stopped")
|
|
195
|
+
|
|
196
|
+
async def stop(self) -> None:
|
|
197
|
+
self._shutdown_event.set()
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# Bus event handler (producer b — WorktreeWatcher)
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
async def _handle_bus_event(self, event: dict[str, Any]) -> None:
|
|
204
|
+
"""Handle local.signal events from the bus."""
|
|
205
|
+
if not isinstance(event, dict):
|
|
206
|
+
return
|
|
207
|
+
if event.get("kind") != "worktree_edit":
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
payload = event.get("payload", {})
|
|
211
|
+
if not isinstance(payload, dict):
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
file_path = payload.get("file_path", "")
|
|
215
|
+
rel_path = payload.get("rel_path", "")
|
|
216
|
+
ts = payload.get("ts") or datetime.now(timezone.utc).isoformat()
|
|
217
|
+
|
|
218
|
+
record = self._build_worktree_edit_record(
|
|
219
|
+
file_path=file_path,
|
|
220
|
+
rel_path=rel_path,
|
|
221
|
+
ts=ts,
|
|
222
|
+
)
|
|
223
|
+
await self._append_record(record)
|
|
224
|
+
|
|
225
|
+
def _build_worktree_edit_record(
|
|
226
|
+
self,
|
|
227
|
+
file_path: str,
|
|
228
|
+
rel_path: str,
|
|
229
|
+
ts: str,
|
|
230
|
+
) -> dict[str, Any]:
|
|
231
|
+
"""Build a coarse worktree_edit intent record (dangling edge)."""
|
|
232
|
+
expires_at = _compute_expires_at(ts, self._ttl_seconds)
|
|
233
|
+
return {
|
|
234
|
+
"kind": "intent.worktree_edit",
|
|
235
|
+
"file_path": file_path,
|
|
236
|
+
"rel_path": rel_path,
|
|
237
|
+
"ts": ts,
|
|
238
|
+
"ttl_seconds": self._ttl_seconds,
|
|
239
|
+
"expires_at": expires_at,
|
|
240
|
+
"source": WRITER_SOURCE_SENTINEL,
|
|
241
|
+
# Dangling edge — no session_id / handle / anchor from a plain save.
|
|
242
|
+
# Consumers that need a paired intent.edit record check for presence
|
|
243
|
+
# of session_id. This coarse record enables file-level advisory even
|
|
244
|
+
# when the CC hook is not active.
|
|
245
|
+
"dangling": True,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
# Tailer loop (producer a — cc-intent.sh hook records)
|
|
250
|
+
# ------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
async def _tail_loop(self) -> None:
|
|
253
|
+
"""Poll the intent file on an interval and process new hook records."""
|
|
254
|
+
while not self._shutdown_event.is_set():
|
|
255
|
+
try:
|
|
256
|
+
await self._process_new_hook_records()
|
|
257
|
+
except asyncio.CancelledError:
|
|
258
|
+
raise
|
|
259
|
+
except Exception as exc: # pragma: no cover
|
|
260
|
+
logger.warning("weave_intent_writer: tail loop error: %s", exc)
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
await asyncio.wait_for(
|
|
264
|
+
asyncio.shield(self._shutdown_event.wait()),
|
|
265
|
+
timeout=self._tail_poll_interval,
|
|
266
|
+
)
|
|
267
|
+
return # shutdown requested
|
|
268
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
269
|
+
pass # normal poll interval expiry
|
|
270
|
+
|
|
271
|
+
async def _process_new_hook_records(self) -> None:
|
|
272
|
+
"""Read new bytes from the intent file since the last tail offset
|
|
273
|
+
and process any hook-originated records.
|
|
274
|
+
|
|
275
|
+
Circular re-processing is prevented by skipping records whose
|
|
276
|
+
``source`` field matches ``WRITER_SOURCE_SENTINEL``.
|
|
277
|
+
"""
|
|
278
|
+
if not self._intent_path.exists():
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
current_size = self._intent_path.stat().st_size
|
|
283
|
+
except OSError:
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
if current_size <= self._tail_offset:
|
|
287
|
+
# File shrunk (rotation) — reset to 0
|
|
288
|
+
if current_size < self._tail_offset:
|
|
289
|
+
logger.debug(
|
|
290
|
+
"weave_intent_writer: file shrunk (rotation?) "
|
|
291
|
+
"old_offset=%d new_size=%d — resetting",
|
|
292
|
+
self._tail_offset,
|
|
293
|
+
current_size,
|
|
294
|
+
)
|
|
295
|
+
self._tail_offset = 0
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
with open(self._intent_path, "rb") as fh:
|
|
300
|
+
fh.seek(self._tail_offset)
|
|
301
|
+
chunk = fh.read(current_size - self._tail_offset)
|
|
302
|
+
except OSError as exc:
|
|
303
|
+
logger.warning("weave_intent_writer: tail read failed: %s", exc)
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
self._tail_offset = current_size
|
|
307
|
+
|
|
308
|
+
if not chunk:
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
lines = chunk.decode("utf-8", errors="replace").splitlines()
|
|
312
|
+
for line in lines:
|
|
313
|
+
line = line.strip()
|
|
314
|
+
if not line:
|
|
315
|
+
continue
|
|
316
|
+
try:
|
|
317
|
+
record = json.loads(line)
|
|
318
|
+
except json.JSONDecodeError:
|
|
319
|
+
continue
|
|
320
|
+
if not isinstance(record, dict):
|
|
321
|
+
continue
|
|
322
|
+
|
|
323
|
+
# Skip records this writer produced (circular-processing guard)
|
|
324
|
+
if record.get("source") == WRITER_SOURCE_SENTINEL:
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
# Only process hook-originated intent.edit records
|
|
328
|
+
if record.get("kind") not in ("intent.edit", None):
|
|
329
|
+
# Accept records that lack a kind (legacy hook format) and
|
|
330
|
+
# explicit intent.edit records.
|
|
331
|
+
if record.get("kind") is not None:
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
await self._enrich_and_append(record)
|
|
336
|
+
except Exception as exc: # pragma: no cover
|
|
337
|
+
logger.warning(
|
|
338
|
+
"weave_intent_writer: failed to enrich record %r: %s — skipping",
|
|
339
|
+
record,
|
|
340
|
+
exc,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
async def _enrich_and_append(self, raw: dict[str, Any]) -> None:
|
|
344
|
+
"""Enrich a raw hook record with semantic_unit (S2) and re-append."""
|
|
345
|
+
file_path = raw.get("file_path", "")
|
|
346
|
+
anchor = raw.get("anchor")
|
|
347
|
+
|
|
348
|
+
# Normalise ts before any downstream use — accepts ISO string, epoch
|
|
349
|
+
# int/float, or None. This is the primary fix for the AttributeError
|
|
350
|
+
# raised when cc-intent.sh (legacy) emitted ts as an epoch integer and
|
|
351
|
+
# _compute_expires_at called ts.rstrip("Z") on it.
|
|
352
|
+
ts = _normalise_ts(raw.get("ts"))
|
|
353
|
+
|
|
354
|
+
# S2: resolve semantic unit — must NEVER block or raise into daemon
|
|
355
|
+
semantic_unit = _resolve_semantic_unit(file_path, anchor)
|
|
356
|
+
|
|
357
|
+
# Accept both ttl_seconds (new contract) and ttl (legacy fallback) so
|
|
358
|
+
# the writer remains resilient to older hook versions in the wild.
|
|
359
|
+
ttl = raw.get("ttl_seconds", raw.get("ttl", self._ttl_seconds))
|
|
360
|
+
try:
|
|
361
|
+
ttl = int(ttl)
|
|
362
|
+
except (TypeError, ValueError):
|
|
363
|
+
ttl = self._ttl_seconds
|
|
364
|
+
|
|
365
|
+
expires_at = _compute_expires_at(ts, ttl)
|
|
366
|
+
|
|
367
|
+
enriched: dict[str, Any] = {
|
|
368
|
+
"kind": "intent.edit",
|
|
369
|
+
"session_id": raw.get("session_id"),
|
|
370
|
+
"handle": raw.get("handle"),
|
|
371
|
+
"file_path": file_path,
|
|
372
|
+
"anchor": anchor,
|
|
373
|
+
"ts": ts,
|
|
374
|
+
"ttl_seconds": ttl,
|
|
375
|
+
"expires_at": expires_at,
|
|
376
|
+
"source": WRITER_SOURCE_SENTINEL,
|
|
377
|
+
}
|
|
378
|
+
if semantic_unit is not None:
|
|
379
|
+
enriched["semantic_unit"] = {
|
|
380
|
+
"symbol": semantic_unit.symbol,
|
|
381
|
+
"kind": semantic_unit.kind,
|
|
382
|
+
"qualified_name": semantic_unit.qualified_name,
|
|
383
|
+
"file_path": semantic_unit.file_path,
|
|
384
|
+
"start_line": semantic_unit.start_line,
|
|
385
|
+
"end_line": semantic_unit.end_line,
|
|
386
|
+
}
|
|
387
|
+
else:
|
|
388
|
+
# Degrade to file-level — record absence explicitly
|
|
389
|
+
enriched["semantic_unit"] = None
|
|
390
|
+
|
|
391
|
+
await self._append_record(enriched)
|
|
392
|
+
|
|
393
|
+
# ------------------------------------------------------------------
|
|
394
|
+
# File operations
|
|
395
|
+
# ------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
async def _append_record(self, record: dict[str, Any]) -> None:
|
|
398
|
+
"""Serialise and append one record to the intent JSONL."""
|
|
399
|
+
async with self._write_lock:
|
|
400
|
+
line = json.dumps(record, separators=(",", ":"), ensure_ascii=False)
|
|
401
|
+
try:
|
|
402
|
+
self._maybe_rotate()
|
|
403
|
+
except OSError as exc:
|
|
404
|
+
logger.warning("weave_intent_writer: rotation failed: %s", exc)
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
self._append_line(line)
|
|
408
|
+
except OSError as exc:
|
|
409
|
+
logger.warning("weave_intent_writer: append failed: %s — dropping record", exc)
|
|
410
|
+
|
|
411
|
+
def _ensure_parent(self) -> None:
|
|
412
|
+
parent = self._intent_path.parent
|
|
413
|
+
if not parent.exists():
|
|
414
|
+
parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
415
|
+
with contextlib.suppress(OSError):
|
|
416
|
+
os.chmod(parent, 0o700)
|
|
417
|
+
|
|
418
|
+
def _append_line(self, line: str) -> None:
|
|
419
|
+
self._ensure_parent()
|
|
420
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
|
|
421
|
+
fd = os.open(self._intent_path, flags, 0o600)
|
|
422
|
+
try:
|
|
423
|
+
with contextlib.suppress(OSError):
|
|
424
|
+
os.fchmod(fd, 0o600)
|
|
425
|
+
try:
|
|
426
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
427
|
+
except OSError as exc:
|
|
428
|
+
if exc.errno not in (errno.ENOTSUP, errno.EINVAL):
|
|
429
|
+
raise
|
|
430
|
+
try:
|
|
431
|
+
os.write(fd, line.encode("utf-8") + b"\n")
|
|
432
|
+
os.fsync(fd)
|
|
433
|
+
finally:
|
|
434
|
+
with contextlib.suppress(OSError):
|
|
435
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
436
|
+
finally:
|
|
437
|
+
os.close(fd)
|
|
438
|
+
|
|
439
|
+
def _maybe_rotate(self) -> None:
|
|
440
|
+
try:
|
|
441
|
+
size = self._intent_path.stat().st_size
|
|
442
|
+
except FileNotFoundError:
|
|
443
|
+
return
|
|
444
|
+
if size <= ROTATION_THRESHOLD_BYTES:
|
|
445
|
+
return
|
|
446
|
+
rotated = self._intent_path.parent / (WEAVE_INTENT_FILENAME + ".1")
|
|
447
|
+
os.replace(self._intent_path, rotated)
|
|
448
|
+
# Reset tail offset — after rotation the active file starts at 0.
|
|
449
|
+
self._tail_offset = 0
|
|
450
|
+
logger.info(
|
|
451
|
+
"weave_intent_writer: rotated %s -> %s (size=%d)",
|
|
452
|
+
self._intent_path,
|
|
453
|
+
rotated,
|
|
454
|
+
size,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
def _current_file_size(self) -> int:
|
|
458
|
+
try:
|
|
459
|
+
return self._intent_path.stat().st_size
|
|
460
|
+
except OSError:
|
|
461
|
+
return 0
|
|
462
|
+
|
|
463
|
+
# ------------------------------------------------------------------
|
|
464
|
+
# Test introspection
|
|
465
|
+
# ------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
@property
|
|
468
|
+
def intent_path(self) -> Path:
|
|
469
|
+
return self._intent_path
|
|
470
|
+
|
|
471
|
+
@property
|
|
472
|
+
def ttl_seconds(self) -> int:
|
|
473
|
+
return self._ttl_seconds
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# ---------------------------------------------------------------------------
|
|
477
|
+
# S2: semantic-unit resolver shim
|
|
478
|
+
# ---------------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _resolve_semantic_unit(
|
|
482
|
+
file_path: str,
|
|
483
|
+
anchor: str | None,
|
|
484
|
+
) -> Any | None:
|
|
485
|
+
"""Call the tree-sitter resolver and return a SemanticUnit or None.
|
|
486
|
+
|
|
487
|
+
Resolution failure NEVER blocks or raises — it degrades to None
|
|
488
|
+
(file-level intent) per the S2 constraint.
|
|
489
|
+
|
|
490
|
+
Parameters
|
|
491
|
+
----------
|
|
492
|
+
file_path:
|
|
493
|
+
Absolute or relative path to the Python source file being edited.
|
|
494
|
+
anchor:
|
|
495
|
+
The head of the old_string / Write target from the CC hook.
|
|
496
|
+
Used to derive an approximate line number when an explicit
|
|
497
|
+
line_range is not available. When absent, defaults to line (1, 1)
|
|
498
|
+
which returns the module-level unit.
|
|
499
|
+
"""
|
|
500
|
+
try:
|
|
501
|
+
from alter_runtime.weave.resolver import resolve as _resolve
|
|
502
|
+
|
|
503
|
+
# Derive a coarse line range from the anchor text if present.
|
|
504
|
+
# The anchor is the first line of the CC old_string, so we
|
|
505
|
+
# search for it in the file and use its 1-based line number.
|
|
506
|
+
line_range = _anchor_to_line_range(file_path, anchor)
|
|
507
|
+
return _resolve(file_path, line_range)
|
|
508
|
+
|
|
509
|
+
except Exception as exc: # pragma: no cover
|
|
510
|
+
logger.debug(
|
|
511
|
+
"weave_intent_writer: resolve failed for %r: %s — degrading to file-level",
|
|
512
|
+
file_path,
|
|
513
|
+
exc,
|
|
514
|
+
)
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _anchor_to_line_range(file_path: str, anchor: str | None) -> tuple[int, int]:
|
|
519
|
+
"""Return a best-guess (start, end) line range for the anchor text.
|
|
520
|
+
|
|
521
|
+
If the anchor is not present or cannot be found in the file, returns (1, 1)
|
|
522
|
+
which causes the resolver to return the module-level unit. This is the
|
|
523
|
+
correct degradation — a missing anchor gives file-level intent, not an error.
|
|
524
|
+
"""
|
|
525
|
+
if not anchor or not file_path:
|
|
526
|
+
return (1, 1)
|
|
527
|
+
|
|
528
|
+
try:
|
|
529
|
+
p = Path(file_path)
|
|
530
|
+
if not p.exists():
|
|
531
|
+
return (1, 1)
|
|
532
|
+
|
|
533
|
+
# Only scan the first line of the anchor for speed — exact match wins.
|
|
534
|
+
anchor_first_line = anchor.splitlines()[0].strip() if anchor.strip() else ""
|
|
535
|
+
if not anchor_first_line:
|
|
536
|
+
return (1, 1)
|
|
537
|
+
|
|
538
|
+
content = p.read_text(encoding="utf-8", errors="replace")
|
|
539
|
+
for lineno, line in enumerate(content.splitlines(), start=1):
|
|
540
|
+
if anchor_first_line in line:
|
|
541
|
+
return (lineno, lineno)
|
|
542
|
+
|
|
543
|
+
return (1, 1)
|
|
544
|
+
|
|
545
|
+
except Exception:
|
|
546
|
+
return (1, 1)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# ---------------------------------------------------------------------------
|
|
550
|
+
# TTL helper
|
|
551
|
+
# ---------------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _normalise_ts(value: Any) -> str:
|
|
555
|
+
"""Return an ISO-8601 UTC string from an ISO string, epoch int/float, or None.
|
|
556
|
+
|
|
557
|
+
Parameters
|
|
558
|
+
----------
|
|
559
|
+
value:
|
|
560
|
+
Accepted inputs:
|
|
561
|
+
- ISO-8601 string (with or without trailing ``Z``): returned as-is
|
|
562
|
+
after stripping the ``Z`` and re-attaching UTC timezone.
|
|
563
|
+
- ``int`` or ``float``: treated as a POSIX epoch and converted via
|
|
564
|
+
``datetime.fromtimestamp``.
|
|
565
|
+
- ``None`` or anything unparseable: falls back to now (UTC).
|
|
566
|
+
"""
|
|
567
|
+
if value is None:
|
|
568
|
+
return datetime.now(timezone.utc).isoformat()
|
|
569
|
+
|
|
570
|
+
if isinstance(value, (int, float)):
|
|
571
|
+
try:
|
|
572
|
+
return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
|
|
573
|
+
except (OSError, OverflowError, ValueError):
|
|
574
|
+
return datetime.now(timezone.utc).isoformat()
|
|
575
|
+
|
|
576
|
+
if isinstance(value, str):
|
|
577
|
+
try:
|
|
578
|
+
ts_stripped = value.rstrip("Z")
|
|
579
|
+
if "+" in ts_stripped:
|
|
580
|
+
dt = datetime.fromisoformat(value)
|
|
581
|
+
else:
|
|
582
|
+
dt = datetime.fromisoformat(ts_stripped).replace(tzinfo=timezone.utc)
|
|
583
|
+
return dt.isoformat()
|
|
584
|
+
except (ValueError, TypeError):
|
|
585
|
+
return datetime.now(timezone.utc).isoformat()
|
|
586
|
+
|
|
587
|
+
# Unknown type — fall back to now
|
|
588
|
+
return datetime.now(timezone.utc).isoformat()
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _compute_expires_at(ts: Any, ttl_seconds: int) -> str:
|
|
592
|
+
"""Return an ISO-8601 UTC datetime string for ``ts + ttl_seconds``.
|
|
593
|
+
|
|
594
|
+
Accepts ``ts`` as an ISO-8601 string, an epoch int/float, or None.
|
|
595
|
+
Falls back to now + ttl_seconds when ``ts`` cannot be parsed.
|
|
596
|
+
"""
|
|
597
|
+
try:
|
|
598
|
+
normalised = _normalise_ts(ts)
|
|
599
|
+
ts_stripped = normalised.rstrip("Z")
|
|
600
|
+
if "+" in ts_stripped:
|
|
601
|
+
dt = datetime.fromisoformat(normalised)
|
|
602
|
+
else:
|
|
603
|
+
dt = datetime.fromisoformat(ts_stripped).replace(tzinfo=timezone.utc)
|
|
604
|
+
except (ValueError, TypeError, AttributeError):
|
|
605
|
+
dt = datetime.now(timezone.utc)
|
|
606
|
+
|
|
607
|
+
expires = dt + timedelta(seconds=ttl_seconds)
|
|
608
|
+
return expires.isoformat()
|