codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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.
- codex_autorunner/__init__.py +12 -1
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/client.py +113 -4
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/runtime.py +59 -18
- codex_autorunner/agents/opencode/supervisor.py +4 -0
- codex_autorunner/agents/registry.py +36 -7
- codex_autorunner/bootstrap.py +226 -4
- codex_autorunner/cli.py +5 -1174
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +20 -0
- codex_autorunner/core/about_car.py +119 -1
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_threads.py +17 -2
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +6 -2
- codex_autorunner/core/config.py +433 -4
- codex_autorunner/core/context_awareness.py +38 -0
- codex_autorunner/core/docs.py +0 -122
- codex_autorunner/core/drafts.py +58 -4
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/filebox.py +265 -0
- codex_autorunner/core/flows/controller.py +96 -2
- codex_autorunner/core/flows/models.py +13 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +134 -0
- codex_autorunner/core/flows/runtime.py +57 -4
- codex_autorunner/core/flows/store.py +142 -7
- codex_autorunner/core/flows/transition.py +27 -15
- codex_autorunner/core/flows/ux_helpers.py +272 -0
- codex_autorunner/core/flows/worker_process.py +32 -6
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +291 -20
- codex_autorunner/core/lifecycle_events.py +253 -0
- codex_autorunner/core/notifications.py +14 -2
- codex_autorunner/core/path_utils.py +2 -1
- codex_autorunner/core/pma_audit.py +224 -0
- codex_autorunner/core/pma_context.py +496 -0
- codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
- codex_autorunner/core/pma_lifecycle.py +527 -0
- codex_autorunner/core/pma_queue.py +367 -0
- codex_autorunner/core/pma_safety.py +221 -0
- codex_autorunner/core/pma_state.py +115 -0
- codex_autorunner/core/ports/__init__.py +28 -0
- codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
- codex_autorunner/core/prompt.py +0 -80
- codex_autorunner/core/prompts.py +56 -172
- codex_autorunner/core/redaction.py +0 -4
- codex_autorunner/core/review_context.py +11 -9
- codex_autorunner/core/runner_controller.py +35 -33
- codex_autorunner/core/runner_state.py +147 -0
- codex_autorunner/core/runtime.py +829 -0
- codex_autorunner/core/sqlite_utils.py +13 -4
- codex_autorunner/core/state.py +7 -10
- codex_autorunner/core/state_roots.py +62 -0
- codex_autorunner/core/supervisor_protocol.py +15 -0
- codex_autorunner/core/templates/__init__.py +39 -0
- codex_autorunner/core/templates/git_mirror.py +234 -0
- codex_autorunner/core/templates/provenance.py +56 -0
- codex_autorunner/core/templates/scan_cache.py +120 -0
- codex_autorunner/core/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +218 -0
- codex_autorunner/core/ticket_manager_cli.py +494 -0
- codex_autorunner/core/time_utils.py +11 -0
- codex_autorunner/core/types.py +18 -0
- codex_autorunner/core/update.py +4 -5
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +125 -15
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
- codex_autorunner/flows/ticket_flow/definition.py +52 -3
- codex_autorunner/integrations/agents/__init__.py +11 -19
- codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +177 -25
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +305 -32
- codex_autorunner/integrations/agents/runner.py +86 -0
- codex_autorunner/integrations/agents/wiring.py +279 -0
- codex_autorunner/integrations/app_server/client.py +7 -60
- codex_autorunner/integrations/app_server/env.py +2 -107
- codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
- codex_autorunner/integrations/telegram/adapter.py +65 -0
- codex_autorunner/integrations/telegram/config.py +46 -0
- codex_autorunner/integrations/telegram/constants.py +1 -1
- codex_autorunner/integrations/telegram/doctor.py +228 -6
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
- codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
- codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +22 -1
- codex_autorunner/integrations/telegram/runtime.py +9 -4
- codex_autorunner/integrations/telegram/service.py +45 -10
- codex_autorunner/integrations/telegram/state.py +38 -0
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
- codex_autorunner/integrations/telegram/transport.py +13 -4
- codex_autorunner/integrations/templates/__init__.py +27 -0
- codex_autorunner/integrations/templates/scan_agent.py +312 -0
- codex_autorunner/routes/__init__.py +37 -76
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +2 -238
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -596
- codex_autorunner/routes/file_chat.py +4 -833
- codex_autorunner/routes/flows.py +4 -977
- codex_autorunner/routes/messages.py +4 -456
- codex_autorunner/routes/repos.py +2 -196
- codex_autorunner/routes/review.py +2 -147
- codex_autorunner/routes/sessions.py +2 -175
- codex_autorunner/routes/settings.py +2 -168
- codex_autorunner/routes/shared.py +2 -275
- codex_autorunner/routes/system.py +4 -193
- codex_autorunner/routes/usage.py +2 -86
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +2 -270
- codex_autorunner/server.py +4 -4
- codex_autorunner/static/agentControls.js +61 -16
- codex_autorunner/static/app.js +126 -14
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +7 -7
- codex_autorunner/static/chatUploads.js +137 -0
- codex_autorunner/static/dashboard.js +224 -171
- codex_autorunner/static/docChatCore.js +185 -13
- codex_autorunner/static/fileChat.js +68 -40
- codex_autorunner/static/fileboxUi.js +159 -0
- codex_autorunner/static/hub.js +114 -131
- codex_autorunner/static/index.html +375 -49
- codex_autorunner/static/messages.js +568 -87
- codex_autorunner/static/notifications.js +255 -0
- codex_autorunner/static/pma.js +1167 -0
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +128 -6
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/streamUtils.js +57 -0
- codex_autorunner/static/styles.css +9798 -6143
- codex_autorunner/static/tabs.js +152 -11
- codex_autorunner/static/templateReposSettings.js +225 -0
- codex_autorunner/static/terminal.js +18 -0
- codex_autorunner/static/ticketChatActions.js +165 -3
- codex_autorunner/static/ticketChatStream.js +17 -119
- codex_autorunner/static/ticketEditor.js +137 -15
- codex_autorunner/static/ticketTemplates.js +798 -0
- codex_autorunner/static/tickets.js +821 -98
- codex_autorunner/static/turnEvents.js +27 -0
- codex_autorunner/static/turnResume.js +33 -0
- codex_autorunner/static/utils.js +39 -0
- codex_autorunner/static/workspace.js +389 -82
- codex_autorunner/static/workspaceFileBrowser.js +15 -13
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +2534 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/cli/pma_cli.py +817 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2223 -0
- codex_autorunner/surfaces/web/hub_jobs.py +192 -0
- codex_autorunner/surfaces/web/middleware.py +587 -0
- codex_autorunner/surfaces/web/pty_session.py +370 -0
- codex_autorunner/surfaces/web/review.py +6 -0
- codex_autorunner/surfaces/web/routes/__init__.py +82 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +284 -0
- codex_autorunner/surfaces/web/routes/app_server.py +132 -0
- codex_autorunner/surfaces/web/routes/archive.py +357 -0
- codex_autorunner/surfaces/web/routes/base.py +615 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
- codex_autorunner/surfaces/web/routes/filebox.py +227 -0
- codex_autorunner/surfaces/web/routes/flows.py +1354 -0
- codex_autorunner/surfaces/web/routes/messages.py +490 -0
- codex_autorunner/surfaces/web/routes/pma.py +1652 -0
- codex_autorunner/surfaces/web/routes/repos.py +197 -0
- codex_autorunner/surfaces/web/routes/review.py +148 -0
- codex_autorunner/surfaces/web/routes/sessions.py +176 -0
- codex_autorunner/surfaces/web/routes/settings.py +169 -0
- codex_autorunner/surfaces/web/routes/shared.py +277 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -0
- codex_autorunner/surfaces/web/routes/templates.py +634 -0
- codex_autorunner/surfaces/web/routes/usage.py +89 -0
- codex_autorunner/surfaces/web/routes/voice.py +120 -0
- codex_autorunner/surfaces/web/routes/workspace.py +271 -0
- codex_autorunner/surfaces/web/runner_manager.py +25 -0
- codex_autorunner/surfaces/web/schemas.py +469 -0
- codex_autorunner/surfaces/web/static_assets.py +490 -0
- codex_autorunner/surfaces/web/static_refresh.py +86 -0
- codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
- codex_autorunner/tickets/__init__.py +8 -1
- codex_autorunner/tickets/agent_pool.py +53 -4
- codex_autorunner/tickets/files.py +37 -16
- codex_autorunner/tickets/lint.py +50 -0
- codex_autorunner/tickets/models.py +6 -1
- codex_autorunner/tickets/outbox.py +50 -2
- codex_autorunner/tickets/runner.py +396 -57
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1949
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -586
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -376
- codex_autorunner/web/static_assets.py +4 -441
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/paths.py +49 -33
- codex_autorunner-1.2.0.dist-info/METADATA +150 -0
- codex_autorunner-1.2.0.dist-info/RECORD +339 -0
- codex_autorunner/core/adapter_utils.py +0 -21
- codex_autorunner/core/engine.py +0 -2653
- codex_autorunner/core/static_assets.py +0 -55
- codex_autorunner-1.0.0.dist-info/METADATA +0 -246
- codex_autorunner-1.0.0.dist-info/RECORD +0 -251
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
|
@@ -13,19 +13,28 @@ SQLITE_PRAGMAS = (
|
|
|
13
13
|
"PRAGMA temp_store=MEMORY;",
|
|
14
14
|
)
|
|
15
15
|
|
|
16
|
+
SQLITE_PRAGMAS_DURABLE = (
|
|
17
|
+
"PRAGMA journal_mode=WAL;",
|
|
18
|
+
"PRAGMA synchronous=FULL;",
|
|
19
|
+
"PRAGMA foreign_keys=ON;",
|
|
20
|
+
"PRAGMA busy_timeout=5000;",
|
|
21
|
+
"PRAGMA temp_store=MEMORY;",
|
|
22
|
+
)
|
|
23
|
+
|
|
16
24
|
|
|
17
|
-
def connect_sqlite(path: Path) -> sqlite3.Connection:
|
|
25
|
+
def connect_sqlite(path: Path, durable: bool = False) -> sqlite3.Connection:
|
|
18
26
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
19
27
|
conn = sqlite3.connect(path)
|
|
20
28
|
conn.row_factory = sqlite3.Row
|
|
21
|
-
|
|
29
|
+
pragmas = SQLITE_PRAGMAS_DURABLE if durable else SQLITE_PRAGMAS
|
|
30
|
+
for pragma in pragmas:
|
|
22
31
|
conn.execute(pragma)
|
|
23
32
|
return conn
|
|
24
33
|
|
|
25
34
|
|
|
26
35
|
@contextmanager
|
|
27
|
-
def open_sqlite(path: Path) -> Iterator[sqlite3.Connection]:
|
|
28
|
-
conn = connect_sqlite(path)
|
|
36
|
+
def open_sqlite(path: Path, durable: bool = False) -> Iterator[sqlite3.Connection]:
|
|
37
|
+
conn = connect_sqlite(path, durable=durable)
|
|
29
38
|
try:
|
|
30
39
|
yield conn
|
|
31
40
|
finally:
|
codex_autorunner/core/state.py
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import dataclasses
|
|
2
2
|
import json
|
|
3
3
|
from contextlib import contextmanager
|
|
4
|
-
from datetime import datetime, timezone
|
|
5
4
|
from pathlib import Path
|
|
6
5
|
from typing import Any, Iterator, Optional
|
|
7
6
|
|
|
8
7
|
from .locks import file_lock
|
|
9
8
|
from .sqlite_utils import open_sqlite
|
|
9
|
+
from .time_utils import now_iso
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
@dataclasses.dataclass
|
|
@@ -93,10 +93,6 @@ class SessionRecord:
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
|
|
96
|
-
def now_iso() -> str:
|
|
97
|
-
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
98
|
-
|
|
99
|
-
|
|
100
96
|
def _ensure_state_schema(conn) -> None:
|
|
101
97
|
with conn:
|
|
102
98
|
conn.execute(
|
|
@@ -197,8 +193,8 @@ def _apply_overrides(state: RunnerState, raw: Optional[str]) -> None:
|
|
|
197
193
|
state.runner_stop_after_runs = runner_stop_after_runs
|
|
198
194
|
|
|
199
195
|
|
|
200
|
-
def load_state(state_path: Path) -> RunnerState:
|
|
201
|
-
with open_sqlite(state_path) as conn:
|
|
196
|
+
def load_state(state_path: Path, durable: bool = False) -> RunnerState:
|
|
197
|
+
with open_sqlite(state_path, durable=durable) as conn:
|
|
202
198
|
_ensure_state_schema(conn)
|
|
203
199
|
row = conn.execute(
|
|
204
200
|
"""
|
|
@@ -253,9 +249,9 @@ def load_state(state_path: Path) -> RunnerState:
|
|
|
253
249
|
return state
|
|
254
250
|
|
|
255
251
|
|
|
256
|
-
def save_state(state_path: Path, state: RunnerState) -> None:
|
|
252
|
+
def save_state(state_path: Path, state: RunnerState, durable: bool = False) -> None:
|
|
257
253
|
overrides_json = _encode_overrides(state)
|
|
258
|
-
with open_sqlite(state_path) as conn:
|
|
254
|
+
with open_sqlite(state_path, durable=durable) as conn:
|
|
259
255
|
_ensure_state_schema(conn)
|
|
260
256
|
updated_at = now_iso()
|
|
261
257
|
with conn:
|
|
@@ -343,9 +339,10 @@ def persist_session_registry(
|
|
|
343
339
|
state_path: Path,
|
|
344
340
|
sessions: dict[str, SessionRecord],
|
|
345
341
|
repo_to_session: dict[str, str],
|
|
342
|
+
durable: bool = False,
|
|
346
343
|
) -> None:
|
|
347
344
|
with state_lock(state_path):
|
|
348
|
-
with open_sqlite(state_path) as conn:
|
|
345
|
+
with open_sqlite(state_path, durable=durable) as conn:
|
|
349
346
|
_ensure_state_schema(conn)
|
|
350
347
|
with conn:
|
|
351
348
|
conn.execute("DELETE FROM sessions")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Mapping, Optional
|
|
6
|
+
|
|
7
|
+
from .path_utils import ConfigPathError, resolve_config_path
|
|
8
|
+
|
|
9
|
+
GLOBAL_STATE_ROOT_ENV = "CAR_GLOBAL_STATE_ROOT"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _read_global_root_from_config(raw: Optional[Mapping[str, Any]]) -> Optional[str]:
|
|
13
|
+
if not raw:
|
|
14
|
+
return None
|
|
15
|
+
state_roots = raw.get("state_roots")
|
|
16
|
+
if not isinstance(state_roots, Mapping):
|
|
17
|
+
return None
|
|
18
|
+
value = state_roots.get("global")
|
|
19
|
+
return value if isinstance(value, str) else None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_global_state_root(
|
|
23
|
+
*,
|
|
24
|
+
config: Optional[Any] = None,
|
|
25
|
+
repo_root: Optional[Path] = None,
|
|
26
|
+
scope: str = "state_roots.global",
|
|
27
|
+
) -> Path:
|
|
28
|
+
"""Resolve the global state root used for cross-repo caches and locks."""
|
|
29
|
+
base_root = repo_root
|
|
30
|
+
raw_config = None
|
|
31
|
+
if config is not None:
|
|
32
|
+
base_root = getattr(config, "root", None) or base_root
|
|
33
|
+
raw_config = getattr(config, "raw", None)
|
|
34
|
+
|
|
35
|
+
if base_root is None:
|
|
36
|
+
base_root = Path.cwd()
|
|
37
|
+
|
|
38
|
+
env_value = os.environ.get(GLOBAL_STATE_ROOT_ENV)
|
|
39
|
+
raw_value = env_value or _read_global_root_from_config(raw_config)
|
|
40
|
+
if raw_value:
|
|
41
|
+
try:
|
|
42
|
+
return resolve_config_path(
|
|
43
|
+
raw_value,
|
|
44
|
+
base_root,
|
|
45
|
+
allow_absolute=True,
|
|
46
|
+
allow_home=True,
|
|
47
|
+
scope=scope,
|
|
48
|
+
)
|
|
49
|
+
except ConfigPathError as exc:
|
|
50
|
+
raise ConfigPathError(str(exc), path=raw_value, scope=scope) from exc
|
|
51
|
+
|
|
52
|
+
return Path.home() / ".codex-autorunner"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def resolve_repo_state_root(repo_root: Path) -> Path:
|
|
56
|
+
"""Return the repo-local state root (.codex-autorunner)."""
|
|
57
|
+
return repo_root / ".codex-autorunner"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_hub_templates_root(hub_root: Path) -> Path:
|
|
61
|
+
"""Return the hub-scoped templates root."""
|
|
62
|
+
return hub_root / ".codex-autorunner" / "templates"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AppServerSupervisorProtocol:
|
|
8
|
+
async def get_client(self, workspace_root: Path) -> Any:
|
|
9
|
+
raise NotImplementedError
|
|
10
|
+
|
|
11
|
+
async def close_all(self) -> None:
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
|
|
14
|
+
async def prune_idle(self) -> int:
|
|
15
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from .git_mirror import (
|
|
2
|
+
FetchedTemplate,
|
|
3
|
+
NetworkUnavailableError,
|
|
4
|
+
RefNotFoundError,
|
|
5
|
+
RepoNotConfiguredError,
|
|
6
|
+
TemplateNotFoundError,
|
|
7
|
+
TemplateRef,
|
|
8
|
+
ensure_git_mirror,
|
|
9
|
+
fetch_template,
|
|
10
|
+
parse_template_ref,
|
|
11
|
+
)
|
|
12
|
+
from .provenance import inject_provenance
|
|
13
|
+
from .scan_cache import (
|
|
14
|
+
TemplateScanRecord,
|
|
15
|
+
get_scan_record,
|
|
16
|
+
scan_lock,
|
|
17
|
+
scan_lock_path,
|
|
18
|
+
scan_record_path,
|
|
19
|
+
write_scan_record,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"FetchedTemplate",
|
|
24
|
+
"NetworkUnavailableError",
|
|
25
|
+
"RefNotFoundError",
|
|
26
|
+
"RepoNotConfiguredError",
|
|
27
|
+
"TemplateNotFoundError",
|
|
28
|
+
"TemplateRef",
|
|
29
|
+
"ensure_git_mirror",
|
|
30
|
+
"fetch_template",
|
|
31
|
+
"parse_template_ref",
|
|
32
|
+
"inject_provenance",
|
|
33
|
+
"TemplateScanRecord",
|
|
34
|
+
"get_scan_record",
|
|
35
|
+
"scan_lock",
|
|
36
|
+
"scan_lock_path",
|
|
37
|
+
"scan_record_path",
|
|
38
|
+
"write_scan_record",
|
|
39
|
+
]
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ..config import TemplateRepoConfig
|
|
8
|
+
from ..git_utils import GitError, run_git
|
|
9
|
+
from ..state_roots import resolve_hub_templates_root
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RepoNotConfiguredError(Exception):
|
|
13
|
+
def __init__(self, repo_id: str, *, detail: Optional[str] = None) -> None:
|
|
14
|
+
message = f"Template repo not configured: {repo_id}"
|
|
15
|
+
if detail:
|
|
16
|
+
message = f"{message} ({detail})"
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.repo_id = repo_id
|
|
19
|
+
self.detail = detail
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TemplateNotFoundError(Exception):
|
|
23
|
+
def __init__(self, repo_id: str, path: str, ref: str) -> None:
|
|
24
|
+
super().__init__(f"Template not found: repo_id={repo_id} path={path} ref={ref}")
|
|
25
|
+
self.repo_id = repo_id
|
|
26
|
+
self.path = path
|
|
27
|
+
self.ref = ref
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RefNotFoundError(Exception):
|
|
31
|
+
def __init__(self, repo_id: str, ref: str) -> None:
|
|
32
|
+
super().__init__(f"Ref not found: repo_id={repo_id} ref={ref}")
|
|
33
|
+
self.repo_id = repo_id
|
|
34
|
+
self.ref = ref
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NetworkUnavailableError(Exception):
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
repo_id: str,
|
|
41
|
+
ref: str,
|
|
42
|
+
path: str,
|
|
43
|
+
*,
|
|
44
|
+
detail: Optional[str] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
message = (
|
|
47
|
+
"Template fetch failed and cache is unavailable: "
|
|
48
|
+
f"repo_id={repo_id} ref={ref} path={path}"
|
|
49
|
+
)
|
|
50
|
+
if detail:
|
|
51
|
+
message = f"{message} ({detail})"
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
self.repo_id = repo_id
|
|
54
|
+
self.ref = ref
|
|
55
|
+
self.path = path
|
|
56
|
+
self.detail = detail
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclasses.dataclass(frozen=True)
|
|
60
|
+
class TemplateRef:
|
|
61
|
+
repo_id: str
|
|
62
|
+
path: str
|
|
63
|
+
ref: Optional[str]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclasses.dataclass(frozen=True)
|
|
67
|
+
class FetchedTemplate:
|
|
68
|
+
repo_id: str
|
|
69
|
+
url: str
|
|
70
|
+
trusted: bool
|
|
71
|
+
path: str
|
|
72
|
+
ref: str
|
|
73
|
+
commit_sha: str
|
|
74
|
+
blob_sha: str
|
|
75
|
+
content: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_template_ref(raw: str) -> TemplateRef:
|
|
79
|
+
"""Parse canonical template reference strings: REPO_ID:PATH[@REF]."""
|
|
80
|
+
if ":" not in raw:
|
|
81
|
+
raise ValueError("template ref must be formatted as REPO_ID:PATH[@REF]")
|
|
82
|
+
repo_id, remainder = raw.split(":", 1)
|
|
83
|
+
if not repo_id:
|
|
84
|
+
raise ValueError("template ref missing repo_id")
|
|
85
|
+
if not remainder:
|
|
86
|
+
raise ValueError("template ref missing path")
|
|
87
|
+
|
|
88
|
+
path: str
|
|
89
|
+
ref: Optional[str]
|
|
90
|
+
if "@" in remainder:
|
|
91
|
+
path, ref = remainder.rsplit("@", 1)
|
|
92
|
+
if not ref:
|
|
93
|
+
raise ValueError("template ref missing ref after '@'")
|
|
94
|
+
else:
|
|
95
|
+
path, ref = remainder, None
|
|
96
|
+
|
|
97
|
+
if not path:
|
|
98
|
+
raise ValueError("template ref missing path")
|
|
99
|
+
|
|
100
|
+
return TemplateRef(repo_id=repo_id, path=path, ref=ref)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def ensure_git_mirror(repo: TemplateRepoConfig, hub_root: Path) -> Path:
|
|
104
|
+
templates_root = resolve_hub_templates_root(hub_root)
|
|
105
|
+
mirror_path = templates_root / "git" / f"{repo.id}.git"
|
|
106
|
+
if mirror_path.exists():
|
|
107
|
+
_ensure_origin_remote(mirror_path, repo.url)
|
|
108
|
+
return mirror_path
|
|
109
|
+
|
|
110
|
+
mirror_path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
run_git(["init", "--bare", str(mirror_path)], mirror_path.parent, check=True)
|
|
112
|
+
_ensure_origin_remote(mirror_path, repo.url)
|
|
113
|
+
return mirror_path
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _ensure_origin_remote(mirror_path: Path, url: str) -> None:
|
|
117
|
+
try:
|
|
118
|
+
proc = run_git(["remote", "get-url", "origin"], mirror_path, check=False)
|
|
119
|
+
except GitError:
|
|
120
|
+
proc = None
|
|
121
|
+
if proc and proc.returncode == 0:
|
|
122
|
+
current = (proc.stdout or "").strip()
|
|
123
|
+
if current and current != url:
|
|
124
|
+
run_git(["remote", "set-url", "origin", url], mirror_path, check=True)
|
|
125
|
+
else:
|
|
126
|
+
run_git(["remote", "add", "origin", url], mirror_path, check=True)
|
|
127
|
+
_configure_mirror_remote(mirror_path)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _configure_mirror_remote(mirror_path: Path) -> None:
|
|
131
|
+
run_git(
|
|
132
|
+
["config", "remote.origin.fetch", "+refs/*:refs/*"],
|
|
133
|
+
mirror_path,
|
|
134
|
+
check=True,
|
|
135
|
+
)
|
|
136
|
+
run_git(["config", "remote.origin.mirror", "true"], mirror_path, check=True)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def fetch_template(
|
|
140
|
+
*,
|
|
141
|
+
repo: TemplateRepoConfig,
|
|
142
|
+
hub_root: Path,
|
|
143
|
+
template_ref: str,
|
|
144
|
+
fetch_timeout_seconds: int = 30,
|
|
145
|
+
) -> FetchedTemplate:
|
|
146
|
+
parsed = parse_template_ref(template_ref)
|
|
147
|
+
if parsed.repo_id != repo.id:
|
|
148
|
+
raise RepoNotConfiguredError(
|
|
149
|
+
parsed.repo_id,
|
|
150
|
+
detail=f"expected repo_id {repo.id}",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
ref = parsed.ref or repo.default_ref
|
|
154
|
+
mirror_path = ensure_git_mirror(repo, hub_root)
|
|
155
|
+
|
|
156
|
+
fetch_error: Optional[str] = None
|
|
157
|
+
try:
|
|
158
|
+
run_git(
|
|
159
|
+
["fetch", "--prune", "origin"],
|
|
160
|
+
mirror_path,
|
|
161
|
+
timeout_seconds=fetch_timeout_seconds,
|
|
162
|
+
check=True,
|
|
163
|
+
)
|
|
164
|
+
except GitError as exc:
|
|
165
|
+
fetch_error = str(exc)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
commit_sha = _resolve_commit(mirror_path, repo.id, ref)
|
|
169
|
+
blob_sha = _resolve_blob(mirror_path, commit_sha, parsed.path, repo.id, ref)
|
|
170
|
+
content = _read_blob(mirror_path, blob_sha)
|
|
171
|
+
except (RefNotFoundError, TemplateNotFoundError) as exc:
|
|
172
|
+
if fetch_error:
|
|
173
|
+
raise NetworkUnavailableError(
|
|
174
|
+
repo.id,
|
|
175
|
+
ref,
|
|
176
|
+
parsed.path,
|
|
177
|
+
detail=fetch_error,
|
|
178
|
+
) from exc
|
|
179
|
+
raise
|
|
180
|
+
|
|
181
|
+
return FetchedTemplate(
|
|
182
|
+
repo_id=repo.id,
|
|
183
|
+
url=repo.url,
|
|
184
|
+
trusted=repo.trusted,
|
|
185
|
+
path=parsed.path,
|
|
186
|
+
ref=ref,
|
|
187
|
+
commit_sha=commit_sha,
|
|
188
|
+
blob_sha=blob_sha,
|
|
189
|
+
content=content,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _resolve_commit(mirror_path: Path, repo_id: str, ref: str) -> str:
|
|
194
|
+
try:
|
|
195
|
+
proc = run_git(
|
|
196
|
+
["rev-parse", f"{ref}^{{commit}}"],
|
|
197
|
+
mirror_path,
|
|
198
|
+
check=True,
|
|
199
|
+
)
|
|
200
|
+
except GitError as exc:
|
|
201
|
+
raise RefNotFoundError(repo_id, ref) from exc
|
|
202
|
+
return (proc.stdout or "").strip()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _resolve_blob(
|
|
206
|
+
mirror_path: Path,
|
|
207
|
+
commit_sha: str,
|
|
208
|
+
path: str,
|
|
209
|
+
repo_id: str,
|
|
210
|
+
ref: str,
|
|
211
|
+
) -> str:
|
|
212
|
+
try:
|
|
213
|
+
proc = run_git(
|
|
214
|
+
["ls-tree", commit_sha, "--", path],
|
|
215
|
+
mirror_path,
|
|
216
|
+
check=True,
|
|
217
|
+
)
|
|
218
|
+
except GitError as exc:
|
|
219
|
+
raise TemplateNotFoundError(repo_id, path, ref) from exc
|
|
220
|
+
|
|
221
|
+
raw = (proc.stdout or "").strip()
|
|
222
|
+
if not raw:
|
|
223
|
+
raise TemplateNotFoundError(repo_id, path, ref)
|
|
224
|
+
|
|
225
|
+
# Format: "<mode> <type> <sha>\t<path>"
|
|
226
|
+
parts = raw.split()
|
|
227
|
+
if len(parts) < 3:
|
|
228
|
+
raise TemplateNotFoundError(repo_id, path, ref)
|
|
229
|
+
return parts[2]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _read_blob(mirror_path: Path, blob_sha: str) -> str:
|
|
233
|
+
proc = run_git(["cat-file", "-p", blob_sha], mirror_path, check=True)
|
|
234
|
+
return proc.stdout or ""
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from .git_mirror import FetchedTemplate
|
|
8
|
+
from .scan_cache import TemplateScanRecord
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def inject_provenance(
|
|
12
|
+
content: str,
|
|
13
|
+
fetched: FetchedTemplate,
|
|
14
|
+
scan_record: Optional[TemplateScanRecord],
|
|
15
|
+
) -> str:
|
|
16
|
+
"""Inject deterministic provenance keys into ticket frontmatter.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
content: Ticket markdown content
|
|
20
|
+
fetched: The fetched template metadata
|
|
21
|
+
scan_record: Optional scan record for the template
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Updated markdown content with provenance keys in frontmatter
|
|
25
|
+
|
|
26
|
+
Notes:
|
|
27
|
+
- Does not embed scan evidence
|
|
28
|
+
- Does not add timestamps (avoids nondeterminism)
|
|
29
|
+
- Creates frontmatter if missing
|
|
30
|
+
- Merges with existing frontmatter without clobbering unrelated keys
|
|
31
|
+
"""
|
|
32
|
+
from ...tickets.frontmatter import split_markdown_frontmatter
|
|
33
|
+
|
|
34
|
+
fm_yaml, body = split_markdown_frontmatter(content)
|
|
35
|
+
|
|
36
|
+
data = {}
|
|
37
|
+
if fm_yaml is not None:
|
|
38
|
+
try:
|
|
39
|
+
parsed = yaml.safe_load(fm_yaml)
|
|
40
|
+
if isinstance(parsed, dict):
|
|
41
|
+
data = parsed
|
|
42
|
+
except yaml.YAMLError:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
data["template"] = f"{fetched.repo_id}:{fetched.path}@{fetched.ref}"
|
|
46
|
+
data["template_commit"] = fetched.commit_sha
|
|
47
|
+
data["template_blob"] = fetched.blob_sha
|
|
48
|
+
data["template_trusted"] = fetched.trusted
|
|
49
|
+
|
|
50
|
+
if scan_record is not None:
|
|
51
|
+
data["template_scan"] = scan_record.decision
|
|
52
|
+
else:
|
|
53
|
+
data["template_scan"] = "skipped"
|
|
54
|
+
|
|
55
|
+
rendered = yaml.safe_dump(data, sort_keys=False).rstrip()
|
|
56
|
+
return f"---\n{rendered}\n---\n{body}"
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import json
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Iterator, Optional
|
|
8
|
+
|
|
9
|
+
from ..locks import FileLock
|
|
10
|
+
from ..state_roots import resolve_hub_templates_root
|
|
11
|
+
from ..utils import atomic_write
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclasses.dataclass(frozen=True)
|
|
15
|
+
class TemplateScanRecord:
|
|
16
|
+
blob_sha: str
|
|
17
|
+
repo_id: str
|
|
18
|
+
path: str
|
|
19
|
+
ref: str
|
|
20
|
+
commit_sha: str
|
|
21
|
+
trusted: bool
|
|
22
|
+
decision: str
|
|
23
|
+
severity: str
|
|
24
|
+
reason: str
|
|
25
|
+
evidence: Optional[list[str]]
|
|
26
|
+
scanned_at: str
|
|
27
|
+
scanner: Optional[dict[str, str]]
|
|
28
|
+
|
|
29
|
+
def to_dict(self, *, include_evidence: bool = True) -> dict[str, Any]:
|
|
30
|
+
payload: dict[str, Any] = {
|
|
31
|
+
"blob_sha": self.blob_sha,
|
|
32
|
+
"repo_id": self.repo_id,
|
|
33
|
+
"path": self.path,
|
|
34
|
+
"ref": self.ref,
|
|
35
|
+
"commit_sha": self.commit_sha,
|
|
36
|
+
"trusted": self.trusted,
|
|
37
|
+
"decision": self.decision,
|
|
38
|
+
"severity": self.severity,
|
|
39
|
+
"reason": self.reason,
|
|
40
|
+
"scanned_at": self.scanned_at,
|
|
41
|
+
}
|
|
42
|
+
if include_evidence and self.evidence:
|
|
43
|
+
payload["evidence"] = list(self.evidence)
|
|
44
|
+
if self.scanner:
|
|
45
|
+
payload["scanner"] = dict(self.scanner)
|
|
46
|
+
return payload
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def from_dict(payload: dict[str, Any]) -> "TemplateScanRecord":
|
|
50
|
+
return TemplateScanRecord(
|
|
51
|
+
blob_sha=str(payload.get("blob_sha", "")),
|
|
52
|
+
repo_id=str(payload.get("repo_id", "")),
|
|
53
|
+
path=str(payload.get("path", "")),
|
|
54
|
+
ref=str(payload.get("ref", "")),
|
|
55
|
+
commit_sha=str(payload.get("commit_sha", "")),
|
|
56
|
+
trusted=bool(payload.get("trusted", False)),
|
|
57
|
+
decision=str(payload.get("decision", "")),
|
|
58
|
+
severity=str(payload.get("severity", "")),
|
|
59
|
+
reason=str(payload.get("reason", "")),
|
|
60
|
+
evidence=_coerce_evidence(payload.get("evidence")),
|
|
61
|
+
scanned_at=str(payload.get("scanned_at", "")),
|
|
62
|
+
scanner=_coerce_scanner(payload.get("scanner")),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _coerce_evidence(value: Any) -> Optional[list[str]]:
|
|
67
|
+
if not value:
|
|
68
|
+
return None
|
|
69
|
+
if isinstance(value, list):
|
|
70
|
+
return [str(item) for item in value]
|
|
71
|
+
return [str(value)]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _coerce_scanner(value: Any) -> Optional[dict[str, str]]:
|
|
75
|
+
if not value or not isinstance(value, dict):
|
|
76
|
+
return None
|
|
77
|
+
return {str(key): str(val) for key, val in value.items()}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _scan_root(hub_root: Path) -> Path:
|
|
81
|
+
return resolve_hub_templates_root(hub_root) / "scans"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def scan_record_path(hub_root: Path, blob_sha: str) -> Path:
|
|
85
|
+
return _scan_root(hub_root) / f"{blob_sha}.json"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def scan_lock_path(hub_root: Path, blob_sha: str) -> Path:
|
|
89
|
+
return _scan_root(hub_root) / "locks" / f"{blob_sha}.lock"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_scan_record(hub_root: Path, blob_sha: str) -> Optional[TemplateScanRecord]:
|
|
93
|
+
path = scan_record_path(hub_root, blob_sha)
|
|
94
|
+
if not path.exists():
|
|
95
|
+
return None
|
|
96
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
97
|
+
payload = json.load(handle)
|
|
98
|
+
if not isinstance(payload, dict):
|
|
99
|
+
return None
|
|
100
|
+
return TemplateScanRecord.from_dict(payload)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def write_scan_record(record: TemplateScanRecord, hub_root: Path) -> None:
|
|
104
|
+
path = scan_record_path(hub_root, record.blob_sha)
|
|
105
|
+
payload = record.to_dict(include_evidence=False)
|
|
106
|
+
if record.evidence:
|
|
107
|
+
payload["evidence_redacted"] = True
|
|
108
|
+
atomic_write(path, json.dumps(payload, indent=2) + "\n")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@contextmanager
|
|
112
|
+
def scan_lock(hub_root: Path, blob_sha: str) -> Iterator[None]:
|
|
113
|
+
path = scan_lock_path(hub_root, blob_sha)
|
|
114
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
lock = FileLock(path)
|
|
116
|
+
lock.acquire()
|
|
117
|
+
try:
|
|
118
|
+
yield
|
|
119
|
+
finally:
|
|
120
|
+
lock.release()
|
|
@@ -41,3 +41,57 @@ class TextDeltaCoalescer:
|
|
|
41
41
|
|
|
42
42
|
def clear(self) -> None:
|
|
43
43
|
self._buffer = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StreamingTextCoalescer:
|
|
47
|
+
"""
|
|
48
|
+
Coalesce small streaming text deltas into larger, readable chunks.
|
|
49
|
+
|
|
50
|
+
- Flushes whole lines as soon as a newline is observed.
|
|
51
|
+
- Flushes buffered text when it grows past `min_flush_chars` and ends at a
|
|
52
|
+
natural boundary (whitespace or sentence punctuation).
|
|
53
|
+
- Enforces an upper bound on the buffer size to avoid unbounded growth.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, min_flush_chars: int = 32, max_buffer_chars: int = 2048):
|
|
57
|
+
self._buffer: str = ""
|
|
58
|
+
self._min_flush_chars = max(1, int(min_flush_chars))
|
|
59
|
+
self._max_buffer_chars = max(self._min_flush_chars, int(max_buffer_chars))
|
|
60
|
+
|
|
61
|
+
def add(self, delta: Optional[str]) -> list[str]:
|
|
62
|
+
chunks: list[str] = []
|
|
63
|
+
if not isinstance(delta, str) or not delta:
|
|
64
|
+
return chunks
|
|
65
|
+
|
|
66
|
+
self._buffer += delta
|
|
67
|
+
self._flush_complete_lines(chunks)
|
|
68
|
+
self._flush_if_boundary(chunks)
|
|
69
|
+
self._flush_if_oversized(chunks)
|
|
70
|
+
return chunks
|
|
71
|
+
|
|
72
|
+
def flush(self) -> list[str]:
|
|
73
|
+
if not self._buffer:
|
|
74
|
+
return []
|
|
75
|
+
chunk = self._buffer
|
|
76
|
+
self._buffer = ""
|
|
77
|
+
return [chunk]
|
|
78
|
+
|
|
79
|
+
def _flush_complete_lines(self, chunks: list[str]) -> None:
|
|
80
|
+
while "\n" in self._buffer:
|
|
81
|
+
line, remainder = self._buffer.split("\n", 1)
|
|
82
|
+
# Preserve the newline boundary so the caller keeps the same text.
|
|
83
|
+
chunks.append(f"{line}\n")
|
|
84
|
+
self._buffer = remainder
|
|
85
|
+
|
|
86
|
+
def _flush_if_boundary(self, chunks: list[str]) -> None:
|
|
87
|
+
if len(self._buffer) < self._min_flush_chars:
|
|
88
|
+
return
|
|
89
|
+
last_char = self._buffer[-1]
|
|
90
|
+
if last_char.isspace() or last_char in ".!?;:":
|
|
91
|
+
chunks.append(self._buffer)
|
|
92
|
+
self._buffer = ""
|
|
93
|
+
|
|
94
|
+
def _flush_if_oversized(self, chunks: list[str]) -> None:
|
|
95
|
+
while len(self._buffer) > self._max_buffer_chars:
|
|
96
|
+
chunks.append(self._buffer[: self._max_buffer_chars])
|
|
97
|
+
self._buffer = self._buffer[self._max_buffer_chars :]
|