foundry-mcp 0.3.3__py3-none-any.whl → 0.8.10__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.
- foundry_mcp/__init__.py +7 -1
- foundry_mcp/cli/__init__.py +0 -13
- foundry_mcp/cli/commands/plan.py +10 -3
- foundry_mcp/cli/commands/review.py +19 -4
- foundry_mcp/cli/commands/session.py +1 -8
- foundry_mcp/cli/commands/specs.py +38 -208
- foundry_mcp/cli/context.py +39 -0
- foundry_mcp/cli/output.py +3 -3
- foundry_mcp/config.py +615 -11
- foundry_mcp/core/ai_consultation.py +146 -9
- foundry_mcp/core/batch_operations.py +1196 -0
- foundry_mcp/core/discovery.py +7 -7
- foundry_mcp/core/error_store.py +2 -2
- foundry_mcp/core/intake.py +933 -0
- foundry_mcp/core/llm_config.py +28 -2
- foundry_mcp/core/metrics_store.py +2 -2
- foundry_mcp/core/naming.py +25 -2
- foundry_mcp/core/progress.py +70 -0
- foundry_mcp/core/prometheus.py +0 -13
- foundry_mcp/core/prompts/fidelity_review.py +149 -4
- foundry_mcp/core/prompts/markdown_plan_review.py +5 -1
- foundry_mcp/core/prompts/plan_review.py +5 -1
- foundry_mcp/core/providers/__init__.py +12 -0
- foundry_mcp/core/providers/base.py +39 -0
- foundry_mcp/core/providers/claude.py +51 -48
- foundry_mcp/core/providers/codex.py +70 -60
- foundry_mcp/core/providers/cursor_agent.py +25 -47
- foundry_mcp/core/providers/detectors.py +34 -7
- foundry_mcp/core/providers/gemini.py +69 -58
- foundry_mcp/core/providers/opencode.py +101 -47
- foundry_mcp/core/providers/package-lock.json +4 -4
- foundry_mcp/core/providers/package.json +1 -1
- foundry_mcp/core/providers/validation.py +128 -0
- foundry_mcp/core/research/__init__.py +68 -0
- foundry_mcp/core/research/memory.py +528 -0
- foundry_mcp/core/research/models.py +1220 -0
- foundry_mcp/core/research/providers/__init__.py +40 -0
- foundry_mcp/core/research/providers/base.py +242 -0
- foundry_mcp/core/research/providers/google.py +507 -0
- foundry_mcp/core/research/providers/perplexity.py +442 -0
- foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
- foundry_mcp/core/research/providers/tavily.py +383 -0
- foundry_mcp/core/research/workflows/__init__.py +25 -0
- foundry_mcp/core/research/workflows/base.py +298 -0
- foundry_mcp/core/research/workflows/chat.py +271 -0
- foundry_mcp/core/research/workflows/consensus.py +539 -0
- foundry_mcp/core/research/workflows/deep_research.py +4020 -0
- foundry_mcp/core/research/workflows/ideate.py +682 -0
- foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
- foundry_mcp/core/responses.py +690 -0
- foundry_mcp/core/spec.py +2439 -236
- foundry_mcp/core/task.py +1205 -31
- foundry_mcp/core/testing.py +512 -123
- foundry_mcp/core/validation.py +319 -43
- foundry_mcp/dashboard/components/charts.py +0 -57
- foundry_mcp/dashboard/launcher.py +11 -0
- foundry_mcp/dashboard/views/metrics.py +25 -35
- foundry_mcp/dashboard/views/overview.py +1 -65
- foundry_mcp/resources/specs.py +25 -25
- foundry_mcp/schemas/intake-schema.json +89 -0
- foundry_mcp/schemas/sdd-spec-schema.json +33 -5
- foundry_mcp/server.py +0 -14
- foundry_mcp/tools/unified/__init__.py +39 -18
- foundry_mcp/tools/unified/authoring.py +2371 -248
- foundry_mcp/tools/unified/documentation_helpers.py +69 -6
- foundry_mcp/tools/unified/environment.py +434 -32
- foundry_mcp/tools/unified/error.py +18 -1
- foundry_mcp/tools/unified/lifecycle.py +8 -0
- foundry_mcp/tools/unified/plan.py +133 -2
- foundry_mcp/tools/unified/provider.py +0 -40
- foundry_mcp/tools/unified/research.py +1283 -0
- foundry_mcp/tools/unified/review.py +374 -17
- foundry_mcp/tools/unified/review_helpers.py +16 -1
- foundry_mcp/tools/unified/server.py +9 -24
- foundry_mcp/tools/unified/spec.py +367 -0
- foundry_mcp/tools/unified/task.py +1664 -30
- foundry_mcp/tools/unified/test.py +69 -8
- {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/METADATA +8 -1
- foundry_mcp-0.8.10.dist-info/RECORD +153 -0
- foundry_mcp/cli/flags.py +0 -266
- foundry_mcp/core/feature_flags.py +0 -592
- foundry_mcp-0.3.3.dist-info/RECORD +0 -135
- {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/WHEEL +0 -0
- {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/entry_points.txt +0 -0
- {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,933 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Intake storage backend for the bikelane fast-capture system.
|
|
3
|
+
|
|
4
|
+
Provides thread-safe JSONL-based storage for intake items with file locking
|
|
5
|
+
for concurrent access. Items are stored in specs/.bikelane/intake.jsonl.
|
|
6
|
+
|
|
7
|
+
Storage Structure:
|
|
8
|
+
specs/.bikelane/
|
|
9
|
+
intake.jsonl - Append-only intake log
|
|
10
|
+
.intake.lock - Lock file for cross-process synchronization
|
|
11
|
+
intake.YYYY-MM.jsonl - Archived intake files (after rotation)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import fcntl
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
import uuid
|
|
25
|
+
from dataclasses import asdict, dataclass, field
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Optional
|
|
29
|
+
|
|
30
|
+
from foundry_mcp.core.security import is_prompt_injection
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# Security constants
|
|
35
|
+
LOG_DESCRIPTION_MAX_LENGTH = 100 # Max chars for descriptions in logs
|
|
36
|
+
|
|
37
|
+
# Constants
|
|
38
|
+
LOCK_TIMEOUT_SECONDS = 5.0
|
|
39
|
+
ROTATION_ITEM_THRESHOLD = 1000
|
|
40
|
+
ROTATION_SIZE_THRESHOLD = 1024 * 1024 # 1MB
|
|
41
|
+
IDEMPOTENCY_SCAN_LIMIT = 100
|
|
42
|
+
DEFAULT_PAGE_SIZE = 50
|
|
43
|
+
MAX_PAGE_SIZE = 200
|
|
44
|
+
|
|
45
|
+
# Validation patterns
|
|
46
|
+
INTAKE_ID_PATTERN = re.compile(r"^intake-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$")
|
|
47
|
+
TAG_PATTERN = re.compile(r"^[a-z0-9_-]+$")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class IntakeItem:
|
|
52
|
+
"""
|
|
53
|
+
Represents a single intake item in the bikelane queue.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
schema_version: Fixed value 'intake-v1'
|
|
57
|
+
id: Unique identifier in format 'intake-<uuid4>'
|
|
58
|
+
title: Brief title (1-140 chars)
|
|
59
|
+
status: 'new' or 'dismissed'
|
|
60
|
+
created_at: ISO 8601 UTC timestamp
|
|
61
|
+
updated_at: ISO 8601 UTC timestamp
|
|
62
|
+
description: Optional detailed description (max 2000 chars)
|
|
63
|
+
priority: Priority level p0-p4 (default p2)
|
|
64
|
+
tags: List of lowercase tags (max 20, each 1-32 chars)
|
|
65
|
+
source: Origin of the intake item (max 100 chars)
|
|
66
|
+
requester: Person/entity who requested (max 100 chars)
|
|
67
|
+
idempotency_key: Optional client key for deduplication (max 64 chars)
|
|
68
|
+
"""
|
|
69
|
+
schema_version: str = "intake-v1"
|
|
70
|
+
id: str = ""
|
|
71
|
+
title: str = ""
|
|
72
|
+
status: str = "new"
|
|
73
|
+
created_at: str = ""
|
|
74
|
+
updated_at: str = ""
|
|
75
|
+
description: Optional[str] = None
|
|
76
|
+
priority: str = "p2"
|
|
77
|
+
tags: list[str] = field(default_factory=list)
|
|
78
|
+
source: Optional[str] = None
|
|
79
|
+
requester: Optional[str] = None
|
|
80
|
+
idempotency_key: Optional[str] = None
|
|
81
|
+
|
|
82
|
+
def to_dict(self) -> dict[str, Any]:
|
|
83
|
+
"""Convert to dictionary, excluding None values for optional fields."""
|
|
84
|
+
result = {
|
|
85
|
+
"schema_version": self.schema_version,
|
|
86
|
+
"id": self.id,
|
|
87
|
+
"title": self.title,
|
|
88
|
+
"status": self.status,
|
|
89
|
+
"created_at": self.created_at,
|
|
90
|
+
"updated_at": self.updated_at,
|
|
91
|
+
"priority": self.priority,
|
|
92
|
+
"tags": self.tags,
|
|
93
|
+
}
|
|
94
|
+
if self.description is not None:
|
|
95
|
+
result["description"] = self.description
|
|
96
|
+
if self.source is not None:
|
|
97
|
+
result["source"] = self.source
|
|
98
|
+
if self.requester is not None:
|
|
99
|
+
result["requester"] = self.requester
|
|
100
|
+
if self.idempotency_key is not None:
|
|
101
|
+
result["idempotency_key"] = self.idempotency_key
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_dict(cls, data: dict[str, Any]) -> "IntakeItem":
|
|
106
|
+
"""Create an IntakeItem from a dictionary."""
|
|
107
|
+
return cls(
|
|
108
|
+
schema_version=data.get("schema_version", "intake-v1"),
|
|
109
|
+
id=data.get("id", ""),
|
|
110
|
+
title=data.get("title", ""),
|
|
111
|
+
status=data.get("status", "new"),
|
|
112
|
+
created_at=data.get("created_at", ""),
|
|
113
|
+
updated_at=data.get("updated_at", ""),
|
|
114
|
+
description=data.get("description"),
|
|
115
|
+
priority=data.get("priority", "p2"),
|
|
116
|
+
tags=data.get("tags", []),
|
|
117
|
+
source=data.get("source"),
|
|
118
|
+
requester=data.get("requester"),
|
|
119
|
+
idempotency_key=data.get("idempotency_key"),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class PaginationCursor:
|
|
125
|
+
"""Cursor for pagination through intake items."""
|
|
126
|
+
last_id: str
|
|
127
|
+
line_hint: int
|
|
128
|
+
version: int = 1
|
|
129
|
+
|
|
130
|
+
def encode(self) -> str:
|
|
131
|
+
"""Encode cursor to base64 string."""
|
|
132
|
+
payload = {
|
|
133
|
+
"last_id": self.last_id,
|
|
134
|
+
"line_hint": self.line_hint,
|
|
135
|
+
"version": self.version,
|
|
136
|
+
}
|
|
137
|
+
json_bytes = json.dumps(payload).encode("utf-8")
|
|
138
|
+
return base64.b64encode(json_bytes).decode("ascii")
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def decode(cls, encoded: str) -> Optional["PaginationCursor"]:
|
|
142
|
+
"""Decode cursor from base64 string. Returns None if invalid."""
|
|
143
|
+
try:
|
|
144
|
+
json_bytes = base64.b64decode(encoded.encode("ascii"))
|
|
145
|
+
payload = json.loads(json_bytes.decode("utf-8"))
|
|
146
|
+
return cls(
|
|
147
|
+
last_id=payload.get("last_id", ""),
|
|
148
|
+
line_hint=payload.get("line_hint", 0),
|
|
149
|
+
version=payload.get("version", 1),
|
|
150
|
+
)
|
|
151
|
+
except (ValueError, json.JSONDecodeError, KeyError) as e:
|
|
152
|
+
logger.warning(f"Failed to decode pagination cursor: {e}")
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class LockAcquisitionError(Exception):
|
|
157
|
+
"""Raised when file lock cannot be acquired within timeout."""
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class IntakeStore:
|
|
162
|
+
"""
|
|
163
|
+
JSONL-based intake storage with thread-safe file locking.
|
|
164
|
+
|
|
165
|
+
Provides append-only storage for intake items with:
|
|
166
|
+
- fcntl file locking for cross-process safety
|
|
167
|
+
- threading.Lock for in-memory thread safety
|
|
168
|
+
- Atomic writes via temp file rename pattern
|
|
169
|
+
- Cursor-based pagination with line hints
|
|
170
|
+
- File rotation when thresholds are exceeded
|
|
171
|
+
|
|
172
|
+
Directory structure:
|
|
173
|
+
specs/.bikelane/
|
|
174
|
+
intake.jsonl - Active intake items
|
|
175
|
+
.intake.lock - Lock file for synchronization
|
|
176
|
+
intake.YYYY-MM.jsonl - Archived files (rotated)
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(
|
|
180
|
+
self,
|
|
181
|
+
specs_dir: str | Path,
|
|
182
|
+
workspace_root: Optional[str | Path] = None,
|
|
183
|
+
bikelane_dir: Optional[str | Path] = None,
|
|
184
|
+
):
|
|
185
|
+
"""
|
|
186
|
+
Initialize the intake store.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
specs_dir: Path to the specs directory (e.g., /workspace/specs)
|
|
190
|
+
workspace_root: Optional workspace root for path validation.
|
|
191
|
+
Defaults to specs_dir parent.
|
|
192
|
+
bikelane_dir: Optional custom path for bikelane storage.
|
|
193
|
+
Defaults to specs_dir/.bikelane if not provided.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
ValueError: If specs_dir or bikelane_dir is outside the workspace root
|
|
197
|
+
(path traversal attempt)
|
|
198
|
+
"""
|
|
199
|
+
self.specs_dir = Path(specs_dir).resolve()
|
|
200
|
+
self.workspace_root = Path(workspace_root).resolve() if workspace_root else self.specs_dir.parent
|
|
201
|
+
|
|
202
|
+
# Validate specs_dir is within workspace (prevent path traversal)
|
|
203
|
+
if not self._validate_path_in_workspace(self.specs_dir):
|
|
204
|
+
raise ValueError(
|
|
205
|
+
f"specs_dir '{specs_dir}' is outside workspace root '{self.workspace_root}'"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Use custom bikelane_dir if provided, otherwise default to specs/.bikelane
|
|
209
|
+
if bikelane_dir is not None:
|
|
210
|
+
self.bikelane_dir = Path(bikelane_dir).resolve()
|
|
211
|
+
# Validate bikelane_dir is within workspace
|
|
212
|
+
if not self._validate_path_in_workspace(self.bikelane_dir):
|
|
213
|
+
raise ValueError(
|
|
214
|
+
f"bikelane_dir '{bikelane_dir}' is outside workspace root '{self.workspace_root}'"
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
self.bikelane_dir = self.specs_dir / ".bikelane"
|
|
218
|
+
|
|
219
|
+
self.intake_file = self.bikelane_dir / "intake.jsonl"
|
|
220
|
+
self.lock_file = self.bikelane_dir / ".intake.lock"
|
|
221
|
+
|
|
222
|
+
self._thread_lock = threading.Lock()
|
|
223
|
+
self._ensure_directory()
|
|
224
|
+
|
|
225
|
+
def _ensure_directory(self) -> None:
|
|
226
|
+
"""Ensure the bikelane directory exists."""
|
|
227
|
+
self.bikelane_dir.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
|
|
229
|
+
def _validate_path_in_workspace(self, path: Path) -> bool:
|
|
230
|
+
"""
|
|
231
|
+
Validate that a path is within the workspace sandbox.
|
|
232
|
+
|
|
233
|
+
Uses resolve() + relative_to() pattern to prevent path traversal attacks
|
|
234
|
+
via symlinks or .. components.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
path: Path to validate (will be resolved)
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
True if path is within workspace_root, False otherwise
|
|
241
|
+
"""
|
|
242
|
+
try:
|
|
243
|
+
resolved = path.resolve()
|
|
244
|
+
resolved.relative_to(self.workspace_root)
|
|
245
|
+
return True
|
|
246
|
+
except ValueError:
|
|
247
|
+
logger.warning(
|
|
248
|
+
f"Path traversal attempt blocked: '{path}' is outside workspace"
|
|
249
|
+
)
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
def _acquire_lock(self, exclusive: bool = True, timeout: float = LOCK_TIMEOUT_SECONDS) -> int:
|
|
253
|
+
"""
|
|
254
|
+
Acquire file lock with timeout.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
exclusive: If True, acquire exclusive lock (for writes).
|
|
258
|
+
If False, acquire shared lock (for reads).
|
|
259
|
+
timeout: Maximum time to wait for lock in seconds.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
File descriptor for the lock file.
|
|
263
|
+
|
|
264
|
+
Raises:
|
|
265
|
+
LockAcquisitionError: If lock cannot be acquired within timeout.
|
|
266
|
+
"""
|
|
267
|
+
lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
|
|
268
|
+
|
|
269
|
+
# Ensure lock file exists
|
|
270
|
+
self.lock_file.touch(exist_ok=True)
|
|
271
|
+
|
|
272
|
+
fd = os.open(str(self.lock_file), os.O_RDWR | os.O_CREAT)
|
|
273
|
+
|
|
274
|
+
start_time = time.monotonic()
|
|
275
|
+
while True:
|
|
276
|
+
try:
|
|
277
|
+
fcntl.flock(fd, lock_type | fcntl.LOCK_NB)
|
|
278
|
+
return fd
|
|
279
|
+
except (IOError, OSError):
|
|
280
|
+
elapsed = time.monotonic() - start_time
|
|
281
|
+
if elapsed >= timeout:
|
|
282
|
+
os.close(fd)
|
|
283
|
+
raise LockAcquisitionError(
|
|
284
|
+
f"Failed to acquire {'exclusive' if exclusive else 'shared'} lock "
|
|
285
|
+
f"within {timeout} seconds"
|
|
286
|
+
)
|
|
287
|
+
time.sleep(0.01) # 10ms between retries
|
|
288
|
+
|
|
289
|
+
def _release_lock(self, fd: int) -> None:
|
|
290
|
+
"""Release file lock and close file descriptor."""
|
|
291
|
+
try:
|
|
292
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
293
|
+
finally:
|
|
294
|
+
os.close(fd)
|
|
295
|
+
|
|
296
|
+
def _generate_id(self) -> str:
|
|
297
|
+
"""Generate a new intake ID."""
|
|
298
|
+
return f"intake-{uuid.uuid4()}"
|
|
299
|
+
|
|
300
|
+
def _now_utc(self) -> str:
|
|
301
|
+
"""Get current UTC timestamp in ISO 8601 format with Z suffix."""
|
|
302
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
303
|
+
|
|
304
|
+
def _strip_control_chars(self, text: str) -> str:
|
|
305
|
+
"""Strip control characters from text for security."""
|
|
306
|
+
if not text:
|
|
307
|
+
return text
|
|
308
|
+
# Remove ASCII control characters (0x00-0x1F) except common whitespace
|
|
309
|
+
return "".join(
|
|
310
|
+
char for char in text
|
|
311
|
+
if ord(char) >= 32 or char in "\n\r\t"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def _truncate_for_log(self, text: str, max_length: int = LOG_DESCRIPTION_MAX_LENGTH) -> str:
|
|
315
|
+
"""
|
|
316
|
+
Truncate text for safe logging.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
text: Text to truncate
|
|
320
|
+
max_length: Maximum length (default: LOG_DESCRIPTION_MAX_LENGTH)
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Truncated text with ellipsis if needed
|
|
324
|
+
"""
|
|
325
|
+
if not text or len(text) <= max_length:
|
|
326
|
+
return text or ""
|
|
327
|
+
return text[:max_length] + "..."
|
|
328
|
+
|
|
329
|
+
def _sanitize_for_injection(self, text: str, field_name: str) -> str:
|
|
330
|
+
"""
|
|
331
|
+
Check text for prompt injection patterns and sanitize if found.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
text: Text to check
|
|
335
|
+
field_name: Name of field for logging
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
Original text if clean, sanitized text if injection detected
|
|
339
|
+
"""
|
|
340
|
+
if not text:
|
|
341
|
+
return text
|
|
342
|
+
|
|
343
|
+
if is_prompt_injection(text):
|
|
344
|
+
logger.warning(
|
|
345
|
+
f"Prompt injection pattern detected in {field_name}, "
|
|
346
|
+
f"input sanitized (preview: {self._truncate_for_log(text, 50)})"
|
|
347
|
+
)
|
|
348
|
+
# Replace known injection patterns with safe placeholder
|
|
349
|
+
# We strip the offending patterns rather than rejecting entirely
|
|
350
|
+
# to maintain usability while preventing the attack
|
|
351
|
+
sanitized = re.sub(
|
|
352
|
+
r"ignore\s+(all\s+)?(previous|prior)\s+(instructions?|prompts?)",
|
|
353
|
+
"[SANITIZED]",
|
|
354
|
+
text,
|
|
355
|
+
flags=re.IGNORECASE,
|
|
356
|
+
)
|
|
357
|
+
sanitized = re.sub(
|
|
358
|
+
r"disregard\s+(all\s+)?(previous|prior|above)",
|
|
359
|
+
"[SANITIZED]",
|
|
360
|
+
sanitized,
|
|
361
|
+
flags=re.IGNORECASE,
|
|
362
|
+
)
|
|
363
|
+
sanitized = re.sub(
|
|
364
|
+
r"system\s*:\s*",
|
|
365
|
+
"[SANITIZED]",
|
|
366
|
+
sanitized,
|
|
367
|
+
flags=re.IGNORECASE,
|
|
368
|
+
)
|
|
369
|
+
sanitized = re.sub(
|
|
370
|
+
r"<\s*system\s*>",
|
|
371
|
+
"[SANITIZED]",
|
|
372
|
+
sanitized,
|
|
373
|
+
flags=re.IGNORECASE,
|
|
374
|
+
)
|
|
375
|
+
return sanitized
|
|
376
|
+
|
|
377
|
+
return text
|
|
378
|
+
|
|
379
|
+
def _normalize_tags(self, tags: list[str]) -> list[str]:
|
|
380
|
+
"""Normalize tags to lowercase and validate format."""
|
|
381
|
+
normalized = []
|
|
382
|
+
for tag in tags:
|
|
383
|
+
tag_lower = tag.lower().strip()
|
|
384
|
+
if tag_lower and TAG_PATTERN.match(tag_lower):
|
|
385
|
+
normalized.append(tag_lower)
|
|
386
|
+
return normalized
|
|
387
|
+
|
|
388
|
+
def _count_items(self) -> int:
|
|
389
|
+
"""Count total items in the intake file."""
|
|
390
|
+
if not self.intake_file.exists():
|
|
391
|
+
return 0
|
|
392
|
+
try:
|
|
393
|
+
with open(self.intake_file, "r") as f:
|
|
394
|
+
return sum(1 for line in f if line.strip())
|
|
395
|
+
except OSError:
|
|
396
|
+
return 0
|
|
397
|
+
|
|
398
|
+
def _get_file_size(self) -> int:
|
|
399
|
+
"""Get size of the intake file in bytes."""
|
|
400
|
+
if not self.intake_file.exists():
|
|
401
|
+
return 0
|
|
402
|
+
try:
|
|
403
|
+
return self.intake_file.stat().st_size
|
|
404
|
+
except OSError:
|
|
405
|
+
return 0
|
|
406
|
+
|
|
407
|
+
def _should_rotate(self) -> bool:
|
|
408
|
+
"""Check if file rotation is needed."""
|
|
409
|
+
item_count = self._count_items()
|
|
410
|
+
file_size = self._get_file_size()
|
|
411
|
+
return item_count >= ROTATION_ITEM_THRESHOLD or file_size >= ROTATION_SIZE_THRESHOLD
|
|
412
|
+
|
|
413
|
+
def _get_oldest_item_date(self) -> Optional[str]:
|
|
414
|
+
"""Get the created_at date of the oldest item for archive naming."""
|
|
415
|
+
if not self.intake_file.exists():
|
|
416
|
+
return None
|
|
417
|
+
try:
|
|
418
|
+
with open(self.intake_file, "r") as f:
|
|
419
|
+
for line in f:
|
|
420
|
+
line = line.strip()
|
|
421
|
+
if line:
|
|
422
|
+
try:
|
|
423
|
+
item = json.loads(line)
|
|
424
|
+
return item.get("created_at", "")[:7] # YYYY-MM
|
|
425
|
+
except json.JSONDecodeError:
|
|
426
|
+
continue
|
|
427
|
+
except OSError:
|
|
428
|
+
pass
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
def rotate_if_needed(self) -> Optional[str]:
|
|
432
|
+
"""
|
|
433
|
+
Rotate the intake file if thresholds are exceeded.
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
Path to archive file if rotation occurred, None otherwise.
|
|
437
|
+
"""
|
|
438
|
+
with self._thread_lock:
|
|
439
|
+
if not self._should_rotate():
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
# Get date for archive naming
|
|
443
|
+
date_str = self._get_oldest_item_date()
|
|
444
|
+
if not date_str:
|
|
445
|
+
date_str = datetime.now(timezone.utc).strftime("%Y-%m")
|
|
446
|
+
|
|
447
|
+
# Generate unique archive name
|
|
448
|
+
archive_name = f"intake.{date_str}.jsonl"
|
|
449
|
+
archive_path = self.bikelane_dir / archive_name
|
|
450
|
+
|
|
451
|
+
# If archive already exists, add a suffix
|
|
452
|
+
counter = 1
|
|
453
|
+
while archive_path.exists():
|
|
454
|
+
archive_name = f"intake.{date_str}.{counter}.jsonl"
|
|
455
|
+
archive_path = self.bikelane_dir / archive_name
|
|
456
|
+
counter += 1
|
|
457
|
+
|
|
458
|
+
fd = None
|
|
459
|
+
try:
|
|
460
|
+
fd = self._acquire_lock(exclusive=True)
|
|
461
|
+
|
|
462
|
+
# Rename current file to archive
|
|
463
|
+
if self.intake_file.exists():
|
|
464
|
+
self.intake_file.rename(archive_path)
|
|
465
|
+
logger.info(f"Rotated intake file to {archive_path}")
|
|
466
|
+
|
|
467
|
+
# Create fresh intake file
|
|
468
|
+
self.intake_file.touch()
|
|
469
|
+
|
|
470
|
+
return str(archive_path)
|
|
471
|
+
|
|
472
|
+
except LockAcquisitionError:
|
|
473
|
+
logger.error("Failed to acquire lock for file rotation")
|
|
474
|
+
raise
|
|
475
|
+
finally:
|
|
476
|
+
if fd is not None:
|
|
477
|
+
self._release_lock(fd)
|
|
478
|
+
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
def add(
|
|
482
|
+
self,
|
|
483
|
+
*,
|
|
484
|
+
title: str,
|
|
485
|
+
description: Optional[str] = None,
|
|
486
|
+
priority: str = "p2",
|
|
487
|
+
tags: Optional[list[str]] = None,
|
|
488
|
+
source: Optional[str] = None,
|
|
489
|
+
requester: Optional[str] = None,
|
|
490
|
+
idempotency_key: Optional[str] = None,
|
|
491
|
+
dry_run: bool = False,
|
|
492
|
+
) -> tuple[IntakeItem, bool, float]:
|
|
493
|
+
"""
|
|
494
|
+
Add a new intake item.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
title: Item title (1-140 chars, required)
|
|
498
|
+
description: Detailed description (max 2000 chars)
|
|
499
|
+
priority: Priority level p0-p4 (default p2)
|
|
500
|
+
tags: List of tags (max 20, normalized to lowercase)
|
|
501
|
+
source: Origin of the item (max 100 chars)
|
|
502
|
+
requester: Who requested the work (max 100 chars)
|
|
503
|
+
idempotency_key: Key for deduplication (max 64 chars)
|
|
504
|
+
dry_run: If True, validate but don't persist
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
Tuple of (created IntakeItem, was_duplicate, lock_wait_ms)
|
|
508
|
+
|
|
509
|
+
Raises:
|
|
510
|
+
LockAcquisitionError: If lock cannot be acquired within timeout.
|
|
511
|
+
"""
|
|
512
|
+
now = self._now_utc()
|
|
513
|
+
lock_wait_ms = 0.0
|
|
514
|
+
|
|
515
|
+
# Sanitize inputs - strip control characters
|
|
516
|
+
title = self._strip_control_chars(title)
|
|
517
|
+
if description:
|
|
518
|
+
description = self._strip_control_chars(description)
|
|
519
|
+
if source:
|
|
520
|
+
source = self._strip_control_chars(source)
|
|
521
|
+
if requester:
|
|
522
|
+
requester = self._strip_control_chars(requester)
|
|
523
|
+
|
|
524
|
+
# Sanitize title and description for prompt injection patterns
|
|
525
|
+
title = self._sanitize_for_injection(title, "title")
|
|
526
|
+
if description:
|
|
527
|
+
description = self._sanitize_for_injection(description, "description")
|
|
528
|
+
|
|
529
|
+
# Normalize tags
|
|
530
|
+
normalized_tags = self._normalize_tags(tags or [])
|
|
531
|
+
|
|
532
|
+
# Create item
|
|
533
|
+
item = IntakeItem(
|
|
534
|
+
id=self._generate_id(),
|
|
535
|
+
title=title,
|
|
536
|
+
description=description,
|
|
537
|
+
status="new",
|
|
538
|
+
priority=priority,
|
|
539
|
+
tags=normalized_tags,
|
|
540
|
+
source=source,
|
|
541
|
+
requester=requester,
|
|
542
|
+
idempotency_key=idempotency_key,
|
|
543
|
+
created_at=now,
|
|
544
|
+
updated_at=now,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
if dry_run:
|
|
548
|
+
return item, False, lock_wait_ms
|
|
549
|
+
|
|
550
|
+
with self._thread_lock:
|
|
551
|
+
fd = None
|
|
552
|
+
lock_start = time.monotonic()
|
|
553
|
+
|
|
554
|
+
try:
|
|
555
|
+
fd = self._acquire_lock(exclusive=True)
|
|
556
|
+
lock_wait_ms = (time.monotonic() - lock_start) * 1000
|
|
557
|
+
|
|
558
|
+
# Check for idempotency key duplicate
|
|
559
|
+
if idempotency_key:
|
|
560
|
+
existing = self._find_by_idempotency_key(idempotency_key)
|
|
561
|
+
if existing:
|
|
562
|
+
return existing, True, lock_wait_ms
|
|
563
|
+
|
|
564
|
+
# Check rotation threshold
|
|
565
|
+
if self._should_rotate():
|
|
566
|
+
self._release_lock(fd)
|
|
567
|
+
fd = None
|
|
568
|
+
self.rotate_if_needed()
|
|
569
|
+
fd = self._acquire_lock(exclusive=True)
|
|
570
|
+
|
|
571
|
+
# Append to file
|
|
572
|
+
with open(self.intake_file, "a") as f:
|
|
573
|
+
f.write(json.dumps(item.to_dict()) + "\n")
|
|
574
|
+
f.flush()
|
|
575
|
+
|
|
576
|
+
logger.info(
|
|
577
|
+
f"Added intake item: {item.id} "
|
|
578
|
+
f"(title: {self._truncate_for_log(item.title)})"
|
|
579
|
+
)
|
|
580
|
+
return item, False, lock_wait_ms
|
|
581
|
+
|
|
582
|
+
finally:
|
|
583
|
+
if fd is not None:
|
|
584
|
+
self._release_lock(fd)
|
|
585
|
+
|
|
586
|
+
def _find_by_idempotency_key(self, key: str) -> Optional[IntakeItem]:
|
|
587
|
+
"""
|
|
588
|
+
Search for an item by idempotency key in the last N items.
|
|
589
|
+
|
|
590
|
+
Note: This should be called while holding the lock.
|
|
591
|
+
"""
|
|
592
|
+
if not self.intake_file.exists():
|
|
593
|
+
return None
|
|
594
|
+
|
|
595
|
+
# Read last IDEMPOTENCY_SCAN_LIMIT lines
|
|
596
|
+
try:
|
|
597
|
+
with open(self.intake_file, "r") as f:
|
|
598
|
+
lines = f.readlines()
|
|
599
|
+
|
|
600
|
+
# Check last N items in reverse order
|
|
601
|
+
for line in reversed(lines[-IDEMPOTENCY_SCAN_LIMIT:]):
|
|
602
|
+
line = line.strip()
|
|
603
|
+
if not line:
|
|
604
|
+
continue
|
|
605
|
+
try:
|
|
606
|
+
data = json.loads(line)
|
|
607
|
+
if data.get("idempotency_key") == key:
|
|
608
|
+
return IntakeItem.from_dict(data)
|
|
609
|
+
except json.JSONDecodeError:
|
|
610
|
+
continue
|
|
611
|
+
|
|
612
|
+
except OSError as e:
|
|
613
|
+
logger.warning(f"Error reading intake file for idempotency check: {e}")
|
|
614
|
+
|
|
615
|
+
return None
|
|
616
|
+
|
|
617
|
+
def list_new(
|
|
618
|
+
self,
|
|
619
|
+
*,
|
|
620
|
+
cursor: Optional[str] = None,
|
|
621
|
+
limit: int = DEFAULT_PAGE_SIZE,
|
|
622
|
+
) -> tuple[list[IntakeItem], int, Optional[str], bool, float]:
|
|
623
|
+
"""
|
|
624
|
+
List intake items with status='new' in FIFO order.
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
cursor: Pagination cursor from previous call
|
|
628
|
+
limit: Maximum items to return (1-200)
|
|
629
|
+
|
|
630
|
+
Returns:
|
|
631
|
+
Tuple of (items, total_count, next_cursor, has_more, lock_wait_ms)
|
|
632
|
+
|
|
633
|
+
Note: This operation is O(n) where n = total items in file.
|
|
634
|
+
File rotation bounds n to ~1000 items.
|
|
635
|
+
"""
|
|
636
|
+
limit = max(1, min(limit, MAX_PAGE_SIZE))
|
|
637
|
+
lock_wait_ms = 0.0
|
|
638
|
+
|
|
639
|
+
with self._thread_lock:
|
|
640
|
+
fd = None
|
|
641
|
+
lock_start = time.monotonic()
|
|
642
|
+
|
|
643
|
+
try:
|
|
644
|
+
fd = self._acquire_lock(exclusive=False) # Shared lock for reads
|
|
645
|
+
lock_wait_ms = (time.monotonic() - lock_start) * 1000
|
|
646
|
+
|
|
647
|
+
if not self.intake_file.exists():
|
|
648
|
+
return [], 0, None, False, lock_wait_ms
|
|
649
|
+
|
|
650
|
+
# Parse cursor if provided
|
|
651
|
+
parsed_cursor: Optional[PaginationCursor] = None
|
|
652
|
+
if cursor:
|
|
653
|
+
parsed_cursor = PaginationCursor.decode(cursor)
|
|
654
|
+
if parsed_cursor is None:
|
|
655
|
+
logger.warning("Invalid cursor provided, starting from beginning")
|
|
656
|
+
|
|
657
|
+
items: list[IntakeItem] = []
|
|
658
|
+
total_count = 0
|
|
659
|
+
found_cursor_position = parsed_cursor is None # True if no cursor
|
|
660
|
+
line_num = 0
|
|
661
|
+
cursor_fallback = False
|
|
662
|
+
|
|
663
|
+
with open(self.intake_file, "r") as f:
|
|
664
|
+
# Try to seek to line hint if cursor provided
|
|
665
|
+
if parsed_cursor and parsed_cursor.line_hint > 0:
|
|
666
|
+
# Read up to line_hint to find position
|
|
667
|
+
for i in range(parsed_cursor.line_hint):
|
|
668
|
+
line = f.readline()
|
|
669
|
+
if not line:
|
|
670
|
+
break
|
|
671
|
+
line_num += 1
|
|
672
|
+
# Still need to count new items for total
|
|
673
|
+
line = line.strip()
|
|
674
|
+
if line:
|
|
675
|
+
try:
|
|
676
|
+
data = json.loads(line)
|
|
677
|
+
if data.get("status") == "new":
|
|
678
|
+
total_count += 1
|
|
679
|
+
except json.JSONDecodeError:
|
|
680
|
+
pass
|
|
681
|
+
|
|
682
|
+
# Check if we found the cursor position
|
|
683
|
+
line = f.readline()
|
|
684
|
+
if line:
|
|
685
|
+
try:
|
|
686
|
+
data = json.loads(line.strip())
|
|
687
|
+
if data.get("id") != parsed_cursor.last_id:
|
|
688
|
+
# Line hint didn't match, need full scan
|
|
689
|
+
cursor_fallback = True
|
|
690
|
+
logger.warning(
|
|
691
|
+
f"Cursor line hint mismatch, falling back to full scan"
|
|
692
|
+
)
|
|
693
|
+
f.seek(0)
|
|
694
|
+
line_num = 0
|
|
695
|
+
total_count = 0
|
|
696
|
+
else:
|
|
697
|
+
# Found cursor position, count this item
|
|
698
|
+
if data.get("status") == "new":
|
|
699
|
+
total_count += 1
|
|
700
|
+
found_cursor_position = True
|
|
701
|
+
line_num += 1
|
|
702
|
+
except json.JSONDecodeError:
|
|
703
|
+
cursor_fallback = True
|
|
704
|
+
f.seek(0)
|
|
705
|
+
line_num = 0
|
|
706
|
+
total_count = 0
|
|
707
|
+
|
|
708
|
+
# Read remaining lines
|
|
709
|
+
for line in f:
|
|
710
|
+
current_line = line_num
|
|
711
|
+
line_num += 1
|
|
712
|
+
line = line.strip()
|
|
713
|
+
if not line:
|
|
714
|
+
continue
|
|
715
|
+
|
|
716
|
+
try:
|
|
717
|
+
data = json.loads(line)
|
|
718
|
+
except json.JSONDecodeError:
|
|
719
|
+
continue
|
|
720
|
+
|
|
721
|
+
# Only count 'new' items
|
|
722
|
+
if data.get("status") != "new":
|
|
723
|
+
continue
|
|
724
|
+
|
|
725
|
+
total_count += 1
|
|
726
|
+
|
|
727
|
+
# Handle cursor position finding
|
|
728
|
+
if not found_cursor_position:
|
|
729
|
+
if data.get("id") == parsed_cursor.last_id:
|
|
730
|
+
found_cursor_position = True
|
|
731
|
+
continue
|
|
732
|
+
|
|
733
|
+
# Collect items up to limit
|
|
734
|
+
if len(items) < limit:
|
|
735
|
+
items.append(IntakeItem.from_dict(data))
|
|
736
|
+
|
|
737
|
+
# Build next cursor
|
|
738
|
+
next_cursor: Optional[str] = None
|
|
739
|
+
has_more = len(items) == limit and total_count > len(items)
|
|
740
|
+
|
|
741
|
+
if items and has_more:
|
|
742
|
+
last_item = items[-1]
|
|
743
|
+
# Estimate line position (not perfect but helpful)
|
|
744
|
+
next_cursor = PaginationCursor(
|
|
745
|
+
last_id=last_item.id,
|
|
746
|
+
line_hint=line_num - 1,
|
|
747
|
+
).encode()
|
|
748
|
+
|
|
749
|
+
if cursor_fallback and lock_wait_ms > 1000:
|
|
750
|
+
logger.warning(f"Cursor fallback with long lock wait: {lock_wait_ms:.2f}ms")
|
|
751
|
+
|
|
752
|
+
return items, total_count, next_cursor, has_more, lock_wait_ms
|
|
753
|
+
|
|
754
|
+
finally:
|
|
755
|
+
if fd is not None:
|
|
756
|
+
self._release_lock(fd)
|
|
757
|
+
|
|
758
|
+
def dismiss(
|
|
759
|
+
self,
|
|
760
|
+
intake_id: str,
|
|
761
|
+
*,
|
|
762
|
+
reason: Optional[str] = None,
|
|
763
|
+
dry_run: bool = False,
|
|
764
|
+
) -> tuple[Optional[IntakeItem], float]:
|
|
765
|
+
"""
|
|
766
|
+
Dismiss an intake item by changing its status to 'dismissed'.
|
|
767
|
+
|
|
768
|
+
Args:
|
|
769
|
+
intake_id: The intake item ID to dismiss
|
|
770
|
+
reason: Optional reason for dismissal (max 200 chars)
|
|
771
|
+
dry_run: If True, find item but don't modify
|
|
772
|
+
|
|
773
|
+
Returns:
|
|
774
|
+
Tuple of (updated IntakeItem or None if not found, lock_wait_ms)
|
|
775
|
+
|
|
776
|
+
Raises:
|
|
777
|
+
LockAcquisitionError: If lock cannot be acquired within timeout.
|
|
778
|
+
"""
|
|
779
|
+
lock_wait_ms = 0.0
|
|
780
|
+
now = self._now_utc()
|
|
781
|
+
|
|
782
|
+
# Sanitize reason
|
|
783
|
+
if reason:
|
|
784
|
+
reason = self._strip_control_chars(reason)
|
|
785
|
+
|
|
786
|
+
with self._thread_lock:
|
|
787
|
+
fd = None
|
|
788
|
+
lock_start = time.monotonic()
|
|
789
|
+
|
|
790
|
+
try:
|
|
791
|
+
fd = self._acquire_lock(exclusive=True)
|
|
792
|
+
lock_wait_ms = (time.monotonic() - lock_start) * 1000
|
|
793
|
+
|
|
794
|
+
if not self.intake_file.exists():
|
|
795
|
+
return None, lock_wait_ms
|
|
796
|
+
|
|
797
|
+
# Read all lines
|
|
798
|
+
with open(self.intake_file, "r") as f:
|
|
799
|
+
lines = f.readlines()
|
|
800
|
+
|
|
801
|
+
# Find and update the item
|
|
802
|
+
found_item: Optional[IntakeItem] = None
|
|
803
|
+
updated_lines: list[str] = []
|
|
804
|
+
|
|
805
|
+
for line in lines:
|
|
806
|
+
stripped = line.strip()
|
|
807
|
+
if not stripped:
|
|
808
|
+
updated_lines.append(line)
|
|
809
|
+
continue
|
|
810
|
+
|
|
811
|
+
try:
|
|
812
|
+
data = json.loads(stripped)
|
|
813
|
+
except json.JSONDecodeError:
|
|
814
|
+
updated_lines.append(line)
|
|
815
|
+
continue
|
|
816
|
+
|
|
817
|
+
if data.get("id") == intake_id:
|
|
818
|
+
# Found the item
|
|
819
|
+
data["status"] = "dismissed"
|
|
820
|
+
data["updated_at"] = now
|
|
821
|
+
found_item = IntakeItem.from_dict(data)
|
|
822
|
+
updated_lines.append(json.dumps(data) + "\n")
|
|
823
|
+
else:
|
|
824
|
+
updated_lines.append(line)
|
|
825
|
+
|
|
826
|
+
if found_item is None:
|
|
827
|
+
return None, lock_wait_ms
|
|
828
|
+
|
|
829
|
+
if dry_run:
|
|
830
|
+
return found_item, lock_wait_ms
|
|
831
|
+
|
|
832
|
+
# Atomic write via temp file
|
|
833
|
+
temp_file = self.intake_file.with_suffix(".tmp")
|
|
834
|
+
with open(temp_file, "w") as f:
|
|
835
|
+
f.writelines(updated_lines)
|
|
836
|
+
f.flush()
|
|
837
|
+
os.fsync(f.fileno())
|
|
838
|
+
|
|
839
|
+
temp_file.rename(self.intake_file)
|
|
840
|
+
|
|
841
|
+
logger.info(f"Dismissed intake item: {intake_id}")
|
|
842
|
+
return found_item, lock_wait_ms
|
|
843
|
+
|
|
844
|
+
finally:
|
|
845
|
+
if fd is not None:
|
|
846
|
+
self._release_lock(fd)
|
|
847
|
+
|
|
848
|
+
def get(self, intake_id: str) -> tuple[Optional[IntakeItem], float]:
|
|
849
|
+
"""
|
|
850
|
+
Get a single intake item by ID.
|
|
851
|
+
|
|
852
|
+
Args:
|
|
853
|
+
intake_id: The intake item ID to retrieve
|
|
854
|
+
|
|
855
|
+
Returns:
|
|
856
|
+
Tuple of (IntakeItem or None if not found, lock_wait_ms)
|
|
857
|
+
"""
|
|
858
|
+
lock_wait_ms = 0.0
|
|
859
|
+
|
|
860
|
+
with self._thread_lock:
|
|
861
|
+
fd = None
|
|
862
|
+
lock_start = time.monotonic()
|
|
863
|
+
|
|
864
|
+
try:
|
|
865
|
+
fd = self._acquire_lock(exclusive=False)
|
|
866
|
+
lock_wait_ms = (time.monotonic() - lock_start) * 1000
|
|
867
|
+
|
|
868
|
+
if not self.intake_file.exists():
|
|
869
|
+
return None, lock_wait_ms
|
|
870
|
+
|
|
871
|
+
with open(self.intake_file, "r") as f:
|
|
872
|
+
for line in f:
|
|
873
|
+
line = line.strip()
|
|
874
|
+
if not line:
|
|
875
|
+
continue
|
|
876
|
+
try:
|
|
877
|
+
data = json.loads(line)
|
|
878
|
+
if data.get("id") == intake_id:
|
|
879
|
+
return IntakeItem.from_dict(data), lock_wait_ms
|
|
880
|
+
except json.JSONDecodeError:
|
|
881
|
+
continue
|
|
882
|
+
|
|
883
|
+
return None, lock_wait_ms
|
|
884
|
+
|
|
885
|
+
finally:
|
|
886
|
+
if fd is not None:
|
|
887
|
+
self._release_lock(fd)
|
|
888
|
+
|
|
889
|
+
@property
|
|
890
|
+
def intake_path(self) -> str:
|
|
891
|
+
"""Get the absolute path to the intake file."""
|
|
892
|
+
return str(self.intake_file.absolute())
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
# Global store instance
|
|
896
|
+
_intake_store: Optional[IntakeStore] = None
|
|
897
|
+
_store_lock = threading.Lock()
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def get_intake_store(
|
|
901
|
+
specs_dir: Optional[str | Path] = None,
|
|
902
|
+
bikelane_dir: Optional[str | Path] = None,
|
|
903
|
+
) -> IntakeStore:
|
|
904
|
+
"""
|
|
905
|
+
Get the global intake store instance.
|
|
906
|
+
|
|
907
|
+
Args:
|
|
908
|
+
specs_dir: Path to specs directory. Required on first call.
|
|
909
|
+
bikelane_dir: Optional custom path for bikelane storage.
|
|
910
|
+
Defaults to specs_dir/.bikelane if not provided.
|
|
911
|
+
|
|
912
|
+
Returns:
|
|
913
|
+
The IntakeStore instance.
|
|
914
|
+
|
|
915
|
+
Raises:
|
|
916
|
+
ValueError: If specs_dir not provided on first call.
|
|
917
|
+
"""
|
|
918
|
+
global _intake_store
|
|
919
|
+
|
|
920
|
+
with _store_lock:
|
|
921
|
+
if _intake_store is None:
|
|
922
|
+
if specs_dir is None:
|
|
923
|
+
raise ValueError("specs_dir required for first IntakeStore initialization")
|
|
924
|
+
_intake_store = IntakeStore(specs_dir, bikelane_dir=bikelane_dir)
|
|
925
|
+
|
|
926
|
+
return _intake_store
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def reset_intake_store() -> None:
|
|
930
|
+
"""Reset the global intake store (for testing)."""
|
|
931
|
+
global _intake_store
|
|
932
|
+
with _store_lock:
|
|
933
|
+
_intake_store = None
|