@researai/deepscientist 1.5.14 → 1.5.16
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.
- package/README.md +336 -90
- package/assets/branding/logo-raster.png +0 -0
- package/bin/ds.js +816 -131
- package/docs/en/00_QUICK_START.md +36 -15
- package/docs/en/01_SETTINGS_REFERENCE.md +53 -4
- package/docs/en/02_START_RESEARCH_GUIDE.md +7 -0
- package/docs/en/03_QQ_CONNECTOR_GUIDE.md +19 -0
- package/docs/en/05_TUI_GUIDE.md +6 -0
- package/docs/en/06_RUNTIME_AND_CANVAS.md +4 -3
- package/docs/en/09_DOCTOR.md +11 -5
- package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
- package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +65 -13
- package/docs/en/15_CODEX_PROVIDER_SETUP.md +25 -8
- package/docs/en/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
- package/docs/en/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
- package/docs/en/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
- package/docs/en/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
- package/docs/en/19_LOCAL_BROWSER_AUTH.md +70 -0
- package/docs/en/20_WORKSPACE_MODES_GUIDE.md +250 -0
- package/docs/en/README.md +24 -0
- package/docs/zh/00_QUICK_START.md +36 -15
- package/docs/zh/01_SETTINGS_REFERENCE.md +53 -4
- package/docs/zh/02_START_RESEARCH_GUIDE.md +7 -0
- package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +19 -0
- package/docs/zh/05_TUI_GUIDE.md +6 -0
- package/docs/zh/09_DOCTOR.md +11 -5
- package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
- package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +65 -13
- package/docs/zh/15_CODEX_PROVIDER_SETUP.md +25 -8
- package/docs/zh/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
- package/docs/zh/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
- package/docs/zh/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
- package/docs/zh/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
- package/docs/zh/19_LOCAL_BROWSER_AUTH.md +68 -0
- package/docs/zh/20_WORKSPACE_MODES_GUIDE.md +251 -0
- package/docs/zh/README.md +24 -0
- package/install.sh +2 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/deepscientist/__init__.py +1 -1
- package/src/deepscientist/acp/envelope.py +6 -0
- package/src/deepscientist/artifact/charts.py +567 -0
- package/src/deepscientist/artifact/guidance.py +50 -10
- package/src/deepscientist/artifact/metrics.py +228 -5
- package/src/deepscientist/artifact/schemas.py +3 -0
- package/src/deepscientist/artifact/service.py +4276 -308
- package/src/deepscientist/bash_exec/models.py +23 -0
- package/src/deepscientist/bash_exec/monitor.py +147 -67
- package/src/deepscientist/bash_exec/runtime.py +218 -156
- package/src/deepscientist/bash_exec/service.py +309 -69
- package/src/deepscientist/bash_exec/shells.py +87 -0
- package/src/deepscientist/bridges/connectors.py +51 -2
- package/src/deepscientist/cli.py +115 -19
- package/src/deepscientist/codex_cli_compat.py +232 -0
- package/src/deepscientist/config/models.py +8 -4
- package/src/deepscientist/config/service.py +38 -11
- package/src/deepscientist/connector/weixin_support.py +122 -1
- package/src/deepscientist/daemon/api/handlers.py +199 -9
- package/src/deepscientist/daemon/api/router.py +5 -0
- package/src/deepscientist/daemon/app.py +1458 -289
- package/src/deepscientist/doctor.py +51 -0
- package/src/deepscientist/file_lock.py +48 -0
- package/src/deepscientist/gitops/__init__.py +10 -1
- package/src/deepscientist/gitops/diff.py +296 -1
- package/src/deepscientist/gitops/service.py +4 -1
- package/src/deepscientist/mcp/server.py +212 -5
- package/src/deepscientist/process_control.py +161 -0
- package/src/deepscientist/prompts/builder.py +501 -453
- package/src/deepscientist/quest/layout.py +15 -2
- package/src/deepscientist/quest/service.py +2539 -195
- package/src/deepscientist/quest/stage_views.py +177 -1
- package/src/deepscientist/runners/base.py +2 -0
- package/src/deepscientist/runners/codex.py +169 -31
- package/src/deepscientist/runners/runtime_overrides.py +17 -1
- package/src/deepscientist/skills/__init__.py +2 -2
- package/src/deepscientist/skills/installer.py +196 -5
- package/src/deepscientist/skills/registry.py +66 -0
- package/src/prompts/connectors/qq.md +18 -8
- package/src/prompts/connectors/weixin.md +16 -6
- package/src/prompts/contracts/shared_interaction.md +24 -4
- package/src/prompts/system.md +921 -72
- package/src/prompts/system_copilot.md +43 -0
- package/src/skills/analysis-campaign/SKILL.md +32 -2
- package/src/skills/analysis-campaign/references/artifact-orchestration.md +1 -1
- package/src/skills/analysis-campaign/references/writing-facing-slice-examples.md +65 -0
- package/src/skills/baseline/SKILL.md +10 -0
- package/src/skills/decision/SKILL.md +27 -2
- package/src/skills/experiment/SKILL.md +16 -2
- package/src/skills/figure-polish/SKILL.md +1 -0
- package/src/skills/finalize/SKILL.md +19 -0
- package/src/skills/idea/SKILL.md +79 -0
- package/src/skills/idea/references/idea-generation-playbook.md +100 -0
- package/src/skills/idea/references/outline-seeding-example.md +60 -0
- package/src/skills/intake-audit/SKILL.md +9 -1
- package/src/skills/mentor/SKILL.md +217 -0
- package/src/skills/mentor/references/correction-rules.md +210 -0
- package/src/skills/mentor/references/knowledge-profile.md +91 -0
- package/src/skills/mentor/references/persona-profile.md +138 -0
- package/src/skills/mentor/references/taste-profile.md +128 -0
- package/src/skills/mentor/references/thought-style-profile.md +138 -0
- package/src/skills/mentor/references/work-profile.md +289 -0
- package/src/skills/mentor/references/workflow-profile.md +240 -0
- package/src/skills/optimize/SKILL.md +1645 -0
- package/src/skills/rebuttal/SKILL.md +3 -1
- package/src/skills/review/SKILL.md +3 -1
- package/src/skills/scout/SKILL.md +8 -0
- package/src/skills/write/SKILL.md +81 -12
- package/src/skills/write/references/outline-evidence-contract-example.md +107 -0
- package/src/tui/dist/app/AppContainer.js +22 -11
- package/src/tui/dist/index.js +4 -1
- package/src/tui/dist/lib/api.js +33 -3
- package/src/tui/package.json +1 -1
- package/src/ui/dist/assets/AiManusChatView-COFACy7V.js +204 -0
- package/src/ui/dist/assets/AnalysisPlugin-DnSm0GZn.js +1 -0
- package/src/ui/dist/assets/CliPlugin-CvwCmDQ5.js +109 -0
- package/src/ui/dist/assets/CodeEditorPlugin-cOqSa0xq.js +2 -0
- package/src/ui/dist/assets/CodeViewerPlugin-itb0tltR.js +270 -0
- package/src/ui/dist/assets/DocViewerPlugin-DqKkiCI6.js +7 -0
- package/src/ui/dist/assets/GitCommitViewerPlugin-DVgNHBCS.js +1 -0
- package/src/ui/dist/assets/GitDiffViewerPlugin-DxL2ezFG.js +6 -0
- package/src/ui/dist/assets/GitSnapshotViewer-B_RQm1YZ.js +30 -0
- package/src/ui/dist/assets/ImageViewerPlugin-tHqlXY3n.js +26 -0
- package/src/ui/dist/assets/LabCopilotPanel-ClMbq5Yu.js +14 -0
- package/src/ui/dist/assets/LabPlugin-L_SuE8ow.js +22 -0
- package/src/ui/dist/assets/LatexPlugin-B495DTXC.js +25 -0
- package/src/ui/dist/assets/MarkdownViewerPlugin-DG28-61B.js +128 -0
- package/src/ui/dist/assets/MarketplacePlugin-BiOGT-Kj.js +13 -0
- package/src/ui/dist/assets/{NotebookEditor-CccQYZjX.css → NotebookEditor-BHH8rdGj.css} +1 -1
- package/src/ui/dist/assets/NotebookEditor-BOr3x3Ej.css +1 -0
- package/src/ui/dist/assets/NotebookEditor-C-4Kt1p9.js +81 -0
- package/src/ui/dist/assets/NotebookEditor-CVsj8h_T.js +361 -0
- package/src/ui/dist/assets/PdfLoader-CASDQmxJ.js +16 -0
- package/src/ui/dist/assets/PdfLoader-Cy5jtWrr.css +1 -0
- package/src/ui/dist/assets/PdfMarkdownPlugin-BFhwoKsY.js +1 -0
- package/src/ui/dist/assets/PdfViewerPlugin-DcOzU9vd.js +17 -0
- package/src/ui/dist/assets/PdfViewerPlugin-nwwE-fjJ.css +1 -0
- package/src/ui/dist/assets/SearchPlugin-CHj7M58O.js +16 -0
- package/src/ui/dist/assets/SearchPlugin-DA4en4hK.css +1 -0
- package/src/ui/dist/assets/TextViewerPlugin-CB4DYfWO.js +54 -0
- package/src/ui/dist/assets/VNCViewer-CjlbyCB3.js +11 -0
- package/src/ui/dist/assets/bot-CFkZY-JP.js +6 -0
- package/src/ui/dist/assets/browser-CTB2jwNe.js +8 -0
- package/src/ui/dist/assets/chevron-up-Dq5ofbht.js +6 -0
- package/src/ui/dist/assets/code-DLC6G24T.js +6 -0
- package/src/ui/dist/assets/file-content-Dv4LoZec.js +1 -0
- package/src/ui/dist/assets/file-diff-panel-Denq-lC3.js +1 -0
- package/src/ui/dist/assets/file-jump-queue-DA-SdG__.js +1 -0
- package/src/ui/dist/assets/file-socket-Cu4Qln7Y.js +1 -0
- package/src/ui/dist/assets/git-commit-horizontal-BUh6G52n.js +6 -0
- package/src/ui/dist/assets/image-B9HUUddG.js +6 -0
- package/src/ui/dist/assets/index-B2B1sg-M.js +1 -0
- package/src/ui/dist/assets/index-Cgla8biy.css +33 -0
- package/src/ui/dist/assets/index-DRyx7vAc.js +1 -0
- package/src/ui/dist/assets/index-Gbl53BNp.js +2496 -0
- package/src/ui/dist/assets/index-wQ7RIIRd.js +11 -0
- package/src/ui/dist/assets/monaco-CiHMMNH_.js +1 -0
- package/src/ui/dist/assets/pdf-effect-queue-ZtnHFCAi.js +6 -0
- package/src/ui/dist/assets/plugin-monaco-C8UgLomw.js +19 -0
- package/src/ui/dist/assets/plugin-notebook-HbW2K-1c.js +169 -0
- package/src/ui/dist/assets/plugin-pdf-CR8hgQBV.js +357 -0
- package/src/ui/dist/assets/plugin-terminal-MXFIPun8.js +227 -0
- package/src/ui/dist/assets/popover-DL6h35vr.js +1 -0
- package/src/ui/dist/assets/project-sync-CsX08Qno.js +1 -0
- package/src/ui/dist/assets/select-DvmXt1yY.js +11 -0
- package/src/ui/dist/assets/sigma-7jpXazui.js +6 -0
- package/src/ui/dist/assets/trash-xA7kFt8i.js +11 -0
- package/src/ui/dist/assets/useCliAccess-DsMwDjOp.js +1 -0
- package/src/ui/dist/assets/useFileDiffOverlay-FuhcnKiw.js +1 -0
- package/src/ui/dist/assets/wrap-text-CwMn-iqb.js +11 -0
- package/src/ui/dist/assets/zoom-out-R-GWEhzS.js +11 -0
- package/src/ui/dist/index.html +5 -2
- package/src/ui/dist/assets/AiManusChatView-DaF9Nge_.js +0 -26597
- package/src/ui/dist/assets/AnalysisPlugin-BSVx6dXE.js +0 -123
- package/src/ui/dist/assets/CliPlugin-C9gzJX41.js +0 -5905
- package/src/ui/dist/assets/CodeEditorPlugin-DU9G0Tox.js +0 -427
- package/src/ui/dist/assets/CodeViewerPlugin-DoX_fI9l.js +0 -905
- package/src/ui/dist/assets/DocViewerPlugin-C4FWIXuU.js +0 -278
- package/src/ui/dist/assets/GitDiffViewerPlugin-BgfFMgtf.js +0 -2661
- package/src/ui/dist/assets/ImageViewerPlugin-tcPkfY_x.js +0 -500
- package/src/ui/dist/assets/LabCopilotPanel-_dKV60Bf.js +0 -4104
- package/src/ui/dist/assets/LabPlugin-Bje0ayoC.js +0 -2677
- package/src/ui/dist/assets/LatexPlugin-CVsBzAln.js +0 -1792
- package/src/ui/dist/assets/MarkdownViewerPlugin-xjmrqv_8.js +0 -308
- package/src/ui/dist/assets/MarketplacePlugin-mMM2A8wP.js +0 -413
- package/src/ui/dist/assets/NotebookEditor-3kVDSOBo.js +0 -4214
- package/src/ui/dist/assets/NotebookEditor-C3VQ7ylN.css +0 -1405
- package/src/ui/dist/assets/NotebookEditor-SoJ8X-MO.js +0 -84873
- package/src/ui/dist/assets/PdfLoader-C-Y707R3.css +0 -49
- package/src/ui/dist/assets/PdfLoader-DElVuHl9.js +0 -25468
- package/src/ui/dist/assets/PdfMarkdownPlugin-Bq88XT4G.js +0 -409
- package/src/ui/dist/assets/PdfViewerPlugin-CsCXMo9S.js +0 -3095
- package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +0 -3627
- package/src/ui/dist/assets/SearchPlugin-DDMrGDkh.css +0 -379
- package/src/ui/dist/assets/SearchPlugin-oUPvy19k.js +0 -741
- package/src/ui/dist/assets/TextViewerPlugin-CRkT9yNy.js +0 -472
- package/src/ui/dist/assets/VNCViewer-BgbuvWhR.js +0 -18821
- package/src/ui/dist/assets/awareness-C0NPR2Dj.js +0 -292
- package/src/ui/dist/assets/bot-v_RASACv.js +0 -21
- package/src/ui/dist/assets/browser-BAcuE0Xj.js +0 -2895
- package/src/ui/dist/assets/code-5hC9d0VH.js +0 -17
- package/src/ui/dist/assets/file-content-D1PxfOrp.js +0 -377
- package/src/ui/dist/assets/file-diff-panel-DG1oT_Hj.js +0 -92
- package/src/ui/dist/assets/file-jump-queue-r5XKgJEV.js +0 -16
- package/src/ui/dist/assets/file-socket-BmdFYQlk.js +0 -58
- package/src/ui/dist/assets/function-B5QZkkHC.js +0 -1895
- package/src/ui/dist/assets/image-Dqe2X2tW.js +0 -18
- package/src/ui/dist/assets/index-BQG-1s2o.css +0 -12553
- package/src/ui/dist/assets/index-DVsMKK_y.js +0 -25
- package/src/ui/dist/assets/index-Duvz8Ip0.js +0 -159
- package/src/ui/dist/assets/index-Nt9hS4ck.js +0 -244829
- package/src/ui/dist/assets/index-RDlNXXx1.js +0 -120
- package/src/ui/dist/assets/monaco-DIXge1CP.js +0 -623
- package/src/ui/dist/assets/pdf-effect-queue-BBTTQaO-.js +0 -47
- package/src/ui/dist/assets/pdf_viewer-e0g1is2C.js +0 -8206
- package/src/ui/dist/assets/popover-BWlolyxo.js +0 -476
- package/src/ui/dist/assets/project-sync-BM5PkFH4.js +0 -297
- package/src/ui/dist/assets/select-D4dAtrA8.js +0 -1690
- package/src/ui/dist/assets/sigma-CKbE5jJT.js +0 -22
- package/src/ui/dist/assets/square-check-big-CZNGMgiB.js +0 -17
- package/src/ui/dist/assets/trash-DaB37xAz.js +0 -32
- package/src/ui/dist/assets/useCliAccess-C2OmAcWe.js +0 -957
- package/src/ui/dist/assets/useFileDiffOverlay-Dowd1Ij4.js +0 -53
- package/src/ui/dist/assets/wrap-text-BGjAhAUq.js +0 -35
- package/src/ui/dist/assets/yjs-DncrqiZ8.js +0 -11243
- package/src/ui/dist/assets/zoom-out-dMZQMXzc.js +0 -34
|
@@ -10,19 +10,21 @@ import json
|
|
|
10
10
|
import mimetypes
|
|
11
11
|
import re
|
|
12
12
|
import threading
|
|
13
|
+
import time
|
|
13
14
|
from pathlib import Path, PurePosixPath
|
|
14
15
|
from typing import Any
|
|
15
16
|
from urllib.parse import quote
|
|
16
17
|
|
|
17
18
|
try:
|
|
18
|
-
import fcntl
|
|
19
|
-
except ImportError: # pragma: no cover
|
|
19
|
+
import fcntl # pragma: no cover - exercised on POSIX
|
|
20
|
+
except ImportError: # pragma: no cover - exercised on Windows
|
|
20
21
|
fcntl = None
|
|
21
22
|
|
|
22
|
-
from ..artifact.metrics import build_metrics_timeline, extract_latest_metric
|
|
23
|
+
from ..artifact.metrics import build_baseline_compare_payload, build_metrics_timeline, extract_latest_metric
|
|
23
24
|
from ..config import ConfigManager
|
|
24
25
|
from ..connector_runtime import conversation_identity_key, normalize_conversation_id, parse_conversation_id
|
|
25
|
-
from ..
|
|
26
|
+
from ..file_lock import advisory_file_lock
|
|
27
|
+
from ..gitops import current_branch, export_git_graph, head_commit, init_repo, list_branch_canvas, list_commit_canvas
|
|
26
28
|
from ..home import repo_root
|
|
27
29
|
from ..registries import BaselineRegistry
|
|
28
30
|
from ..shared import append_jsonl, ensure_dir, generate_id, iter_jsonl, read_json, read_jsonl, read_jsonl_tail, read_text, read_yaml, resolve_within, run_command, sha256_text, slugify, utc_now, write_json, write_text, write_yaml
|
|
@@ -50,9 +52,13 @@ _CODEX_HISTORY_TAIL_LIMIT = 400
|
|
|
50
52
|
_JSONL_STREAM_CHUNK_BYTES = 64 * 1024
|
|
51
53
|
_EVENTS_OVERSIZED_LINE_BYTES = 8 * 1024 * 1024
|
|
52
54
|
_OVERSIZED_EVENT_PREFIX_BYTES = 4096
|
|
55
|
+
_PROJECTION_SCHEMA_VERSION = 1
|
|
56
|
+
_PROJECTION_BUILD_TOTAL_STEPS = 3
|
|
57
|
+
_PROJECTION_REFRESH_THROTTLE_SECONDS = 1.0
|
|
53
58
|
_EVENT_TYPE_BYTES_RE = re.compile(rb'"(?:type|event_type)"\s*:\s*"([^"]+)"')
|
|
54
59
|
_EVENT_TOOL_NAME_BYTES_RE = re.compile(rb'"tool_name"\s*:\s*"([^"]+)"')
|
|
55
60
|
_EVENT_RUN_ID_BYTES_RE = re.compile(rb'"run_id"\s*:\s*"([^"]+)"')
|
|
61
|
+
CONTINUATION_POLICIES = {"auto", "when_external_progress", "wait_for_user_or_resume", "none"}
|
|
56
62
|
|
|
57
63
|
|
|
58
64
|
def _oversized_event_placeholder(*, prefix: bytes, line_bytes: int) -> dict[str, Any]:
|
|
@@ -166,6 +172,127 @@ def _iter_jsonl_records_safely(
|
|
|
166
172
|
yield payload
|
|
167
173
|
|
|
168
174
|
|
|
175
|
+
def _parse_jsonl_record_line_safely(
|
|
176
|
+
raw_line: bytes,
|
|
177
|
+
*,
|
|
178
|
+
oversized_line_bytes: int = _EVENTS_OVERSIZED_LINE_BYTES,
|
|
179
|
+
) -> dict[str, Any] | None:
|
|
180
|
+
raw = bytes(raw_line).strip()
|
|
181
|
+
if not raw:
|
|
182
|
+
return None
|
|
183
|
+
line_bytes = len(raw)
|
|
184
|
+
if line_bytes > oversized_line_bytes:
|
|
185
|
+
return _oversized_event_placeholder(
|
|
186
|
+
prefix=raw[:_OVERSIZED_EVENT_PREFIX_BYTES],
|
|
187
|
+
line_bytes=line_bytes,
|
|
188
|
+
)
|
|
189
|
+
try:
|
|
190
|
+
payload = json.loads(raw)
|
|
191
|
+
except json.JSONDecodeError:
|
|
192
|
+
return None
|
|
193
|
+
return payload if isinstance(payload, dict) else None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _tail_jsonl_records_safely(
|
|
197
|
+
path: Path,
|
|
198
|
+
*,
|
|
199
|
+
limit: int,
|
|
200
|
+
oversized_line_bytes: int = _EVENTS_OVERSIZED_LINE_BYTES,
|
|
201
|
+
) -> tuple[list[tuple[int, dict[str, Any]]], int]:
|
|
202
|
+
normalized_limit = max(int(limit or 0), 0)
|
|
203
|
+
if normalized_limit <= 0 or not path.exists():
|
|
204
|
+
return [], 0
|
|
205
|
+
total = _count_jsonl_lines_fast(path)
|
|
206
|
+
if total <= 0:
|
|
207
|
+
return [], 0
|
|
208
|
+
|
|
209
|
+
raw_tail = _read_jsonl_tail_lines_fast(path, normalized_limit)
|
|
210
|
+
if not raw_tail:
|
|
211
|
+
return [], total
|
|
212
|
+
|
|
213
|
+
cursor_start = max(total - len(raw_tail) + 1, 1)
|
|
214
|
+
parsed: list[tuple[int, dict[str, Any]]] = []
|
|
215
|
+
for cursor, raw_line in enumerate(raw_tail, start=cursor_start):
|
|
216
|
+
payload = _parse_jsonl_record_line_safely(
|
|
217
|
+
raw_line,
|
|
218
|
+
oversized_line_bytes=oversized_line_bytes,
|
|
219
|
+
)
|
|
220
|
+
if isinstance(payload, dict):
|
|
221
|
+
parsed.append((cursor, payload))
|
|
222
|
+
return parsed, total
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _count_jsonl_lines_fast(path: Path, *, chunk_size: int = 1024 * 1024) -> int:
|
|
226
|
+
if not path.exists():
|
|
227
|
+
return 0
|
|
228
|
+
total = 0
|
|
229
|
+
last_byte = b""
|
|
230
|
+
with path.open("rb") as handle:
|
|
231
|
+
while True:
|
|
232
|
+
chunk = handle.read(chunk_size)
|
|
233
|
+
if not chunk:
|
|
234
|
+
break
|
|
235
|
+
total += chunk.count(b"\n")
|
|
236
|
+
last_byte = chunk[-1:]
|
|
237
|
+
if total == 0 and last_byte:
|
|
238
|
+
return 1
|
|
239
|
+
if last_byte not in {b"", b"\n"}:
|
|
240
|
+
total += 1
|
|
241
|
+
return total
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _read_jsonl_tail_lines_fast(path: Path, limit: int, *, chunk_size: int = 1024 * 1024) -> list[bytes]:
|
|
245
|
+
normalized_limit = max(int(limit or 0), 0)
|
|
246
|
+
if normalized_limit <= 0 or not path.exists():
|
|
247
|
+
return []
|
|
248
|
+
|
|
249
|
+
size = path.stat().st_size
|
|
250
|
+
if size <= 0:
|
|
251
|
+
return []
|
|
252
|
+
|
|
253
|
+
lines: deque[bytes] = deque()
|
|
254
|
+
remainder = b""
|
|
255
|
+
with path.open("rb") as handle:
|
|
256
|
+
position = size
|
|
257
|
+
while position > 0 and len(lines) < normalized_limit:
|
|
258
|
+
read_size = min(chunk_size, position)
|
|
259
|
+
position -= read_size
|
|
260
|
+
handle.seek(position)
|
|
261
|
+
chunk = handle.read(read_size)
|
|
262
|
+
payload = chunk + remainder
|
|
263
|
+
parts = payload.split(b"\n")
|
|
264
|
+
remainder = parts[0]
|
|
265
|
+
for raw_line in reversed(parts[1:]):
|
|
266
|
+
stripped = raw_line.rstrip(b"\r")
|
|
267
|
+
if not stripped.strip():
|
|
268
|
+
continue
|
|
269
|
+
lines.appendleft(stripped)
|
|
270
|
+
if len(lines) >= normalized_limit:
|
|
271
|
+
break
|
|
272
|
+
if len(lines) < normalized_limit and remainder.strip():
|
|
273
|
+
lines.appendleft(remainder.rstrip(b"\r"))
|
|
274
|
+
return list(lines)[-normalized_limit:]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _iter_jsonl_records_from_offset_safely(
|
|
278
|
+
path: Path,
|
|
279
|
+
*,
|
|
280
|
+
start_offset: int,
|
|
281
|
+
oversized_line_bytes: int = _EVENTS_OVERSIZED_LINE_BYTES,
|
|
282
|
+
):
|
|
283
|
+
if not path.exists():
|
|
284
|
+
return
|
|
285
|
+
with path.open("rb") as handle:
|
|
286
|
+
handle.seek(max(int(start_offset or 0), 0))
|
|
287
|
+
for raw_line in handle:
|
|
288
|
+
payload = _parse_jsonl_record_line_safely(
|
|
289
|
+
raw_line,
|
|
290
|
+
oversized_line_bytes=oversized_line_bytes,
|
|
291
|
+
)
|
|
292
|
+
if isinstance(payload, dict):
|
|
293
|
+
yield payload
|
|
294
|
+
|
|
295
|
+
|
|
169
296
|
class QuestService:
|
|
170
297
|
def __init__(self, home: Path, skill_installer: SkillInstaller | None = None) -> None:
|
|
171
298
|
self.home = home
|
|
@@ -176,12 +303,21 @@ class QuestService:
|
|
|
176
303
|
self._file_cache: dict[str, dict[str, Any]] = {}
|
|
177
304
|
self._jsonl_cache_lock = threading.Lock()
|
|
178
305
|
self._jsonl_cache: dict[str, dict[str, Any]] = {}
|
|
306
|
+
self._jsonl_tail_cache: dict[str, dict[str, Any]] = {}
|
|
179
307
|
self._snapshot_cache_lock = threading.Lock()
|
|
180
308
|
self._snapshot_cache: dict[str, dict[str, Any]] = {}
|
|
181
309
|
self._codex_history_cache_lock = threading.Lock()
|
|
182
310
|
self._codex_history_cache: dict[str, dict[str, Any]] = {}
|
|
183
311
|
self._runtime_state_locks_lock = threading.Lock()
|
|
184
312
|
self._runtime_state_locks: dict[str, threading.Lock] = {}
|
|
313
|
+
self._artifact_projection_locks_lock = threading.Lock()
|
|
314
|
+
self._artifact_projection_locks: dict[str, threading.Lock] = {}
|
|
315
|
+
self._quest_projection_locks_lock = threading.Lock()
|
|
316
|
+
self._quest_projection_locks: dict[str, threading.Lock] = {}
|
|
317
|
+
self._quest_projection_builds_lock = threading.Lock()
|
|
318
|
+
self._quest_projection_builds: dict[str, threading.Thread] = {}
|
|
319
|
+
self._quest_projection_refresh_lock = threading.Lock()
|
|
320
|
+
self._quest_projection_refresh_at: dict[str, float] = {}
|
|
185
321
|
|
|
186
322
|
def _quest_root(self, quest_id: str) -> Path:
|
|
187
323
|
return self.quests_root / quest_id
|
|
@@ -274,6 +410,13 @@ class QuestService:
|
|
|
274
410
|
return quest_root / ".ds" / "lab_canvas_state.json"
|
|
275
411
|
|
|
276
412
|
def _default_research_state(self, quest_root: Path) -> dict[str, Any]:
|
|
413
|
+
quest_yaml = self.read_quest_yaml(quest_root)
|
|
414
|
+
startup_contract = (
|
|
415
|
+
dict(quest_yaml.get("startup_contract") or {})
|
|
416
|
+
if isinstance(quest_yaml.get("startup_contract"), dict)
|
|
417
|
+
else {}
|
|
418
|
+
)
|
|
419
|
+
workspace_mode = str(startup_contract.get("workspace_mode") or "").strip().lower() or "quest"
|
|
277
420
|
return {
|
|
278
421
|
"version": 1,
|
|
279
422
|
"active_idea_id": None,
|
|
@@ -290,7 +433,7 @@ class QuestService:
|
|
|
290
433
|
"paper_parent_worktree_root": None,
|
|
291
434
|
"paper_parent_run_id": None,
|
|
292
435
|
"next_pending_slice_id": None,
|
|
293
|
-
"workspace_mode":
|
|
436
|
+
"workspace_mode": workspace_mode,
|
|
294
437
|
"last_flow_type": None,
|
|
295
438
|
"updated_at": utc_now(),
|
|
296
439
|
}
|
|
@@ -339,7 +482,9 @@ class QuestService:
|
|
|
339
482
|
if value is _UNSET:
|
|
340
483
|
continue
|
|
341
484
|
current[key] = str(value) if isinstance(value, Path) else value
|
|
342
|
-
|
|
485
|
+
payload = self.write_research_state(quest_root, current)
|
|
486
|
+
self.schedule_projection_refresh(quest_root, kinds=("details", "canvas", "git_canvas"))
|
|
487
|
+
return payload
|
|
343
488
|
|
|
344
489
|
def read_lab_canvas_state(self, quest_root: Path) -> dict[str, Any]:
|
|
345
490
|
self._initialize_runtime_files(quest_root)
|
|
@@ -443,7 +588,288 @@ class QuestService:
|
|
|
443
588
|
str(path),
|
|
444
589
|
)
|
|
445
590
|
|
|
446
|
-
|
|
591
|
+
@staticmethod
|
|
592
|
+
def _artifact_projection_path(quest_root: Path) -> Path:
|
|
593
|
+
return quest_root / ".ds" / "cache" / "artifact_projection.v2.json"
|
|
594
|
+
|
|
595
|
+
@staticmethod
|
|
596
|
+
def _artifact_projection_lock_path(quest_root: Path) -> Path:
|
|
597
|
+
return quest_root / ".ds" / "artifact_projection.lock"
|
|
598
|
+
|
|
599
|
+
@staticmethod
|
|
600
|
+
def _metrics_timeline_cache_path(quest_root: Path) -> Path:
|
|
601
|
+
return quest_root / ".ds" / "cache" / "metrics_timeline.v1.json"
|
|
602
|
+
|
|
603
|
+
@staticmethod
|
|
604
|
+
def _metrics_timeline_cache_lock_path(quest_root: Path) -> Path:
|
|
605
|
+
return quest_root / ".ds" / "cache" / "metrics_timeline.lock"
|
|
606
|
+
|
|
607
|
+
@staticmethod
|
|
608
|
+
def _baseline_compare_cache_path(quest_root: Path) -> Path:
|
|
609
|
+
return quest_root / ".ds" / "cache" / "baseline_compare.v1.json"
|
|
610
|
+
|
|
611
|
+
@staticmethod
|
|
612
|
+
def _baseline_compare_cache_lock_path(quest_root: Path) -> Path:
|
|
613
|
+
return quest_root / ".ds" / "cache" / "baseline_compare.lock"
|
|
614
|
+
|
|
615
|
+
@staticmethod
|
|
616
|
+
def _json_compatible_state(value: Any) -> Any:
|
|
617
|
+
if isinstance(value, tuple):
|
|
618
|
+
return [QuestService._json_compatible_state(item) for item in value]
|
|
619
|
+
if isinstance(value, list):
|
|
620
|
+
return [QuestService._json_compatible_state(item) for item in value]
|
|
621
|
+
if isinstance(value, dict):
|
|
622
|
+
return {
|
|
623
|
+
str(key): QuestService._json_compatible_state(item)
|
|
624
|
+
for key, item in value.items()
|
|
625
|
+
}
|
|
626
|
+
return value
|
|
627
|
+
|
|
628
|
+
@contextmanager
|
|
629
|
+
def _artifact_projection_lock(self, quest_root: Path):
|
|
630
|
+
lock_key = str(quest_root.resolve())
|
|
631
|
+
with self._artifact_projection_locks_lock:
|
|
632
|
+
thread_lock = self._artifact_projection_locks.setdefault(lock_key, threading.Lock())
|
|
633
|
+
with thread_lock:
|
|
634
|
+
with advisory_file_lock(self._artifact_projection_lock_path(quest_root)):
|
|
635
|
+
yield
|
|
636
|
+
|
|
637
|
+
def _artifact_index_collection_state(self, quest_root: Path) -> list[list[Any]]:
|
|
638
|
+
states: list[list[Any]] = []
|
|
639
|
+
for root in self._artifact_roots(quest_root):
|
|
640
|
+
artifacts_root = root / "artifacts"
|
|
641
|
+
if not artifacts_root.exists():
|
|
642
|
+
continue
|
|
643
|
+
try:
|
|
644
|
+
label = str(root.relative_to(quest_root))
|
|
645
|
+
except ValueError:
|
|
646
|
+
label = str(root)
|
|
647
|
+
states.append(
|
|
648
|
+
[
|
|
649
|
+
label,
|
|
650
|
+
self._json_compatible_state(self._path_state(artifacts_root / "_index.jsonl")),
|
|
651
|
+
]
|
|
652
|
+
)
|
|
653
|
+
return states
|
|
654
|
+
|
|
655
|
+
def _metrics_timeline_attachment_state(self, quest_root: Path, workspace_root: Path) -> list[list[Any]]:
|
|
656
|
+
states: list[list[Any]] = []
|
|
657
|
+
seen_paths: set[str] = set()
|
|
658
|
+
for root in (workspace_root, quest_root):
|
|
659
|
+
attachment_root = root / "baselines" / "imported"
|
|
660
|
+
if not attachment_root.exists():
|
|
661
|
+
continue
|
|
662
|
+
for path in sorted(attachment_root.glob("*/attachment.yaml")):
|
|
663
|
+
key = str(path.resolve())
|
|
664
|
+
if key in seen_paths:
|
|
665
|
+
continue
|
|
666
|
+
seen_paths.add(key)
|
|
667
|
+
try:
|
|
668
|
+
label = str(path.relative_to(quest_root))
|
|
669
|
+
except ValueError:
|
|
670
|
+
label = str(path)
|
|
671
|
+
states.append([label, self._json_compatible_state(self._path_state(path))])
|
|
672
|
+
return states
|
|
673
|
+
|
|
674
|
+
def _metrics_timeline_state(self, quest_root: Path, workspace_root: Path) -> list[Any]:
|
|
675
|
+
return [
|
|
676
|
+
str(workspace_root.resolve()),
|
|
677
|
+
self._artifact_index_collection_state(quest_root),
|
|
678
|
+
self._metrics_timeline_attachment_state(quest_root, workspace_root),
|
|
679
|
+
]
|
|
680
|
+
|
|
681
|
+
def _baseline_compare_state(self, quest_root: Path, workspace_root: Path) -> list[Any]:
|
|
682
|
+
return [
|
|
683
|
+
str(workspace_root.resolve()),
|
|
684
|
+
self._artifact_index_collection_state(quest_root),
|
|
685
|
+
self._metrics_timeline_attachment_state(quest_root, workspace_root),
|
|
686
|
+
self._json_compatible_state(self._path_state(self._quest_yaml_path(quest_root))),
|
|
687
|
+
]
|
|
688
|
+
|
|
689
|
+
def _baseline_compare_entries(self, quest_root: Path, workspace_root: Path) -> list[dict[str, Any]]:
|
|
690
|
+
entries: list[dict[str, Any]] = []
|
|
691
|
+
for item in self._collect_artifacts_raw(quest_root):
|
|
692
|
+
if str(item.get("kind") or "").strip() != "baselines":
|
|
693
|
+
continue
|
|
694
|
+
payload = item.get("payload") or {}
|
|
695
|
+
if not isinstance(payload, dict):
|
|
696
|
+
continue
|
|
697
|
+
status = str(payload.get("status") or "").strip().lower()
|
|
698
|
+
if status not in {"confirmed", "published", "quest_confirmed"}:
|
|
699
|
+
continue
|
|
700
|
+
entries.append(dict(payload))
|
|
701
|
+
attachment = self._active_baseline_attachment(quest_root, workspace_root)
|
|
702
|
+
attachment_entry = dict(attachment.get("entry") or {}) if isinstance(attachment, dict) else None
|
|
703
|
+
if attachment_entry:
|
|
704
|
+
entries.append(attachment_entry)
|
|
705
|
+
return entries
|
|
706
|
+
|
|
707
|
+
def _artifact_projection_state(self, quest_root: Path) -> tuple[str, Any]:
|
|
708
|
+
index_state = self._artifact_index_collection_state(quest_root)
|
|
709
|
+
if index_state and all(item[1] is not None for item in index_state):
|
|
710
|
+
return "index", index_state
|
|
711
|
+
if not index_state:
|
|
712
|
+
return "index", []
|
|
713
|
+
return "raw", self._json_compatible_state(self._artifact_collection_state(quest_root))
|
|
714
|
+
|
|
715
|
+
def _projection_artifact_item(
|
|
716
|
+
self,
|
|
717
|
+
*,
|
|
718
|
+
record: dict[str, Any],
|
|
719
|
+
artifact_path: Path,
|
|
720
|
+
workspace_root: Path,
|
|
721
|
+
) -> dict[str, Any]:
|
|
722
|
+
return {
|
|
723
|
+
"kind": artifact_path.parent.name,
|
|
724
|
+
"path": str(artifact_path),
|
|
725
|
+
"payload": copy.deepcopy(record),
|
|
726
|
+
"workspace_root": str(workspace_root),
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
def _write_artifact_projection_locked(
|
|
730
|
+
self,
|
|
731
|
+
quest_root: Path,
|
|
732
|
+
*,
|
|
733
|
+
state_kind: str,
|
|
734
|
+
state: Any,
|
|
735
|
+
artifacts: list[dict[str, Any]],
|
|
736
|
+
) -> list[dict[str, Any]]:
|
|
737
|
+
projection_path = self._artifact_projection_path(quest_root)
|
|
738
|
+
ensure_dir(projection_path.parent)
|
|
739
|
+
payload = {
|
|
740
|
+
"schema_version": 2,
|
|
741
|
+
"generated_at": utc_now(),
|
|
742
|
+
"state_kind": state_kind,
|
|
743
|
+
"state": self._json_compatible_state(state),
|
|
744
|
+
"artifacts": copy.deepcopy(artifacts),
|
|
745
|
+
}
|
|
746
|
+
write_json(projection_path, payload)
|
|
747
|
+
return copy.deepcopy(artifacts)
|
|
748
|
+
|
|
749
|
+
def refresh_artifact_projection(
|
|
750
|
+
self,
|
|
751
|
+
quest_root: Path,
|
|
752
|
+
*,
|
|
753
|
+
state_kind: str | None = None,
|
|
754
|
+
state: Any | None = None,
|
|
755
|
+
) -> list[dict[str, Any]]:
|
|
756
|
+
resolved_state_kind, resolved_state = (
|
|
757
|
+
(state_kind, state)
|
|
758
|
+
if state_kind is not None and state is not None
|
|
759
|
+
else self._artifact_projection_state(quest_root)
|
|
760
|
+
)
|
|
761
|
+
artifacts = self._collect_artifacts_raw(quest_root)
|
|
762
|
+
return self._write_artifact_projection_locked(
|
|
763
|
+
quest_root,
|
|
764
|
+
state_kind=resolved_state_kind,
|
|
765
|
+
state=resolved_state,
|
|
766
|
+
artifacts=artifacts,
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
def update_artifact_projection(
|
|
770
|
+
self,
|
|
771
|
+
quest_root: Path,
|
|
772
|
+
*,
|
|
773
|
+
record: dict[str, Any],
|
|
774
|
+
artifact_path: Path,
|
|
775
|
+
workspace_root: Path,
|
|
776
|
+
previous_state_kind: str | None = None,
|
|
777
|
+
previous_state: Any | None = None,
|
|
778
|
+
current_state_kind: str | None = None,
|
|
779
|
+
current_state: Any | None = None,
|
|
780
|
+
) -> list[dict[str, Any]]:
|
|
781
|
+
resolved_previous_kind = previous_state_kind
|
|
782
|
+
resolved_previous_state = self._json_compatible_state(previous_state) if previous_state is not None else None
|
|
783
|
+
resolved_current_kind, resolved_current_state = (
|
|
784
|
+
(current_state_kind, self._json_compatible_state(current_state))
|
|
785
|
+
if current_state_kind is not None and current_state is not None
|
|
786
|
+
else self._artifact_projection_state(quest_root)
|
|
787
|
+
)
|
|
788
|
+
projection_path = self._artifact_projection_path(quest_root)
|
|
789
|
+
with self._artifact_projection_lock(quest_root):
|
|
790
|
+
payload = read_json(projection_path, {})
|
|
791
|
+
projected_artifacts = payload.get("artifacts") if isinstance(payload.get("artifacts"), list) else None
|
|
792
|
+
can_incrementally_update = (
|
|
793
|
+
isinstance(payload, dict)
|
|
794
|
+
and int(payload.get("schema_version") or 0) == 2
|
|
795
|
+
and isinstance(projected_artifacts, list)
|
|
796
|
+
and resolved_previous_kind is not None
|
|
797
|
+
and payload.get("state_kind") == resolved_previous_kind
|
|
798
|
+
and self._json_compatible_state(payload.get("state")) == resolved_previous_state
|
|
799
|
+
)
|
|
800
|
+
if not can_incrementally_update:
|
|
801
|
+
return self.refresh_artifact_projection(
|
|
802
|
+
quest_root,
|
|
803
|
+
state_kind=resolved_current_kind,
|
|
804
|
+
state=resolved_current_state,
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
artifacts: list[dict[str, Any]] = [
|
|
808
|
+
dict(item)
|
|
809
|
+
for item in projected_artifacts
|
|
810
|
+
if isinstance(item, dict)
|
|
811
|
+
]
|
|
812
|
+
next_item = self._projection_artifact_item(
|
|
813
|
+
record=record,
|
|
814
|
+
artifact_path=artifact_path,
|
|
815
|
+
workspace_root=workspace_root,
|
|
816
|
+
)
|
|
817
|
+
next_identity = self._artifact_item_identity(
|
|
818
|
+
artifact_path,
|
|
819
|
+
record,
|
|
820
|
+
kind=str(next_item.get("kind") or ""),
|
|
821
|
+
)
|
|
822
|
+
try:
|
|
823
|
+
next_mtime_ns = artifact_path.stat().st_mtime_ns
|
|
824
|
+
except OSError:
|
|
825
|
+
next_mtime_ns = 0
|
|
826
|
+
replaced = False
|
|
827
|
+
for index, existing in enumerate(artifacts):
|
|
828
|
+
existing_payload = existing.get("payload") if isinstance(existing.get("payload"), dict) else {}
|
|
829
|
+
existing_path = Path(str(existing.get("path") or artifact_path))
|
|
830
|
+
if (
|
|
831
|
+
self._artifact_item_identity(
|
|
832
|
+
existing_path,
|
|
833
|
+
existing_payload,
|
|
834
|
+
kind=str(existing.get("kind") or existing_path.parent.name or ""),
|
|
835
|
+
)
|
|
836
|
+
!= next_identity
|
|
837
|
+
):
|
|
838
|
+
continue
|
|
839
|
+
try:
|
|
840
|
+
existing_mtime_ns = existing_path.stat().st_mtime_ns
|
|
841
|
+
except OSError:
|
|
842
|
+
existing_mtime_ns = 0
|
|
843
|
+
if self._artifact_item_rank(
|
|
844
|
+
record,
|
|
845
|
+
path=artifact_path,
|
|
846
|
+
mtime_ns=next_mtime_ns,
|
|
847
|
+
) >= self._artifact_item_rank(
|
|
848
|
+
existing_payload,
|
|
849
|
+
path=existing_path,
|
|
850
|
+
mtime_ns=existing_mtime_ns,
|
|
851
|
+
):
|
|
852
|
+
artifacts[index] = next_item
|
|
853
|
+
replaced = True
|
|
854
|
+
break
|
|
855
|
+
if not replaced:
|
|
856
|
+
artifacts.append(next_item)
|
|
857
|
+
artifacts.sort(
|
|
858
|
+
key=lambda item: str(
|
|
859
|
+
((item.get("payload") or {}).get("updated_at"))
|
|
860
|
+
or ((item.get("payload") or {}).get("created_at"))
|
|
861
|
+
or item.get("path")
|
|
862
|
+
or ""
|
|
863
|
+
)
|
|
864
|
+
)
|
|
865
|
+
return self._write_artifact_projection_locked(
|
|
866
|
+
quest_root,
|
|
867
|
+
state_kind=resolved_current_kind,
|
|
868
|
+
state=resolved_current_state,
|
|
869
|
+
artifacts=artifacts,
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
def _collect_artifacts_raw(self, quest_root: Path) -> list[dict[str, Any]]:
|
|
447
873
|
artifacts_by_identity: dict[str, dict[str, Any]] = {}
|
|
448
874
|
for root in self._artifact_roots(quest_root):
|
|
449
875
|
artifacts_root = root / "artifacts"
|
|
@@ -494,33 +920,1689 @@ class QuestService:
|
|
|
494
920
|
)
|
|
495
921
|
return artifacts
|
|
496
922
|
|
|
497
|
-
def
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
923
|
+
def _collect_artifacts(self, quest_root: Path) -> list[dict[str, Any]]:
|
|
924
|
+
state_kind, state = self._artifact_projection_state(quest_root)
|
|
925
|
+
projection_path = self._artifact_projection_path(quest_root)
|
|
926
|
+
cached_projection = self._read_cached_json(projection_path, {})
|
|
927
|
+
if (
|
|
928
|
+
isinstance(cached_projection, dict)
|
|
929
|
+
and int(cached_projection.get("schema_version") or 0) == 2
|
|
930
|
+
and cached_projection.get("state_kind") == state_kind
|
|
931
|
+
and self._json_compatible_state(cached_projection.get("state")) == self._json_compatible_state(state)
|
|
932
|
+
and isinstance(cached_projection.get("artifacts"), list)
|
|
933
|
+
):
|
|
934
|
+
return [
|
|
935
|
+
dict(item)
|
|
936
|
+
for item in cached_projection.get("artifacts") or []
|
|
937
|
+
if isinstance(item, dict)
|
|
938
|
+
]
|
|
939
|
+
|
|
940
|
+
with self._artifact_projection_lock(quest_root):
|
|
941
|
+
cached_projection = self._read_cached_json(projection_path, {})
|
|
942
|
+
if (
|
|
943
|
+
isinstance(cached_projection, dict)
|
|
944
|
+
and int(cached_projection.get("schema_version") or 0) == 2
|
|
945
|
+
and cached_projection.get("state_kind") == state_kind
|
|
946
|
+
and self._json_compatible_state(cached_projection.get("state")) == self._json_compatible_state(state)
|
|
947
|
+
and isinstance(cached_projection.get("artifacts"), list)
|
|
948
|
+
):
|
|
949
|
+
return [
|
|
950
|
+
dict(item)
|
|
951
|
+
for item in cached_projection.get("artifacts") or []
|
|
952
|
+
if isinstance(item, dict)
|
|
953
|
+
]
|
|
954
|
+
return self.refresh_artifact_projection(
|
|
955
|
+
quest_root,
|
|
956
|
+
state_kind=state_kind,
|
|
957
|
+
state=state,
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
def _collect_run_artifacts_raw(
|
|
961
|
+
self,
|
|
962
|
+
quest_root: Path,
|
|
963
|
+
*,
|
|
964
|
+
run_kind: str | None = None,
|
|
965
|
+
) -> list[dict[str, Any]]:
|
|
966
|
+
artifacts_by_identity: dict[str, dict[str, Any]] = {}
|
|
967
|
+
normalized_run_kind = str(run_kind or "").strip()
|
|
968
|
+
for root in self._artifact_roots(quest_root):
|
|
969
|
+
runs_root = root / "artifacts" / "runs"
|
|
970
|
+
if not runs_root.exists():
|
|
971
|
+
continue
|
|
972
|
+
for path in sorted(runs_root.glob("*.json")):
|
|
973
|
+
item = self._read_cached_json(path, {})
|
|
974
|
+
payload = item if isinstance(item, dict) else {}
|
|
975
|
+
if normalized_run_kind and str(payload.get("run_kind") or "").strip() != normalized_run_kind:
|
|
976
|
+
continue
|
|
977
|
+
try:
|
|
978
|
+
mtime_ns = path.stat().st_mtime_ns
|
|
979
|
+
except OSError:
|
|
980
|
+
mtime_ns = 0
|
|
981
|
+
artifact = {
|
|
982
|
+
"kind": "run",
|
|
983
|
+
"path": str(path),
|
|
984
|
+
"payload": item,
|
|
985
|
+
"workspace_root": str(root),
|
|
986
|
+
}
|
|
987
|
+
identity = self._artifact_item_identity(path, payload, kind="run")
|
|
988
|
+
existing = artifacts_by_identity.get(identity)
|
|
989
|
+
existing_payload = existing.get("payload") if isinstance((existing or {}).get("payload"), dict) else {}
|
|
990
|
+
existing_path = Path(str((existing or {}).get("path") or path))
|
|
991
|
+
try:
|
|
992
|
+
existing_mtime_ns = existing_path.stat().st_mtime_ns if existing else 0
|
|
993
|
+
except OSError:
|
|
994
|
+
existing_mtime_ns = 0
|
|
995
|
+
if existing is None or self._artifact_item_rank(
|
|
996
|
+
payload,
|
|
997
|
+
path=path,
|
|
998
|
+
mtime_ns=mtime_ns,
|
|
999
|
+
) >= self._artifact_item_rank(
|
|
1000
|
+
existing_payload,
|
|
1001
|
+
path=existing_path,
|
|
1002
|
+
mtime_ns=existing_mtime_ns,
|
|
1003
|
+
):
|
|
1004
|
+
artifacts_by_identity[identity] = artifact
|
|
1005
|
+
artifacts = list(artifacts_by_identity.values())
|
|
1006
|
+
artifacts.sort(
|
|
1007
|
+
key=lambda item: str(
|
|
1008
|
+
((item.get("payload") or {}).get("updated_at"))
|
|
1009
|
+
or ((item.get("payload") or {}).get("created_at"))
|
|
1010
|
+
or item.get("path")
|
|
1011
|
+
or ""
|
|
1012
|
+
)
|
|
1013
|
+
)
|
|
1014
|
+
return artifacts
|
|
1015
|
+
|
|
1016
|
+
@staticmethod
|
|
1017
|
+
def _projection_id(kind: str) -> str:
|
|
1018
|
+
return f"{kind}.v1"
|
|
1019
|
+
|
|
1020
|
+
@staticmethod
|
|
1021
|
+
def _projection_directory(quest_root: Path) -> Path:
|
|
1022
|
+
return quest_root / ".ds" / "projections"
|
|
1023
|
+
|
|
1024
|
+
@classmethod
|
|
1025
|
+
def _projection_manifest_path(cls, quest_root: Path) -> Path:
|
|
1026
|
+
return cls._projection_directory(quest_root) / "manifest.json"
|
|
1027
|
+
|
|
1028
|
+
@classmethod
|
|
1029
|
+
def _projection_payload_path(cls, quest_root: Path, kind: str) -> Path:
|
|
1030
|
+
return cls._projection_directory(quest_root) / f"{cls._projection_id(kind)}.json"
|
|
1031
|
+
|
|
1032
|
+
@classmethod
|
|
1033
|
+
def _projection_lock_path(cls, quest_root: Path, kind: str) -> Path:
|
|
1034
|
+
return cls._projection_directory(quest_root) / f"{cls._projection_id(kind)}.lock"
|
|
1035
|
+
|
|
1036
|
+
def _projection_build_key(self, quest_root: Path, kind: str) -> str:
|
|
1037
|
+
return f"{quest_root.resolve()}::{kind}"
|
|
1038
|
+
|
|
1039
|
+
def _codex_history_events_state(self, quest_root: Path) -> tuple[tuple[str, tuple[int, int, int] | None], ...]:
|
|
1040
|
+
return self._glob_states(quest_root / ".ds" / "codex_history", "*/events.jsonl")
|
|
1041
|
+
|
|
1042
|
+
def _details_projection_state(self, quest_root: Path) -> tuple[Any, ...]:
|
|
1043
|
+
workspace_root = self.active_workspace_root(quest_root)
|
|
1044
|
+
core_paths = [
|
|
1045
|
+
self._quest_yaml_path(quest_root),
|
|
1046
|
+
quest_root / "status.md",
|
|
1047
|
+
quest_root / ".ds" / "runtime_state.json",
|
|
1048
|
+
quest_root / ".ds" / "research_state.json",
|
|
1049
|
+
quest_root / ".ds" / "interaction_state.json",
|
|
1050
|
+
quest_root / ".ds" / "bindings.json",
|
|
1051
|
+
quest_root / ".ds" / "bash_exec" / "summary.json",
|
|
1052
|
+
self._artifact_projection_path(quest_root),
|
|
1053
|
+
workspace_root / "brief.md",
|
|
1054
|
+
workspace_root / "plan.md",
|
|
1055
|
+
workspace_root / "status.md",
|
|
1056
|
+
workspace_root / "SUMMARY.md",
|
|
1057
|
+
]
|
|
1058
|
+
return (
|
|
1059
|
+
str(workspace_root.resolve()),
|
|
1060
|
+
self._path_states(core_paths),
|
|
1061
|
+
self._codex_meta_state(quest_root),
|
|
1062
|
+
self._codex_history_events_state(quest_root),
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
def _git_branch_projection_state(self, quest_root: Path) -> dict[str, Any]:
|
|
1066
|
+
result = run_command(
|
|
1067
|
+
[
|
|
1068
|
+
"git",
|
|
1069
|
+
"for-each-ref",
|
|
1070
|
+
"--sort=refname",
|
|
1071
|
+
"--format=%(refname:short)%09%(objectname)%09%(committerdate:iso-strict)",
|
|
1072
|
+
"refs/heads",
|
|
1073
|
+
],
|
|
1074
|
+
cwd=quest_root,
|
|
1075
|
+
check=False,
|
|
1076
|
+
)
|
|
1077
|
+
refs = [line.strip() for line in str(result.stdout or "").splitlines() if line.strip()]
|
|
1078
|
+
if result.returncode != 0:
|
|
1079
|
+
refs = [f"error:{result.returncode}:{str(result.stderr or '').strip()}"]
|
|
1080
|
+
return {
|
|
1081
|
+
"current_ref": current_branch(quest_root),
|
|
1082
|
+
"head": head_commit(quest_root),
|
|
1083
|
+
"refs": refs,
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
def _canvas_projection_state(self, quest_root: Path) -> tuple[Any, ...]:
|
|
1087
|
+
return (
|
|
1088
|
+
self._path_states(
|
|
1089
|
+
[
|
|
1090
|
+
self._quest_yaml_path(quest_root),
|
|
1091
|
+
quest_root / ".ds" / "research_state.json",
|
|
1092
|
+
self._artifact_projection_path(quest_root),
|
|
1093
|
+
]
|
|
1094
|
+
),
|
|
1095
|
+
self._git_branch_projection_state(quest_root),
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
def _projection_state_for_kind(self, quest_root: Path, kind: str) -> Any:
|
|
1099
|
+
if kind == "details":
|
|
1100
|
+
return self._details_projection_state(quest_root)
|
|
1101
|
+
if kind == "canvas":
|
|
1102
|
+
return self._canvas_projection_state(quest_root)
|
|
1103
|
+
if kind == "git_canvas":
|
|
1104
|
+
return self._canvas_projection_state(quest_root)
|
|
1105
|
+
raise ValueError(f"Unsupported projection kind `{kind}`.")
|
|
1106
|
+
|
|
1107
|
+
def _projection_source_signature(self, quest_root: Path, kind: str) -> str:
|
|
1108
|
+
state = {
|
|
1109
|
+
"projection_id": self._projection_id(kind),
|
|
1110
|
+
"state": self._json_compatible_state(self._projection_state_for_kind(quest_root, kind)),
|
|
1111
|
+
}
|
|
1112
|
+
return sha256_text(json.dumps(state, ensure_ascii=False, sort_keys=True))
|
|
1113
|
+
|
|
1114
|
+
def _default_projection_status(self, kind: str) -> dict[str, Any]:
|
|
1115
|
+
return {
|
|
1116
|
+
"projection_id": self._projection_id(kind),
|
|
1117
|
+
"state": "missing",
|
|
1118
|
+
"progress_current": 0,
|
|
1119
|
+
"progress_total": 0,
|
|
1120
|
+
"current_step": None,
|
|
1121
|
+
"source_signature": None,
|
|
1122
|
+
"generated_at": None,
|
|
1123
|
+
"last_success_at": None,
|
|
1124
|
+
"error": None,
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
def _normalize_projection_status(self, kind: str, raw: Any) -> dict[str, Any]:
|
|
1128
|
+
normalized = self._default_projection_status(kind)
|
|
1129
|
+
if isinstance(raw, dict):
|
|
1130
|
+
normalized.update(
|
|
1131
|
+
{
|
|
1132
|
+
"state": str(raw.get("state") or normalized["state"]).strip() or normalized["state"],
|
|
1133
|
+
"progress_current": max(0, int(raw.get("progress_current") or 0)),
|
|
1134
|
+
"progress_total": max(0, int(raw.get("progress_total") or 0)),
|
|
1135
|
+
"current_step": str(raw.get("current_step") or "").strip() or None,
|
|
1136
|
+
"source_signature": str(raw.get("source_signature") or "").strip() or None,
|
|
1137
|
+
"generated_at": str(raw.get("generated_at") or "").strip() or None,
|
|
1138
|
+
"last_success_at": str(raw.get("last_success_at") or "").strip() or None,
|
|
1139
|
+
"error": str(raw.get("error") or "").strip() or None,
|
|
1140
|
+
}
|
|
1141
|
+
)
|
|
1142
|
+
return normalized
|
|
1143
|
+
|
|
1144
|
+
def _read_projection_manifest(self, quest_root: Path) -> dict[str, Any]:
|
|
1145
|
+
manifest = self._read_cached_json(
|
|
1146
|
+
self._projection_manifest_path(quest_root),
|
|
1147
|
+
{
|
|
1148
|
+
"schema_version": _PROJECTION_SCHEMA_VERSION,
|
|
1149
|
+
"projections": {},
|
|
1150
|
+
},
|
|
1151
|
+
)
|
|
1152
|
+
if not isinstance(manifest, dict):
|
|
1153
|
+
return {
|
|
1154
|
+
"schema_version": _PROJECTION_SCHEMA_VERSION,
|
|
1155
|
+
"projections": {},
|
|
1156
|
+
}
|
|
1157
|
+
return manifest
|
|
1158
|
+
|
|
1159
|
+
def _read_projection_payload_file(self, quest_root: Path, kind: str) -> dict[str, Any] | None:
|
|
1160
|
+
payload = self._read_cached_json(self._projection_payload_path(quest_root, kind), {})
|
|
1161
|
+
if not isinstance(payload, dict):
|
|
1162
|
+
return None
|
|
1163
|
+
if str(payload.get("projection_id") or "").strip() != self._projection_id(kind):
|
|
1164
|
+
return None
|
|
1165
|
+
if not isinstance(payload.get("payload"), dict):
|
|
1166
|
+
return None
|
|
1167
|
+
return payload
|
|
1168
|
+
|
|
1169
|
+
def _write_projection_manifest_locked(
|
|
1170
|
+
self,
|
|
1171
|
+
quest_root: Path,
|
|
1172
|
+
kind: str,
|
|
1173
|
+
status: dict[str, Any],
|
|
1174
|
+
) -> dict[str, Any]:
|
|
1175
|
+
path = self._projection_manifest_path(quest_root)
|
|
1176
|
+
ensure_dir(path.parent)
|
|
1177
|
+
manifest = read_json(path, {})
|
|
1178
|
+
if not isinstance(manifest, dict):
|
|
1179
|
+
manifest = {}
|
|
1180
|
+
projections = manifest.get("projections") if isinstance(manifest.get("projections"), dict) else {}
|
|
1181
|
+
next_status = self._normalize_projection_status(kind, status)
|
|
1182
|
+
projections = {
|
|
1183
|
+
**projections,
|
|
1184
|
+
kind: next_status,
|
|
1185
|
+
}
|
|
1186
|
+
write_json(
|
|
1187
|
+
path,
|
|
1188
|
+
{
|
|
1189
|
+
"schema_version": _PROJECTION_SCHEMA_VERSION,
|
|
1190
|
+
"updated_at": utc_now(),
|
|
1191
|
+
"projections": projections,
|
|
1192
|
+
},
|
|
1193
|
+
)
|
|
1194
|
+
return next_status
|
|
1195
|
+
|
|
1196
|
+
def _write_projection_payload_locked(
|
|
1197
|
+
self,
|
|
1198
|
+
quest_root: Path,
|
|
1199
|
+
kind: str,
|
|
1200
|
+
*,
|
|
1201
|
+
source_signature: str,
|
|
1202
|
+
payload: dict[str, Any],
|
|
1203
|
+
generated_at: str | None = None,
|
|
1204
|
+
) -> dict[str, Any]:
|
|
1205
|
+
path = self._projection_payload_path(quest_root, kind)
|
|
1206
|
+
ensure_dir(path.parent)
|
|
1207
|
+
resolved_generated_at = generated_at or utc_now()
|
|
1208
|
+
wrapper = {
|
|
1209
|
+
"schema_version": _PROJECTION_SCHEMA_VERSION,
|
|
1210
|
+
"projection_id": self._projection_id(kind),
|
|
1211
|
+
"generated_at": resolved_generated_at,
|
|
1212
|
+
"source_signature": source_signature,
|
|
1213
|
+
"payload": copy.deepcopy(payload),
|
|
1214
|
+
}
|
|
1215
|
+
write_json(path, wrapper)
|
|
1216
|
+
return copy.deepcopy(payload)
|
|
1217
|
+
|
|
1218
|
+
@contextmanager
|
|
1219
|
+
def _projection_lock(self, quest_root: Path, kind: str):
|
|
1220
|
+
lock_key = self._projection_build_key(quest_root, kind)
|
|
1221
|
+
with self._quest_projection_locks_lock:
|
|
1222
|
+
thread_lock = self._quest_projection_locks.setdefault(lock_key, threading.Lock())
|
|
1223
|
+
with thread_lock:
|
|
1224
|
+
with advisory_file_lock(self._projection_lock_path(quest_root, kind)):
|
|
1225
|
+
yield
|
|
1226
|
+
|
|
1227
|
+
def _projection_build_active(self, quest_root: Path, kind: str) -> bool:
|
|
1228
|
+
build_key = self._projection_build_key(quest_root, kind)
|
|
1229
|
+
with self._quest_projection_builds_lock:
|
|
1230
|
+
thread = self._quest_projection_builds.get(build_key)
|
|
1231
|
+
if thread is not None and not thread.is_alive():
|
|
1232
|
+
self._quest_projection_builds.pop(build_key, None)
|
|
1233
|
+
thread = None
|
|
1234
|
+
return thread is not None
|
|
1235
|
+
|
|
1236
|
+
def _present_projection_status(
|
|
1237
|
+
self,
|
|
1238
|
+
quest_root: Path,
|
|
1239
|
+
kind: str,
|
|
1240
|
+
*,
|
|
1241
|
+
source_signature: str,
|
|
1242
|
+
payload_wrapper: dict[str, Any] | None,
|
|
1243
|
+
) -> dict[str, Any]:
|
|
1244
|
+
manifest = self._read_projection_manifest(quest_root)
|
|
1245
|
+
projections = manifest.get("projections") if isinstance(manifest.get("projections"), dict) else {}
|
|
1246
|
+
status = self._normalize_projection_status(kind, projections.get(kind))
|
|
1247
|
+
payload_signature = (
|
|
1248
|
+
str(payload_wrapper.get("source_signature") or "").strip()
|
|
1249
|
+
if isinstance(payload_wrapper, dict)
|
|
1250
|
+
else None
|
|
1251
|
+
) or None
|
|
1252
|
+
payload_generated_at = (
|
|
1253
|
+
str(payload_wrapper.get("generated_at") or "").strip()
|
|
1254
|
+
if isinstance(payload_wrapper, dict)
|
|
1255
|
+
else None
|
|
1256
|
+
) or None
|
|
1257
|
+
payload_ready = (
|
|
1258
|
+
isinstance(payload_wrapper, dict)
|
|
1259
|
+
and isinstance(payload_wrapper.get("payload"), dict)
|
|
1260
|
+
and payload_signature == source_signature
|
|
1261
|
+
)
|
|
1262
|
+
if payload_ready:
|
|
1263
|
+
status.update(
|
|
1264
|
+
{
|
|
1265
|
+
"state": "ready",
|
|
1266
|
+
"source_signature": source_signature,
|
|
1267
|
+
"generated_at": payload_generated_at,
|
|
1268
|
+
"last_success_at": payload_generated_at or status.get("last_success_at"),
|
|
1269
|
+
"progress_current": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1270
|
+
"progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1271
|
+
"current_step": None,
|
|
1272
|
+
"error": None,
|
|
1273
|
+
}
|
|
1274
|
+
)
|
|
1275
|
+
return status
|
|
1276
|
+
if self._projection_build_active(quest_root, kind):
|
|
1277
|
+
status["state"] = "building" if status.get("state") != "queued" else "queued"
|
|
1278
|
+
status["progress_total"] = max(int(status.get("progress_total") or 0), _PROJECTION_BUILD_TOTAL_STEPS)
|
|
1279
|
+
status["current_step"] = status.get("current_step") or "Building projection"
|
|
1280
|
+
return status
|
|
1281
|
+
if isinstance(payload_wrapper, dict) and isinstance(payload_wrapper.get("payload"), dict):
|
|
1282
|
+
status.update(
|
|
1283
|
+
{
|
|
1284
|
+
"state": "stale",
|
|
1285
|
+
"generated_at": payload_generated_at,
|
|
1286
|
+
"last_success_at": payload_generated_at or status.get("last_success_at"),
|
|
1287
|
+
"progress_current": 0,
|
|
1288
|
+
"progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1289
|
+
"current_step": "Queued for refresh",
|
|
1290
|
+
}
|
|
1291
|
+
)
|
|
1292
|
+
return status
|
|
1293
|
+
if status.get("state") == "failed":
|
|
1294
|
+
status["progress_total"] = max(int(status.get("progress_total") or 0), _PROJECTION_BUILD_TOTAL_STEPS)
|
|
1295
|
+
return status
|
|
1296
|
+
return self._default_projection_status(kind)
|
|
1297
|
+
|
|
1298
|
+
def _queue_projection_build(self, quest_root: Path, kind: str, *, source_signature: str) -> None:
|
|
1299
|
+
if self._projection_build_active(quest_root, kind):
|
|
1300
|
+
return
|
|
1301
|
+
|
|
1302
|
+
with self._projection_lock(quest_root, kind):
|
|
1303
|
+
payload_wrapper = self._read_projection_payload_file(quest_root, kind)
|
|
1304
|
+
if (
|
|
1305
|
+
isinstance(payload_wrapper, dict)
|
|
1306
|
+
and str(payload_wrapper.get("source_signature") or "").strip() == source_signature
|
|
1307
|
+
and isinstance(payload_wrapper.get("payload"), dict)
|
|
1308
|
+
):
|
|
1309
|
+
ready_status = self._default_projection_status(kind)
|
|
1310
|
+
ready_status.update(
|
|
1311
|
+
{
|
|
1312
|
+
"state": "ready",
|
|
1313
|
+
"source_signature": source_signature,
|
|
1314
|
+
"generated_at": str(payload_wrapper.get("generated_at") or "").strip() or None,
|
|
1315
|
+
"last_success_at": str(payload_wrapper.get("generated_at") or "").strip() or None,
|
|
1316
|
+
"progress_current": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1317
|
+
"progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1318
|
+
}
|
|
1319
|
+
)
|
|
1320
|
+
self._write_projection_manifest_locked(quest_root, kind, ready_status)
|
|
1321
|
+
return
|
|
1322
|
+
queued_status = self._default_projection_status(kind)
|
|
1323
|
+
queued_status.update(
|
|
1324
|
+
{
|
|
1325
|
+
"state": "queued",
|
|
1326
|
+
"source_signature": source_signature,
|
|
1327
|
+
"progress_current": 0,
|
|
1328
|
+
"progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1329
|
+
"current_step": "Queued for background rebuild",
|
|
1330
|
+
"error": None,
|
|
1331
|
+
}
|
|
1332
|
+
)
|
|
1333
|
+
self._write_projection_manifest_locked(quest_root, kind, queued_status)
|
|
1334
|
+
|
|
1335
|
+
build_key = self._projection_build_key(quest_root, kind)
|
|
1336
|
+
|
|
1337
|
+
def _update_progress(current: int, step: str | None) -> None:
|
|
1338
|
+
with self._projection_lock(quest_root, kind):
|
|
1339
|
+
manifest = self._read_projection_manifest(quest_root)
|
|
1340
|
+
projections = manifest.get("projections") if isinstance(manifest.get("projections"), dict) else {}
|
|
1341
|
+
status = self._normalize_projection_status(kind, projections.get(kind))
|
|
1342
|
+
status.update(
|
|
1343
|
+
{
|
|
1344
|
+
"state": "building",
|
|
1345
|
+
"source_signature": source_signature,
|
|
1346
|
+
"progress_current": max(0, min(current, _PROJECTION_BUILD_TOTAL_STEPS)),
|
|
1347
|
+
"progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1348
|
+
"current_step": step,
|
|
1349
|
+
"error": None,
|
|
1350
|
+
}
|
|
1351
|
+
)
|
|
1352
|
+
self._write_projection_manifest_locked(quest_root, kind, status)
|
|
1353
|
+
|
|
1354
|
+
def _worker() -> None:
|
|
1355
|
+
try:
|
|
1356
|
+
_update_progress(0, "Preparing projection inputs")
|
|
1357
|
+
payload = self._build_projection_payload(
|
|
1358
|
+
quest_root,
|
|
1359
|
+
kind,
|
|
1360
|
+
source_signature=source_signature,
|
|
1361
|
+
update_progress=_update_progress,
|
|
1362
|
+
)
|
|
1363
|
+
_update_progress(_PROJECTION_BUILD_TOTAL_STEPS, "Writing projection")
|
|
1364
|
+
generated_at = utc_now()
|
|
1365
|
+
with self._projection_lock(quest_root, kind):
|
|
1366
|
+
self._write_projection_payload_locked(
|
|
1367
|
+
quest_root,
|
|
1368
|
+
kind,
|
|
1369
|
+
source_signature=source_signature,
|
|
1370
|
+
payload=payload,
|
|
1371
|
+
generated_at=generated_at,
|
|
1372
|
+
)
|
|
1373
|
+
ready_status = self._default_projection_status(kind)
|
|
1374
|
+
ready_status.update(
|
|
1375
|
+
{
|
|
1376
|
+
"state": "ready",
|
|
1377
|
+
"source_signature": source_signature,
|
|
1378
|
+
"generated_at": generated_at,
|
|
1379
|
+
"last_success_at": generated_at,
|
|
1380
|
+
"progress_current": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1381
|
+
"progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1382
|
+
"current_step": None,
|
|
1383
|
+
"error": None,
|
|
1384
|
+
}
|
|
1385
|
+
)
|
|
1386
|
+
self._write_projection_manifest_locked(quest_root, kind, ready_status)
|
|
1387
|
+
except Exception as exc:
|
|
1388
|
+
with self._projection_lock(quest_root, kind):
|
|
1389
|
+
failed_status = self._default_projection_status(kind)
|
|
1390
|
+
failed_status.update(
|
|
1391
|
+
{
|
|
1392
|
+
"state": "failed",
|
|
1393
|
+
"source_signature": source_signature,
|
|
1394
|
+
"progress_current": 0,
|
|
1395
|
+
"progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1396
|
+
"current_step": None,
|
|
1397
|
+
"error": str(exc),
|
|
1398
|
+
}
|
|
1399
|
+
)
|
|
1400
|
+
self._write_projection_manifest_locked(quest_root, kind, failed_status)
|
|
1401
|
+
finally:
|
|
1402
|
+
with self._quest_projection_builds_lock:
|
|
1403
|
+
active = self._quest_projection_builds.get(build_key)
|
|
1404
|
+
if active is threading.current_thread():
|
|
1405
|
+
self._quest_projection_builds.pop(build_key, None)
|
|
1406
|
+
|
|
1407
|
+
worker = threading.Thread(
|
|
1408
|
+
target=_worker,
|
|
1409
|
+
daemon=True,
|
|
1410
|
+
name=f"ds-projection-{quest_root.name}-{kind}",
|
|
1411
|
+
)
|
|
1412
|
+
with self._quest_projection_builds_lock:
|
|
1413
|
+
self._quest_projection_builds[build_key] = worker
|
|
1414
|
+
worker.start()
|
|
1415
|
+
|
|
1416
|
+
def _recent_codex_runs(self, quest_root: Path, *, limit: int = 5) -> list[dict[str, Any]]:
|
|
1417
|
+
history_root = quest_root / ".ds" / "codex_history"
|
|
1418
|
+
if not history_root.exists():
|
|
1419
|
+
return []
|
|
1420
|
+
runs: list[dict[str, Any]] = []
|
|
1421
|
+
for meta_path in sorted(history_root.glob("*/meta.json")):
|
|
1422
|
+
payload = self._read_cached_json(meta_path, {})
|
|
1423
|
+
if not isinstance(payload, dict) or not payload:
|
|
1424
|
+
continue
|
|
1425
|
+
record = dict(payload)
|
|
1426
|
+
record.setdefault("history_root", str(meta_path.parent))
|
|
1427
|
+
runs.append(record)
|
|
1428
|
+
runs.sort(
|
|
1429
|
+
key=lambda item: str(
|
|
1430
|
+
item.get("updated_at")
|
|
1431
|
+
or item.get("completed_at")
|
|
1432
|
+
or item.get("created_at")
|
|
1433
|
+
or item.get("run_id")
|
|
1434
|
+
or ""
|
|
1435
|
+
)
|
|
1436
|
+
)
|
|
1437
|
+
return runs[-limit:]
|
|
1438
|
+
|
|
1439
|
+
def _build_workflow_payload(
|
|
1440
|
+
self,
|
|
1441
|
+
quest_id: str,
|
|
1442
|
+
quest_root: Path,
|
|
1443
|
+
workspace_root: Path,
|
|
1444
|
+
*,
|
|
1445
|
+
recent_runs: list[dict[str, Any]],
|
|
1446
|
+
recent_artifacts: list[dict[str, Any]],
|
|
1447
|
+
) -> dict[str, Any]:
|
|
1448
|
+
entries: list[dict[str, Any]] = []
|
|
1449
|
+
changed_files: list[dict[str, Any]] = []
|
|
1450
|
+
seen_files: set[str] = set()
|
|
1451
|
+
|
|
1452
|
+
def add_file(path: str | None, *, source: str, document_id: str | None = None, writable: bool | None = None) -> None:
|
|
1453
|
+
if not path:
|
|
1454
|
+
return
|
|
1455
|
+
normalized = str(path)
|
|
1456
|
+
if normalized in seen_files:
|
|
1457
|
+
return
|
|
1458
|
+
seen_files.add(normalized)
|
|
1459
|
+
resolved_document_id = document_id or self._path_to_document_id(
|
|
1460
|
+
normalized,
|
|
1461
|
+
quest_root=quest_root,
|
|
1462
|
+
workspace_root=workspace_root,
|
|
1463
|
+
)
|
|
1464
|
+
changed_files.append(
|
|
1465
|
+
{
|
|
1466
|
+
"path": normalized,
|
|
1467
|
+
"source": source,
|
|
1468
|
+
"document_id": resolved_document_id,
|
|
1469
|
+
"writable": writable,
|
|
1470
|
+
}
|
|
1471
|
+
)
|
|
1472
|
+
|
|
1473
|
+
for relative in ("brief.md", "plan.md", "status.md", "SUMMARY.md"):
|
|
1474
|
+
add_file(
|
|
1475
|
+
str(workspace_root / relative),
|
|
1476
|
+
source="document",
|
|
1477
|
+
document_id=relative,
|
|
1478
|
+
writable=True,
|
|
1479
|
+
)
|
|
1480
|
+
|
|
1481
|
+
for run in recent_runs:
|
|
1482
|
+
run_id = str(run.get("run_id") or "run")
|
|
1483
|
+
entries.append(
|
|
1484
|
+
{
|
|
1485
|
+
"id": f"run:{run_id}",
|
|
1486
|
+
"kind": "run",
|
|
1487
|
+
"run_id": run_id,
|
|
1488
|
+
"skill_id": run.get("skill_id"),
|
|
1489
|
+
"title": run_id,
|
|
1490
|
+
"summary": run.get("summary") or "Run completed.",
|
|
1491
|
+
"status": "completed" if run.get("exit_code", 0) == 0 else "failed",
|
|
1492
|
+
"created_at": run.get("completed_at") or run.get("created_at") or run.get("updated_at"),
|
|
1493
|
+
"paths": [item for item in [run.get("history_root"), run.get("run_root"), run.get("output_path")] if item],
|
|
1494
|
+
}
|
|
1495
|
+
)
|
|
1496
|
+
for path in (run.get("history_root"), run.get("run_root"), run.get("output_path")):
|
|
1497
|
+
add_file(path, source="run")
|
|
1498
|
+
history_root = run.get("history_root")
|
|
1499
|
+
if history_root:
|
|
1500
|
+
entries.extend(
|
|
1501
|
+
self._parse_codex_history_cached(
|
|
1502
|
+
Path(str(history_root)),
|
|
1503
|
+
quest_id=quest_id,
|
|
1504
|
+
run_id=run_id,
|
|
1505
|
+
skill_id=run.get("skill_id"),
|
|
1506
|
+
)
|
|
1507
|
+
)
|
|
1508
|
+
|
|
1509
|
+
for artifact in recent_artifacts:
|
|
1510
|
+
payload = artifact.get("payload") if isinstance(artifact.get("payload"), dict) else {}
|
|
1511
|
+
artifact_path = artifact.get("path")
|
|
1512
|
+
entries.append(
|
|
1513
|
+
{
|
|
1514
|
+
"id": f"artifact:{payload.get('artifact_id') or artifact_path}",
|
|
1515
|
+
"kind": "artifact",
|
|
1516
|
+
"title": str(payload.get("artifact_id") or artifact.get("kind") or "artifact"),
|
|
1517
|
+
"summary": payload.get("summary") or payload.get("message") or payload.get("reason") or "Artifact updated.",
|
|
1518
|
+
"status": payload.get("status"),
|
|
1519
|
+
"reason": payload.get("reason"),
|
|
1520
|
+
"created_at": payload.get("updated_at") or payload.get("created_at"),
|
|
1521
|
+
"paths": list((payload.get("paths") or {}).values()) + ([str(artifact_path)] if artifact_path else []),
|
|
1522
|
+
}
|
|
1523
|
+
)
|
|
1524
|
+
add_file(str(artifact_path) if artifact_path else None, source="artifact")
|
|
1525
|
+
for path in (payload.get("paths") or {}).values():
|
|
1526
|
+
add_file(str(path), source="artifact_path")
|
|
1527
|
+
|
|
1528
|
+
entries.sort(key=lambda item: str(item.get("created_at") or item.get("id") or ""))
|
|
1529
|
+
return {
|
|
1530
|
+
"quest_id": quest_id,
|
|
1531
|
+
"quest_root": str(quest_root.resolve()),
|
|
1532
|
+
"entries": entries[-80:],
|
|
1533
|
+
"changed_files": changed_files[-30:],
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
def _build_details_projection_payload(
|
|
1537
|
+
self,
|
|
1538
|
+
quest_root: Path,
|
|
1539
|
+
*,
|
|
1540
|
+
source_signature: str,
|
|
1541
|
+
update_progress: Any,
|
|
1542
|
+
) -> dict[str, Any]:
|
|
1543
|
+
quest_id = quest_root.name
|
|
1544
|
+
workspace_root = self.active_workspace_root(quest_root)
|
|
1545
|
+
update_progress(1, "Loading recent workflow sources")
|
|
1546
|
+
recent_artifacts = self._collect_artifacts(quest_root)[-8:]
|
|
1547
|
+
recent_runs = self._recent_codex_runs(quest_root, limit=5)
|
|
1548
|
+
update_progress(2, "Materializing workflow timeline")
|
|
1549
|
+
return self._build_workflow_payload(
|
|
1550
|
+
quest_id,
|
|
1551
|
+
quest_root,
|
|
1552
|
+
workspace_root,
|
|
1553
|
+
recent_runs=recent_runs,
|
|
1554
|
+
recent_artifacts=recent_artifacts,
|
|
1555
|
+
)
|
|
1556
|
+
|
|
1557
|
+
def _build_canvas_projection_payload(
|
|
1558
|
+
self,
|
|
1559
|
+
quest_root: Path,
|
|
1560
|
+
*,
|
|
1561
|
+
source_signature: str,
|
|
1562
|
+
update_progress: Any,
|
|
1563
|
+
) -> dict[str, Any]:
|
|
1564
|
+
update_progress(1, "Scanning branch references")
|
|
1565
|
+
update_progress(2, "Computing branch canvas")
|
|
1566
|
+
return list_branch_canvas(quest_root, quest_id=quest_root.name)
|
|
1567
|
+
|
|
1568
|
+
def _build_git_canvas_projection_payload(
|
|
1569
|
+
self,
|
|
1570
|
+
quest_root: Path,
|
|
1571
|
+
*,
|
|
1572
|
+
source_signature: str,
|
|
1573
|
+
update_progress: Any,
|
|
1574
|
+
) -> dict[str, Any]:
|
|
1575
|
+
update_progress(1, "Scanning commit history")
|
|
1576
|
+
update_progress(2, "Computing commit canvas")
|
|
1577
|
+
return list_commit_canvas(quest_root, quest_id=quest_root.name)
|
|
1578
|
+
|
|
1579
|
+
def _build_projection_payload(
|
|
1580
|
+
self,
|
|
1581
|
+
quest_root: Path,
|
|
1582
|
+
kind: str,
|
|
1583
|
+
*,
|
|
1584
|
+
source_signature: str,
|
|
1585
|
+
update_progress: Any,
|
|
1586
|
+
) -> dict[str, Any]:
|
|
1587
|
+
if kind == "details":
|
|
1588
|
+
return self._build_details_projection_payload(
|
|
1589
|
+
quest_root,
|
|
1590
|
+
source_signature=source_signature,
|
|
1591
|
+
update_progress=update_progress,
|
|
1592
|
+
)
|
|
1593
|
+
if kind == "canvas":
|
|
1594
|
+
return self._build_canvas_projection_payload(
|
|
1595
|
+
quest_root,
|
|
1596
|
+
source_signature=source_signature,
|
|
1597
|
+
update_progress=update_progress,
|
|
1598
|
+
)
|
|
1599
|
+
if kind == "git_canvas":
|
|
1600
|
+
return self._build_git_canvas_projection_payload(
|
|
1601
|
+
quest_root,
|
|
1602
|
+
source_signature=source_signature,
|
|
1603
|
+
update_progress=update_progress,
|
|
1604
|
+
)
|
|
1605
|
+
raise ValueError(f"Unsupported projection kind `{kind}`.")
|
|
1606
|
+
|
|
1607
|
+
def _placeholder_workflow_payload(self, quest_id: str, quest_root: Path) -> dict[str, Any]:
|
|
1608
|
+
workspace_root = self.active_workspace_root(quest_root)
|
|
1609
|
+
return self._build_workflow_payload(
|
|
1610
|
+
quest_id,
|
|
1611
|
+
quest_root,
|
|
1612
|
+
workspace_root,
|
|
1613
|
+
recent_runs=[],
|
|
1614
|
+
recent_artifacts=[],
|
|
1615
|
+
)
|
|
1616
|
+
|
|
1617
|
+
def _placeholder_canvas_payload(self, quest_id: str, quest_root: Path) -> dict[str, Any]:
|
|
1618
|
+
research_state = self.read_research_state(quest_root)
|
|
1619
|
+
default_ref = (
|
|
1620
|
+
str(research_state.get("research_head_branch") or "").strip()
|
|
1621
|
+
or str(research_state.get("current_workspace_branch") or "").strip()
|
|
1622
|
+
or current_branch(quest_root)
|
|
1623
|
+
)
|
|
1624
|
+
return {
|
|
1625
|
+
"quest_id": quest_id,
|
|
1626
|
+
"default_ref": default_ref,
|
|
1627
|
+
"current_ref": default_ref,
|
|
1628
|
+
"head": head_commit(quest_root),
|
|
1629
|
+
"nodes": [],
|
|
1630
|
+
"edges": [],
|
|
1631
|
+
"views": {
|
|
1632
|
+
"ideas": [],
|
|
1633
|
+
"analysis": [],
|
|
1634
|
+
},
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
def _placeholder_git_canvas_payload(self, quest_id: str, quest_root: Path) -> dict[str, Any]:
|
|
1638
|
+
research_state = self.read_research_state(quest_root)
|
|
1639
|
+
return {
|
|
1640
|
+
"quest_id": quest_id,
|
|
1641
|
+
"workspace_mode": str(research_state.get("workspace_mode") or "copilot").strip() or "copilot",
|
|
1642
|
+
"head": head_commit(quest_root),
|
|
1643
|
+
"current_ref": current_branch(quest_root),
|
|
1644
|
+
"nodes": [],
|
|
1645
|
+
"edges": [],
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
def _projected_payload(self, quest_id: str, kind: str) -> dict[str, Any]:
|
|
1649
|
+
quest_root = self._quest_root(quest_id)
|
|
1650
|
+
source_signature = self._projection_source_signature(quest_root, kind)
|
|
1651
|
+
payload_wrapper = self._read_projection_payload_file(quest_root, kind)
|
|
1652
|
+
payload_ready = (
|
|
1653
|
+
isinstance(payload_wrapper, dict)
|
|
1654
|
+
and str(payload_wrapper.get("source_signature") or "").strip() == source_signature
|
|
1655
|
+
and isinstance(payload_wrapper.get("payload"), dict)
|
|
1656
|
+
)
|
|
1657
|
+
if not payload_ready:
|
|
1658
|
+
self._queue_projection_build(quest_root, kind, source_signature=source_signature)
|
|
1659
|
+
payload_wrapper = self._read_projection_payload_file(quest_root, kind)
|
|
1660
|
+
status = self._present_projection_status(
|
|
1661
|
+
quest_root,
|
|
1662
|
+
kind,
|
|
1663
|
+
source_signature=source_signature,
|
|
1664
|
+
payload_wrapper=payload_wrapper,
|
|
1665
|
+
)
|
|
1666
|
+
payload = (
|
|
1667
|
+
copy.deepcopy(payload_wrapper.get("payload"))
|
|
1668
|
+
if isinstance(payload_wrapper, dict) and isinstance(payload_wrapper.get("payload"), dict)
|
|
1669
|
+
else None
|
|
1670
|
+
)
|
|
1671
|
+
if payload is None:
|
|
1672
|
+
if kind == "details":
|
|
1673
|
+
payload = self._placeholder_workflow_payload(quest_id, quest_root)
|
|
1674
|
+
elif kind == "git_canvas":
|
|
1675
|
+
payload = self._placeholder_git_canvas_payload(quest_id, quest_root)
|
|
1676
|
+
else:
|
|
1677
|
+
payload = self._placeholder_canvas_payload(quest_id, quest_root)
|
|
1678
|
+
payload["projection_status"] = status
|
|
1679
|
+
return payload
|
|
1680
|
+
|
|
1681
|
+
def prime_projection(self, quest_id: str, kind: str) -> None:
|
|
1682
|
+
quest_root = self._quest_root(quest_id)
|
|
1683
|
+
self._queue_projection_build(
|
|
1684
|
+
quest_root,
|
|
1685
|
+
kind,
|
|
1686
|
+
source_signature=self._projection_source_signature(quest_root, kind),
|
|
1687
|
+
)
|
|
1688
|
+
|
|
1689
|
+
def schedule_projection_refresh(
|
|
1690
|
+
self,
|
|
1691
|
+
quest_root: Path,
|
|
1692
|
+
*,
|
|
1693
|
+
kinds: tuple[str, ...] | list[str] | None = None,
|
|
1694
|
+
throttle_seconds: float = _PROJECTION_REFRESH_THROTTLE_SECONDS,
|
|
1695
|
+
) -> None:
|
|
1696
|
+
resolved_kinds = [
|
|
1697
|
+
str(kind).strip()
|
|
1698
|
+
for kind in (kinds or ("details", "canvas", "git_canvas"))
|
|
1699
|
+
if str(kind).strip() in {"details", "canvas", "git_canvas"}
|
|
1700
|
+
]
|
|
1701
|
+
if not resolved_kinds:
|
|
1702
|
+
return
|
|
1703
|
+
min_interval = max(0.0, float(throttle_seconds))
|
|
1704
|
+
now = time.monotonic()
|
|
1705
|
+
for kind in resolved_kinds:
|
|
1706
|
+
build_key = self._projection_build_key(quest_root, kind)
|
|
1707
|
+
if self._projection_build_active(quest_root, kind):
|
|
1708
|
+
continue
|
|
1709
|
+
with self._quest_projection_refresh_lock:
|
|
1710
|
+
previous = float(self._quest_projection_refresh_at.get(build_key) or 0.0)
|
|
1711
|
+
if min_interval > 0 and now - previous < min_interval:
|
|
1712
|
+
continue
|
|
1713
|
+
self._quest_projection_refresh_at[build_key] = now
|
|
1714
|
+
try:
|
|
1715
|
+
self._queue_projection_build(
|
|
1716
|
+
quest_root,
|
|
1717
|
+
kind,
|
|
1718
|
+
source_signature=self._projection_source_signature(quest_root, kind),
|
|
1719
|
+
)
|
|
1720
|
+
except Exception:
|
|
1721
|
+
continue
|
|
1722
|
+
|
|
1723
|
+
def git_branch_canvas(self, quest_id: str) -> dict[str, Any]:
|
|
1724
|
+
return self._projected_payload(quest_id, "canvas")
|
|
1725
|
+
|
|
1726
|
+
def git_commit_canvas(self, quest_id: str) -> dict[str, Any]:
|
|
1727
|
+
return self._projected_payload(quest_id, "git_canvas")
|
|
1728
|
+
|
|
1729
|
+
def _active_baseline_attachment(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
|
|
1730
|
+
attachments: list[dict[str, Any]] = []
|
|
1731
|
+
seen_paths: set[str] = set()
|
|
1732
|
+
for root in (workspace_root, quest_root):
|
|
1733
|
+
attachment_root = root / "baselines" / "imported"
|
|
1734
|
+
if not attachment_root.exists():
|
|
1735
|
+
continue
|
|
1736
|
+
for path in sorted(attachment_root.glob("*/attachment.yaml")):
|
|
1737
|
+
key = str(path.resolve())
|
|
1738
|
+
if key in seen_paths:
|
|
1739
|
+
continue
|
|
1740
|
+
seen_paths.add(key)
|
|
1741
|
+
payload = self._read_cached_yaml(path, {})
|
|
1742
|
+
baseline_id = str(payload.get("source_baseline_id") or "").strip() if isinstance(payload, dict) else ""
|
|
1743
|
+
if baseline_id and self.baseline_registry.is_deleted(baseline_id):
|
|
1744
|
+
continue
|
|
1745
|
+
if isinstance(payload, dict) and payload:
|
|
1746
|
+
attachments.append(payload)
|
|
1747
|
+
if not attachments:
|
|
1748
|
+
return None
|
|
1749
|
+
return max(
|
|
1750
|
+
attachments,
|
|
1751
|
+
key=lambda item: (
|
|
1752
|
+
str(item.get("attached_at") or ""),
|
|
1753
|
+
str(item.get("source_baseline_id") or ""),
|
|
1754
|
+
),
|
|
1755
|
+
)
|
|
1756
|
+
|
|
1757
|
+
@staticmethod
|
|
1758
|
+
def _markdown_excerpt(path: Path, *, max_lines: int = 8) -> str | None:
|
|
1759
|
+
if not path.exists() or not path.is_file():
|
|
1760
|
+
return None
|
|
1761
|
+
text = read_text(path, "")
|
|
1762
|
+
if not text.strip():
|
|
1763
|
+
return None
|
|
1764
|
+
lines = [line.rstrip() for line in text.splitlines() if line.strip()]
|
|
1765
|
+
if not lines:
|
|
1766
|
+
return None
|
|
1767
|
+
excerpt = "\n".join(lines[:max_lines]).strip()
|
|
1768
|
+
return excerpt or None
|
|
1769
|
+
|
|
1770
|
+
def _snapshot_workspace_candidates(self, quest_root: Path, workspace_root: Path) -> list[Path]:
|
|
1771
|
+
candidates: list[Path] = []
|
|
1772
|
+
seen: set[str] = set()
|
|
1773
|
+
|
|
1774
|
+
def add(path: Path | None) -> None:
|
|
1775
|
+
if path is None:
|
|
1776
|
+
return
|
|
1777
|
+
resolved = path.resolve()
|
|
1778
|
+
key = str(resolved)
|
|
1779
|
+
if key in seen or not resolved.exists():
|
|
1780
|
+
return
|
|
1781
|
+
seen.add(key)
|
|
1782
|
+
candidates.append(resolved)
|
|
1783
|
+
|
|
1784
|
+
add(workspace_root)
|
|
1785
|
+
add(quest_root)
|
|
1786
|
+
worktrees_root = quest_root / ".ds" / "worktrees"
|
|
1787
|
+
if worktrees_root.exists():
|
|
1788
|
+
for item in sorted(worktrees_root.iterdir()):
|
|
1789
|
+
if item.is_dir():
|
|
1790
|
+
add(item)
|
|
1791
|
+
return candidates
|
|
1792
|
+
|
|
1793
|
+
@staticmethod
|
|
1794
|
+
def _path_mtime(path: Path) -> float:
|
|
1795
|
+
try:
|
|
1796
|
+
return path.stat().st_mtime
|
|
1797
|
+
except OSError:
|
|
1798
|
+
return 0.0
|
|
1799
|
+
|
|
1800
|
+
def _best_paper_root(self, quest_root: Path, workspace_root: Path) -> Path | None:
|
|
1801
|
+
best_root: Path | None = None
|
|
1802
|
+
best_rank: tuple[int, float] = (-1, -1.0)
|
|
1803
|
+
for candidate in self._snapshot_workspace_candidates(quest_root, workspace_root):
|
|
1804
|
+
paper_root = candidate / "paper"
|
|
1805
|
+
if not paper_root.exists() or not paper_root.is_dir():
|
|
1806
|
+
continue
|
|
1807
|
+
selected_outline = paper_root / "selected_outline.json"
|
|
1808
|
+
bundle_manifest = paper_root / "paper_bundle_manifest.json"
|
|
1809
|
+
draft = paper_root / "draft.md"
|
|
1810
|
+
score = 0
|
|
1811
|
+
if selected_outline.exists():
|
|
1812
|
+
score += 4
|
|
1813
|
+
if bundle_manifest.exists():
|
|
1814
|
+
score += 5
|
|
1815
|
+
if draft.exists():
|
|
1816
|
+
score += 2
|
|
1817
|
+
latest = max(
|
|
1818
|
+
self._path_mtime(selected_outline),
|
|
1819
|
+
self._path_mtime(bundle_manifest),
|
|
1820
|
+
self._path_mtime(draft),
|
|
1821
|
+
self._path_mtime(paper_root),
|
|
1822
|
+
)
|
|
1823
|
+
rank = (score, latest)
|
|
1824
|
+
if rank > best_rank:
|
|
1825
|
+
best_rank = rank
|
|
1826
|
+
best_root = paper_root
|
|
1827
|
+
return best_root
|
|
1828
|
+
|
|
1829
|
+
def _outline_record_from_paper_root(self, paper_root: Path) -> dict[str, Any]:
|
|
1830
|
+
outline_root = paper_root / "outline"
|
|
1831
|
+
manifest_path = outline_root / "manifest.json"
|
|
1832
|
+
if manifest_path.exists():
|
|
1833
|
+
manifest = read_json(manifest_path, {})
|
|
1834
|
+
if isinstance(manifest, dict) and manifest:
|
|
1835
|
+
manifest_sections = [
|
|
1836
|
+
dict(item) for item in (manifest.get("sections") or []) if isinstance(item, dict)
|
|
1837
|
+
]
|
|
1838
|
+
by_id = {
|
|
1839
|
+
str(item.get("section_id") or "").strip(): dict(item)
|
|
1840
|
+
for item in manifest_sections
|
|
1841
|
+
if str(item.get("section_id") or "").strip()
|
|
1842
|
+
}
|
|
1843
|
+
section_order = [
|
|
1844
|
+
str(item).strip() for item in (manifest.get("section_order") or []) if str(item).strip()
|
|
1845
|
+
]
|
|
1846
|
+
sections_root = outline_root / "sections"
|
|
1847
|
+
if sections_root.exists():
|
|
1848
|
+
for section_dir in sorted(sections_root.iterdir()):
|
|
1849
|
+
if not section_dir.is_dir():
|
|
1850
|
+
continue
|
|
1851
|
+
section_id = section_dir.name
|
|
1852
|
+
section = dict(by_id.get(section_id) or {})
|
|
1853
|
+
section.setdefault("section_id", section_id)
|
|
1854
|
+
section.setdefault("title", section_id)
|
|
1855
|
+
result_table_payload = read_json(section_dir / "result_table.json", {})
|
|
1856
|
+
rows = result_table_payload.get("rows") if isinstance(result_table_payload, dict) else []
|
|
1857
|
+
section["result_table"] = rows if isinstance(rows, list) else []
|
|
1858
|
+
by_id[section_id] = section
|
|
1859
|
+
ordered_sections: list[dict[str, Any]] = []
|
|
1860
|
+
emitted: set[str] = set()
|
|
1861
|
+
for section_id in section_order:
|
|
1862
|
+
section = by_id.get(section_id)
|
|
1863
|
+
if section is None:
|
|
1864
|
+
continue
|
|
1865
|
+
ordered_sections.append(section)
|
|
1866
|
+
emitted.add(section_id)
|
|
1867
|
+
for section_id, section in by_id.items():
|
|
1868
|
+
if section_id in emitted:
|
|
1869
|
+
continue
|
|
1870
|
+
ordered_sections.append(section)
|
|
1871
|
+
return {
|
|
1872
|
+
"schema_version": 1,
|
|
1873
|
+
"outline_id": manifest.get("outline_id"),
|
|
1874
|
+
"status": manifest.get("status"),
|
|
1875
|
+
"title": manifest.get("title"),
|
|
1876
|
+
"note": manifest.get("note"),
|
|
1877
|
+
"story": manifest.get("story"),
|
|
1878
|
+
"ten_questions": manifest.get("ten_questions") if isinstance(manifest.get("ten_questions"), list) else [],
|
|
1879
|
+
"detailed_outline": manifest.get("detailed_outline") if isinstance(manifest.get("detailed_outline"), dict) else {},
|
|
1880
|
+
"sections": ordered_sections,
|
|
1881
|
+
"evidence_contract": manifest.get("evidence_contract") if isinstance(manifest.get("evidence_contract"), dict) else None,
|
|
1882
|
+
"created_at": manifest.get("created_at"),
|
|
1883
|
+
"updated_at": manifest.get("updated_at"),
|
|
1884
|
+
}
|
|
1885
|
+
selected_outline_path = paper_root / "selected_outline.json"
|
|
1886
|
+
payload = read_json(selected_outline_path, {})
|
|
1887
|
+
return payload if isinstance(payload, dict) else {}
|
|
1888
|
+
|
|
1889
|
+
def _paper_evidence_payload(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
|
|
1890
|
+
best_payload: dict[str, Any] | None = None
|
|
1891
|
+
best_rank: tuple[str, float] = ("", -1.0)
|
|
1892
|
+
for candidate in self._snapshot_workspace_candidates(quest_root, workspace_root):
|
|
1893
|
+
paper_root = candidate / "paper"
|
|
1894
|
+
ledger_json_path = paper_root / "evidence_ledger.json"
|
|
1895
|
+
if not ledger_json_path.exists():
|
|
1896
|
+
continue
|
|
1897
|
+
payload = read_json(ledger_json_path, {})
|
|
1898
|
+
if not isinstance(payload, dict) or not payload:
|
|
1899
|
+
continue
|
|
1900
|
+
items = [dict(item) for item in (payload.get("items") or []) if isinstance(item, dict)]
|
|
1901
|
+
latest = max(
|
|
1902
|
+
self._path_mtime(ledger_json_path),
|
|
1903
|
+
self._path_mtime(paper_root / "evidence_ledger.md"),
|
|
1904
|
+
self._path_mtime(paper_root),
|
|
1905
|
+
)
|
|
1906
|
+
rank = (str(payload.get("updated_at") or payload.get("created_at") or ""), latest)
|
|
1907
|
+
if rank < best_rank:
|
|
1908
|
+
continue
|
|
1909
|
+
best_rank = rank
|
|
1910
|
+
best_payload = {
|
|
1911
|
+
"paper_root": str(paper_root),
|
|
1912
|
+
"workspace_root": str(paper_root.parent),
|
|
1913
|
+
"selected_outline_ref": str(payload.get("selected_outline_ref") or "").strip() or None,
|
|
1914
|
+
"item_count": len(items),
|
|
1915
|
+
"main_text_ready_count": sum(
|
|
1916
|
+
1
|
|
1917
|
+
for item in items
|
|
1918
|
+
if str(item.get("paper_role") or "").strip() == "main_text"
|
|
1919
|
+
and str(item.get("status") or "").strip().lower() in {"ready", "completed", "analyzed", "written", "recorded", "supported"}
|
|
1920
|
+
),
|
|
1921
|
+
"appendix_item_count": sum(
|
|
1922
|
+
1 for item in items if str(item.get("paper_role") or "").strip() == "appendix"
|
|
1923
|
+
),
|
|
1924
|
+
"unmapped_item_count": sum(
|
|
1925
|
+
1
|
|
1926
|
+
for item in items
|
|
1927
|
+
if not str(item.get("section_id") or "").strip() or not str(item.get("paper_role") or "").strip()
|
|
1928
|
+
),
|
|
1929
|
+
"items": items[:40],
|
|
1930
|
+
"paths": {
|
|
1931
|
+
"ledger_json": str(ledger_json_path),
|
|
1932
|
+
"ledger_md": str(paper_root / "evidence_ledger.md") if (paper_root / "evidence_ledger.md").exists() else None,
|
|
1933
|
+
},
|
|
1934
|
+
}
|
|
1935
|
+
return best_payload
|
|
1936
|
+
|
|
1937
|
+
def _paper_contract_payload(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
|
|
1938
|
+
paper_root = self._best_paper_root(quest_root, workspace_root)
|
|
1939
|
+
if paper_root is None:
|
|
1940
|
+
return None
|
|
1941
|
+
selected_outline_path = paper_root / "selected_outline.json"
|
|
1942
|
+
selected_outline = self._outline_record_from_paper_root(paper_root)
|
|
1943
|
+
selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
|
|
1944
|
+
detailed_outline = (
|
|
1945
|
+
dict(selected_outline.get("detailed_outline") or {})
|
|
1946
|
+
if isinstance(selected_outline.get("detailed_outline"), dict)
|
|
1947
|
+
else {}
|
|
1948
|
+
)
|
|
1949
|
+
outline_manifest_path = paper_root / "outline" / "manifest.json"
|
|
1950
|
+
bundle_manifest_path = paper_root / "paper_bundle_manifest.json"
|
|
1951
|
+
bundle_manifest = read_json(bundle_manifest_path, {})
|
|
1952
|
+
bundle_manifest = bundle_manifest if isinstance(bundle_manifest, dict) else {}
|
|
1953
|
+
experiment_matrix_path = paper_root / "paper_experiment_matrix.md"
|
|
1954
|
+
experiment_matrix_json_path = paper_root / "paper_experiment_matrix.json"
|
|
1955
|
+
claim_map_path = paper_root / "claim_evidence_map.json"
|
|
1956
|
+
paper_line_state_path = paper_root / "paper_line_state.json"
|
|
1957
|
+
evidence_ledger = self._paper_evidence_payload(quest_root, workspace_root)
|
|
1958
|
+
checklist_path = paper_root / "review" / "submission_checklist.json"
|
|
1959
|
+
draft_path = paper_root / "draft.md"
|
|
1960
|
+
status_path = paper_root.parent / "status.md"
|
|
1961
|
+
summary_path = paper_root.parent / "SUMMARY.md"
|
|
1962
|
+
|
|
1963
|
+
raw_sections = selected_outline.get("sections") if isinstance(selected_outline.get("sections"), list) else []
|
|
1964
|
+
sections = []
|
|
1965
|
+
if raw_sections:
|
|
1966
|
+
for index, raw in enumerate(raw_sections, start=1):
|
|
1967
|
+
if not isinstance(raw, dict):
|
|
1968
|
+
continue
|
|
1969
|
+
title = str(raw.get("title") or raw.get("section_id") or "").strip()
|
|
1970
|
+
if not title:
|
|
1971
|
+
title = f"Section {index}"
|
|
1972
|
+
sections.append(
|
|
1973
|
+
{
|
|
1974
|
+
"section_id": str(raw.get("section_id") or slugify(title, f"section-{index}")).strip() or slugify(title, f"section-{index}"),
|
|
1975
|
+
"title": title,
|
|
1976
|
+
"paper_role": str(raw.get("paper_role") or "").strip() or None,
|
|
1977
|
+
"status": str(raw.get("status") or "").strip() or None,
|
|
1978
|
+
"claims": raw.get("claims") if isinstance(raw.get("claims"), list) else [],
|
|
1979
|
+
"required_items": raw.get("required_items") if isinstance(raw.get("required_items"), list) else [],
|
|
1980
|
+
"optional_items": raw.get("optional_items") if isinstance(raw.get("optional_items"), list) else [],
|
|
1981
|
+
"result_table": raw.get("result_table") if isinstance(raw.get("result_table"), list) else [],
|
|
1982
|
+
}
|
|
1983
|
+
)
|
|
1984
|
+
else:
|
|
1985
|
+
for item in detailed_outline.get("experimental_designs") or []:
|
|
1986
|
+
text = str(item or "").strip()
|
|
1987
|
+
if not text:
|
|
1988
|
+
continue
|
|
1989
|
+
sections.append(
|
|
1990
|
+
{
|
|
1991
|
+
"section_id": slugify(text, "section"),
|
|
1992
|
+
"title": text,
|
|
1993
|
+
"paper_role": "main_text",
|
|
1994
|
+
"status": "recorded",
|
|
1995
|
+
"claims": [],
|
|
1996
|
+
"required_items": [],
|
|
1997
|
+
"optional_items": [],
|
|
1998
|
+
"result_table": [],
|
|
1999
|
+
}
|
|
2000
|
+
)
|
|
2001
|
+
|
|
2002
|
+
return {
|
|
2003
|
+
"paper_root": str(paper_root),
|
|
2004
|
+
"workspace_root": str(paper_root.parent),
|
|
2005
|
+
"paper_branch": str(bundle_manifest.get("paper_branch") or "").strip() or current_branch(paper_root.parent),
|
|
2006
|
+
"source_branch": str(bundle_manifest.get("source_branch") or "").strip() or None,
|
|
2007
|
+
"selected_outline_ref": str(selected_outline.get("outline_id") or bundle_manifest.get("selected_outline_ref") or "").strip() or None,
|
|
2008
|
+
"title": str(selected_outline.get("title") or bundle_manifest.get("title") or "").strip() or None,
|
|
2009
|
+
"story": str(selected_outline.get("story") or "").strip() or None,
|
|
2010
|
+
"research_questions": detailed_outline.get("research_questions") if isinstance(detailed_outline.get("research_questions"), list) else [],
|
|
2011
|
+
"experimental_designs": detailed_outline.get("experimental_designs") if isinstance(detailed_outline.get("experimental_designs"), list) else [],
|
|
2012
|
+
"contributions": detailed_outline.get("contributions") if isinstance(detailed_outline.get("contributions"), list) else [],
|
|
2013
|
+
"evidence_contract": selected_outline.get("evidence_contract") if isinstance(selected_outline.get("evidence_contract"), dict) else None,
|
|
2014
|
+
"sections": sections,
|
|
2015
|
+
"evidence_summary": {
|
|
2016
|
+
"item_count": int((evidence_ledger or {}).get("item_count") or 0),
|
|
2017
|
+
"main_text_ready_count": int((evidence_ledger or {}).get("main_text_ready_count") or 0),
|
|
2018
|
+
"appendix_item_count": int((evidence_ledger or {}).get("appendix_item_count") or 0),
|
|
2019
|
+
"unmapped_item_count": int((evidence_ledger or {}).get("unmapped_item_count") or 0),
|
|
2020
|
+
},
|
|
2021
|
+
"summary": str(bundle_manifest.get("summary") or "").strip() or self._markdown_excerpt(summary_path),
|
|
2022
|
+
"paths": {
|
|
2023
|
+
"selected_outline": str(selected_outline_path) if selected_outline_path.exists() else None,
|
|
2024
|
+
"outline_manifest": str(outline_manifest_path) if outline_manifest_path.exists() else None,
|
|
2025
|
+
"experiment_matrix": str(experiment_matrix_path) if experiment_matrix_path.exists() else None,
|
|
2026
|
+
"experiment_matrix_json": str(experiment_matrix_json_path) if experiment_matrix_json_path.exists() else None,
|
|
2027
|
+
"bundle_manifest": str(bundle_manifest_path) if bundle_manifest_path.exists() else None,
|
|
2028
|
+
"claim_evidence_map": str(claim_map_path) if claim_map_path.exists() else None,
|
|
2029
|
+
"paper_line_state": str(paper_line_state_path) if paper_line_state_path.exists() else None,
|
|
2030
|
+
"evidence_ledger_json": str(((evidence_ledger or {}).get("paths") or {}).get("ledger_json")) if ((evidence_ledger or {}).get("paths") or {}).get("ledger_json") else None,
|
|
2031
|
+
"evidence_ledger_md": str(((evidence_ledger or {}).get("paths") or {}).get("ledger_md")) if ((evidence_ledger or {}).get("paths") or {}).get("ledger_md") else None,
|
|
2032
|
+
"submission_checklist": str(checklist_path) if checklist_path.exists() else None,
|
|
2033
|
+
"draft": str(draft_path) if draft_path.exists() else None,
|
|
2034
|
+
"status": str(status_path) if status_path.exists() else None,
|
|
2035
|
+
"summary": str(summary_path) if summary_path.exists() else None,
|
|
2036
|
+
},
|
|
2037
|
+
"bundle_manifest": bundle_manifest or None,
|
|
2038
|
+
"outline_payload": selected_outline or None,
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
def _paper_lines_payload(self, quest_root: Path, workspace_root: Path) -> tuple[list[dict[str, Any]], str | None]:
|
|
2042
|
+
lines_by_id: dict[str, dict[str, Any]] = {}
|
|
2043
|
+
active_ref: str | None = None
|
|
2044
|
+
for candidate in self._snapshot_workspace_candidates(quest_root, workspace_root):
|
|
2045
|
+
paper_root = candidate / "paper"
|
|
2046
|
+
if not paper_root.exists() or not paper_root.is_dir():
|
|
2047
|
+
continue
|
|
2048
|
+
state_path = paper_root / "paper_line_state.json"
|
|
2049
|
+
payload = read_json(state_path, {}) if state_path.exists() else {}
|
|
2050
|
+
if not isinstance(payload, dict) or not payload:
|
|
2051
|
+
contract = self._paper_contract_payload(quest_root, candidate)
|
|
2052
|
+
if not contract:
|
|
2053
|
+
continue
|
|
2054
|
+
bundle_manifest = (
|
|
2055
|
+
dict(contract.get("bundle_manifest") or {})
|
|
2056
|
+
if isinstance(contract.get("bundle_manifest"), dict)
|
|
2057
|
+
else {}
|
|
2058
|
+
)
|
|
2059
|
+
payload = {
|
|
2060
|
+
"paper_line_id": slugify(
|
|
2061
|
+
"::".join(
|
|
2062
|
+
[
|
|
2063
|
+
str(contract.get("paper_branch") or "paper").strip() or "paper",
|
|
2064
|
+
str(contract.get("selected_outline_ref") or "outline").strip() or "outline",
|
|
2065
|
+
str(bundle_manifest.get("source_run_id") or "run").strip() or "run",
|
|
2066
|
+
]
|
|
2067
|
+
),
|
|
2068
|
+
"paper-line",
|
|
2069
|
+
),
|
|
2070
|
+
"paper_branch": contract.get("paper_branch"),
|
|
2071
|
+
"paper_root": str(paper_root),
|
|
2072
|
+
"workspace_root": str(candidate),
|
|
2073
|
+
"source_branch": contract.get("source_branch"),
|
|
2074
|
+
"source_run_id": bundle_manifest.get("source_run_id"),
|
|
2075
|
+
"source_idea_id": bundle_manifest.get("source_idea_id"),
|
|
2076
|
+
"selected_outline_ref": contract.get("selected_outline_ref"),
|
|
2077
|
+
"title": contract.get("title"),
|
|
2078
|
+
"required_count": sum(len(item.get("required_items") or []) for item in (contract.get("sections") or [])),
|
|
2079
|
+
"ready_required_count": int((contract.get("evidence_summary") or {}).get("main_text_ready_count") or 0),
|
|
2080
|
+
"section_count": len(contract.get("sections") or []),
|
|
2081
|
+
"ready_section_count": 0,
|
|
2082
|
+
"unmapped_count": int((contract.get("evidence_summary") or {}).get("unmapped_item_count") or 0),
|
|
2083
|
+
"open_supplementary_count": 0,
|
|
2084
|
+
"draft_status": "present" if (paper_root / "draft.md").exists() else "missing",
|
|
2085
|
+
"bundle_status": "present" if (paper_root / "paper_bundle_manifest.json").exists() else "missing",
|
|
2086
|
+
"updated_at": "",
|
|
2087
|
+
}
|
|
2088
|
+
paper_line_id = str(payload.get("paper_line_id") or "").strip()
|
|
2089
|
+
if not paper_line_id:
|
|
2090
|
+
continue
|
|
2091
|
+
payload["paths"] = {
|
|
2092
|
+
"paper_line_state": str(state_path) if state_path.exists() else None,
|
|
2093
|
+
"paper_root": str(paper_root),
|
|
2094
|
+
}
|
|
2095
|
+
current = lines_by_id.get(paper_line_id)
|
|
2096
|
+
if current is None or str(payload.get("updated_at") or "") >= str(current.get("updated_at") or ""):
|
|
2097
|
+
lines_by_id[paper_line_id] = payload
|
|
2098
|
+
if str(candidate) == str(workspace_root):
|
|
2099
|
+
active_ref = paper_line_id
|
|
2100
|
+
lines = sorted(lines_by_id.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
|
2101
|
+
if not active_ref and lines:
|
|
2102
|
+
active_ref = str(lines[0].get("paper_line_id") or "").strip() or None
|
|
2103
|
+
return lines, active_ref
|
|
2104
|
+
|
|
2105
|
+
def _analysis_inventory_payload(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
|
|
2106
|
+
manifest_by_id: dict[str, dict[str, Any]] = {}
|
|
2107
|
+
campaigns_root = quest_root / ".ds" / "analysis_campaigns"
|
|
2108
|
+
if campaigns_root.exists():
|
|
2109
|
+
for path in sorted(campaigns_root.glob("*.json")):
|
|
2110
|
+
payload = read_json(path, {})
|
|
2111
|
+
if not isinstance(payload, dict) or not payload:
|
|
2112
|
+
continue
|
|
2113
|
+
campaign_id = str(payload.get("campaign_id") or path.stem).strip() or path.stem
|
|
2114
|
+
manifest_by_id[campaign_id] = payload
|
|
2115
|
+
campaigns_by_id: dict[str, dict[str, Any]] = {}
|
|
2116
|
+
for candidate in self._snapshot_workspace_candidates(quest_root, workspace_root):
|
|
2117
|
+
analysis_root = candidate / "experiments" / "analysis-results"
|
|
2118
|
+
if not analysis_root.exists() or not analysis_root.is_dir():
|
|
2119
|
+
continue
|
|
2120
|
+
for campaign_dir in sorted(analysis_root.iterdir()):
|
|
2121
|
+
if not campaign_dir.is_dir():
|
|
2122
|
+
continue
|
|
2123
|
+
campaign_id = campaign_dir.name
|
|
2124
|
+
todo_manifest_path = campaign_dir / "todo_manifest.json"
|
|
2125
|
+
campaign_md_path = campaign_dir / "campaign.md"
|
|
2126
|
+
summary_md_path = campaign_dir / "SUMMARY.md"
|
|
2127
|
+
todo_manifest = read_json(todo_manifest_path, {})
|
|
2128
|
+
todo_manifest = todo_manifest if isinstance(todo_manifest, dict) else {}
|
|
2129
|
+
campaign_manifest = dict(manifest_by_id.get(campaign_id) or {})
|
|
2130
|
+
todo_items = todo_manifest.get("todo_items") if isinstance(todo_manifest.get("todo_items"), list) else []
|
|
2131
|
+
manifest_slices = {
|
|
2132
|
+
str(item.get("slice_id") or "").strip(): dict(item)
|
|
2133
|
+
for item in (campaign_manifest.get("slices") or [])
|
|
2134
|
+
if isinstance(item, dict) and str(item.get("slice_id") or "").strip()
|
|
2135
|
+
}
|
|
2136
|
+
slice_files = []
|
|
2137
|
+
for path in sorted(campaign_dir.glob("*.md")):
|
|
2138
|
+
if path.name in {"campaign.md", "SUMMARY.md"}:
|
|
2139
|
+
continue
|
|
2140
|
+
slice_files.append(path)
|
|
2141
|
+
slices: list[dict[str, Any]] = []
|
|
2142
|
+
for index, path in enumerate(slice_files):
|
|
2143
|
+
matched_todo = todo_items[index] if index < len(todo_items) and isinstance(todo_items[index], dict) else {}
|
|
2144
|
+
slice_id = str(matched_todo.get("slice_id") or path.stem).strip() or path.stem
|
|
2145
|
+
title = str(matched_todo.get("title") or path.stem).strip() or path.stem
|
|
2146
|
+
manifest_slice = dict(manifest_slices.get(slice_id) or {})
|
|
2147
|
+
slices.append(
|
|
2148
|
+
{
|
|
2149
|
+
"slice_id": slice_id,
|
|
2150
|
+
"title": title,
|
|
2151
|
+
"status": str(manifest_slice.get("status") or matched_todo.get("status") or "completed").strip() or "completed",
|
|
2152
|
+
"tier": str(matched_todo.get("tier") or "").strip() or None,
|
|
2153
|
+
"exp_id": str(matched_todo.get("exp_id") or "").strip() or None,
|
|
2154
|
+
"paper_role": str(matched_todo.get("paper_placement") or matched_todo.get("paper_role") or "").strip() or None,
|
|
2155
|
+
"section_id": str(matched_todo.get("section_id") or "").strip() or None,
|
|
2156
|
+
"item_id": str(matched_todo.get("item_id") or "").strip() or None,
|
|
2157
|
+
"claim_links": matched_todo.get("claim_links") if isinstance(matched_todo.get("claim_links"), list) else [],
|
|
2158
|
+
"research_question": str(matched_todo.get("research_question") or "").strip() or None,
|
|
2159
|
+
"experimental_design": str(matched_todo.get("experimental_design") or "").strip() or None,
|
|
2160
|
+
"branch": str(manifest_slice.get("branch") or "").strip() or None,
|
|
2161
|
+
"worktree_root": str(manifest_slice.get("worktree_root") or "").strip() or None,
|
|
2162
|
+
"mapped": bool(
|
|
2163
|
+
str(matched_todo.get("section_id") or "").strip()
|
|
2164
|
+
and str(matched_todo.get("item_id") or "").strip()
|
|
2165
|
+
and str(matched_todo.get("paper_placement") or matched_todo.get("paper_role") or "").strip()
|
|
2166
|
+
),
|
|
2167
|
+
"result_path": str(path),
|
|
2168
|
+
"result_excerpt": self._markdown_excerpt(path, max_lines=6),
|
|
2169
|
+
}
|
|
2170
|
+
)
|
|
2171
|
+
record = {
|
|
2172
|
+
"campaign_id": campaign_id,
|
|
2173
|
+
"title": str((todo_manifest.get("campaign_origin") or {}).get("reason") or campaign_id).strip() or campaign_id,
|
|
2174
|
+
"active_idea_id": str(campaign_manifest.get("active_idea_id") or "").strip() or None,
|
|
2175
|
+
"parent_run_id": str(campaign_manifest.get("parent_run_id") or "").strip() or None,
|
|
2176
|
+
"parent_branch": str(campaign_manifest.get("parent_branch") or "").strip() or None,
|
|
2177
|
+
"paper_line_id": str(campaign_manifest.get("paper_line_id") or "").strip() or None,
|
|
2178
|
+
"paper_line_branch": str(campaign_manifest.get("paper_line_branch") or "").strip() or None,
|
|
2179
|
+
"paper_line_root": str(campaign_manifest.get("paper_line_root") or "").strip() or None,
|
|
2180
|
+
"selected_outline_ref": str(campaign_manifest.get("selected_outline_ref") or todo_manifest.get("selected_outline_ref") or "").strip() or None,
|
|
2181
|
+
"todo_manifest_path": str(todo_manifest_path) if todo_manifest_path.exists() else None,
|
|
2182
|
+
"campaign_path": str(campaign_md_path) if campaign_md_path.exists() else None,
|
|
2183
|
+
"summary_path": str(summary_md_path) if summary_md_path.exists() else None,
|
|
2184
|
+
"summary_excerpt": self._markdown_excerpt(summary_md_path, max_lines=10),
|
|
2185
|
+
"updated_at": str(campaign_manifest.get("updated_at") or "").strip() or None,
|
|
2186
|
+
"slice_count": len(slices),
|
|
2187
|
+
"completed_slice_count": sum(1 for item in slices if str(item.get("status") or "") == "completed"),
|
|
2188
|
+
"mapped_slice_count": sum(1 for item in slices if bool(item.get("mapped"))),
|
|
2189
|
+
"pending_slice_count": sum(1 for item in slices if str(item.get("status") or "") != "completed"),
|
|
2190
|
+
"slices": slices,
|
|
2191
|
+
"_rank": (
|
|
2192
|
+
len(slices),
|
|
2193
|
+
max(
|
|
2194
|
+
self._path_mtime(summary_md_path),
|
|
2195
|
+
self._path_mtime(campaign_md_path),
|
|
2196
|
+
self._path_mtime(todo_manifest_path),
|
|
2197
|
+
self._path_mtime(campaigns_root / f"{campaign_id}.json"),
|
|
2198
|
+
self._path_mtime(campaign_dir),
|
|
2199
|
+
),
|
|
2200
|
+
),
|
|
2201
|
+
}
|
|
2202
|
+
current = campaigns_by_id.get(campaign_id)
|
|
2203
|
+
if current is None or record["_rank"] >= current["_rank"]:
|
|
2204
|
+
campaigns_by_id[campaign_id] = record
|
|
2205
|
+
|
|
2206
|
+
if not campaigns_by_id:
|
|
2207
|
+
return None
|
|
2208
|
+
campaigns = []
|
|
2209
|
+
total_slices = 0
|
|
2210
|
+
total_completed = 0
|
|
2211
|
+
total_mapped = 0
|
|
2212
|
+
for item in sorted(
|
|
2213
|
+
campaigns_by_id.values(),
|
|
2214
|
+
key=lambda payload: (payload["_rank"][1], payload["campaign_id"]),
|
|
2215
|
+
reverse=True,
|
|
2216
|
+
):
|
|
2217
|
+
total_slices += int(item.get("slice_count") or 0)
|
|
2218
|
+
total_completed += int(item.get("completed_slice_count") or 0)
|
|
2219
|
+
total_mapped += int(item.get("mapped_slice_count") or 0)
|
|
2220
|
+
campaigns.append({key: value for key, value in item.items() if key != "_rank"})
|
|
2221
|
+
return {
|
|
2222
|
+
"campaign_count": len(campaigns),
|
|
2223
|
+
"slice_count": total_slices,
|
|
2224
|
+
"completed_slice_count": total_completed,
|
|
2225
|
+
"mapped_slice_count": total_mapped,
|
|
2226
|
+
"campaigns": campaigns,
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
def _idea_lines_payload(
|
|
2230
|
+
self,
|
|
2231
|
+
quest_root: Path,
|
|
2232
|
+
*,
|
|
2233
|
+
paper_lines: list[dict[str, Any]],
|
|
2234
|
+
analysis_inventory: dict[str, Any] | None,
|
|
2235
|
+
) -> tuple[list[dict[str, Any]], str | None]:
|
|
2236
|
+
artifacts = self._collect_artifacts(quest_root)
|
|
2237
|
+
research_state = self.read_research_state(quest_root)
|
|
2238
|
+
active_idea_id = str(research_state.get("active_idea_id") or "").strip() or None
|
|
2239
|
+
active_ref: str | None = None
|
|
2240
|
+
lines_by_id: dict[str, dict[str, Any]] = {}
|
|
2241
|
+
|
|
2242
|
+
def ensure_line(idea_id: str) -> dict[str, Any]:
|
|
2243
|
+
current = lines_by_id.get(idea_id)
|
|
2244
|
+
if current is None:
|
|
2245
|
+
current = {
|
|
2246
|
+
"idea_line_id": idea_id,
|
|
2247
|
+
"idea_id": idea_id,
|
|
2248
|
+
"idea_branch": None,
|
|
2249
|
+
"idea_title": None,
|
|
2250
|
+
"lineage_intent": None,
|
|
2251
|
+
"parent_branch": None,
|
|
2252
|
+
"latest_main_run_id": None,
|
|
2253
|
+
"latest_main_run_branch": None,
|
|
2254
|
+
"paper_line_id": None,
|
|
2255
|
+
"paper_branch": None,
|
|
2256
|
+
"selected_outline_ref": None,
|
|
2257
|
+
"analysis_campaign_count": 0,
|
|
2258
|
+
"analysis_slice_count": 0,
|
|
2259
|
+
"completed_analysis_slice_count": 0,
|
|
2260
|
+
"mapped_analysis_slice_count": 0,
|
|
2261
|
+
"required_count": 0,
|
|
2262
|
+
"ready_required_count": 0,
|
|
2263
|
+
"unmapped_count": 0,
|
|
2264
|
+
"open_supplementary_count": 0,
|
|
2265
|
+
"draft_status": None,
|
|
2266
|
+
"bundle_status": None,
|
|
2267
|
+
"updated_at": "",
|
|
2268
|
+
"paths": {
|
|
2269
|
+
"idea_md": None,
|
|
2270
|
+
"idea_draft": None,
|
|
2271
|
+
"paper_line_state": None,
|
|
2272
|
+
},
|
|
2273
|
+
}
|
|
2274
|
+
lines_by_id[idea_id] = current
|
|
2275
|
+
return current
|
|
2276
|
+
|
|
2277
|
+
def updated_rank(value: object) -> str:
|
|
2278
|
+
return str(value or "").strip()
|
|
2279
|
+
|
|
2280
|
+
for artifact in artifacts:
|
|
2281
|
+
kind = str(artifact.get("kind") or "").strip()
|
|
2282
|
+
payload = artifact.get("payload") if isinstance(artifact.get("payload"), dict) else {}
|
|
2283
|
+
if not payload:
|
|
503
2284
|
continue
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if
|
|
2285
|
+
idea_id = str(payload.get("idea_id") or "").strip()
|
|
2286
|
+
if not idea_id:
|
|
2287
|
+
continue
|
|
2288
|
+
entry = ensure_line(idea_id)
|
|
2289
|
+
if kind == "ideas":
|
|
2290
|
+
current_rank = updated_rank(entry.get("updated_at"))
|
|
2291
|
+
candidate_rank = updated_rank(payload.get("updated_at") or payload.get("created_at"))
|
|
2292
|
+
if candidate_rank >= current_rank:
|
|
2293
|
+
details = dict(payload.get("details") or {}) if isinstance(payload.get("details"), dict) else {}
|
|
2294
|
+
paths = dict(payload.get("paths") or {}) if isinstance(payload.get("paths"), dict) else {}
|
|
2295
|
+
entry["idea_branch"] = str(payload.get("branch") or "").strip() or entry.get("idea_branch")
|
|
2296
|
+
entry["idea_title"] = str(details.get("title") or payload.get("title") or "").strip() or entry.get("idea_title")
|
|
2297
|
+
entry["lineage_intent"] = str(payload.get("lineage_intent") or details.get("lineage_intent") or "").strip() or entry.get("lineage_intent")
|
|
2298
|
+
entry["parent_branch"] = str(payload.get("parent_branch") or details.get("parent_branch") or "").strip() or entry.get("parent_branch")
|
|
2299
|
+
entry["updated_at"] = candidate_rank or entry.get("updated_at")
|
|
2300
|
+
entry["paths"] = {
|
|
2301
|
+
**dict(entry.get("paths") or {}),
|
|
2302
|
+
"idea_md": str(paths.get("idea_md") or "").strip() or dict(entry.get("paths") or {}).get("idea_md"),
|
|
2303
|
+
"idea_draft": str(paths.get("idea_draft_md") or details.get("idea_draft_path") or "").strip()
|
|
2304
|
+
or dict(entry.get("paths") or {}).get("idea_draft"),
|
|
2305
|
+
}
|
|
2306
|
+
elif kind == "runs":
|
|
2307
|
+
branch = str(payload.get("branch") or "").strip()
|
|
2308
|
+
run_id = str(payload.get("run_id") or "").strip()
|
|
2309
|
+
run_kind = str(payload.get("run_kind") or "").strip().lower()
|
|
2310
|
+
if not run_id or branch.startswith("analysis/") or branch.startswith("paper/") or run_kind.startswith("analysis"):
|
|
512
2311
|
continue
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
2312
|
+
current_rank = updated_rank(entry.get("latest_main_run_updated_at"))
|
|
2313
|
+
candidate_rank = updated_rank(payload.get("updated_at") or payload.get("created_at"))
|
|
2314
|
+
if candidate_rank >= current_rank:
|
|
2315
|
+
entry["latest_main_run_id"] = run_id
|
|
2316
|
+
entry["latest_main_run_branch"] = branch or entry.get("latest_main_run_branch")
|
|
2317
|
+
entry["latest_main_run_updated_at"] = candidate_rank
|
|
2318
|
+
entry["updated_at"] = max(updated_rank(entry.get("updated_at")), candidate_rank)
|
|
2319
|
+
|
|
2320
|
+
for line in paper_lines:
|
|
2321
|
+
idea_id = str(line.get("source_idea_id") or "").strip()
|
|
2322
|
+
if not idea_id:
|
|
2323
|
+
continue
|
|
2324
|
+
entry = ensure_line(idea_id)
|
|
2325
|
+
current_rank = updated_rank(entry.get("paper_line_updated_at"))
|
|
2326
|
+
candidate_rank = updated_rank(line.get("updated_at"))
|
|
2327
|
+
if candidate_rank >= current_rank:
|
|
2328
|
+
entry["paper_line_id"] = str(line.get("paper_line_id") or "").strip() or entry.get("paper_line_id")
|
|
2329
|
+
entry["paper_branch"] = str(line.get("paper_branch") or "").strip() or entry.get("paper_branch")
|
|
2330
|
+
entry["selected_outline_ref"] = str(line.get("selected_outline_ref") or "").strip() or entry.get("selected_outline_ref")
|
|
2331
|
+
entry["required_count"] = int(line.get("required_count") or 0)
|
|
2332
|
+
entry["ready_required_count"] = int(line.get("ready_required_count") or 0)
|
|
2333
|
+
entry["unmapped_count"] = int(line.get("unmapped_count") or 0)
|
|
2334
|
+
entry["open_supplementary_count"] = int(line.get("open_supplementary_count") or 0)
|
|
2335
|
+
entry["draft_status"] = str(line.get("draft_status") or "").strip() or None
|
|
2336
|
+
entry["bundle_status"] = str(line.get("bundle_status") or "").strip() or None
|
|
2337
|
+
entry["paper_line_updated_at"] = candidate_rank
|
|
2338
|
+
entry["updated_at"] = max(updated_rank(entry.get("updated_at")), candidate_rank)
|
|
2339
|
+
entry["paths"] = {
|
|
2340
|
+
**dict(entry.get("paths") or {}),
|
|
2341
|
+
"paper_line_state": str(((line.get("paths") or {}) if isinstance(line.get("paths"), dict) else {}).get("paper_line_state") or "").strip()
|
|
2342
|
+
or dict(entry.get("paths") or {}).get("paper_line_state"),
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
campaigns = list((analysis_inventory or {}).get("campaigns") or []) if isinstance(analysis_inventory, dict) else []
|
|
2346
|
+
for campaign in campaigns:
|
|
2347
|
+
if not isinstance(campaign, dict):
|
|
2348
|
+
continue
|
|
2349
|
+
matched_idea_id = str(campaign.get("active_idea_id") or "").strip()
|
|
2350
|
+
if not matched_idea_id:
|
|
2351
|
+
matched_run_id = str(campaign.get("parent_run_id") or "").strip()
|
|
2352
|
+
matched_branch = str(campaign.get("parent_branch") or "").strip()
|
|
2353
|
+
for candidate in lines_by_id.values():
|
|
2354
|
+
if matched_run_id and matched_run_id == str(candidate.get("latest_main_run_id") or "").strip():
|
|
2355
|
+
matched_idea_id = str(candidate.get("idea_id") or "").strip()
|
|
2356
|
+
break
|
|
2357
|
+
if matched_branch and matched_branch in {
|
|
2358
|
+
str(candidate.get("idea_branch") or "").strip(),
|
|
2359
|
+
str(candidate.get("latest_main_run_branch") or "").strip(),
|
|
2360
|
+
}:
|
|
2361
|
+
matched_idea_id = str(candidate.get("idea_id") or "").strip()
|
|
2362
|
+
break
|
|
2363
|
+
if not matched_idea_id:
|
|
2364
|
+
continue
|
|
2365
|
+
entry = ensure_line(matched_idea_id)
|
|
2366
|
+
entry["analysis_campaign_count"] = int(entry.get("analysis_campaign_count") or 0) + 1
|
|
2367
|
+
entry["analysis_slice_count"] = int(entry.get("analysis_slice_count") or 0) + int(campaign.get("slice_count") or 0)
|
|
2368
|
+
entry["completed_analysis_slice_count"] = int(entry.get("completed_analysis_slice_count") or 0) + int(
|
|
2369
|
+
campaign.get("completed_slice_count") or 0
|
|
2370
|
+
)
|
|
2371
|
+
entry["mapped_analysis_slice_count"] = int(entry.get("mapped_analysis_slice_count") or 0) + int(
|
|
2372
|
+
campaign.get("mapped_slice_count") or 0
|
|
2373
|
+
)
|
|
2374
|
+
if not entry.get("paper_line_id") and str(campaign.get("paper_line_id") or "").strip():
|
|
2375
|
+
entry["paper_line_id"] = str(campaign.get("paper_line_id") or "").strip()
|
|
2376
|
+
entry["paper_branch"] = str(campaign.get("paper_line_branch") or "").strip() or entry.get("paper_branch")
|
|
2377
|
+
entry["selected_outline_ref"] = str(campaign.get("selected_outline_ref") or "").strip() or entry.get("selected_outline_ref")
|
|
2378
|
+
entry["updated_at"] = max(
|
|
2379
|
+
updated_rank(entry.get("updated_at")),
|
|
2380
|
+
updated_rank(campaign.get("updated_at")),
|
|
2381
|
+
)
|
|
2382
|
+
|
|
2383
|
+
lines = sorted(
|
|
2384
|
+
lines_by_id.values(),
|
|
519
2385
|
key=lambda item: (
|
|
520
|
-
str(item.get("
|
|
521
|
-
str(item.get("
|
|
2386
|
+
0 if str(item.get("idea_id") or "").strip() == active_idea_id else 1,
|
|
2387
|
+
str(item.get("updated_at") or ""),
|
|
2388
|
+
str(item.get("idea_line_id") or ""),
|
|
2389
|
+
),
|
|
2390
|
+
)
|
|
2391
|
+
for item in lines:
|
|
2392
|
+
if not item.get("open_supplementary_count"):
|
|
2393
|
+
pending = max(
|
|
2394
|
+
0,
|
|
2395
|
+
int(item.get("analysis_slice_count") or 0) - int(item.get("completed_analysis_slice_count") or 0),
|
|
2396
|
+
)
|
|
2397
|
+
item["open_supplementary_count"] = pending
|
|
2398
|
+
item.pop("latest_main_run_updated_at", None)
|
|
2399
|
+
item.pop("paper_line_updated_at", None)
|
|
2400
|
+
if active_idea_id and active_idea_id in lines_by_id:
|
|
2401
|
+
active_ref = active_idea_id
|
|
2402
|
+
elif lines:
|
|
2403
|
+
active_ref = str(lines[0].get("idea_line_id") or "").strip() or None
|
|
2404
|
+
return lines, active_ref
|
|
2405
|
+
|
|
2406
|
+
def _paper_contract_health_payload(
|
|
2407
|
+
self,
|
|
2408
|
+
*,
|
|
2409
|
+
paper_contract: dict[str, Any] | None,
|
|
2410
|
+
paper_evidence: dict[str, Any] | None,
|
|
2411
|
+
analysis_inventory: dict[str, Any] | None,
|
|
2412
|
+
paper_lines: list[dict[str, Any]],
|
|
2413
|
+
active_paper_line_ref: str | None,
|
|
2414
|
+
) -> dict[str, Any] | None:
|
|
2415
|
+
if not isinstance(paper_contract, dict) or not paper_contract:
|
|
2416
|
+
return None
|
|
2417
|
+
evidence_items = [
|
|
2418
|
+
dict(item) for item in ((paper_evidence or {}).get("items") or []) if isinstance(item, dict)
|
|
2419
|
+
]
|
|
2420
|
+
ledger_by_item = {
|
|
2421
|
+
str(item.get("item_id") or "").strip(): item
|
|
2422
|
+
for item in evidence_items
|
|
2423
|
+
if str(item.get("item_id") or "").strip()
|
|
2424
|
+
}
|
|
2425
|
+
unresolved_required_items: list[dict[str, Any]] = []
|
|
2426
|
+
ready_section_count = 0
|
|
2427
|
+
for section in paper_contract.get("sections") or []:
|
|
2428
|
+
if not isinstance(section, dict):
|
|
2429
|
+
continue
|
|
2430
|
+
required_items = [str(item).strip() for item in (section.get("required_items") or []) if str(item).strip()]
|
|
2431
|
+
section_ready = True
|
|
2432
|
+
for item_id in required_items:
|
|
2433
|
+
ledger_item = ledger_by_item.get(item_id)
|
|
2434
|
+
status = str((ledger_item or {}).get("status") or "").strip().lower()
|
|
2435
|
+
if status not in {"ready", "completed", "analyzed", "written", "recorded", "supported"}:
|
|
2436
|
+
unresolved_required_items.append(
|
|
2437
|
+
{
|
|
2438
|
+
"section_id": str(section.get("section_id") or "").strip() or None,
|
|
2439
|
+
"section_title": str(section.get("title") or "").strip() or None,
|
|
2440
|
+
"item_id": item_id,
|
|
2441
|
+
"status": str((ledger_item or {}).get("status") or "").strip() or None,
|
|
2442
|
+
}
|
|
2443
|
+
)
|
|
2444
|
+
section_ready = False
|
|
2445
|
+
if required_items and section_ready:
|
|
2446
|
+
ready_section_count += 1
|
|
2447
|
+
|
|
2448
|
+
selected_outline_ref = str(paper_contract.get("selected_outline_ref") or "").strip() or None
|
|
2449
|
+
active_line = next(
|
|
2450
|
+
(
|
|
2451
|
+
dict(item)
|
|
2452
|
+
for item in paper_lines
|
|
2453
|
+
if isinstance(item, dict)
|
|
2454
|
+
and str(item.get("paper_line_id") or "").strip()
|
|
2455
|
+
and str(item.get("paper_line_id") or "").strip() == str(active_paper_line_ref or "").strip()
|
|
522
2456
|
),
|
|
2457
|
+
dict(paper_lines[0]) if paper_lines else {},
|
|
2458
|
+
)
|
|
2459
|
+
active_line_id = str(active_line.get("paper_line_id") or "").strip() or None
|
|
2460
|
+
active_line_branch = str(active_line.get("paper_branch") or "").strip() or None
|
|
2461
|
+
|
|
2462
|
+
campaigns = [dict(item) for item in ((analysis_inventory or {}).get("campaigns") or []) if isinstance(item, dict)]
|
|
2463
|
+
relevant_campaigns: list[dict[str, Any]] = []
|
|
2464
|
+
for campaign in campaigns:
|
|
2465
|
+
campaign_outline = str(campaign.get("selected_outline_ref") or "").strip() or None
|
|
2466
|
+
campaign_line_id = str(campaign.get("paper_line_id") or "").strip() or None
|
|
2467
|
+
campaign_line_branch = str(campaign.get("paper_line_branch") or "").strip() or None
|
|
2468
|
+
if active_line_id and campaign_line_id == active_line_id:
|
|
2469
|
+
relevant_campaigns.append(campaign)
|
|
2470
|
+
continue
|
|
2471
|
+
if active_line_branch and campaign_line_branch == active_line_branch:
|
|
2472
|
+
relevant_campaigns.append(campaign)
|
|
2473
|
+
continue
|
|
2474
|
+
if selected_outline_ref and campaign_outline == selected_outline_ref:
|
|
2475
|
+
relevant_campaigns.append(campaign)
|
|
2476
|
+
|
|
2477
|
+
unmapped_completed_items: list[dict[str, Any]] = []
|
|
2478
|
+
blocking_pending_slices: list[dict[str, Any]] = []
|
|
2479
|
+
for campaign in relevant_campaigns:
|
|
2480
|
+
for slice_item in campaign.get("slices") or []:
|
|
2481
|
+
if not isinstance(slice_item, dict):
|
|
2482
|
+
continue
|
|
2483
|
+
status = str(slice_item.get("status") or "").strip().lower()
|
|
2484
|
+
if status == "completed" and not bool(slice_item.get("mapped")):
|
|
2485
|
+
unmapped_completed_items.append(
|
|
2486
|
+
{
|
|
2487
|
+
"campaign_id": str(campaign.get("campaign_id") or "").strip() or None,
|
|
2488
|
+
"slice_id": str(slice_item.get("slice_id") or "").strip() or None,
|
|
2489
|
+
"item_id": str(slice_item.get("item_id") or "").strip() or None,
|
|
2490
|
+
"section_id": str(slice_item.get("section_id") or "").strip() or None,
|
|
2491
|
+
"title": str(slice_item.get("title") or "").strip() or None,
|
|
2492
|
+
}
|
|
2493
|
+
)
|
|
2494
|
+
if status in {"", "pending"}:
|
|
2495
|
+
paper_role = str(slice_item.get("paper_role") or "").strip().lower()
|
|
2496
|
+
tier = str(slice_item.get("tier") or "").strip().lower()
|
|
2497
|
+
if paper_role == "main_text" or tier == "main_required":
|
|
2498
|
+
blocking_pending_slices.append(
|
|
2499
|
+
{
|
|
2500
|
+
"campaign_id": str(campaign.get("campaign_id") or "").strip() or None,
|
|
2501
|
+
"slice_id": str(slice_item.get("slice_id") or "").strip() or None,
|
|
2502
|
+
"item_id": str(slice_item.get("item_id") or "").strip() or None,
|
|
2503
|
+
"section_id": str(slice_item.get("section_id") or "").strip() or None,
|
|
2504
|
+
"title": str(slice_item.get("title") or "").strip() or None,
|
|
2505
|
+
}
|
|
2506
|
+
)
|
|
2507
|
+
|
|
2508
|
+
contract_ok = not unresolved_required_items and not unmapped_completed_items
|
|
2509
|
+
writing_ready = contract_ok and not blocking_pending_slices
|
|
2510
|
+
draft_path = str((paper_contract.get("paths") or {}).get("draft") or "").strip()
|
|
2511
|
+
draft_status = str(active_line.get("draft_status") or "").strip() or ("present" if draft_path else "missing")
|
|
2512
|
+
bundle_status = str(active_line.get("bundle_status") or "").strip() or (
|
|
2513
|
+
"present" if str((paper_contract.get("paths") or {}).get("bundle_manifest") or "").strip() else "missing"
|
|
523
2514
|
)
|
|
2515
|
+
bundle_manifest = (
|
|
2516
|
+
dict(paper_contract.get("bundle_manifest") or {})
|
|
2517
|
+
if isinstance(paper_contract.get("bundle_manifest"), dict)
|
|
2518
|
+
else {}
|
|
2519
|
+
)
|
|
2520
|
+
submission_checklist_path = str(((paper_contract.get("paths") or {}).get("submission_checklist") or "")).strip()
|
|
2521
|
+
submission_checklist = read_json(Path(submission_checklist_path), {}) if submission_checklist_path else {}
|
|
2522
|
+
submission_checklist = submission_checklist if isinstance(submission_checklist, dict) else {}
|
|
2523
|
+
overall_status = str(submission_checklist.get("overall_status") or bundle_manifest.get("status") or "").strip().lower()
|
|
2524
|
+
delivered_at = str(
|
|
2525
|
+
bundle_manifest.get("paper_delivered_to_user_at")
|
|
2526
|
+
or bundle_manifest.get("delivered_at")
|
|
2527
|
+
or submission_checklist.get("paper_delivered_to_user_at")
|
|
2528
|
+
or ""
|
|
2529
|
+
).strip() or None
|
|
2530
|
+
closure_state = "bundle_not_ready"
|
|
2531
|
+
delivery_state = "not_ready"
|
|
2532
|
+
keep_bundle_fixed_by_default = False
|
|
2533
|
+
if bundle_status == "present":
|
|
2534
|
+
closure_state = "delivery_ready"
|
|
2535
|
+
delivery_state = "bundle_ready"
|
|
2536
|
+
if delivered_at or "delivered" in overall_status:
|
|
2537
|
+
delivery_state = "delivered"
|
|
2538
|
+
closure_state = "delivered_continue_research" if "continue" in overall_status else "delivered_parked"
|
|
2539
|
+
keep_bundle_fixed_by_default = True
|
|
2540
|
+
|
|
2541
|
+
if unmapped_completed_items:
|
|
2542
|
+
recommended_next_stage = "write"
|
|
2543
|
+
recommended_action = "sync_paper_contract"
|
|
2544
|
+
elif unresolved_required_items or blocking_pending_slices:
|
|
2545
|
+
recommended_next_stage = "analysis-campaign"
|
|
2546
|
+
recommended_action = "complete_required_supplementary"
|
|
2547
|
+
elif draft_status != "present":
|
|
2548
|
+
recommended_next_stage = "write"
|
|
2549
|
+
recommended_action = "draft_paper"
|
|
2550
|
+
elif bundle_status != "present":
|
|
2551
|
+
recommended_next_stage = "write"
|
|
2552
|
+
recommended_action = "prepare_bundle"
|
|
2553
|
+
else:
|
|
2554
|
+
recommended_next_stage = "finalize"
|
|
2555
|
+
recommended_action = "finalize_paper_line"
|
|
2556
|
+
|
|
2557
|
+
blocking_reasons: list[str] = []
|
|
2558
|
+
if unmapped_completed_items:
|
|
2559
|
+
blocking_reasons.append("completed analysis remains unmapped into the paper contract")
|
|
2560
|
+
if unresolved_required_items:
|
|
2561
|
+
blocking_reasons.append("required outline items are still unresolved")
|
|
2562
|
+
if blocking_pending_slices:
|
|
2563
|
+
blocking_reasons.append("main-text supplementary slices are still pending")
|
|
2564
|
+
|
|
2565
|
+
return {
|
|
2566
|
+
"paper_line_id": active_line_id,
|
|
2567
|
+
"paper_branch": active_line_branch,
|
|
2568
|
+
"selected_outline_ref": selected_outline_ref,
|
|
2569
|
+
"contract_ok": contract_ok,
|
|
2570
|
+
"writing_ready": writing_ready,
|
|
2571
|
+
"finalize_ready": writing_ready and bundle_status == "present",
|
|
2572
|
+
"closure_state": closure_state,
|
|
2573
|
+
"delivery_state": delivery_state,
|
|
2574
|
+
"delivered_at": delivered_at,
|
|
2575
|
+
"keep_bundle_fixed_by_default": keep_bundle_fixed_by_default,
|
|
2576
|
+
"required_count": sum(
|
|
2577
|
+
len(section.get("required_items") or [])
|
|
2578
|
+
for section in (paper_contract.get("sections") or [])
|
|
2579
|
+
if isinstance(section, dict)
|
|
2580
|
+
),
|
|
2581
|
+
"ready_required_count": max(
|
|
2582
|
+
0,
|
|
2583
|
+
sum(
|
|
2584
|
+
len(section.get("required_items") or [])
|
|
2585
|
+
for section in (paper_contract.get("sections") or [])
|
|
2586
|
+
if isinstance(section, dict)
|
|
2587
|
+
)
|
|
2588
|
+
- len(unresolved_required_items),
|
|
2589
|
+
),
|
|
2590
|
+
"section_count": len([section for section in (paper_contract.get("sections") or []) if isinstance(section, dict)]),
|
|
2591
|
+
"ready_section_count": ready_section_count,
|
|
2592
|
+
"ledger_item_count": len(evidence_items),
|
|
2593
|
+
"unresolved_required_count": len(unresolved_required_items),
|
|
2594
|
+
"unmapped_completed_count": len(unmapped_completed_items),
|
|
2595
|
+
"open_supplementary_count": int(active_line.get("open_supplementary_count") or 0),
|
|
2596
|
+
"blocking_open_supplementary_count": len(blocking_pending_slices),
|
|
2597
|
+
"draft_status": draft_status,
|
|
2598
|
+
"bundle_status": bundle_status,
|
|
2599
|
+
"blocking_reasons": blocking_reasons,
|
|
2600
|
+
"recommended_next_stage": recommended_next_stage,
|
|
2601
|
+
"recommended_action": recommended_action,
|
|
2602
|
+
"unresolved_required_items": unresolved_required_items[:12],
|
|
2603
|
+
"unmapped_completed_items": unmapped_completed_items[:12],
|
|
2604
|
+
"blocking_pending_slices": blocking_pending_slices[:12],
|
|
2605
|
+
}
|
|
524
2606
|
|
|
525
2607
|
@staticmethod
|
|
526
2608
|
def _latest_metric_from_payload(payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
@@ -551,14 +2633,8 @@ class QuestService:
|
|
|
551
2633
|
def _quest_id_state_lock(self):
|
|
552
2634
|
lock_path = self._quest_id_lock_path()
|
|
553
2635
|
ensure_dir(lock_path.parent)
|
|
554
|
-
with lock_path
|
|
555
|
-
|
|
556
|
-
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
557
|
-
try:
|
|
558
|
-
yield
|
|
559
|
-
finally:
|
|
560
|
-
if fcntl is not None:
|
|
561
|
-
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
2636
|
+
with advisory_file_lock(lock_path):
|
|
2637
|
+
yield
|
|
562
2638
|
|
|
563
2639
|
@contextmanager
|
|
564
2640
|
def _runtime_state_lock(self, quest_root: Path):
|
|
@@ -568,14 +2644,8 @@ class QuestService:
|
|
|
568
2644
|
with thread_lock:
|
|
569
2645
|
lock_path = self._runtime_state_lock_path(quest_root)
|
|
570
2646
|
ensure_dir(lock_path.parent)
|
|
571
|
-
with lock_path
|
|
572
|
-
|
|
573
|
-
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
574
|
-
try:
|
|
575
|
-
yield
|
|
576
|
-
finally:
|
|
577
|
-
if fcntl is not None:
|
|
578
|
-
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
2647
|
+
with advisory_file_lock(lock_path):
|
|
2648
|
+
yield
|
|
579
2649
|
|
|
580
2650
|
def _scan_next_numeric_quest_id(self) -> int:
|
|
581
2651
|
max_numeric_id = 0
|
|
@@ -695,7 +2765,7 @@ class QuestService:
|
|
|
695
2765
|
)
|
|
696
2766
|
write_text(quest_root / "brief.md", initial_brief(goal))
|
|
697
2767
|
write_text(quest_root / "plan.md", initial_plan())
|
|
698
|
-
write_text(quest_root / "status.md", initial_status())
|
|
2768
|
+
write_text(quest_root / "status.md", initial_status(startup_contract))
|
|
699
2769
|
write_text(quest_root / "SUMMARY.md", initial_summary())
|
|
700
2770
|
write_text(quest_root / ".gitignore", gitignore())
|
|
701
2771
|
self._write_active_user_requirements(
|
|
@@ -878,9 +2948,27 @@ class QuestService:
|
|
|
878
2948
|
"requested_baseline_ref": quest_yaml.get("requested_baseline_ref"),
|
|
879
2949
|
"startup_contract": quest_yaml.get("startup_contract"),
|
|
880
2950
|
"runner": quest_yaml.get("default_runner", "codex"),
|
|
2951
|
+
"active_workspace_root": str(workspace_root),
|
|
2952
|
+
"research_head_branch": research_state.get("research_head_branch"),
|
|
2953
|
+
"research_head_worktree_root": research_state.get("research_head_worktree_root"),
|
|
2954
|
+
"current_workspace_branch": research_state.get("current_workspace_branch"),
|
|
2955
|
+
"current_workspace_root": research_state.get("current_workspace_root"),
|
|
2956
|
+
"workspace_mode": research_state.get("workspace_mode") or "quest",
|
|
2957
|
+
"active_idea_id": research_state.get("active_idea_id"),
|
|
881
2958
|
"active_baseline_id": active_baseline_id,
|
|
882
2959
|
"active_baseline_variant_id": active_baseline_variant_id,
|
|
883
2960
|
"active_run_id": runtime_state.get("active_run_id"),
|
|
2961
|
+
"continuation_policy": runtime_state.get("continuation_policy") or "auto",
|
|
2962
|
+
"continuation_anchor": runtime_state.get("continuation_anchor"),
|
|
2963
|
+
"continuation_reason": runtime_state.get("continuation_reason"),
|
|
2964
|
+
"continuation_updated_at": runtime_state.get("continuation_updated_at"),
|
|
2965
|
+
"last_resume_source": runtime_state.get("last_resume_source"),
|
|
2966
|
+
"last_resume_at": runtime_state.get("last_resume_at"),
|
|
2967
|
+
"last_recovery_abandoned_run_id": runtime_state.get("last_recovery_abandoned_run_id"),
|
|
2968
|
+
"last_recovery_summary": runtime_state.get("last_recovery_summary"),
|
|
2969
|
+
"last_stage_fingerprint": runtime_state.get("last_stage_fingerprint"),
|
|
2970
|
+
"last_stage_fingerprint_at": runtime_state.get("last_stage_fingerprint_at"),
|
|
2971
|
+
"same_fingerprint_auto_turn_count": int(runtime_state.get("same_fingerprint_auto_turn_count") or 0),
|
|
884
2972
|
"pending_decisions": pending_decisions,
|
|
885
2973
|
"waiting_interaction_id": waiting_interaction_id,
|
|
886
2974
|
"default_reply_interaction_id": default_reply_interaction_id,
|
|
@@ -952,8 +3040,8 @@ class QuestService:
|
|
|
952
3040
|
}
|
|
953
3041
|
return items
|
|
954
3042
|
|
|
955
|
-
@staticmethod
|
|
956
3043
|
def _read_jsonl_cursor_slice(
|
|
3044
|
+
self,
|
|
957
3045
|
path: Path,
|
|
958
3046
|
*,
|
|
959
3047
|
after: int = 0,
|
|
@@ -962,7 +3050,10 @@ class QuestService:
|
|
|
962
3050
|
tail: bool = False,
|
|
963
3051
|
) -> tuple[list[tuple[int, dict[str, Any]]], int, bool]:
|
|
964
3052
|
normalized_limit = max(int(limit or 0), 0)
|
|
3053
|
+
cache_key = self._cache_key_for_path(path)
|
|
965
3054
|
if not path.exists():
|
|
3055
|
+
with self._jsonl_cache_lock:
|
|
3056
|
+
self._jsonl_tail_cache.pop(cache_key, None)
|
|
966
3057
|
return [], 0, False
|
|
967
3058
|
if normalized_limit <= 0:
|
|
968
3059
|
total = sum(1 for _ in _iter_jsonl_records_safely(path))
|
|
@@ -981,11 +3072,71 @@ class QuestService:
|
|
|
981
3072
|
return list(window), total, has_more
|
|
982
3073
|
|
|
983
3074
|
if tail:
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
3075
|
+
state = self._path_state(path)
|
|
3076
|
+
cached_tail: dict[str, Any] | None = None
|
|
3077
|
+
with self._jsonl_cache_lock:
|
|
3078
|
+
candidate = self._jsonl_tail_cache.get(cache_key)
|
|
3079
|
+
if isinstance(candidate, dict):
|
|
3080
|
+
cached_tail = dict(candidate)
|
|
3081
|
+
|
|
3082
|
+
if cached_tail and cached_tail.get("state") == state:
|
|
3083
|
+
cached_limit = int(cached_tail.get("limit") or 0)
|
|
3084
|
+
cached_records = list(cached_tail.get("records") or [])
|
|
3085
|
+
cached_total = int(cached_tail.get("total") or 0)
|
|
3086
|
+
if cached_limit >= normalized_limit and cached_records:
|
|
3087
|
+
window = cached_records[-normalized_limit:]
|
|
3088
|
+
has_more = cached_total > len(window)
|
|
3089
|
+
return window, cached_total, has_more
|
|
3090
|
+
|
|
3091
|
+
if (
|
|
3092
|
+
cached_tail
|
|
3093
|
+
and state is not None
|
|
3094
|
+
and cached_tail.get("state")
|
|
3095
|
+
and tuple(cached_tail.get("state"))[0] == state[0]
|
|
3096
|
+
and state[2] >= tuple(cached_tail.get("state"))[2]
|
|
3097
|
+
):
|
|
3098
|
+
cached_state = tuple(cached_tail.get("state"))
|
|
3099
|
+
cached_limit = int(cached_tail.get("limit") or 0)
|
|
3100
|
+
cached_total = int(cached_tail.get("total") or 0)
|
|
3101
|
+
max_limit = max(normalized_limit, cached_limit)
|
|
3102
|
+
window = deque(
|
|
3103
|
+
list(cached_tail.get("records") or []),
|
|
3104
|
+
maxlen=max_limit,
|
|
3105
|
+
)
|
|
3106
|
+
appended_records = list(
|
|
3107
|
+
_iter_jsonl_records_from_offset_safely(
|
|
3108
|
+
path,
|
|
3109
|
+
start_offset=int(cached_state[2]),
|
|
3110
|
+
)
|
|
3111
|
+
)
|
|
3112
|
+
if appended_records:
|
|
3113
|
+
next_cursor = cached_total + 1
|
|
3114
|
+
for payload in appended_records:
|
|
3115
|
+
window.append((next_cursor, payload))
|
|
3116
|
+
next_cursor += 1
|
|
3117
|
+
total = cached_total + len(appended_records)
|
|
3118
|
+
else:
|
|
3119
|
+
total = cached_total
|
|
3120
|
+
stored_records = list(window)
|
|
3121
|
+
with self._jsonl_cache_lock:
|
|
3122
|
+
self._jsonl_tail_cache[cache_key] = {
|
|
3123
|
+
"state": state,
|
|
3124
|
+
"limit": max_limit,
|
|
3125
|
+
"total": total,
|
|
3126
|
+
"records": stored_records,
|
|
3127
|
+
}
|
|
3128
|
+
selected = stored_records[-normalized_limit:]
|
|
3129
|
+
has_more = total > len(selected)
|
|
3130
|
+
return selected, total, has_more
|
|
3131
|
+
|
|
3132
|
+
window, total = _tail_jsonl_records_safely(path, limit=normalized_limit)
|
|
3133
|
+
with self._jsonl_cache_lock:
|
|
3134
|
+
self._jsonl_tail_cache[cache_key] = {
|
|
3135
|
+
"state": state,
|
|
3136
|
+
"limit": normalized_limit,
|
|
3137
|
+
"total": total,
|
|
3138
|
+
"records": list(window),
|
|
3139
|
+
}
|
|
989
3140
|
has_more = total > len(window)
|
|
990
3141
|
return list(window), total, has_more
|
|
991
3142
|
|
|
@@ -1021,6 +3172,14 @@ class QuestService:
|
|
|
1021
3172
|
except FileNotFoundError:
|
|
1022
3173
|
return str(path.absolute())
|
|
1023
3174
|
|
|
3175
|
+
def jsonl_tail_cache_entry(self, path: Path) -> dict[str, Any] | None:
|
|
3176
|
+
cache_key = self._cache_key_for_path(path)
|
|
3177
|
+
with self._jsonl_cache_lock:
|
|
3178
|
+
candidate = self._jsonl_tail_cache.get(cache_key)
|
|
3179
|
+
if isinstance(candidate, dict):
|
|
3180
|
+
return dict(candidate)
|
|
3181
|
+
return None
|
|
3182
|
+
|
|
1024
3183
|
def _read_cached_path(
|
|
1025
3184
|
self,
|
|
1026
3185
|
path: Path,
|
|
@@ -1089,7 +3248,7 @@ class QuestService:
|
|
|
1089
3248
|
return entries
|
|
1090
3249
|
|
|
1091
3250
|
def snapshot_fast(self, quest_id: str) -> dict:
|
|
1092
|
-
return self.
|
|
3251
|
+
return self.summary_compact(quest_id)
|
|
1093
3252
|
|
|
1094
3253
|
def snapshot(self, quest_id: str) -> dict:
|
|
1095
3254
|
return self._snapshot(quest_id)
|
|
@@ -1206,6 +3365,22 @@ class QuestService:
|
|
|
1206
3365
|
bash_service = BashExecService(self.home)
|
|
1207
3366
|
bash_summary = bash_service.summary(quest_root)
|
|
1208
3367
|
latest_bash_session = bash_summary.get("latest_session")
|
|
3368
|
+
paper_contract = self._paper_contract_payload(quest_root, workspace_root)
|
|
3369
|
+
paper_evidence = self._paper_evidence_payload(quest_root, workspace_root)
|
|
3370
|
+
analysis_inventory = self._analysis_inventory_payload(quest_root, workspace_root)
|
|
3371
|
+
paper_lines, active_paper_line_ref = self._paper_lines_payload(quest_root, workspace_root)
|
|
3372
|
+
idea_lines, active_idea_line_ref = self._idea_lines_payload(
|
|
3373
|
+
quest_root,
|
|
3374
|
+
paper_lines=paper_lines,
|
|
3375
|
+
analysis_inventory=analysis_inventory,
|
|
3376
|
+
)
|
|
3377
|
+
paper_contract_health = self._paper_contract_health_payload(
|
|
3378
|
+
paper_contract=paper_contract,
|
|
3379
|
+
paper_evidence=paper_evidence,
|
|
3380
|
+
analysis_inventory=analysis_inventory,
|
|
3381
|
+
paper_lines=paper_lines,
|
|
3382
|
+
active_paper_line_ref=active_paper_line_ref,
|
|
3383
|
+
)
|
|
1209
3384
|
paths = {
|
|
1210
3385
|
"brief": str(workspace_root / "brief.md"),
|
|
1211
3386
|
"plan": str(workspace_root / "plan.md"),
|
|
@@ -1277,11 +3452,27 @@ class QuestService:
|
|
|
1277
3452
|
"paper_parent_branch": research_state.get("paper_parent_branch"),
|
|
1278
3453
|
"paper_parent_worktree_root": research_state.get("paper_parent_worktree_root"),
|
|
1279
3454
|
"paper_parent_run_id": research_state.get("paper_parent_run_id"),
|
|
3455
|
+
"idea_lines": idea_lines,
|
|
3456
|
+
"active_idea_line_ref": active_idea_line_ref,
|
|
3457
|
+
"paper_lines": paper_lines,
|
|
3458
|
+
"active_paper_line_ref": active_paper_line_ref,
|
|
3459
|
+
"paper_contract_health": paper_contract_health,
|
|
1280
3460
|
"next_pending_slice_id": research_state.get("next_pending_slice_id"),
|
|
1281
3461
|
"workspace_mode": research_state.get("workspace_mode") or "quest",
|
|
1282
3462
|
"active_baseline_id": active_baseline_id,
|
|
1283
3463
|
"active_baseline_variant_id": active_baseline_variant_id,
|
|
1284
3464
|
"active_run_id": runtime_state.get("active_run_id"),
|
|
3465
|
+
"continuation_policy": runtime_state.get("continuation_policy") or "auto",
|
|
3466
|
+
"continuation_anchor": runtime_state.get("continuation_anchor"),
|
|
3467
|
+
"continuation_reason": runtime_state.get("continuation_reason"),
|
|
3468
|
+
"continuation_updated_at": runtime_state.get("continuation_updated_at"),
|
|
3469
|
+
"last_resume_source": runtime_state.get("last_resume_source"),
|
|
3470
|
+
"last_resume_at": runtime_state.get("last_resume_at"),
|
|
3471
|
+
"last_recovery_abandoned_run_id": runtime_state.get("last_recovery_abandoned_run_id"),
|
|
3472
|
+
"last_recovery_summary": runtime_state.get("last_recovery_summary"),
|
|
3473
|
+
"last_stage_fingerprint": runtime_state.get("last_stage_fingerprint"),
|
|
3474
|
+
"last_stage_fingerprint_at": runtime_state.get("last_stage_fingerprint_at"),
|
|
3475
|
+
"same_fingerprint_auto_turn_count": int(runtime_state.get("same_fingerprint_auto_turn_count") or 0),
|
|
1285
3476
|
"pending_decisions": pending_decisions,
|
|
1286
3477
|
"active_interactions": active_interactions,
|
|
1287
3478
|
"recent_reply_threads": recent_reply_threads,
|
|
@@ -1320,6 +3511,9 @@ class QuestService:
|
|
|
1320
3511
|
"artifact_count": len(artifacts),
|
|
1321
3512
|
"recent_artifacts": artifacts[-5:],
|
|
1322
3513
|
"recent_runs": recent_runs[-5:],
|
|
3514
|
+
"paper_contract": paper_contract,
|
|
3515
|
+
"paper_evidence": paper_evidence,
|
|
3516
|
+
"analysis_inventory": analysis_inventory,
|
|
1323
3517
|
"guidance": guidance,
|
|
1324
3518
|
}
|
|
1325
3519
|
with self._snapshot_cache_lock:
|
|
@@ -1651,10 +3845,11 @@ class QuestService:
|
|
|
1651
3845
|
normalized_anchor = str(active_anchor).strip()
|
|
1652
3846
|
if not normalized_anchor:
|
|
1653
3847
|
raise ValueError("`active_anchor` cannot be empty.")
|
|
1654
|
-
from ..prompts.builder import
|
|
3848
|
+
from ..prompts.builder import current_standard_skills
|
|
1655
3849
|
|
|
1656
|
-
|
|
1657
|
-
|
|
3850
|
+
available_stage_skills = current_standard_skills(repo_root())
|
|
3851
|
+
if normalized_anchor not in available_stage_skills:
|
|
3852
|
+
allowed = ", ".join(available_stage_skills)
|
|
1658
3853
|
raise ValueError(f"Unsupported active anchor `{normalized_anchor}`. Allowed values: {allowed}.")
|
|
1659
3854
|
if quest_data.get("active_anchor") != normalized_anchor:
|
|
1660
3855
|
quest_data["active_anchor"] = normalized_anchor
|
|
@@ -1711,10 +3906,11 @@ class QuestService:
|
|
|
1711
3906
|
normalized_anchor = str(active_anchor or "").strip()
|
|
1712
3907
|
if not normalized_anchor:
|
|
1713
3908
|
raise ValueError("`active_anchor` cannot be empty.")
|
|
1714
|
-
from ..prompts.builder import
|
|
3909
|
+
from ..prompts.builder import current_standard_skills
|
|
1715
3910
|
|
|
1716
|
-
|
|
1717
|
-
|
|
3911
|
+
available_stage_skills = current_standard_skills(repo_root())
|
|
3912
|
+
if normalized_anchor not in available_stage_skills:
|
|
3913
|
+
allowed = ", ".join(available_stage_skills)
|
|
1718
3914
|
raise ValueError(f"Unsupported active anchor `{normalized_anchor}`. Allowed values: {allowed}.")
|
|
1719
3915
|
if quest_data.get("active_anchor") != normalized_anchor:
|
|
1720
3916
|
quest_data["active_anchor"] = normalized_anchor
|
|
@@ -1860,97 +4056,7 @@ class QuestService:
|
|
|
1860
4056
|
return self._read_cached_jsonl(self._quest_root(quest_id) / ".ds" / "conversations" / "main.jsonl")[-limit:]
|
|
1861
4057
|
|
|
1862
4058
|
def workflow(self, quest_id: str) -> dict:
|
|
1863
|
-
|
|
1864
|
-
workspace_root = self.active_workspace_root(quest_root)
|
|
1865
|
-
snapshot = self.snapshot(quest_id)
|
|
1866
|
-
entries: list[dict] = []
|
|
1867
|
-
changed_files: list[dict] = []
|
|
1868
|
-
seen_files: set[str] = set()
|
|
1869
|
-
|
|
1870
|
-
def add_file(path: str | None, *, source: str, document_id: str | None = None, writable: bool | None = None) -> None:
|
|
1871
|
-
if not path:
|
|
1872
|
-
return
|
|
1873
|
-
normalized = str(path)
|
|
1874
|
-
if normalized in seen_files:
|
|
1875
|
-
return
|
|
1876
|
-
seen_files.add(normalized)
|
|
1877
|
-
resolved_document_id = document_id or self._path_to_document_id(
|
|
1878
|
-
normalized,
|
|
1879
|
-
quest_root=quest_root,
|
|
1880
|
-
workspace_root=workspace_root,
|
|
1881
|
-
)
|
|
1882
|
-
changed_files.append(
|
|
1883
|
-
{
|
|
1884
|
-
"path": normalized,
|
|
1885
|
-
"source": source,
|
|
1886
|
-
"document_id": resolved_document_id,
|
|
1887
|
-
"writable": writable,
|
|
1888
|
-
}
|
|
1889
|
-
)
|
|
1890
|
-
|
|
1891
|
-
for relative in ("brief.md", "plan.md", "status.md", "SUMMARY.md"):
|
|
1892
|
-
add_file(
|
|
1893
|
-
str(workspace_root / relative),
|
|
1894
|
-
source="document",
|
|
1895
|
-
document_id=relative,
|
|
1896
|
-
writable=True,
|
|
1897
|
-
)
|
|
1898
|
-
|
|
1899
|
-
recent_runs = snapshot.get("recent_runs") or []
|
|
1900
|
-
for run in recent_runs:
|
|
1901
|
-
run_id = str(run.get("run_id") or "run")
|
|
1902
|
-
entries.append(
|
|
1903
|
-
{
|
|
1904
|
-
"id": f"run:{run_id}",
|
|
1905
|
-
"kind": "run",
|
|
1906
|
-
"run_id": run_id,
|
|
1907
|
-
"skill_id": run.get("skill_id"),
|
|
1908
|
-
"title": run_id,
|
|
1909
|
-
"summary": run.get("summary") or "Run completed.",
|
|
1910
|
-
"status": "completed" if run.get("exit_code", 0) == 0 else "failed",
|
|
1911
|
-
"created_at": run.get("completed_at") or run.get("created_at") or run.get("updated_at"),
|
|
1912
|
-
"paths": [item for item in [run.get("history_root"), run.get("run_root"), run.get("output_path")] if item],
|
|
1913
|
-
}
|
|
1914
|
-
)
|
|
1915
|
-
for path in (run.get("history_root"), run.get("run_root"), run.get("output_path")):
|
|
1916
|
-
add_file(path, source="run")
|
|
1917
|
-
history_root = run.get("history_root")
|
|
1918
|
-
if history_root:
|
|
1919
|
-
entries.extend(
|
|
1920
|
-
self._parse_codex_history_cached(
|
|
1921
|
-
Path(str(history_root)),
|
|
1922
|
-
quest_id=quest_id,
|
|
1923
|
-
run_id=run_id,
|
|
1924
|
-
skill_id=run.get("skill_id"),
|
|
1925
|
-
)
|
|
1926
|
-
)
|
|
1927
|
-
|
|
1928
|
-
for artifact in snapshot.get("recent_artifacts") or []:
|
|
1929
|
-
payload = artifact.get("payload") or {}
|
|
1930
|
-
artifact_path = artifact.get("path")
|
|
1931
|
-
entries.append(
|
|
1932
|
-
{
|
|
1933
|
-
"id": f"artifact:{payload.get('artifact_id') or artifact_path}",
|
|
1934
|
-
"kind": "artifact",
|
|
1935
|
-
"title": str(payload.get("artifact_id") or artifact.get("kind") or "artifact"),
|
|
1936
|
-
"summary": payload.get("summary") or payload.get("message") or payload.get("reason") or "Artifact updated.",
|
|
1937
|
-
"status": payload.get("status"),
|
|
1938
|
-
"reason": payload.get("reason"),
|
|
1939
|
-
"created_at": payload.get("updated_at"),
|
|
1940
|
-
"paths": list((payload.get("paths") or {}).values()) + ([str(artifact_path)] if artifact_path else []),
|
|
1941
|
-
}
|
|
1942
|
-
)
|
|
1943
|
-
add_file(str(artifact_path) if artifact_path else None, source="artifact")
|
|
1944
|
-
for path in (payload.get("paths") or {}).values():
|
|
1945
|
-
add_file(str(path), source="artifact_path")
|
|
1946
|
-
|
|
1947
|
-
entries.sort(key=lambda item: str(item.get("created_at") or item.get("id") or ""))
|
|
1948
|
-
return {
|
|
1949
|
-
"quest_id": quest_id,
|
|
1950
|
-
"quest_root": snapshot.get("quest_root"),
|
|
1951
|
-
"entries": entries[-80:],
|
|
1952
|
-
"changed_files": changed_files[-30:],
|
|
1953
|
-
}
|
|
4059
|
+
return self._projected_payload(quest_id, "details")
|
|
1954
4060
|
|
|
1955
4061
|
def events(
|
|
1956
4062
|
self,
|
|
@@ -2077,23 +4183,129 @@ class QuestService:
|
|
|
2077
4183
|
def metrics_timeline(self, quest_id: str) -> dict:
|
|
2078
4184
|
quest_root = self._quest_root(quest_id)
|
|
2079
4185
|
workspace_root = self.active_workspace_root(quest_root)
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
4186
|
+
state = self._json_compatible_state(self._metrics_timeline_state(quest_root, workspace_root))
|
|
4187
|
+
cache_path = self._metrics_timeline_cache_path(quest_root)
|
|
4188
|
+
cache_schema_version = 2
|
|
4189
|
+
cached = self._read_cached_json(cache_path, {})
|
|
4190
|
+
if (
|
|
4191
|
+
isinstance(cached, dict)
|
|
4192
|
+
and int(cached.get("schema_version") or 0) == cache_schema_version
|
|
4193
|
+
and self._json_compatible_state(cached.get("state")) == state
|
|
4194
|
+
and isinstance(cached.get("payload"), dict)
|
|
4195
|
+
):
|
|
4196
|
+
return dict(cached.get("payload") or {})
|
|
4197
|
+
|
|
4198
|
+
with advisory_file_lock(self._metrics_timeline_cache_lock_path(quest_root)):
|
|
4199
|
+
cached = read_json(cache_path, {})
|
|
4200
|
+
if (
|
|
4201
|
+
isinstance(cached, dict)
|
|
4202
|
+
and int(cached.get("schema_version") or 0) == cache_schema_version
|
|
4203
|
+
and self._json_compatible_state(cached.get("state")) == state
|
|
4204
|
+
and isinstance(cached.get("payload"), dict)
|
|
4205
|
+
):
|
|
4206
|
+
return dict(cached.get("payload") or {})
|
|
4207
|
+
|
|
4208
|
+
attachment = self._active_baseline_attachment(quest_root, workspace_root)
|
|
4209
|
+
baseline_entry = dict(attachment.get("entry") or {}) if isinstance(attachment, dict) else None
|
|
4210
|
+
selected_variant_id = (
|
|
4211
|
+
str(attachment.get("source_variant_id") or "").strip() or None if isinstance(attachment, dict) else None
|
|
4212
|
+
)
|
|
4213
|
+
if not baseline_entry:
|
|
4214
|
+
latest_baseline_payload = None
|
|
4215
|
+
for item in reversed(self._collect_artifacts_raw(quest_root)):
|
|
4216
|
+
if str(item.get("kind") or "").strip() != "baselines":
|
|
4217
|
+
continue
|
|
4218
|
+
payload = item.get("payload") or {}
|
|
4219
|
+
if not isinstance(payload, dict):
|
|
4220
|
+
continue
|
|
4221
|
+
if str(payload.get("status") or "").strip().lower() != "confirmed":
|
|
4222
|
+
continue
|
|
4223
|
+
latest_baseline_payload = payload
|
|
4224
|
+
break
|
|
4225
|
+
if isinstance(latest_baseline_payload, dict) and latest_baseline_payload:
|
|
4226
|
+
baseline_entry = dict(latest_baseline_payload)
|
|
4227
|
+
selected_variant_id = (
|
|
4228
|
+
str(latest_baseline_payload.get("baseline_variant_id") or "").strip() or None
|
|
4229
|
+
)
|
|
4230
|
+
run_records = [
|
|
4231
|
+
item.get("payload") or {}
|
|
4232
|
+
for item in self._collect_run_artifacts_raw(quest_root, run_kind="main_experiment")
|
|
4233
|
+
if isinstance(item.get("payload"), dict)
|
|
4234
|
+
]
|
|
4235
|
+
payload = build_metrics_timeline(
|
|
4236
|
+
quest_id=quest_id,
|
|
4237
|
+
run_records=run_records,
|
|
4238
|
+
baseline_entry=baseline_entry,
|
|
4239
|
+
selected_variant_id=selected_variant_id,
|
|
4240
|
+
)
|
|
4241
|
+
write_json(
|
|
4242
|
+
cache_path,
|
|
4243
|
+
{
|
|
4244
|
+
"schema_version": cache_schema_version,
|
|
4245
|
+
"generated_at": utc_now(),
|
|
4246
|
+
"state": state,
|
|
4247
|
+
"payload": payload,
|
|
4248
|
+
},
|
|
4249
|
+
)
|
|
4250
|
+
return payload
|
|
4251
|
+
|
|
4252
|
+
def baseline_compare(self, quest_id: str) -> dict:
|
|
4253
|
+
quest_root = self._quest_root(quest_id)
|
|
4254
|
+
workspace_root = self.active_workspace_root(quest_root)
|
|
4255
|
+
state = self._json_compatible_state(self._baseline_compare_state(quest_root, workspace_root))
|
|
4256
|
+
cache_path = self._baseline_compare_cache_path(quest_root)
|
|
4257
|
+
cache_schema_version = 1
|
|
4258
|
+
cached = self._read_cached_json(cache_path, {})
|
|
4259
|
+
if (
|
|
4260
|
+
isinstance(cached, dict)
|
|
4261
|
+
and int(cached.get("schema_version") or 0) == cache_schema_version
|
|
4262
|
+
and self._json_compatible_state(cached.get("state")) == state
|
|
4263
|
+
and isinstance(cached.get("payload"), dict)
|
|
4264
|
+
):
|
|
4265
|
+
return dict(cached.get("payload") or {})
|
|
4266
|
+
|
|
4267
|
+
with advisory_file_lock(self._baseline_compare_cache_lock_path(quest_root)):
|
|
4268
|
+
cached = read_json(cache_path, {})
|
|
4269
|
+
if (
|
|
4270
|
+
isinstance(cached, dict)
|
|
4271
|
+
and int(cached.get("schema_version") or 0) == cache_schema_version
|
|
4272
|
+
and self._json_compatible_state(cached.get("state")) == state
|
|
4273
|
+
and isinstance(cached.get("payload"), dict)
|
|
4274
|
+
):
|
|
4275
|
+
return dict(cached.get("payload") or {})
|
|
4276
|
+
|
|
4277
|
+
quest_data = self.read_quest_yaml(quest_root)
|
|
4278
|
+
confirmed_ref = (
|
|
4279
|
+
dict(quest_data.get("confirmed_baseline_ref") or {})
|
|
4280
|
+
if isinstance(quest_data.get("confirmed_baseline_ref"), dict)
|
|
4281
|
+
else {}
|
|
4282
|
+
)
|
|
4283
|
+
attachment = self._active_baseline_attachment(quest_root, workspace_root)
|
|
4284
|
+
active_baseline_id = (
|
|
4285
|
+
str(confirmed_ref.get("baseline_id") or "").strip()
|
|
4286
|
+
or (str(attachment.get("source_baseline_id") or "").strip() if isinstance(attachment, dict) else "")
|
|
4287
|
+
or None
|
|
4288
|
+
)
|
|
4289
|
+
active_variant_id = (
|
|
4290
|
+
str(confirmed_ref.get("variant_id") or "").strip()
|
|
4291
|
+
or (str(attachment.get("source_variant_id") or "").strip() if isinstance(attachment, dict) else "")
|
|
4292
|
+
or None
|
|
4293
|
+
)
|
|
4294
|
+
payload = build_baseline_compare_payload(
|
|
4295
|
+
quest_id=quest_id,
|
|
4296
|
+
baseline_entries=self._baseline_compare_entries(quest_root, workspace_root),
|
|
4297
|
+
active_baseline_id=active_baseline_id,
|
|
4298
|
+
active_variant_id=active_variant_id,
|
|
4299
|
+
)
|
|
4300
|
+
write_json(
|
|
4301
|
+
cache_path,
|
|
4302
|
+
{
|
|
4303
|
+
"schema_version": cache_schema_version,
|
|
4304
|
+
"state": state,
|
|
4305
|
+
"payload": payload,
|
|
4306
|
+
},
|
|
4307
|
+
)
|
|
4308
|
+
return payload
|
|
2097
4309
|
|
|
2098
4310
|
def list_documents(self, quest_id: str) -> list[dict]:
|
|
2099
4311
|
quest_root = self._quest_root(quest_id)
|
|
@@ -2310,23 +4522,7 @@ class QuestService:
|
|
|
2310
4522
|
},
|
|
2311
4523
|
}
|
|
2312
4524
|
|
|
2313
|
-
|
|
2314
|
-
quest_root
|
|
2315
|
-
if document_id.startswith(("questpath::", "memory::"))
|
|
2316
|
-
else workspace_root
|
|
2317
|
-
)
|
|
2318
|
-
try:
|
|
2319
|
-
path, writable, scope, source_kind = self._resolve_document(resolution_root, document_id)
|
|
2320
|
-
except FileNotFoundError:
|
|
2321
|
-
legacy_relative = None
|
|
2322
|
-
if document_id.startswith("path::"):
|
|
2323
|
-
legacy_relative = document_id.split("::", 1)[1].lstrip("/")
|
|
2324
|
-
if legacy_relative and legacy_relative.startswith("literature/arxiv/"):
|
|
2325
|
-
path, writable, scope, source_kind = self._resolve_document(
|
|
2326
|
-
quest_root, f"questpath::{legacy_relative}"
|
|
2327
|
-
)
|
|
2328
|
-
else:
|
|
2329
|
-
raise
|
|
4525
|
+
path, writable, scope, source_kind = self.resolve_document(quest_id, document_id)
|
|
2330
4526
|
renderer_hint, mime_type = self._renderer_hint_for(path)
|
|
2331
4527
|
is_text = self._is_text_document(path, mime_type, renderer_hint)
|
|
2332
4528
|
content = read_text(path) if is_text else ""
|
|
@@ -2354,6 +4550,24 @@ class QuestService:
|
|
|
2354
4550
|
},
|
|
2355
4551
|
}
|
|
2356
4552
|
|
|
4553
|
+
def resolve_document(self, quest_id: str, document_id: str) -> tuple[Path, bool, str, str]:
|
|
4554
|
+
quest_root = self._quest_root(quest_id)
|
|
4555
|
+
workspace_root = self.active_workspace_root(quest_root)
|
|
4556
|
+
resolution_root = self._document_resolution_root(
|
|
4557
|
+
quest_root=quest_root,
|
|
4558
|
+
workspace_root=workspace_root,
|
|
4559
|
+
document_id=document_id,
|
|
4560
|
+
)
|
|
4561
|
+
try:
|
|
4562
|
+
return self._resolve_document(resolution_root, document_id)
|
|
4563
|
+
except FileNotFoundError:
|
|
4564
|
+
legacy_relative = None
|
|
4565
|
+
if document_id.startswith("path::"):
|
|
4566
|
+
legacy_relative = document_id.split("::", 1)[1].lstrip("/")
|
|
4567
|
+
if legacy_relative and legacy_relative.startswith("literature/arxiv/"):
|
|
4568
|
+
return self._resolve_document(quest_root, f"questpath::{legacy_relative}")
|
|
4569
|
+
raise
|
|
4570
|
+
|
|
2357
4571
|
def save_document(self, quest_id: str, document_id: str, content: str, previous_revision: str | None = None) -> dict:
|
|
2358
4572
|
current = self.open_document(quest_id, document_id)
|
|
2359
4573
|
if not current.get("writable", False):
|
|
@@ -2689,6 +4903,17 @@ class QuestService:
|
|
|
2689
4903
|
"last_tool_activity_at": None,
|
|
2690
4904
|
"last_tool_activity_name": None,
|
|
2691
4905
|
"tool_calls_since_last_artifact_interact": 0,
|
|
4906
|
+
"continuation_policy": "auto",
|
|
4907
|
+
"continuation_anchor": None,
|
|
4908
|
+
"continuation_reason": None,
|
|
4909
|
+
"continuation_updated_at": None,
|
|
4910
|
+
"last_resume_source": None,
|
|
4911
|
+
"last_resume_at": None,
|
|
4912
|
+
"last_recovery_abandoned_run_id": None,
|
|
4913
|
+
"last_recovery_summary": None,
|
|
4914
|
+
"last_stage_fingerprint": None,
|
|
4915
|
+
"last_stage_fingerprint_at": None,
|
|
4916
|
+
"same_fingerprint_auto_turn_count": 0,
|
|
2692
4917
|
"pending_user_message_count": pending_count,
|
|
2693
4918
|
"last_delivered_batch_id": None,
|
|
2694
4919
|
"last_delivered_at": None,
|
|
@@ -2754,6 +4979,20 @@ class QuestService:
|
|
|
2754
4979
|
merged = {**defaults, **payload}
|
|
2755
4980
|
merged["pending_user_message_count"] = int(merged.get("pending_user_message_count") or 0)
|
|
2756
4981
|
merged["tool_calls_since_last_artifact_interact"] = int(merged.get("tool_calls_since_last_artifact_interact") or 0)
|
|
4982
|
+
merged["continuation_policy"] = self._normalize_continuation_policy(
|
|
4983
|
+
merged.get("continuation_policy"),
|
|
4984
|
+
default=str(defaults.get("continuation_policy") or "auto"),
|
|
4985
|
+
)
|
|
4986
|
+
merged["continuation_anchor"] = str(merged.get("continuation_anchor") or "").strip() or None
|
|
4987
|
+
merged["continuation_reason"] = str(merged.get("continuation_reason") or "").strip() or None
|
|
4988
|
+
merged["continuation_updated_at"] = str(merged.get("continuation_updated_at") or "").strip() or None
|
|
4989
|
+
merged["last_resume_source"] = str(merged.get("last_resume_source") or "").strip() or None
|
|
4990
|
+
merged["last_resume_at"] = str(merged.get("last_resume_at") or "").strip() or None
|
|
4991
|
+
merged["last_recovery_abandoned_run_id"] = str(merged.get("last_recovery_abandoned_run_id") or "").strip() or None
|
|
4992
|
+
merged["last_recovery_summary"] = str(merged.get("last_recovery_summary") or "").strip() or None
|
|
4993
|
+
merged["last_stage_fingerprint"] = str(merged.get("last_stage_fingerprint") or "").strip() or None
|
|
4994
|
+
merged["last_stage_fingerprint_at"] = str(merged.get("last_stage_fingerprint_at") or "").strip() or None
|
|
4995
|
+
merged["same_fingerprint_auto_turn_count"] = int(merged.get("same_fingerprint_auto_turn_count") or 0)
|
|
2757
4996
|
merged["retry_state"] = dict(merged.get("retry_state") or {}) if isinstance(merged.get("retry_state"), dict) else None
|
|
2758
4997
|
return merged
|
|
2759
4998
|
|
|
@@ -2773,6 +5012,17 @@ class QuestService:
|
|
|
2773
5012
|
last_tool_activity_at: str | None | object = _UNSET,
|
|
2774
5013
|
last_tool_activity_name: str | None | object = _UNSET,
|
|
2775
5014
|
tool_calls_since_last_artifact_interact: int | object = _UNSET,
|
|
5015
|
+
continuation_policy: str | object = _UNSET,
|
|
5016
|
+
continuation_anchor: str | None | object = _UNSET,
|
|
5017
|
+
continuation_reason: str | None | object = _UNSET,
|
|
5018
|
+
continuation_updated_at: str | None | object = _UNSET,
|
|
5019
|
+
last_resume_source: str | None | object = _UNSET,
|
|
5020
|
+
last_resume_at: str | None | object = _UNSET,
|
|
5021
|
+
last_recovery_abandoned_run_id: str | None | object = _UNSET,
|
|
5022
|
+
last_recovery_summary: str | None | object = _UNSET,
|
|
5023
|
+
last_stage_fingerprint: str | None | object = _UNSET,
|
|
5024
|
+
last_stage_fingerprint_at: str | None | object = _UNSET,
|
|
5025
|
+
same_fingerprint_auto_turn_count: int | object = _UNSET,
|
|
2776
5026
|
pending_user_message_count: int | object = _UNSET,
|
|
2777
5027
|
last_delivered_batch_id: str | None | object = _UNSET,
|
|
2778
5028
|
last_delivered_at: str | None | object = _UNSET,
|
|
@@ -2810,6 +5060,44 @@ class QuestService:
|
|
|
2810
5060
|
state["last_tool_activity_name"] = str(last_tool_activity_name).strip() if last_tool_activity_name else None
|
|
2811
5061
|
if tool_calls_since_last_artifact_interact is not _UNSET:
|
|
2812
5062
|
state["tool_calls_since_last_artifact_interact"] = max(0, int(tool_calls_since_last_artifact_interact))
|
|
5063
|
+
continuation_changed = False
|
|
5064
|
+
if continuation_policy is not _UNSET:
|
|
5065
|
+
state["continuation_policy"] = self._normalize_continuation_policy(continuation_policy)
|
|
5066
|
+
continuation_changed = True
|
|
5067
|
+
if continuation_anchor is not _UNSET:
|
|
5068
|
+
normalized_anchor = str(continuation_anchor or "").strip() or None
|
|
5069
|
+
if normalized_anchor is not None:
|
|
5070
|
+
from ..prompts.builder import current_standard_skills
|
|
5071
|
+
|
|
5072
|
+
available_stage_skills = current_standard_skills(repo_root())
|
|
5073
|
+
if normalized_anchor not in available_stage_skills:
|
|
5074
|
+
allowed = ", ".join(available_stage_skills)
|
|
5075
|
+
raise ValueError(
|
|
5076
|
+
f"Unsupported continuation anchor `{normalized_anchor}`. Allowed values: {allowed}."
|
|
5077
|
+
)
|
|
5078
|
+
state["continuation_anchor"] = normalized_anchor
|
|
5079
|
+
continuation_changed = True
|
|
5080
|
+
if continuation_reason is not _UNSET:
|
|
5081
|
+
state["continuation_reason"] = str(continuation_reason or "").strip() or None
|
|
5082
|
+
continuation_changed = True
|
|
5083
|
+
if continuation_updated_at is not _UNSET:
|
|
5084
|
+
state["continuation_updated_at"] = str(continuation_updated_at or "").strip() or None
|
|
5085
|
+
elif continuation_changed:
|
|
5086
|
+
state["continuation_updated_at"] = now
|
|
5087
|
+
if last_resume_source is not _UNSET:
|
|
5088
|
+
state["last_resume_source"] = str(last_resume_source or "").strip() or None
|
|
5089
|
+
if last_resume_at is not _UNSET:
|
|
5090
|
+
state["last_resume_at"] = str(last_resume_at or "").strip() or None
|
|
5091
|
+
if last_recovery_abandoned_run_id is not _UNSET:
|
|
5092
|
+
state["last_recovery_abandoned_run_id"] = str(last_recovery_abandoned_run_id or "").strip() or None
|
|
5093
|
+
if last_recovery_summary is not _UNSET:
|
|
5094
|
+
state["last_recovery_summary"] = str(last_recovery_summary or "").strip() or None
|
|
5095
|
+
if last_stage_fingerprint is not _UNSET:
|
|
5096
|
+
state["last_stage_fingerprint"] = str(last_stage_fingerprint or "").strip() or None
|
|
5097
|
+
if last_stage_fingerprint_at is not _UNSET:
|
|
5098
|
+
state["last_stage_fingerprint_at"] = str(last_stage_fingerprint_at or "").strip() or None
|
|
5099
|
+
if same_fingerprint_auto_turn_count is not _UNSET:
|
|
5100
|
+
state["same_fingerprint_auto_turn_count"] = max(0, int(same_fingerprint_auto_turn_count or 0))
|
|
2813
5101
|
if pending_user_message_count is not _UNSET:
|
|
2814
5102
|
state["pending_user_message_count"] = max(0, int(pending_user_message_count))
|
|
2815
5103
|
if last_delivered_batch_id is not _UNSET:
|
|
@@ -2836,8 +5124,29 @@ class QuestService:
|
|
|
2836
5124
|
quest_data.pop("active_run_id", None)
|
|
2837
5125
|
quest_data["updated_at"] = now
|
|
2838
5126
|
write_yaml(quest_root / "quest.yaml", quest_data)
|
|
5127
|
+
self.schedule_projection_refresh(quest_root, kinds=("details",))
|
|
2839
5128
|
return state
|
|
2840
5129
|
|
|
5130
|
+
@staticmethod
|
|
5131
|
+
def _normalize_continuation_policy(value: object, *, default: str = "auto") -> str:
|
|
5132
|
+
normalized = str(value or "").strip().lower() or default
|
|
5133
|
+
return normalized if normalized in CONTINUATION_POLICIES else default
|
|
5134
|
+
|
|
5135
|
+
def set_continuation_state(
|
|
5136
|
+
self,
|
|
5137
|
+
quest_root: Path,
|
|
5138
|
+
*,
|
|
5139
|
+
policy: str,
|
|
5140
|
+
anchor: str | None = None,
|
|
5141
|
+
reason: str | None = None,
|
|
5142
|
+
) -> dict[str, Any]:
|
|
5143
|
+
return self.update_runtime_state(
|
|
5144
|
+
quest_root=quest_root,
|
|
5145
|
+
continuation_policy=policy,
|
|
5146
|
+
continuation_anchor=anchor,
|
|
5147
|
+
continuation_reason=reason,
|
|
5148
|
+
)
|
|
5149
|
+
|
|
2841
5150
|
def _enqueue_user_message(self, quest_root: Path, record: dict[str, Any]) -> dict[str, Any]:
|
|
2842
5151
|
queue_payload = self._read_message_queue(quest_root)
|
|
2843
5152
|
source = str(record.get("source") or "local")
|
|
@@ -3055,11 +5364,15 @@ class QuestService:
|
|
|
3055
5364
|
artifact_id: str | None,
|
|
3056
5365
|
kind: str,
|
|
3057
5366
|
message: str,
|
|
5367
|
+
summary_preview: str | None = None,
|
|
5368
|
+
dedupe_key: str | None = None,
|
|
3058
5369
|
response_phase: str | None = None,
|
|
3059
5370
|
reply_mode: str | None = None,
|
|
3060
5371
|
surface_actions: list[dict[str, Any]] | None = None,
|
|
3061
5372
|
connector_hints: dict[str, Any] | None = None,
|
|
3062
5373
|
created_at: str | None = None,
|
|
5374
|
+
counts_as_visible: bool = True,
|
|
5375
|
+
deliver_to_bound_conversations: bool | None = None,
|
|
3063
5376
|
) -> dict[str, Any]:
|
|
3064
5377
|
timestamp = created_at or utc_now()
|
|
3065
5378
|
payload = {
|
|
@@ -3070,22 +5383,31 @@ class QuestService:
|
|
|
3070
5383
|
"artifact_id": artifact_id,
|
|
3071
5384
|
"kind": kind,
|
|
3072
5385
|
"message": message,
|
|
5386
|
+
"summary_preview": str(summary_preview or "").strip() or None,
|
|
5387
|
+
"dedupe_key": str(dedupe_key or "").strip() or None,
|
|
3073
5388
|
"response_phase": response_phase,
|
|
3074
5389
|
"reply_mode": reply_mode,
|
|
3075
5390
|
"surface_actions": [dict(item) for item in (surface_actions or []) if isinstance(item, dict)],
|
|
3076
5391
|
"connector_hints": dict(connector_hints) if isinstance(connector_hints, dict) else {},
|
|
5392
|
+
"deliver_to_bound_conversations": (
|
|
5393
|
+
bool(deliver_to_bound_conversations)
|
|
5394
|
+
if deliver_to_bound_conversations is not None
|
|
5395
|
+
else None
|
|
5396
|
+
),
|
|
3077
5397
|
"created_at": timestamp,
|
|
3078
5398
|
}
|
|
3079
5399
|
append_jsonl(self._interaction_journal_path(quest_root), payload)
|
|
3080
|
-
|
|
3081
|
-
quest_root
|
|
3082
|
-
active_interaction_id
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
5400
|
+
runtime_updates: dict[str, Any] = {
|
|
5401
|
+
"quest_root": quest_root,
|
|
5402
|
+
"active_interaction_id": interaction_id or artifact_id,
|
|
5403
|
+
"last_tool_activity_at": timestamp,
|
|
5404
|
+
"last_tool_activity_name": "artifact.interact",
|
|
5405
|
+
"tool_calls_since_last_artifact_interact": 0,
|
|
5406
|
+
"pending_user_message_count": len((self._read_message_queue(quest_root).get("pending") or [])),
|
|
5407
|
+
}
|
|
5408
|
+
if counts_as_visible:
|
|
5409
|
+
runtime_updates["last_artifact_interact_at"] = timestamp
|
|
5410
|
+
self.update_runtime_state(**runtime_updates)
|
|
3089
5411
|
return payload
|
|
3090
5412
|
|
|
3091
5413
|
def record_tool_activity(
|
|
@@ -3133,13 +5455,25 @@ class QuestService:
|
|
|
3133
5455
|
runtime_state = self._read_runtime_state(quest_root)
|
|
3134
5456
|
last_artifact_interact_at = str(runtime_state.get("last_artifact_interact_at") or "").strip() or None
|
|
3135
5457
|
last_tool_activity_at = str(runtime_state.get("last_tool_activity_at") or "").strip() or None
|
|
5458
|
+
tool_count = int(runtime_state.get("tool_calls_since_last_artifact_interact") or 0)
|
|
5459
|
+
silence_seconds = self._seconds_since_iso_timestamp(last_artifact_interact_at)
|
|
5460
|
+
inspection_due = bool(
|
|
5461
|
+
tool_count >= 25
|
|
5462
|
+
or (
|
|
5463
|
+
tool_count > 0
|
|
5464
|
+
and silence_seconds is not None
|
|
5465
|
+
and silence_seconds >= 30 * 60
|
|
5466
|
+
)
|
|
5467
|
+
)
|
|
3136
5468
|
return {
|
|
3137
5469
|
"last_artifact_interact_at": last_artifact_interact_at,
|
|
3138
|
-
"seconds_since_last_artifact_interact":
|
|
3139
|
-
"tool_calls_since_last_artifact_interact":
|
|
5470
|
+
"seconds_since_last_artifact_interact": silence_seconds,
|
|
5471
|
+
"tool_calls_since_last_artifact_interact": tool_count,
|
|
3140
5472
|
"last_tool_activity_at": last_tool_activity_at,
|
|
3141
5473
|
"seconds_since_last_tool_activity": self._seconds_since_iso_timestamp(last_tool_activity_at),
|
|
3142
5474
|
"last_tool_activity_name": str(runtime_state.get("last_tool_activity_name") or "").strip() or None,
|
|
5475
|
+
"inspection_due": inspection_due,
|
|
5476
|
+
"user_update_due": False,
|
|
3143
5477
|
}
|
|
3144
5478
|
|
|
3145
5479
|
def latest_artifact_interaction_records(self, quest_root: Path, limit: int = 10) -> list[dict[str, Any]]:
|
|
@@ -3309,6 +5643,12 @@ class QuestService:
|
|
|
3309
5643
|
"queued_message_count_after_delivery": len(queue_payload.get("pending") or []),
|
|
3310
5644
|
}
|
|
3311
5645
|
|
|
5646
|
+
@staticmethod
|
|
5647
|
+
def _document_resolution_root(quest_root: Path, workspace_root: Path, document_id: str) -> Path:
|
|
5648
|
+
if document_id.startswith(("questpath::", "memory::")):
|
|
5649
|
+
return quest_root
|
|
5650
|
+
return workspace_root
|
|
5651
|
+
|
|
3312
5652
|
@staticmethod
|
|
3313
5653
|
def _resolve_document(quest_root: Path, document_id: str) -> tuple[Path, bool, str, str]:
|
|
3314
5654
|
if document_id.startswith("memory::"):
|
|
@@ -3935,9 +6275,11 @@ def _tool_output(event: dict, item: dict) -> str:
|
|
|
3935
6275
|
item.get("result"),
|
|
3936
6276
|
item.get("output"),
|
|
3937
6277
|
item.get("content"),
|
|
6278
|
+
item.get("error"),
|
|
3938
6279
|
event.get("result"),
|
|
3939
6280
|
event.get("output"),
|
|
3940
6281
|
event.get("content"),
|
|
6282
|
+
event.get("error"),
|
|
3941
6283
|
item.get("aggregated_output"),
|
|
3942
6284
|
event.get("aggregated_output"),
|
|
3943
6285
|
):
|
|
@@ -3951,11 +6293,13 @@ def _tool_output(event: dict, item: dict) -> str:
|
|
|
3951
6293
|
item.get("output"),
|
|
3952
6294
|
item.get("result"),
|
|
3953
6295
|
item.get("content"),
|
|
6296
|
+
item.get("error"),
|
|
3954
6297
|
event.get("aggregated_output"),
|
|
3955
6298
|
event.get("changes"),
|
|
3956
6299
|
event.get("output"),
|
|
3957
6300
|
event.get("result"),
|
|
3958
6301
|
event.get("content"),
|
|
6302
|
+
event.get("error"),
|
|
3959
6303
|
):
|
|
3960
6304
|
text = _compact_text(value, limit=1200)
|
|
3961
6305
|
if text:
|