@researai/deepscientist 1.5.14 → 1.5.15
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 +8 -0
- package/assets/branding/logo-raster.png +0 -0
- package/bin/ds.js +134 -49
- package/docs/en/00_QUICK_START.md +2 -2
- package/docs/en/01_SETTINGS_REFERENCE.md +20 -4
- package/docs/en/03_QQ_CONNECTOR_GUIDE.md +19 -0
- package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
- package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
- 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/README.md +6 -0
- package/docs/zh/00_QUICK_START.md +2 -2
- package/docs/zh/01_SETTINGS_REFERENCE.md +20 -4
- package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +19 -0
- package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
- package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
- 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/README.md +6 -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/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 +3534 -191
- 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 +79 -64
- package/src/deepscientist/bash_exec/shells.py +87 -0
- package/src/deepscientist/bridges/connectors.py +51 -2
- package/src/deepscientist/config/models.py +6 -3
- package/src/deepscientist/config/service.py +7 -2
- package/src/deepscientist/connector/weixin_support.py +122 -1
- package/src/deepscientist/daemon/api/handlers.py +75 -4
- package/src/deepscientist/daemon/api/router.py +1 -0
- package/src/deepscientist/daemon/app.py +758 -206
- package/src/deepscientist/doctor.py +51 -0
- package/src/deepscientist/file_lock.py +48 -0
- package/src/deepscientist/gitops/diff.py +167 -1
- package/src/deepscientist/mcp/server.py +173 -5
- package/src/deepscientist/process_control.py +161 -0
- package/src/deepscientist/prompts/builder.py +267 -442
- package/src/deepscientist/quest/service.py +2255 -163
- package/src/deepscientist/quest/stage_views.py +171 -0
- package/src/deepscientist/runners/base.py +2 -0
- package/src/deepscientist/runners/codex.py +88 -5
- package/src/deepscientist/runners/runtime_overrides.py +17 -1
- package/src/prompts/contracts/shared_interaction.md +13 -4
- package/src/prompts/system.md +916 -72
- package/src/skills/analysis-campaign/SKILL.md +31 -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 +2 -0
- package/src/skills/decision/SKILL.md +19 -2
- package/src/skills/experiment/SKILL.md +8 -2
- package/src/skills/finalize/SKILL.md +18 -0
- package/src/skills/idea/SKILL.md +78 -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 +1 -1
- package/src/skills/optimize/SKILL.md +1644 -0
- package/src/skills/rebuttal/SKILL.md +2 -1
- package/src/skills/review/SKILL.md +2 -1
- package/src/skills/write/SKILL.md +80 -12
- package/src/skills/write/references/outline-evidence-contract-example.md +107 -0
- package/src/tui/dist/app/AppContainer.js +3 -0
- package/src/tui/package.json +1 -1
- package/src/ui/dist/assets/{AiManusChatView-DaF9Nge_.js → AiManusChatView-DDjbFnbt.js} +12 -12
- package/src/ui/dist/assets/{AnalysisPlugin-BSVx6dXE.js → AnalysisPlugin-Yb5IdmaU.js} +1 -1
- package/src/ui/dist/assets/CliPlugin-e64sreyu.js +31037 -0
- package/src/ui/dist/assets/{CodeEditorPlugin-DU9G0Tox.js → CodeEditorPlugin-C4D2TIkU.js} +8 -8
- package/src/ui/dist/assets/{CodeViewerPlugin-DoX_fI9l.js → CodeViewerPlugin-BVoNZIvC.js} +5 -5
- package/src/ui/dist/assets/{DocViewerPlugin-C4FWIXuU.js → DocViewerPlugin-CLChbllo.js} +3 -3
- package/src/ui/dist/assets/{GitDiffViewerPlugin-BgfFMgtf.js → GitDiffViewerPlugin-C4xeFyFQ.js} +20 -20
- package/src/ui/dist/assets/{ImageViewerPlugin-tcPkfY_x.js → ImageViewerPlugin-OiMUAcLi.js} +5 -5
- package/src/ui/dist/assets/{LabCopilotPanel-_dKV60Bf.js → LabCopilotPanel-BjD2ThQF.js} +11 -11
- package/src/ui/dist/assets/{LabPlugin-Bje0ayoC.js → LabPlugin-DQPg-NrB.js} +2 -2
- package/src/ui/dist/assets/{LatexPlugin-CVsBzAln.js → LatexPlugin-CI05XAV9.js} +7 -7
- package/src/ui/dist/assets/{MarkdownViewerPlugin-xjmrqv_8.js → MarkdownViewerPlugin-DpeBLYZf.js} +4 -4
- package/src/ui/dist/assets/{MarketplacePlugin-mMM2A8wP.js → MarketplacePlugin-DolE58Q2.js} +3 -3
- package/src/ui/dist/assets/{NotebookEditor-3kVDSOBo.js → NotebookEditor-7Qm2rSWD.js} +11 -11
- package/src/ui/dist/assets/{NotebookEditor-SoJ8X-MO.js → NotebookEditor-C1kWaxKi.js} +1 -1
- package/src/ui/dist/assets/{PdfLoader-DElVuHl9.js → PdfLoader-BfOHw8Zw.js} +1 -1
- package/src/ui/dist/assets/{PdfMarkdownPlugin-Bq88XT4G.js → PdfMarkdownPlugin-BulDREv1.js} +2 -2
- package/src/ui/dist/assets/{PdfViewerPlugin-CsCXMo9S.js → PdfViewerPlugin-C-daaOaL.js} +10 -10
- package/src/ui/dist/assets/{SearchPlugin-oUPvy19k.js → SearchPlugin-CjpaiJ3A.js} +1 -1
- package/src/ui/dist/assets/{TextViewerPlugin-CRkT9yNy.js → TextViewerPlugin-BxIyqPQC.js} +5 -5
- package/src/ui/dist/assets/{VNCViewer-BgbuvWhR.js → VNCViewer-HAg9mF7M.js} +10 -10
- package/src/ui/dist/assets/{bot-v_RASACv.js → bot-0DYntytV.js} +1 -1
- package/src/ui/dist/assets/{code-5hC9d0VH.js → code-B20Slj_w.js} +1 -1
- package/src/ui/dist/assets/{file-content-D1PxfOrp.js → file-content-DT24KFma.js} +1 -1
- package/src/ui/dist/assets/{file-diff-panel-DG1oT_Hj.js → file-diff-panel-DK13YPql.js} +1 -1
- package/src/ui/dist/assets/{file-socket-BmdFYQlk.js → file-socket-B4T2o4nR.js} +1 -1
- package/src/ui/dist/assets/{image-Dqe2X2tW.js → image-DSeR_sDS.js} +1 -1
- package/src/ui/dist/assets/{index-RDlNXXx1.js → index-BrFje2Uk.js} +2 -2
- package/src/ui/dist/assets/{index-DVsMKK_y.js → index-BwRJaoTl.js} +1 -1
- package/src/ui/dist/assets/{index-Nt9hS4ck.js → index-D_E4281X.js} +5007 -28514
- package/src/ui/dist/assets/{index-Duvz8Ip0.js → index-DnYB3xb1.js} +12 -12
- package/src/ui/dist/assets/{index-BQG-1s2o.css → index-G7AcWcMu.css} +43 -2
- package/src/ui/dist/assets/{monaco-DIXge1CP.js → monaco-LExaAN3Y.js} +1 -1
- package/src/ui/dist/assets/{pdf-effect-queue-BBTTQaO-.js → pdf-effect-queue-BJk5okWJ.js} +1 -1
- package/src/ui/dist/assets/{popover-BWlolyxo.js → popover-D3Gg_FoV.js} +1 -1
- package/src/ui/dist/assets/{project-sync-BM5PkFH4.js → project-sync-C_ygLlVU.js} +1 -1
- package/src/ui/dist/assets/{select-D4dAtrA8.js → select-CpAK6uWm.js} +2 -2
- package/src/ui/dist/assets/{sigma-CKbE5jJT.js → sigma-DEccaSgk.js} +1 -1
- package/src/ui/dist/assets/{square-check-big-CZNGMgiB.js → square-check-big-uUfyVsbD.js} +1 -1
- package/src/ui/dist/assets/{trash-DaB37xAz.js → trash-CXvwwSe8.js} +1 -1
- package/src/ui/dist/assets/{useCliAccess-C2OmAcWe.js → useCliAccess-Bnop4mgR.js} +1 -1
- package/src/ui/dist/assets/{useFileDiffOverlay-Dowd1Ij4.js → useFileDiffOverlay-B8eUAX0I.js} +1 -1
- package/src/ui/dist/assets/{wrap-text-BGjAhAUq.js → wrap-text-9vbOBpkW.js} +1 -1
- package/src/ui/dist/assets/{zoom-out-dMZQMXzc.js → zoom-out-BgVMmOW4.js} +1 -1
- package/src/ui/dist/index.html +2 -2
- package/src/ui/dist/assets/CliPlugin-C9gzJX41.js +0 -5905
|
@@ -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
|
|
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]:
|
|
@@ -182,6 +188,14 @@ class QuestService:
|
|
|
182
188
|
self._codex_history_cache: dict[str, dict[str, Any]] = {}
|
|
183
189
|
self._runtime_state_locks_lock = threading.Lock()
|
|
184
190
|
self._runtime_state_locks: dict[str, threading.Lock] = {}
|
|
191
|
+
self._artifact_projection_locks_lock = threading.Lock()
|
|
192
|
+
self._artifact_projection_locks: dict[str, threading.Lock] = {}
|
|
193
|
+
self._quest_projection_locks_lock = threading.Lock()
|
|
194
|
+
self._quest_projection_locks: dict[str, threading.Lock] = {}
|
|
195
|
+
self._quest_projection_builds_lock = threading.Lock()
|
|
196
|
+
self._quest_projection_builds: dict[str, threading.Thread] = {}
|
|
197
|
+
self._quest_projection_refresh_lock = threading.Lock()
|
|
198
|
+
self._quest_projection_refresh_at: dict[str, float] = {}
|
|
185
199
|
|
|
186
200
|
def _quest_root(self, quest_id: str) -> Path:
|
|
187
201
|
return self.quests_root / quest_id
|
|
@@ -339,7 +353,9 @@ class QuestService:
|
|
|
339
353
|
if value is _UNSET:
|
|
340
354
|
continue
|
|
341
355
|
current[key] = str(value) if isinstance(value, Path) else value
|
|
342
|
-
|
|
356
|
+
payload = self.write_research_state(quest_root, current)
|
|
357
|
+
self.schedule_projection_refresh(quest_root, kinds=("details", "canvas"))
|
|
358
|
+
return payload
|
|
343
359
|
|
|
344
360
|
def read_lab_canvas_state(self, quest_root: Path) -> dict[str, Any]:
|
|
345
361
|
self._initialize_runtime_files(quest_root)
|
|
@@ -443,7 +459,288 @@ class QuestService:
|
|
|
443
459
|
str(path),
|
|
444
460
|
)
|
|
445
461
|
|
|
446
|
-
|
|
462
|
+
@staticmethod
|
|
463
|
+
def _artifact_projection_path(quest_root: Path) -> Path:
|
|
464
|
+
return quest_root / ".ds" / "cache" / "artifact_projection.v2.json"
|
|
465
|
+
|
|
466
|
+
@staticmethod
|
|
467
|
+
def _artifact_projection_lock_path(quest_root: Path) -> Path:
|
|
468
|
+
return quest_root / ".ds" / "artifact_projection.lock"
|
|
469
|
+
|
|
470
|
+
@staticmethod
|
|
471
|
+
def _metrics_timeline_cache_path(quest_root: Path) -> Path:
|
|
472
|
+
return quest_root / ".ds" / "cache" / "metrics_timeline.v1.json"
|
|
473
|
+
|
|
474
|
+
@staticmethod
|
|
475
|
+
def _metrics_timeline_cache_lock_path(quest_root: Path) -> Path:
|
|
476
|
+
return quest_root / ".ds" / "cache" / "metrics_timeline.lock"
|
|
477
|
+
|
|
478
|
+
@staticmethod
|
|
479
|
+
def _baseline_compare_cache_path(quest_root: Path) -> Path:
|
|
480
|
+
return quest_root / ".ds" / "cache" / "baseline_compare.v1.json"
|
|
481
|
+
|
|
482
|
+
@staticmethod
|
|
483
|
+
def _baseline_compare_cache_lock_path(quest_root: Path) -> Path:
|
|
484
|
+
return quest_root / ".ds" / "cache" / "baseline_compare.lock"
|
|
485
|
+
|
|
486
|
+
@staticmethod
|
|
487
|
+
def _json_compatible_state(value: Any) -> Any:
|
|
488
|
+
if isinstance(value, tuple):
|
|
489
|
+
return [QuestService._json_compatible_state(item) for item in value]
|
|
490
|
+
if isinstance(value, list):
|
|
491
|
+
return [QuestService._json_compatible_state(item) for item in value]
|
|
492
|
+
if isinstance(value, dict):
|
|
493
|
+
return {
|
|
494
|
+
str(key): QuestService._json_compatible_state(item)
|
|
495
|
+
for key, item in value.items()
|
|
496
|
+
}
|
|
497
|
+
return value
|
|
498
|
+
|
|
499
|
+
@contextmanager
|
|
500
|
+
def _artifact_projection_lock(self, quest_root: Path):
|
|
501
|
+
lock_key = str(quest_root.resolve())
|
|
502
|
+
with self._artifact_projection_locks_lock:
|
|
503
|
+
thread_lock = self._artifact_projection_locks.setdefault(lock_key, threading.Lock())
|
|
504
|
+
with thread_lock:
|
|
505
|
+
with advisory_file_lock(self._artifact_projection_lock_path(quest_root)):
|
|
506
|
+
yield
|
|
507
|
+
|
|
508
|
+
def _artifact_index_collection_state(self, quest_root: Path) -> list[list[Any]]:
|
|
509
|
+
states: list[list[Any]] = []
|
|
510
|
+
for root in self._artifact_roots(quest_root):
|
|
511
|
+
artifacts_root = root / "artifacts"
|
|
512
|
+
if not artifacts_root.exists():
|
|
513
|
+
continue
|
|
514
|
+
try:
|
|
515
|
+
label = str(root.relative_to(quest_root))
|
|
516
|
+
except ValueError:
|
|
517
|
+
label = str(root)
|
|
518
|
+
states.append(
|
|
519
|
+
[
|
|
520
|
+
label,
|
|
521
|
+
self._json_compatible_state(self._path_state(artifacts_root / "_index.jsonl")),
|
|
522
|
+
]
|
|
523
|
+
)
|
|
524
|
+
return states
|
|
525
|
+
|
|
526
|
+
def _metrics_timeline_attachment_state(self, quest_root: Path, workspace_root: Path) -> list[list[Any]]:
|
|
527
|
+
states: list[list[Any]] = []
|
|
528
|
+
seen_paths: set[str] = set()
|
|
529
|
+
for root in (workspace_root, quest_root):
|
|
530
|
+
attachment_root = root / "baselines" / "imported"
|
|
531
|
+
if not attachment_root.exists():
|
|
532
|
+
continue
|
|
533
|
+
for path in sorted(attachment_root.glob("*/attachment.yaml")):
|
|
534
|
+
key = str(path.resolve())
|
|
535
|
+
if key in seen_paths:
|
|
536
|
+
continue
|
|
537
|
+
seen_paths.add(key)
|
|
538
|
+
try:
|
|
539
|
+
label = str(path.relative_to(quest_root))
|
|
540
|
+
except ValueError:
|
|
541
|
+
label = str(path)
|
|
542
|
+
states.append([label, self._json_compatible_state(self._path_state(path))])
|
|
543
|
+
return states
|
|
544
|
+
|
|
545
|
+
def _metrics_timeline_state(self, quest_root: Path, workspace_root: Path) -> list[Any]:
|
|
546
|
+
return [
|
|
547
|
+
str(workspace_root.resolve()),
|
|
548
|
+
self._artifact_index_collection_state(quest_root),
|
|
549
|
+
self._metrics_timeline_attachment_state(quest_root, workspace_root),
|
|
550
|
+
]
|
|
551
|
+
|
|
552
|
+
def _baseline_compare_state(self, quest_root: Path, workspace_root: Path) -> list[Any]:
|
|
553
|
+
return [
|
|
554
|
+
str(workspace_root.resolve()),
|
|
555
|
+
self._artifact_index_collection_state(quest_root),
|
|
556
|
+
self._metrics_timeline_attachment_state(quest_root, workspace_root),
|
|
557
|
+
self._json_compatible_state(self._path_state(self._quest_yaml_path(quest_root))),
|
|
558
|
+
]
|
|
559
|
+
|
|
560
|
+
def _baseline_compare_entries(self, quest_root: Path, workspace_root: Path) -> list[dict[str, Any]]:
|
|
561
|
+
entries: list[dict[str, Any]] = []
|
|
562
|
+
for item in self._collect_artifacts_raw(quest_root):
|
|
563
|
+
if str(item.get("kind") or "").strip() != "baselines":
|
|
564
|
+
continue
|
|
565
|
+
payload = item.get("payload") or {}
|
|
566
|
+
if not isinstance(payload, dict):
|
|
567
|
+
continue
|
|
568
|
+
status = str(payload.get("status") or "").strip().lower()
|
|
569
|
+
if status not in {"confirmed", "published", "quest_confirmed"}:
|
|
570
|
+
continue
|
|
571
|
+
entries.append(dict(payload))
|
|
572
|
+
attachment = self._active_baseline_attachment(quest_root, workspace_root)
|
|
573
|
+
attachment_entry = dict(attachment.get("entry") or {}) if isinstance(attachment, dict) else None
|
|
574
|
+
if attachment_entry:
|
|
575
|
+
entries.append(attachment_entry)
|
|
576
|
+
return entries
|
|
577
|
+
|
|
578
|
+
def _artifact_projection_state(self, quest_root: Path) -> tuple[str, Any]:
|
|
579
|
+
index_state = self._artifact_index_collection_state(quest_root)
|
|
580
|
+
if index_state and all(item[1] is not None for item in index_state):
|
|
581
|
+
return "index", index_state
|
|
582
|
+
if not index_state:
|
|
583
|
+
return "index", []
|
|
584
|
+
return "raw", self._json_compatible_state(self._artifact_collection_state(quest_root))
|
|
585
|
+
|
|
586
|
+
def _projection_artifact_item(
|
|
587
|
+
self,
|
|
588
|
+
*,
|
|
589
|
+
record: dict[str, Any],
|
|
590
|
+
artifact_path: Path,
|
|
591
|
+
workspace_root: Path,
|
|
592
|
+
) -> dict[str, Any]:
|
|
593
|
+
return {
|
|
594
|
+
"kind": artifact_path.parent.name,
|
|
595
|
+
"path": str(artifact_path),
|
|
596
|
+
"payload": copy.deepcopy(record),
|
|
597
|
+
"workspace_root": str(workspace_root),
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
def _write_artifact_projection_locked(
|
|
601
|
+
self,
|
|
602
|
+
quest_root: Path,
|
|
603
|
+
*,
|
|
604
|
+
state_kind: str,
|
|
605
|
+
state: Any,
|
|
606
|
+
artifacts: list[dict[str, Any]],
|
|
607
|
+
) -> list[dict[str, Any]]:
|
|
608
|
+
projection_path = self._artifact_projection_path(quest_root)
|
|
609
|
+
ensure_dir(projection_path.parent)
|
|
610
|
+
payload = {
|
|
611
|
+
"schema_version": 2,
|
|
612
|
+
"generated_at": utc_now(),
|
|
613
|
+
"state_kind": state_kind,
|
|
614
|
+
"state": self._json_compatible_state(state),
|
|
615
|
+
"artifacts": copy.deepcopy(artifacts),
|
|
616
|
+
}
|
|
617
|
+
write_json(projection_path, payload)
|
|
618
|
+
return copy.deepcopy(artifacts)
|
|
619
|
+
|
|
620
|
+
def refresh_artifact_projection(
|
|
621
|
+
self,
|
|
622
|
+
quest_root: Path,
|
|
623
|
+
*,
|
|
624
|
+
state_kind: str | None = None,
|
|
625
|
+
state: Any | None = None,
|
|
626
|
+
) -> list[dict[str, Any]]:
|
|
627
|
+
resolved_state_kind, resolved_state = (
|
|
628
|
+
(state_kind, state)
|
|
629
|
+
if state_kind is not None and state is not None
|
|
630
|
+
else self._artifact_projection_state(quest_root)
|
|
631
|
+
)
|
|
632
|
+
artifacts = self._collect_artifacts_raw(quest_root)
|
|
633
|
+
return self._write_artifact_projection_locked(
|
|
634
|
+
quest_root,
|
|
635
|
+
state_kind=resolved_state_kind,
|
|
636
|
+
state=resolved_state,
|
|
637
|
+
artifacts=artifacts,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
def update_artifact_projection(
|
|
641
|
+
self,
|
|
642
|
+
quest_root: Path,
|
|
643
|
+
*,
|
|
644
|
+
record: dict[str, Any],
|
|
645
|
+
artifact_path: Path,
|
|
646
|
+
workspace_root: Path,
|
|
647
|
+
previous_state_kind: str | None = None,
|
|
648
|
+
previous_state: Any | None = None,
|
|
649
|
+
current_state_kind: str | None = None,
|
|
650
|
+
current_state: Any | None = None,
|
|
651
|
+
) -> list[dict[str, Any]]:
|
|
652
|
+
resolved_previous_kind = previous_state_kind
|
|
653
|
+
resolved_previous_state = self._json_compatible_state(previous_state) if previous_state is not None else None
|
|
654
|
+
resolved_current_kind, resolved_current_state = (
|
|
655
|
+
(current_state_kind, self._json_compatible_state(current_state))
|
|
656
|
+
if current_state_kind is not None and current_state is not None
|
|
657
|
+
else self._artifact_projection_state(quest_root)
|
|
658
|
+
)
|
|
659
|
+
projection_path = self._artifact_projection_path(quest_root)
|
|
660
|
+
with self._artifact_projection_lock(quest_root):
|
|
661
|
+
payload = read_json(projection_path, {})
|
|
662
|
+
projected_artifacts = payload.get("artifacts") if isinstance(payload.get("artifacts"), list) else None
|
|
663
|
+
can_incrementally_update = (
|
|
664
|
+
isinstance(payload, dict)
|
|
665
|
+
and int(payload.get("schema_version") or 0) == 2
|
|
666
|
+
and isinstance(projected_artifacts, list)
|
|
667
|
+
and resolved_previous_kind is not None
|
|
668
|
+
and payload.get("state_kind") == resolved_previous_kind
|
|
669
|
+
and self._json_compatible_state(payload.get("state")) == resolved_previous_state
|
|
670
|
+
)
|
|
671
|
+
if not can_incrementally_update:
|
|
672
|
+
return self.refresh_artifact_projection(
|
|
673
|
+
quest_root,
|
|
674
|
+
state_kind=resolved_current_kind,
|
|
675
|
+
state=resolved_current_state,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
artifacts: list[dict[str, Any]] = [
|
|
679
|
+
dict(item)
|
|
680
|
+
for item in projected_artifacts
|
|
681
|
+
if isinstance(item, dict)
|
|
682
|
+
]
|
|
683
|
+
next_item = self._projection_artifact_item(
|
|
684
|
+
record=record,
|
|
685
|
+
artifact_path=artifact_path,
|
|
686
|
+
workspace_root=workspace_root,
|
|
687
|
+
)
|
|
688
|
+
next_identity = self._artifact_item_identity(
|
|
689
|
+
artifact_path,
|
|
690
|
+
record,
|
|
691
|
+
kind=str(next_item.get("kind") or ""),
|
|
692
|
+
)
|
|
693
|
+
try:
|
|
694
|
+
next_mtime_ns = artifact_path.stat().st_mtime_ns
|
|
695
|
+
except OSError:
|
|
696
|
+
next_mtime_ns = 0
|
|
697
|
+
replaced = False
|
|
698
|
+
for index, existing in enumerate(artifacts):
|
|
699
|
+
existing_payload = existing.get("payload") if isinstance(existing.get("payload"), dict) else {}
|
|
700
|
+
existing_path = Path(str(existing.get("path") or artifact_path))
|
|
701
|
+
if (
|
|
702
|
+
self._artifact_item_identity(
|
|
703
|
+
existing_path,
|
|
704
|
+
existing_payload,
|
|
705
|
+
kind=str(existing.get("kind") or existing_path.parent.name or ""),
|
|
706
|
+
)
|
|
707
|
+
!= next_identity
|
|
708
|
+
):
|
|
709
|
+
continue
|
|
710
|
+
try:
|
|
711
|
+
existing_mtime_ns = existing_path.stat().st_mtime_ns
|
|
712
|
+
except OSError:
|
|
713
|
+
existing_mtime_ns = 0
|
|
714
|
+
if self._artifact_item_rank(
|
|
715
|
+
record,
|
|
716
|
+
path=artifact_path,
|
|
717
|
+
mtime_ns=next_mtime_ns,
|
|
718
|
+
) >= self._artifact_item_rank(
|
|
719
|
+
existing_payload,
|
|
720
|
+
path=existing_path,
|
|
721
|
+
mtime_ns=existing_mtime_ns,
|
|
722
|
+
):
|
|
723
|
+
artifacts[index] = next_item
|
|
724
|
+
replaced = True
|
|
725
|
+
break
|
|
726
|
+
if not replaced:
|
|
727
|
+
artifacts.append(next_item)
|
|
728
|
+
artifacts.sort(
|
|
729
|
+
key=lambda item: str(
|
|
730
|
+
((item.get("payload") or {}).get("updated_at"))
|
|
731
|
+
or ((item.get("payload") or {}).get("created_at"))
|
|
732
|
+
or item.get("path")
|
|
733
|
+
or ""
|
|
734
|
+
)
|
|
735
|
+
)
|
|
736
|
+
return self._write_artifact_projection_locked(
|
|
737
|
+
quest_root,
|
|
738
|
+
state_kind=resolved_current_kind,
|
|
739
|
+
state=resolved_current_state,
|
|
740
|
+
artifacts=artifacts,
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
def _collect_artifacts_raw(self, quest_root: Path) -> list[dict[str, Any]]:
|
|
447
744
|
artifacts_by_identity: dict[str, dict[str, Any]] = {}
|
|
448
745
|
for root in self._artifact_roots(quest_root):
|
|
449
746
|
artifacts_root = root / "artifacts"
|
|
@@ -492,35 +789,1657 @@ class QuestService:
|
|
|
492
789
|
or ""
|
|
493
790
|
)
|
|
494
791
|
)
|
|
495
|
-
return artifacts
|
|
792
|
+
return artifacts
|
|
793
|
+
|
|
794
|
+
def _collect_artifacts(self, quest_root: Path) -> list[dict[str, Any]]:
|
|
795
|
+
state_kind, state = self._artifact_projection_state(quest_root)
|
|
796
|
+
projection_path = self._artifact_projection_path(quest_root)
|
|
797
|
+
cached_projection = self._read_cached_json(projection_path, {})
|
|
798
|
+
if (
|
|
799
|
+
isinstance(cached_projection, dict)
|
|
800
|
+
and int(cached_projection.get("schema_version") or 0) == 2
|
|
801
|
+
and cached_projection.get("state_kind") == state_kind
|
|
802
|
+
and self._json_compatible_state(cached_projection.get("state")) == self._json_compatible_state(state)
|
|
803
|
+
and isinstance(cached_projection.get("artifacts"), list)
|
|
804
|
+
):
|
|
805
|
+
return [
|
|
806
|
+
dict(item)
|
|
807
|
+
for item in cached_projection.get("artifacts") or []
|
|
808
|
+
if isinstance(item, dict)
|
|
809
|
+
]
|
|
810
|
+
|
|
811
|
+
with self._artifact_projection_lock(quest_root):
|
|
812
|
+
cached_projection = self._read_cached_json(projection_path, {})
|
|
813
|
+
if (
|
|
814
|
+
isinstance(cached_projection, dict)
|
|
815
|
+
and int(cached_projection.get("schema_version") or 0) == 2
|
|
816
|
+
and cached_projection.get("state_kind") == state_kind
|
|
817
|
+
and self._json_compatible_state(cached_projection.get("state")) == self._json_compatible_state(state)
|
|
818
|
+
and isinstance(cached_projection.get("artifacts"), list)
|
|
819
|
+
):
|
|
820
|
+
return [
|
|
821
|
+
dict(item)
|
|
822
|
+
for item in cached_projection.get("artifacts") or []
|
|
823
|
+
if isinstance(item, dict)
|
|
824
|
+
]
|
|
825
|
+
return self.refresh_artifact_projection(
|
|
826
|
+
quest_root,
|
|
827
|
+
state_kind=state_kind,
|
|
828
|
+
state=state,
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
def _collect_run_artifacts_raw(
|
|
832
|
+
self,
|
|
833
|
+
quest_root: Path,
|
|
834
|
+
*,
|
|
835
|
+
run_kind: str | None = None,
|
|
836
|
+
) -> list[dict[str, Any]]:
|
|
837
|
+
artifacts_by_identity: dict[str, dict[str, Any]] = {}
|
|
838
|
+
normalized_run_kind = str(run_kind or "").strip()
|
|
839
|
+
for root in self._artifact_roots(quest_root):
|
|
840
|
+
runs_root = root / "artifacts" / "runs"
|
|
841
|
+
if not runs_root.exists():
|
|
842
|
+
continue
|
|
843
|
+
for path in sorted(runs_root.glob("*.json")):
|
|
844
|
+
item = self._read_cached_json(path, {})
|
|
845
|
+
payload = item if isinstance(item, dict) else {}
|
|
846
|
+
if normalized_run_kind and str(payload.get("run_kind") or "").strip() != normalized_run_kind:
|
|
847
|
+
continue
|
|
848
|
+
try:
|
|
849
|
+
mtime_ns = path.stat().st_mtime_ns
|
|
850
|
+
except OSError:
|
|
851
|
+
mtime_ns = 0
|
|
852
|
+
artifact = {
|
|
853
|
+
"kind": "run",
|
|
854
|
+
"path": str(path),
|
|
855
|
+
"payload": item,
|
|
856
|
+
"workspace_root": str(root),
|
|
857
|
+
}
|
|
858
|
+
identity = self._artifact_item_identity(path, payload, kind="run")
|
|
859
|
+
existing = artifacts_by_identity.get(identity)
|
|
860
|
+
existing_payload = existing.get("payload") if isinstance((existing or {}).get("payload"), dict) else {}
|
|
861
|
+
existing_path = Path(str((existing or {}).get("path") or path))
|
|
862
|
+
try:
|
|
863
|
+
existing_mtime_ns = existing_path.stat().st_mtime_ns if existing else 0
|
|
864
|
+
except OSError:
|
|
865
|
+
existing_mtime_ns = 0
|
|
866
|
+
if existing is None or self._artifact_item_rank(
|
|
867
|
+
payload,
|
|
868
|
+
path=path,
|
|
869
|
+
mtime_ns=mtime_ns,
|
|
870
|
+
) >= self._artifact_item_rank(
|
|
871
|
+
existing_payload,
|
|
872
|
+
path=existing_path,
|
|
873
|
+
mtime_ns=existing_mtime_ns,
|
|
874
|
+
):
|
|
875
|
+
artifacts_by_identity[identity] = artifact
|
|
876
|
+
artifacts = list(artifacts_by_identity.values())
|
|
877
|
+
artifacts.sort(
|
|
878
|
+
key=lambda item: str(
|
|
879
|
+
((item.get("payload") or {}).get("updated_at"))
|
|
880
|
+
or ((item.get("payload") or {}).get("created_at"))
|
|
881
|
+
or item.get("path")
|
|
882
|
+
or ""
|
|
883
|
+
)
|
|
884
|
+
)
|
|
885
|
+
return artifacts
|
|
886
|
+
|
|
887
|
+
@staticmethod
|
|
888
|
+
def _projection_id(kind: str) -> str:
|
|
889
|
+
return f"{kind}.v1"
|
|
890
|
+
|
|
891
|
+
@staticmethod
|
|
892
|
+
def _projection_directory(quest_root: Path) -> Path:
|
|
893
|
+
return quest_root / ".ds" / "projections"
|
|
894
|
+
|
|
895
|
+
@classmethod
|
|
896
|
+
def _projection_manifest_path(cls, quest_root: Path) -> Path:
|
|
897
|
+
return cls._projection_directory(quest_root) / "manifest.json"
|
|
898
|
+
|
|
899
|
+
@classmethod
|
|
900
|
+
def _projection_payload_path(cls, quest_root: Path, kind: str) -> Path:
|
|
901
|
+
return cls._projection_directory(quest_root) / f"{cls._projection_id(kind)}.json"
|
|
902
|
+
|
|
903
|
+
@classmethod
|
|
904
|
+
def _projection_lock_path(cls, quest_root: Path, kind: str) -> Path:
|
|
905
|
+
return cls._projection_directory(quest_root) / f"{cls._projection_id(kind)}.lock"
|
|
906
|
+
|
|
907
|
+
def _projection_build_key(self, quest_root: Path, kind: str) -> str:
|
|
908
|
+
return f"{quest_root.resolve()}::{kind}"
|
|
909
|
+
|
|
910
|
+
def _codex_history_events_state(self, quest_root: Path) -> tuple[tuple[str, tuple[int, int, int] | None], ...]:
|
|
911
|
+
return self._glob_states(quest_root / ".ds" / "codex_history", "*/events.jsonl")
|
|
912
|
+
|
|
913
|
+
def _details_projection_state(self, quest_root: Path) -> tuple[Any, ...]:
|
|
914
|
+
workspace_root = self.active_workspace_root(quest_root)
|
|
915
|
+
core_paths = [
|
|
916
|
+
self._quest_yaml_path(quest_root),
|
|
917
|
+
quest_root / "status.md",
|
|
918
|
+
quest_root / ".ds" / "runtime_state.json",
|
|
919
|
+
quest_root / ".ds" / "research_state.json",
|
|
920
|
+
quest_root / ".ds" / "interaction_state.json",
|
|
921
|
+
quest_root / ".ds" / "bindings.json",
|
|
922
|
+
quest_root / ".ds" / "bash_exec" / "summary.json",
|
|
923
|
+
self._artifact_projection_path(quest_root),
|
|
924
|
+
workspace_root / "brief.md",
|
|
925
|
+
workspace_root / "plan.md",
|
|
926
|
+
workspace_root / "status.md",
|
|
927
|
+
workspace_root / "SUMMARY.md",
|
|
928
|
+
]
|
|
929
|
+
return (
|
|
930
|
+
str(workspace_root.resolve()),
|
|
931
|
+
self._path_states(core_paths),
|
|
932
|
+
self._codex_meta_state(quest_root),
|
|
933
|
+
self._codex_history_events_state(quest_root),
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
def _git_branch_projection_state(self, quest_root: Path) -> dict[str, Any]:
|
|
937
|
+
result = run_command(
|
|
938
|
+
[
|
|
939
|
+
"git",
|
|
940
|
+
"for-each-ref",
|
|
941
|
+
"--sort=refname",
|
|
942
|
+
"--format=%(refname:short)%09%(objectname)%09%(committerdate:iso-strict)",
|
|
943
|
+
"refs/heads",
|
|
944
|
+
],
|
|
945
|
+
cwd=quest_root,
|
|
946
|
+
check=False,
|
|
947
|
+
)
|
|
948
|
+
refs = [line.strip() for line in str(result.stdout or "").splitlines() if line.strip()]
|
|
949
|
+
if result.returncode != 0:
|
|
950
|
+
refs = [f"error:{result.returncode}:{str(result.stderr or '').strip()}"]
|
|
951
|
+
return {
|
|
952
|
+
"current_ref": current_branch(quest_root),
|
|
953
|
+
"head": head_commit(quest_root),
|
|
954
|
+
"refs": refs,
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
def _canvas_projection_state(self, quest_root: Path) -> tuple[Any, ...]:
|
|
958
|
+
return (
|
|
959
|
+
self._path_states(
|
|
960
|
+
[
|
|
961
|
+
self._quest_yaml_path(quest_root),
|
|
962
|
+
quest_root / ".ds" / "research_state.json",
|
|
963
|
+
self._artifact_projection_path(quest_root),
|
|
964
|
+
]
|
|
965
|
+
),
|
|
966
|
+
self._git_branch_projection_state(quest_root),
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
def _projection_state_for_kind(self, quest_root: Path, kind: str) -> Any:
|
|
970
|
+
if kind == "details":
|
|
971
|
+
return self._details_projection_state(quest_root)
|
|
972
|
+
if kind == "canvas":
|
|
973
|
+
return self._canvas_projection_state(quest_root)
|
|
974
|
+
raise ValueError(f"Unsupported projection kind `{kind}`.")
|
|
975
|
+
|
|
976
|
+
def _projection_source_signature(self, quest_root: Path, kind: str) -> str:
|
|
977
|
+
state = {
|
|
978
|
+
"projection_id": self._projection_id(kind),
|
|
979
|
+
"state": self._json_compatible_state(self._projection_state_for_kind(quest_root, kind)),
|
|
980
|
+
}
|
|
981
|
+
return sha256_text(json.dumps(state, ensure_ascii=False, sort_keys=True))
|
|
982
|
+
|
|
983
|
+
def _default_projection_status(self, kind: str) -> dict[str, Any]:
|
|
984
|
+
return {
|
|
985
|
+
"projection_id": self._projection_id(kind),
|
|
986
|
+
"state": "missing",
|
|
987
|
+
"progress_current": 0,
|
|
988
|
+
"progress_total": 0,
|
|
989
|
+
"current_step": None,
|
|
990
|
+
"source_signature": None,
|
|
991
|
+
"generated_at": None,
|
|
992
|
+
"last_success_at": None,
|
|
993
|
+
"error": None,
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
def _normalize_projection_status(self, kind: str, raw: Any) -> dict[str, Any]:
|
|
997
|
+
normalized = self._default_projection_status(kind)
|
|
998
|
+
if isinstance(raw, dict):
|
|
999
|
+
normalized.update(
|
|
1000
|
+
{
|
|
1001
|
+
"state": str(raw.get("state") or normalized["state"]).strip() or normalized["state"],
|
|
1002
|
+
"progress_current": max(0, int(raw.get("progress_current") or 0)),
|
|
1003
|
+
"progress_total": max(0, int(raw.get("progress_total") or 0)),
|
|
1004
|
+
"current_step": str(raw.get("current_step") or "").strip() or None,
|
|
1005
|
+
"source_signature": str(raw.get("source_signature") or "").strip() or None,
|
|
1006
|
+
"generated_at": str(raw.get("generated_at") or "").strip() or None,
|
|
1007
|
+
"last_success_at": str(raw.get("last_success_at") or "").strip() or None,
|
|
1008
|
+
"error": str(raw.get("error") or "").strip() or None,
|
|
1009
|
+
}
|
|
1010
|
+
)
|
|
1011
|
+
return normalized
|
|
1012
|
+
|
|
1013
|
+
def _read_projection_manifest(self, quest_root: Path) -> dict[str, Any]:
|
|
1014
|
+
manifest = self._read_cached_json(
|
|
1015
|
+
self._projection_manifest_path(quest_root),
|
|
1016
|
+
{
|
|
1017
|
+
"schema_version": _PROJECTION_SCHEMA_VERSION,
|
|
1018
|
+
"projections": {},
|
|
1019
|
+
},
|
|
1020
|
+
)
|
|
1021
|
+
if not isinstance(manifest, dict):
|
|
1022
|
+
return {
|
|
1023
|
+
"schema_version": _PROJECTION_SCHEMA_VERSION,
|
|
1024
|
+
"projections": {},
|
|
1025
|
+
}
|
|
1026
|
+
return manifest
|
|
1027
|
+
|
|
1028
|
+
def _read_projection_payload_file(self, quest_root: Path, kind: str) -> dict[str, Any] | None:
|
|
1029
|
+
payload = self._read_cached_json(self._projection_payload_path(quest_root, kind), {})
|
|
1030
|
+
if not isinstance(payload, dict):
|
|
1031
|
+
return None
|
|
1032
|
+
if str(payload.get("projection_id") or "").strip() != self._projection_id(kind):
|
|
1033
|
+
return None
|
|
1034
|
+
if not isinstance(payload.get("payload"), dict):
|
|
1035
|
+
return None
|
|
1036
|
+
return payload
|
|
1037
|
+
|
|
1038
|
+
def _write_projection_manifest_locked(
|
|
1039
|
+
self,
|
|
1040
|
+
quest_root: Path,
|
|
1041
|
+
kind: str,
|
|
1042
|
+
status: dict[str, Any],
|
|
1043
|
+
) -> dict[str, Any]:
|
|
1044
|
+
path = self._projection_manifest_path(quest_root)
|
|
1045
|
+
ensure_dir(path.parent)
|
|
1046
|
+
manifest = read_json(path, {})
|
|
1047
|
+
if not isinstance(manifest, dict):
|
|
1048
|
+
manifest = {}
|
|
1049
|
+
projections = manifest.get("projections") if isinstance(manifest.get("projections"), dict) else {}
|
|
1050
|
+
next_status = self._normalize_projection_status(kind, status)
|
|
1051
|
+
projections = {
|
|
1052
|
+
**projections,
|
|
1053
|
+
kind: next_status,
|
|
1054
|
+
}
|
|
1055
|
+
write_json(
|
|
1056
|
+
path,
|
|
1057
|
+
{
|
|
1058
|
+
"schema_version": _PROJECTION_SCHEMA_VERSION,
|
|
1059
|
+
"updated_at": utc_now(),
|
|
1060
|
+
"projections": projections,
|
|
1061
|
+
},
|
|
1062
|
+
)
|
|
1063
|
+
return next_status
|
|
1064
|
+
|
|
1065
|
+
def _write_projection_payload_locked(
|
|
1066
|
+
self,
|
|
1067
|
+
quest_root: Path,
|
|
1068
|
+
kind: str,
|
|
1069
|
+
*,
|
|
1070
|
+
source_signature: str,
|
|
1071
|
+
payload: dict[str, Any],
|
|
1072
|
+
generated_at: str | None = None,
|
|
1073
|
+
) -> dict[str, Any]:
|
|
1074
|
+
path = self._projection_payload_path(quest_root, kind)
|
|
1075
|
+
ensure_dir(path.parent)
|
|
1076
|
+
resolved_generated_at = generated_at or utc_now()
|
|
1077
|
+
wrapper = {
|
|
1078
|
+
"schema_version": _PROJECTION_SCHEMA_VERSION,
|
|
1079
|
+
"projection_id": self._projection_id(kind),
|
|
1080
|
+
"generated_at": resolved_generated_at,
|
|
1081
|
+
"source_signature": source_signature,
|
|
1082
|
+
"payload": copy.deepcopy(payload),
|
|
1083
|
+
}
|
|
1084
|
+
write_json(path, wrapper)
|
|
1085
|
+
return copy.deepcopy(payload)
|
|
1086
|
+
|
|
1087
|
+
@contextmanager
|
|
1088
|
+
def _projection_lock(self, quest_root: Path, kind: str):
|
|
1089
|
+
lock_key = self._projection_build_key(quest_root, kind)
|
|
1090
|
+
with self._quest_projection_locks_lock:
|
|
1091
|
+
thread_lock = self._quest_projection_locks.setdefault(lock_key, threading.Lock())
|
|
1092
|
+
with thread_lock:
|
|
1093
|
+
with advisory_file_lock(self._projection_lock_path(quest_root, kind)):
|
|
1094
|
+
yield
|
|
1095
|
+
|
|
1096
|
+
def _projection_build_active(self, quest_root: Path, kind: str) -> bool:
|
|
1097
|
+
build_key = self._projection_build_key(quest_root, kind)
|
|
1098
|
+
with self._quest_projection_builds_lock:
|
|
1099
|
+
thread = self._quest_projection_builds.get(build_key)
|
|
1100
|
+
if thread is not None and not thread.is_alive():
|
|
1101
|
+
self._quest_projection_builds.pop(build_key, None)
|
|
1102
|
+
thread = None
|
|
1103
|
+
return thread is not None
|
|
1104
|
+
|
|
1105
|
+
def _present_projection_status(
|
|
1106
|
+
self,
|
|
1107
|
+
quest_root: Path,
|
|
1108
|
+
kind: str,
|
|
1109
|
+
*,
|
|
1110
|
+
source_signature: str,
|
|
1111
|
+
payload_wrapper: dict[str, Any] | None,
|
|
1112
|
+
) -> dict[str, Any]:
|
|
1113
|
+
manifest = self._read_projection_manifest(quest_root)
|
|
1114
|
+
projections = manifest.get("projections") if isinstance(manifest.get("projections"), dict) else {}
|
|
1115
|
+
status = self._normalize_projection_status(kind, projections.get(kind))
|
|
1116
|
+
payload_signature = (
|
|
1117
|
+
str(payload_wrapper.get("source_signature") or "").strip()
|
|
1118
|
+
if isinstance(payload_wrapper, dict)
|
|
1119
|
+
else None
|
|
1120
|
+
) or None
|
|
1121
|
+
payload_generated_at = (
|
|
1122
|
+
str(payload_wrapper.get("generated_at") or "").strip()
|
|
1123
|
+
if isinstance(payload_wrapper, dict)
|
|
1124
|
+
else None
|
|
1125
|
+
) or None
|
|
1126
|
+
payload_ready = (
|
|
1127
|
+
isinstance(payload_wrapper, dict)
|
|
1128
|
+
and isinstance(payload_wrapper.get("payload"), dict)
|
|
1129
|
+
and payload_signature == source_signature
|
|
1130
|
+
)
|
|
1131
|
+
if payload_ready:
|
|
1132
|
+
status.update(
|
|
1133
|
+
{
|
|
1134
|
+
"state": "ready",
|
|
1135
|
+
"source_signature": source_signature,
|
|
1136
|
+
"generated_at": payload_generated_at,
|
|
1137
|
+
"last_success_at": payload_generated_at or status.get("last_success_at"),
|
|
1138
|
+
"progress_current": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1139
|
+
"progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1140
|
+
"current_step": None,
|
|
1141
|
+
"error": None,
|
|
1142
|
+
}
|
|
1143
|
+
)
|
|
1144
|
+
return status
|
|
1145
|
+
if self._projection_build_active(quest_root, kind):
|
|
1146
|
+
status["state"] = "building" if status.get("state") != "queued" else "queued"
|
|
1147
|
+
status["progress_total"] = max(int(status.get("progress_total") or 0), _PROJECTION_BUILD_TOTAL_STEPS)
|
|
1148
|
+
status["current_step"] = status.get("current_step") or "Building projection"
|
|
1149
|
+
return status
|
|
1150
|
+
if isinstance(payload_wrapper, dict) and isinstance(payload_wrapper.get("payload"), dict):
|
|
1151
|
+
status.update(
|
|
1152
|
+
{
|
|
1153
|
+
"state": "stale",
|
|
1154
|
+
"generated_at": payload_generated_at,
|
|
1155
|
+
"last_success_at": payload_generated_at or status.get("last_success_at"),
|
|
1156
|
+
"progress_current": 0,
|
|
1157
|
+
"progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1158
|
+
"current_step": "Queued for refresh",
|
|
1159
|
+
}
|
|
1160
|
+
)
|
|
1161
|
+
return status
|
|
1162
|
+
if status.get("state") == "failed":
|
|
1163
|
+
status["progress_total"] = max(int(status.get("progress_total") or 0), _PROJECTION_BUILD_TOTAL_STEPS)
|
|
1164
|
+
return status
|
|
1165
|
+
return self._default_projection_status(kind)
|
|
1166
|
+
|
|
1167
|
+
def _queue_projection_build(self, quest_root: Path, kind: str, *, source_signature: str) -> None:
|
|
1168
|
+
if self._projection_build_active(quest_root, kind):
|
|
1169
|
+
return
|
|
1170
|
+
|
|
1171
|
+
with self._projection_lock(quest_root, kind):
|
|
1172
|
+
payload_wrapper = self._read_projection_payload_file(quest_root, kind)
|
|
1173
|
+
if (
|
|
1174
|
+
isinstance(payload_wrapper, dict)
|
|
1175
|
+
and str(payload_wrapper.get("source_signature") or "").strip() == source_signature
|
|
1176
|
+
and isinstance(payload_wrapper.get("payload"), dict)
|
|
1177
|
+
):
|
|
1178
|
+
ready_status = self._default_projection_status(kind)
|
|
1179
|
+
ready_status.update(
|
|
1180
|
+
{
|
|
1181
|
+
"state": "ready",
|
|
1182
|
+
"source_signature": source_signature,
|
|
1183
|
+
"generated_at": str(payload_wrapper.get("generated_at") or "").strip() or None,
|
|
1184
|
+
"last_success_at": str(payload_wrapper.get("generated_at") or "").strip() or None,
|
|
1185
|
+
"progress_current": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1186
|
+
"progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1187
|
+
}
|
|
1188
|
+
)
|
|
1189
|
+
self._write_projection_manifest_locked(quest_root, kind, ready_status)
|
|
1190
|
+
return
|
|
1191
|
+
queued_status = self._default_projection_status(kind)
|
|
1192
|
+
queued_status.update(
|
|
1193
|
+
{
|
|
1194
|
+
"state": "queued",
|
|
1195
|
+
"source_signature": source_signature,
|
|
1196
|
+
"progress_current": 0,
|
|
1197
|
+
"progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1198
|
+
"current_step": "Queued for background rebuild",
|
|
1199
|
+
"error": None,
|
|
1200
|
+
}
|
|
1201
|
+
)
|
|
1202
|
+
self._write_projection_manifest_locked(quest_root, kind, queued_status)
|
|
1203
|
+
|
|
1204
|
+
build_key = self._projection_build_key(quest_root, kind)
|
|
1205
|
+
|
|
1206
|
+
def _update_progress(current: int, step: str | None) -> None:
|
|
1207
|
+
with self._projection_lock(quest_root, kind):
|
|
1208
|
+
manifest = self._read_projection_manifest(quest_root)
|
|
1209
|
+
projections = manifest.get("projections") if isinstance(manifest.get("projections"), dict) else {}
|
|
1210
|
+
status = self._normalize_projection_status(kind, projections.get(kind))
|
|
1211
|
+
status.update(
|
|
1212
|
+
{
|
|
1213
|
+
"state": "building",
|
|
1214
|
+
"source_signature": source_signature,
|
|
1215
|
+
"progress_current": max(0, min(current, _PROJECTION_BUILD_TOTAL_STEPS)),
|
|
1216
|
+
"progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1217
|
+
"current_step": step,
|
|
1218
|
+
"error": None,
|
|
1219
|
+
}
|
|
1220
|
+
)
|
|
1221
|
+
self._write_projection_manifest_locked(quest_root, kind, status)
|
|
1222
|
+
|
|
1223
|
+
def _worker() -> None:
|
|
1224
|
+
try:
|
|
1225
|
+
_update_progress(0, "Preparing projection inputs")
|
|
1226
|
+
payload = self._build_projection_payload(
|
|
1227
|
+
quest_root,
|
|
1228
|
+
kind,
|
|
1229
|
+
source_signature=source_signature,
|
|
1230
|
+
update_progress=_update_progress,
|
|
1231
|
+
)
|
|
1232
|
+
_update_progress(_PROJECTION_BUILD_TOTAL_STEPS, "Writing projection")
|
|
1233
|
+
generated_at = utc_now()
|
|
1234
|
+
with self._projection_lock(quest_root, kind):
|
|
1235
|
+
self._write_projection_payload_locked(
|
|
1236
|
+
quest_root,
|
|
1237
|
+
kind,
|
|
1238
|
+
source_signature=source_signature,
|
|
1239
|
+
payload=payload,
|
|
1240
|
+
generated_at=generated_at,
|
|
1241
|
+
)
|
|
1242
|
+
ready_status = self._default_projection_status(kind)
|
|
1243
|
+
ready_status.update(
|
|
1244
|
+
{
|
|
1245
|
+
"state": "ready",
|
|
1246
|
+
"source_signature": source_signature,
|
|
1247
|
+
"generated_at": generated_at,
|
|
1248
|
+
"last_success_at": generated_at,
|
|
1249
|
+
"progress_current": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1250
|
+
"progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1251
|
+
"current_step": None,
|
|
1252
|
+
"error": None,
|
|
1253
|
+
}
|
|
1254
|
+
)
|
|
1255
|
+
self._write_projection_manifest_locked(quest_root, kind, ready_status)
|
|
1256
|
+
except Exception as exc:
|
|
1257
|
+
with self._projection_lock(quest_root, kind):
|
|
1258
|
+
failed_status = self._default_projection_status(kind)
|
|
1259
|
+
failed_status.update(
|
|
1260
|
+
{
|
|
1261
|
+
"state": "failed",
|
|
1262
|
+
"source_signature": source_signature,
|
|
1263
|
+
"progress_current": 0,
|
|
1264
|
+
"progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
|
|
1265
|
+
"current_step": None,
|
|
1266
|
+
"error": str(exc),
|
|
1267
|
+
}
|
|
1268
|
+
)
|
|
1269
|
+
self._write_projection_manifest_locked(quest_root, kind, failed_status)
|
|
1270
|
+
finally:
|
|
1271
|
+
with self._quest_projection_builds_lock:
|
|
1272
|
+
active = self._quest_projection_builds.get(build_key)
|
|
1273
|
+
if active is threading.current_thread():
|
|
1274
|
+
self._quest_projection_builds.pop(build_key, None)
|
|
1275
|
+
|
|
1276
|
+
worker = threading.Thread(
|
|
1277
|
+
target=_worker,
|
|
1278
|
+
daemon=True,
|
|
1279
|
+
name=f"ds-projection-{quest_root.name}-{kind}",
|
|
1280
|
+
)
|
|
1281
|
+
with self._quest_projection_builds_lock:
|
|
1282
|
+
self._quest_projection_builds[build_key] = worker
|
|
1283
|
+
worker.start()
|
|
1284
|
+
|
|
1285
|
+
def _recent_codex_runs(self, quest_root: Path, *, limit: int = 5) -> list[dict[str, Any]]:
|
|
1286
|
+
history_root = quest_root / ".ds" / "codex_history"
|
|
1287
|
+
if not history_root.exists():
|
|
1288
|
+
return []
|
|
1289
|
+
runs: list[dict[str, Any]] = []
|
|
1290
|
+
for meta_path in sorted(history_root.glob("*/meta.json")):
|
|
1291
|
+
payload = self._read_cached_json(meta_path, {})
|
|
1292
|
+
if not isinstance(payload, dict) or not payload:
|
|
1293
|
+
continue
|
|
1294
|
+
record = dict(payload)
|
|
1295
|
+
record.setdefault("history_root", str(meta_path.parent))
|
|
1296
|
+
runs.append(record)
|
|
1297
|
+
runs.sort(
|
|
1298
|
+
key=lambda item: str(
|
|
1299
|
+
item.get("updated_at")
|
|
1300
|
+
or item.get("completed_at")
|
|
1301
|
+
or item.get("created_at")
|
|
1302
|
+
or item.get("run_id")
|
|
1303
|
+
or ""
|
|
1304
|
+
)
|
|
1305
|
+
)
|
|
1306
|
+
return runs[-limit:]
|
|
1307
|
+
|
|
1308
|
+
def _build_workflow_payload(
|
|
1309
|
+
self,
|
|
1310
|
+
quest_id: str,
|
|
1311
|
+
quest_root: Path,
|
|
1312
|
+
workspace_root: Path,
|
|
1313
|
+
*,
|
|
1314
|
+
recent_runs: list[dict[str, Any]],
|
|
1315
|
+
recent_artifacts: list[dict[str, Any]],
|
|
1316
|
+
) -> dict[str, Any]:
|
|
1317
|
+
entries: list[dict[str, Any]] = []
|
|
1318
|
+
changed_files: list[dict[str, Any]] = []
|
|
1319
|
+
seen_files: set[str] = set()
|
|
1320
|
+
|
|
1321
|
+
def add_file(path: str | None, *, source: str, document_id: str | None = None, writable: bool | None = None) -> None:
|
|
1322
|
+
if not path:
|
|
1323
|
+
return
|
|
1324
|
+
normalized = str(path)
|
|
1325
|
+
if normalized in seen_files:
|
|
1326
|
+
return
|
|
1327
|
+
seen_files.add(normalized)
|
|
1328
|
+
resolved_document_id = document_id or self._path_to_document_id(
|
|
1329
|
+
normalized,
|
|
1330
|
+
quest_root=quest_root,
|
|
1331
|
+
workspace_root=workspace_root,
|
|
1332
|
+
)
|
|
1333
|
+
changed_files.append(
|
|
1334
|
+
{
|
|
1335
|
+
"path": normalized,
|
|
1336
|
+
"source": source,
|
|
1337
|
+
"document_id": resolved_document_id,
|
|
1338
|
+
"writable": writable,
|
|
1339
|
+
}
|
|
1340
|
+
)
|
|
1341
|
+
|
|
1342
|
+
for relative in ("brief.md", "plan.md", "status.md", "SUMMARY.md"):
|
|
1343
|
+
add_file(
|
|
1344
|
+
str(workspace_root / relative),
|
|
1345
|
+
source="document",
|
|
1346
|
+
document_id=relative,
|
|
1347
|
+
writable=True,
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
for run in recent_runs:
|
|
1351
|
+
run_id = str(run.get("run_id") or "run")
|
|
1352
|
+
entries.append(
|
|
1353
|
+
{
|
|
1354
|
+
"id": f"run:{run_id}",
|
|
1355
|
+
"kind": "run",
|
|
1356
|
+
"run_id": run_id,
|
|
1357
|
+
"skill_id": run.get("skill_id"),
|
|
1358
|
+
"title": run_id,
|
|
1359
|
+
"summary": run.get("summary") or "Run completed.",
|
|
1360
|
+
"status": "completed" if run.get("exit_code", 0) == 0 else "failed",
|
|
1361
|
+
"created_at": run.get("completed_at") or run.get("created_at") or run.get("updated_at"),
|
|
1362
|
+
"paths": [item for item in [run.get("history_root"), run.get("run_root"), run.get("output_path")] if item],
|
|
1363
|
+
}
|
|
1364
|
+
)
|
|
1365
|
+
for path in (run.get("history_root"), run.get("run_root"), run.get("output_path")):
|
|
1366
|
+
add_file(path, source="run")
|
|
1367
|
+
history_root = run.get("history_root")
|
|
1368
|
+
if history_root:
|
|
1369
|
+
entries.extend(
|
|
1370
|
+
self._parse_codex_history_cached(
|
|
1371
|
+
Path(str(history_root)),
|
|
1372
|
+
quest_id=quest_id,
|
|
1373
|
+
run_id=run_id,
|
|
1374
|
+
skill_id=run.get("skill_id"),
|
|
1375
|
+
)
|
|
1376
|
+
)
|
|
1377
|
+
|
|
1378
|
+
for artifact in recent_artifacts:
|
|
1379
|
+
payload = artifact.get("payload") if isinstance(artifact.get("payload"), dict) else {}
|
|
1380
|
+
artifact_path = artifact.get("path")
|
|
1381
|
+
entries.append(
|
|
1382
|
+
{
|
|
1383
|
+
"id": f"artifact:{payload.get('artifact_id') or artifact_path}",
|
|
1384
|
+
"kind": "artifact",
|
|
1385
|
+
"title": str(payload.get("artifact_id") or artifact.get("kind") or "artifact"),
|
|
1386
|
+
"summary": payload.get("summary") or payload.get("message") or payload.get("reason") or "Artifact updated.",
|
|
1387
|
+
"status": payload.get("status"),
|
|
1388
|
+
"reason": payload.get("reason"),
|
|
1389
|
+
"created_at": payload.get("updated_at") or payload.get("created_at"),
|
|
1390
|
+
"paths": list((payload.get("paths") or {}).values()) + ([str(artifact_path)] if artifact_path else []),
|
|
1391
|
+
}
|
|
1392
|
+
)
|
|
1393
|
+
add_file(str(artifact_path) if artifact_path else None, source="artifact")
|
|
1394
|
+
for path in (payload.get("paths") or {}).values():
|
|
1395
|
+
add_file(str(path), source="artifact_path")
|
|
1396
|
+
|
|
1397
|
+
entries.sort(key=lambda item: str(item.get("created_at") or item.get("id") or ""))
|
|
1398
|
+
return {
|
|
1399
|
+
"quest_id": quest_id,
|
|
1400
|
+
"quest_root": str(quest_root.resolve()),
|
|
1401
|
+
"entries": entries[-80:],
|
|
1402
|
+
"changed_files": changed_files[-30:],
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
def _build_details_projection_payload(
|
|
1406
|
+
self,
|
|
1407
|
+
quest_root: Path,
|
|
1408
|
+
*,
|
|
1409
|
+
source_signature: str,
|
|
1410
|
+
update_progress: Any,
|
|
1411
|
+
) -> dict[str, Any]:
|
|
1412
|
+
quest_id = quest_root.name
|
|
1413
|
+
workspace_root = self.active_workspace_root(quest_root)
|
|
1414
|
+
update_progress(1, "Loading recent workflow sources")
|
|
1415
|
+
recent_artifacts = self._collect_artifacts(quest_root)[-8:]
|
|
1416
|
+
recent_runs = self._recent_codex_runs(quest_root, limit=5)
|
|
1417
|
+
update_progress(2, "Materializing workflow timeline")
|
|
1418
|
+
return self._build_workflow_payload(
|
|
1419
|
+
quest_id,
|
|
1420
|
+
quest_root,
|
|
1421
|
+
workspace_root,
|
|
1422
|
+
recent_runs=recent_runs,
|
|
1423
|
+
recent_artifacts=recent_artifacts,
|
|
1424
|
+
)
|
|
1425
|
+
|
|
1426
|
+
def _build_canvas_projection_payload(
|
|
1427
|
+
self,
|
|
1428
|
+
quest_root: Path,
|
|
1429
|
+
*,
|
|
1430
|
+
source_signature: str,
|
|
1431
|
+
update_progress: Any,
|
|
1432
|
+
) -> dict[str, Any]:
|
|
1433
|
+
update_progress(1, "Scanning branch references")
|
|
1434
|
+
update_progress(2, "Computing branch canvas")
|
|
1435
|
+
return list_branch_canvas(quest_root, quest_id=quest_root.name)
|
|
1436
|
+
|
|
1437
|
+
def _build_projection_payload(
|
|
1438
|
+
self,
|
|
1439
|
+
quest_root: Path,
|
|
1440
|
+
kind: str,
|
|
1441
|
+
*,
|
|
1442
|
+
source_signature: str,
|
|
1443
|
+
update_progress: Any,
|
|
1444
|
+
) -> dict[str, Any]:
|
|
1445
|
+
if kind == "details":
|
|
1446
|
+
return self._build_details_projection_payload(
|
|
1447
|
+
quest_root,
|
|
1448
|
+
source_signature=source_signature,
|
|
1449
|
+
update_progress=update_progress,
|
|
1450
|
+
)
|
|
1451
|
+
if kind == "canvas":
|
|
1452
|
+
return self._build_canvas_projection_payload(
|
|
1453
|
+
quest_root,
|
|
1454
|
+
source_signature=source_signature,
|
|
1455
|
+
update_progress=update_progress,
|
|
1456
|
+
)
|
|
1457
|
+
raise ValueError(f"Unsupported projection kind `{kind}`.")
|
|
1458
|
+
|
|
1459
|
+
def _placeholder_workflow_payload(self, quest_id: str, quest_root: Path) -> dict[str, Any]:
|
|
1460
|
+
workspace_root = self.active_workspace_root(quest_root)
|
|
1461
|
+
return self._build_workflow_payload(
|
|
1462
|
+
quest_id,
|
|
1463
|
+
quest_root,
|
|
1464
|
+
workspace_root,
|
|
1465
|
+
recent_runs=[],
|
|
1466
|
+
recent_artifacts=[],
|
|
1467
|
+
)
|
|
1468
|
+
|
|
1469
|
+
def _placeholder_canvas_payload(self, quest_id: str, quest_root: Path) -> dict[str, Any]:
|
|
1470
|
+
research_state = self.read_research_state(quest_root)
|
|
1471
|
+
default_ref = (
|
|
1472
|
+
str(research_state.get("research_head_branch") or "").strip()
|
|
1473
|
+
or str(research_state.get("current_workspace_branch") or "").strip()
|
|
1474
|
+
or current_branch(quest_root)
|
|
1475
|
+
)
|
|
1476
|
+
return {
|
|
1477
|
+
"quest_id": quest_id,
|
|
1478
|
+
"default_ref": default_ref,
|
|
1479
|
+
"current_ref": default_ref,
|
|
1480
|
+
"head": head_commit(quest_root),
|
|
1481
|
+
"nodes": [],
|
|
1482
|
+
"edges": [],
|
|
1483
|
+
"views": {
|
|
1484
|
+
"ideas": [],
|
|
1485
|
+
"analysis": [],
|
|
1486
|
+
},
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
def _projected_payload(self, quest_id: str, kind: str) -> dict[str, Any]:
|
|
1490
|
+
quest_root = self._quest_root(quest_id)
|
|
1491
|
+
source_signature = self._projection_source_signature(quest_root, kind)
|
|
1492
|
+
payload_wrapper = self._read_projection_payload_file(quest_root, kind)
|
|
1493
|
+
payload_ready = (
|
|
1494
|
+
isinstance(payload_wrapper, dict)
|
|
1495
|
+
and str(payload_wrapper.get("source_signature") or "").strip() == source_signature
|
|
1496
|
+
and isinstance(payload_wrapper.get("payload"), dict)
|
|
1497
|
+
)
|
|
1498
|
+
if not payload_ready:
|
|
1499
|
+
self._queue_projection_build(quest_root, kind, source_signature=source_signature)
|
|
1500
|
+
payload_wrapper = self._read_projection_payload_file(quest_root, kind)
|
|
1501
|
+
status = self._present_projection_status(
|
|
1502
|
+
quest_root,
|
|
1503
|
+
kind,
|
|
1504
|
+
source_signature=source_signature,
|
|
1505
|
+
payload_wrapper=payload_wrapper,
|
|
1506
|
+
)
|
|
1507
|
+
payload = (
|
|
1508
|
+
copy.deepcopy(payload_wrapper.get("payload"))
|
|
1509
|
+
if isinstance(payload_wrapper, dict) and isinstance(payload_wrapper.get("payload"), dict)
|
|
1510
|
+
else None
|
|
1511
|
+
)
|
|
1512
|
+
if payload is None:
|
|
1513
|
+
payload = (
|
|
1514
|
+
self._placeholder_workflow_payload(quest_id, quest_root)
|
|
1515
|
+
if kind == "details"
|
|
1516
|
+
else self._placeholder_canvas_payload(quest_id, quest_root)
|
|
1517
|
+
)
|
|
1518
|
+
payload["projection_status"] = status
|
|
1519
|
+
return payload
|
|
1520
|
+
|
|
1521
|
+
def prime_projection(self, quest_id: str, kind: str) -> None:
|
|
1522
|
+
quest_root = self._quest_root(quest_id)
|
|
1523
|
+
self._queue_projection_build(
|
|
1524
|
+
quest_root,
|
|
1525
|
+
kind,
|
|
1526
|
+
source_signature=self._projection_source_signature(quest_root, kind),
|
|
1527
|
+
)
|
|
1528
|
+
|
|
1529
|
+
def schedule_projection_refresh(
|
|
1530
|
+
self,
|
|
1531
|
+
quest_root: Path,
|
|
1532
|
+
*,
|
|
1533
|
+
kinds: tuple[str, ...] | list[str] | None = None,
|
|
1534
|
+
throttle_seconds: float = _PROJECTION_REFRESH_THROTTLE_SECONDS,
|
|
1535
|
+
) -> None:
|
|
1536
|
+
resolved_kinds = [
|
|
1537
|
+
str(kind).strip()
|
|
1538
|
+
for kind in (kinds or ("details", "canvas"))
|
|
1539
|
+
if str(kind).strip() in {"details", "canvas"}
|
|
1540
|
+
]
|
|
1541
|
+
if not resolved_kinds:
|
|
1542
|
+
return
|
|
1543
|
+
min_interval = max(0.0, float(throttle_seconds))
|
|
1544
|
+
now = time.monotonic()
|
|
1545
|
+
for kind in resolved_kinds:
|
|
1546
|
+
build_key = self._projection_build_key(quest_root, kind)
|
|
1547
|
+
if self._projection_build_active(quest_root, kind):
|
|
1548
|
+
continue
|
|
1549
|
+
with self._quest_projection_refresh_lock:
|
|
1550
|
+
previous = float(self._quest_projection_refresh_at.get(build_key) or 0.0)
|
|
1551
|
+
if min_interval > 0 and now - previous < min_interval:
|
|
1552
|
+
continue
|
|
1553
|
+
self._quest_projection_refresh_at[build_key] = now
|
|
1554
|
+
try:
|
|
1555
|
+
self._queue_projection_build(
|
|
1556
|
+
quest_root,
|
|
1557
|
+
kind,
|
|
1558
|
+
source_signature=self._projection_source_signature(quest_root, kind),
|
|
1559
|
+
)
|
|
1560
|
+
except Exception:
|
|
1561
|
+
continue
|
|
1562
|
+
|
|
1563
|
+
def git_branch_canvas(self, quest_id: str) -> dict[str, Any]:
|
|
1564
|
+
return self._projected_payload(quest_id, "canvas")
|
|
1565
|
+
|
|
1566
|
+
def _active_baseline_attachment(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
|
|
1567
|
+
attachments: list[dict[str, Any]] = []
|
|
1568
|
+
seen_paths: set[str] = set()
|
|
1569
|
+
for root in (workspace_root, quest_root):
|
|
1570
|
+
attachment_root = root / "baselines" / "imported"
|
|
1571
|
+
if not attachment_root.exists():
|
|
1572
|
+
continue
|
|
1573
|
+
for path in sorted(attachment_root.glob("*/attachment.yaml")):
|
|
1574
|
+
key = str(path.resolve())
|
|
1575
|
+
if key in seen_paths:
|
|
1576
|
+
continue
|
|
1577
|
+
seen_paths.add(key)
|
|
1578
|
+
payload = self._read_cached_yaml(path, {})
|
|
1579
|
+
baseline_id = str(payload.get("source_baseline_id") or "").strip() if isinstance(payload, dict) else ""
|
|
1580
|
+
if baseline_id and self.baseline_registry.is_deleted(baseline_id):
|
|
1581
|
+
continue
|
|
1582
|
+
if isinstance(payload, dict) and payload:
|
|
1583
|
+
attachments.append(payload)
|
|
1584
|
+
if not attachments:
|
|
1585
|
+
return None
|
|
1586
|
+
return max(
|
|
1587
|
+
attachments,
|
|
1588
|
+
key=lambda item: (
|
|
1589
|
+
str(item.get("attached_at") or ""),
|
|
1590
|
+
str(item.get("source_baseline_id") or ""),
|
|
1591
|
+
),
|
|
1592
|
+
)
|
|
1593
|
+
|
|
1594
|
+
@staticmethod
|
|
1595
|
+
def _markdown_excerpt(path: Path, *, max_lines: int = 8) -> str | None:
|
|
1596
|
+
if not path.exists() or not path.is_file():
|
|
1597
|
+
return None
|
|
1598
|
+
text = read_text(path, "")
|
|
1599
|
+
if not text.strip():
|
|
1600
|
+
return None
|
|
1601
|
+
lines = [line.rstrip() for line in text.splitlines() if line.strip()]
|
|
1602
|
+
if not lines:
|
|
1603
|
+
return None
|
|
1604
|
+
excerpt = "\n".join(lines[:max_lines]).strip()
|
|
1605
|
+
return excerpt or None
|
|
1606
|
+
|
|
1607
|
+
def _snapshot_workspace_candidates(self, quest_root: Path, workspace_root: Path) -> list[Path]:
|
|
1608
|
+
candidates: list[Path] = []
|
|
1609
|
+
seen: set[str] = set()
|
|
1610
|
+
|
|
1611
|
+
def add(path: Path | None) -> None:
|
|
1612
|
+
if path is None:
|
|
1613
|
+
return
|
|
1614
|
+
resolved = path.resolve()
|
|
1615
|
+
key = str(resolved)
|
|
1616
|
+
if key in seen or not resolved.exists():
|
|
1617
|
+
return
|
|
1618
|
+
seen.add(key)
|
|
1619
|
+
candidates.append(resolved)
|
|
1620
|
+
|
|
1621
|
+
add(workspace_root)
|
|
1622
|
+
add(quest_root)
|
|
1623
|
+
worktrees_root = quest_root / ".ds" / "worktrees"
|
|
1624
|
+
if worktrees_root.exists():
|
|
1625
|
+
for item in sorted(worktrees_root.iterdir()):
|
|
1626
|
+
if item.is_dir():
|
|
1627
|
+
add(item)
|
|
1628
|
+
return candidates
|
|
1629
|
+
|
|
1630
|
+
@staticmethod
|
|
1631
|
+
def _path_mtime(path: Path) -> float:
|
|
1632
|
+
try:
|
|
1633
|
+
return path.stat().st_mtime
|
|
1634
|
+
except OSError:
|
|
1635
|
+
return 0.0
|
|
1636
|
+
|
|
1637
|
+
def _best_paper_root(self, quest_root: Path, workspace_root: Path) -> Path | None:
|
|
1638
|
+
best_root: Path | None = None
|
|
1639
|
+
best_rank: tuple[int, float] = (-1, -1.0)
|
|
1640
|
+
for candidate in self._snapshot_workspace_candidates(quest_root, workspace_root):
|
|
1641
|
+
paper_root = candidate / "paper"
|
|
1642
|
+
if not paper_root.exists() or not paper_root.is_dir():
|
|
1643
|
+
continue
|
|
1644
|
+
selected_outline = paper_root / "selected_outline.json"
|
|
1645
|
+
bundle_manifest = paper_root / "paper_bundle_manifest.json"
|
|
1646
|
+
draft = paper_root / "draft.md"
|
|
1647
|
+
score = 0
|
|
1648
|
+
if selected_outline.exists():
|
|
1649
|
+
score += 4
|
|
1650
|
+
if bundle_manifest.exists():
|
|
1651
|
+
score += 5
|
|
1652
|
+
if draft.exists():
|
|
1653
|
+
score += 2
|
|
1654
|
+
latest = max(
|
|
1655
|
+
self._path_mtime(selected_outline),
|
|
1656
|
+
self._path_mtime(bundle_manifest),
|
|
1657
|
+
self._path_mtime(draft),
|
|
1658
|
+
self._path_mtime(paper_root),
|
|
1659
|
+
)
|
|
1660
|
+
rank = (score, latest)
|
|
1661
|
+
if rank > best_rank:
|
|
1662
|
+
best_rank = rank
|
|
1663
|
+
best_root = paper_root
|
|
1664
|
+
return best_root
|
|
1665
|
+
|
|
1666
|
+
def _outline_record_from_paper_root(self, paper_root: Path) -> dict[str, Any]:
|
|
1667
|
+
outline_root = paper_root / "outline"
|
|
1668
|
+
manifest_path = outline_root / "manifest.json"
|
|
1669
|
+
if manifest_path.exists():
|
|
1670
|
+
manifest = read_json(manifest_path, {})
|
|
1671
|
+
if isinstance(manifest, dict) and manifest:
|
|
1672
|
+
manifest_sections = [
|
|
1673
|
+
dict(item) for item in (manifest.get("sections") or []) if isinstance(item, dict)
|
|
1674
|
+
]
|
|
1675
|
+
by_id = {
|
|
1676
|
+
str(item.get("section_id") or "").strip(): dict(item)
|
|
1677
|
+
for item in manifest_sections
|
|
1678
|
+
if str(item.get("section_id") or "").strip()
|
|
1679
|
+
}
|
|
1680
|
+
section_order = [
|
|
1681
|
+
str(item).strip() for item in (manifest.get("section_order") or []) if str(item).strip()
|
|
1682
|
+
]
|
|
1683
|
+
sections_root = outline_root / "sections"
|
|
1684
|
+
if sections_root.exists():
|
|
1685
|
+
for section_dir in sorted(sections_root.iterdir()):
|
|
1686
|
+
if not section_dir.is_dir():
|
|
1687
|
+
continue
|
|
1688
|
+
section_id = section_dir.name
|
|
1689
|
+
section = dict(by_id.get(section_id) or {})
|
|
1690
|
+
section.setdefault("section_id", section_id)
|
|
1691
|
+
section.setdefault("title", section_id)
|
|
1692
|
+
result_table_payload = read_json(section_dir / "result_table.json", {})
|
|
1693
|
+
rows = result_table_payload.get("rows") if isinstance(result_table_payload, dict) else []
|
|
1694
|
+
section["result_table"] = rows if isinstance(rows, list) else []
|
|
1695
|
+
by_id[section_id] = section
|
|
1696
|
+
ordered_sections: list[dict[str, Any]] = []
|
|
1697
|
+
emitted: set[str] = set()
|
|
1698
|
+
for section_id in section_order:
|
|
1699
|
+
section = by_id.get(section_id)
|
|
1700
|
+
if section is None:
|
|
1701
|
+
continue
|
|
1702
|
+
ordered_sections.append(section)
|
|
1703
|
+
emitted.add(section_id)
|
|
1704
|
+
for section_id, section in by_id.items():
|
|
1705
|
+
if section_id in emitted:
|
|
1706
|
+
continue
|
|
1707
|
+
ordered_sections.append(section)
|
|
1708
|
+
return {
|
|
1709
|
+
"schema_version": 1,
|
|
1710
|
+
"outline_id": manifest.get("outline_id"),
|
|
1711
|
+
"status": manifest.get("status"),
|
|
1712
|
+
"title": manifest.get("title"),
|
|
1713
|
+
"note": manifest.get("note"),
|
|
1714
|
+
"story": manifest.get("story"),
|
|
1715
|
+
"ten_questions": manifest.get("ten_questions") if isinstance(manifest.get("ten_questions"), list) else [],
|
|
1716
|
+
"detailed_outline": manifest.get("detailed_outline") if isinstance(manifest.get("detailed_outline"), dict) else {},
|
|
1717
|
+
"sections": ordered_sections,
|
|
1718
|
+
"evidence_contract": manifest.get("evidence_contract") if isinstance(manifest.get("evidence_contract"), dict) else None,
|
|
1719
|
+
"created_at": manifest.get("created_at"),
|
|
1720
|
+
"updated_at": manifest.get("updated_at"),
|
|
1721
|
+
}
|
|
1722
|
+
selected_outline_path = paper_root / "selected_outline.json"
|
|
1723
|
+
payload = read_json(selected_outline_path, {})
|
|
1724
|
+
return payload if isinstance(payload, dict) else {}
|
|
1725
|
+
|
|
1726
|
+
def _paper_evidence_payload(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
|
|
1727
|
+
best_payload: dict[str, Any] | None = None
|
|
1728
|
+
best_rank: tuple[str, float] = ("", -1.0)
|
|
1729
|
+
for candidate in self._snapshot_workspace_candidates(quest_root, workspace_root):
|
|
1730
|
+
paper_root = candidate / "paper"
|
|
1731
|
+
ledger_json_path = paper_root / "evidence_ledger.json"
|
|
1732
|
+
if not ledger_json_path.exists():
|
|
1733
|
+
continue
|
|
1734
|
+
payload = read_json(ledger_json_path, {})
|
|
1735
|
+
if not isinstance(payload, dict) or not payload:
|
|
1736
|
+
continue
|
|
1737
|
+
items = [dict(item) for item in (payload.get("items") or []) if isinstance(item, dict)]
|
|
1738
|
+
latest = max(
|
|
1739
|
+
self._path_mtime(ledger_json_path),
|
|
1740
|
+
self._path_mtime(paper_root / "evidence_ledger.md"),
|
|
1741
|
+
self._path_mtime(paper_root),
|
|
1742
|
+
)
|
|
1743
|
+
rank = (str(payload.get("updated_at") or payload.get("created_at") or ""), latest)
|
|
1744
|
+
if rank < best_rank:
|
|
1745
|
+
continue
|
|
1746
|
+
best_rank = rank
|
|
1747
|
+
best_payload = {
|
|
1748
|
+
"paper_root": str(paper_root),
|
|
1749
|
+
"workspace_root": str(paper_root.parent),
|
|
1750
|
+
"selected_outline_ref": str(payload.get("selected_outline_ref") or "").strip() or None,
|
|
1751
|
+
"item_count": len(items),
|
|
1752
|
+
"main_text_ready_count": sum(
|
|
1753
|
+
1
|
|
1754
|
+
for item in items
|
|
1755
|
+
if str(item.get("paper_role") or "").strip() == "main_text"
|
|
1756
|
+
and str(item.get("status") or "").strip().lower() in {"ready", "completed", "analyzed", "written", "recorded", "supported"}
|
|
1757
|
+
),
|
|
1758
|
+
"appendix_item_count": sum(
|
|
1759
|
+
1 for item in items if str(item.get("paper_role") or "").strip() == "appendix"
|
|
1760
|
+
),
|
|
1761
|
+
"unmapped_item_count": sum(
|
|
1762
|
+
1
|
|
1763
|
+
for item in items
|
|
1764
|
+
if not str(item.get("section_id") or "").strip() or not str(item.get("paper_role") or "").strip()
|
|
1765
|
+
),
|
|
1766
|
+
"items": items[:40],
|
|
1767
|
+
"paths": {
|
|
1768
|
+
"ledger_json": str(ledger_json_path),
|
|
1769
|
+
"ledger_md": str(paper_root / "evidence_ledger.md") if (paper_root / "evidence_ledger.md").exists() else None,
|
|
1770
|
+
},
|
|
1771
|
+
}
|
|
1772
|
+
return best_payload
|
|
1773
|
+
|
|
1774
|
+
def _paper_contract_payload(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
|
|
1775
|
+
paper_root = self._best_paper_root(quest_root, workspace_root)
|
|
1776
|
+
if paper_root is None:
|
|
1777
|
+
return None
|
|
1778
|
+
selected_outline_path = paper_root / "selected_outline.json"
|
|
1779
|
+
selected_outline = self._outline_record_from_paper_root(paper_root)
|
|
1780
|
+
selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
|
|
1781
|
+
detailed_outline = (
|
|
1782
|
+
dict(selected_outline.get("detailed_outline") or {})
|
|
1783
|
+
if isinstance(selected_outline.get("detailed_outline"), dict)
|
|
1784
|
+
else {}
|
|
1785
|
+
)
|
|
1786
|
+
outline_manifest_path = paper_root / "outline" / "manifest.json"
|
|
1787
|
+
bundle_manifest_path = paper_root / "paper_bundle_manifest.json"
|
|
1788
|
+
bundle_manifest = read_json(bundle_manifest_path, {})
|
|
1789
|
+
bundle_manifest = bundle_manifest if isinstance(bundle_manifest, dict) else {}
|
|
1790
|
+
experiment_matrix_path = paper_root / "paper_experiment_matrix.md"
|
|
1791
|
+
experiment_matrix_json_path = paper_root / "paper_experiment_matrix.json"
|
|
1792
|
+
claim_map_path = paper_root / "claim_evidence_map.json"
|
|
1793
|
+
paper_line_state_path = paper_root / "paper_line_state.json"
|
|
1794
|
+
evidence_ledger = self._paper_evidence_payload(quest_root, workspace_root)
|
|
1795
|
+
checklist_path = paper_root / "review" / "submission_checklist.json"
|
|
1796
|
+
draft_path = paper_root / "draft.md"
|
|
1797
|
+
status_path = paper_root.parent / "status.md"
|
|
1798
|
+
summary_path = paper_root.parent / "SUMMARY.md"
|
|
1799
|
+
|
|
1800
|
+
raw_sections = selected_outline.get("sections") if isinstance(selected_outline.get("sections"), list) else []
|
|
1801
|
+
sections = []
|
|
1802
|
+
if raw_sections:
|
|
1803
|
+
for index, raw in enumerate(raw_sections, start=1):
|
|
1804
|
+
if not isinstance(raw, dict):
|
|
1805
|
+
continue
|
|
1806
|
+
title = str(raw.get("title") or raw.get("section_id") or "").strip()
|
|
1807
|
+
if not title:
|
|
1808
|
+
title = f"Section {index}"
|
|
1809
|
+
sections.append(
|
|
1810
|
+
{
|
|
1811
|
+
"section_id": str(raw.get("section_id") or slugify(title, f"section-{index}")).strip() or slugify(title, f"section-{index}"),
|
|
1812
|
+
"title": title,
|
|
1813
|
+
"paper_role": str(raw.get("paper_role") or "").strip() or None,
|
|
1814
|
+
"status": str(raw.get("status") or "").strip() or None,
|
|
1815
|
+
"claims": raw.get("claims") if isinstance(raw.get("claims"), list) else [],
|
|
1816
|
+
"required_items": raw.get("required_items") if isinstance(raw.get("required_items"), list) else [],
|
|
1817
|
+
"optional_items": raw.get("optional_items") if isinstance(raw.get("optional_items"), list) else [],
|
|
1818
|
+
"result_table": raw.get("result_table") if isinstance(raw.get("result_table"), list) else [],
|
|
1819
|
+
}
|
|
1820
|
+
)
|
|
1821
|
+
else:
|
|
1822
|
+
for item in detailed_outline.get("experimental_designs") or []:
|
|
1823
|
+
text = str(item or "").strip()
|
|
1824
|
+
if not text:
|
|
1825
|
+
continue
|
|
1826
|
+
sections.append(
|
|
1827
|
+
{
|
|
1828
|
+
"section_id": slugify(text, "section"),
|
|
1829
|
+
"title": text,
|
|
1830
|
+
"paper_role": "main_text",
|
|
1831
|
+
"status": "recorded",
|
|
1832
|
+
"claims": [],
|
|
1833
|
+
"required_items": [],
|
|
1834
|
+
"optional_items": [],
|
|
1835
|
+
"result_table": [],
|
|
1836
|
+
}
|
|
1837
|
+
)
|
|
496
1838
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
1839
|
+
return {
|
|
1840
|
+
"paper_root": str(paper_root),
|
|
1841
|
+
"workspace_root": str(paper_root.parent),
|
|
1842
|
+
"paper_branch": str(bundle_manifest.get("paper_branch") or "").strip() or current_branch(paper_root.parent),
|
|
1843
|
+
"source_branch": str(bundle_manifest.get("source_branch") or "").strip() or None,
|
|
1844
|
+
"selected_outline_ref": str(selected_outline.get("outline_id") or bundle_manifest.get("selected_outline_ref") or "").strip() or None,
|
|
1845
|
+
"title": str(selected_outline.get("title") or bundle_manifest.get("title") or "").strip() or None,
|
|
1846
|
+
"story": str(selected_outline.get("story") or "").strip() or None,
|
|
1847
|
+
"research_questions": detailed_outline.get("research_questions") if isinstance(detailed_outline.get("research_questions"), list) else [],
|
|
1848
|
+
"experimental_designs": detailed_outline.get("experimental_designs") if isinstance(detailed_outline.get("experimental_designs"), list) else [],
|
|
1849
|
+
"contributions": detailed_outline.get("contributions") if isinstance(detailed_outline.get("contributions"), list) else [],
|
|
1850
|
+
"evidence_contract": selected_outline.get("evidence_contract") if isinstance(selected_outline.get("evidence_contract"), dict) else None,
|
|
1851
|
+
"sections": sections,
|
|
1852
|
+
"evidence_summary": {
|
|
1853
|
+
"item_count": int((evidence_ledger or {}).get("item_count") or 0),
|
|
1854
|
+
"main_text_ready_count": int((evidence_ledger or {}).get("main_text_ready_count") or 0),
|
|
1855
|
+
"appendix_item_count": int((evidence_ledger or {}).get("appendix_item_count") or 0),
|
|
1856
|
+
"unmapped_item_count": int((evidence_ledger or {}).get("unmapped_item_count") or 0),
|
|
1857
|
+
},
|
|
1858
|
+
"summary": str(bundle_manifest.get("summary") or "").strip() or self._markdown_excerpt(summary_path),
|
|
1859
|
+
"paths": {
|
|
1860
|
+
"selected_outline": str(selected_outline_path) if selected_outline_path.exists() else None,
|
|
1861
|
+
"outline_manifest": str(outline_manifest_path) if outline_manifest_path.exists() else None,
|
|
1862
|
+
"experiment_matrix": str(experiment_matrix_path) if experiment_matrix_path.exists() else None,
|
|
1863
|
+
"experiment_matrix_json": str(experiment_matrix_json_path) if experiment_matrix_json_path.exists() else None,
|
|
1864
|
+
"bundle_manifest": str(bundle_manifest_path) if bundle_manifest_path.exists() else None,
|
|
1865
|
+
"claim_evidence_map": str(claim_map_path) if claim_map_path.exists() else None,
|
|
1866
|
+
"paper_line_state": str(paper_line_state_path) if paper_line_state_path.exists() else None,
|
|
1867
|
+
"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,
|
|
1868
|
+
"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,
|
|
1869
|
+
"submission_checklist": str(checklist_path) if checklist_path.exists() else None,
|
|
1870
|
+
"draft": str(draft_path) if draft_path.exists() else None,
|
|
1871
|
+
"status": str(status_path) if status_path.exists() else None,
|
|
1872
|
+
"summary": str(summary_path) if summary_path.exists() else None,
|
|
1873
|
+
},
|
|
1874
|
+
"bundle_manifest": bundle_manifest or None,
|
|
1875
|
+
"outline_payload": selected_outline or None,
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
def _paper_lines_payload(self, quest_root: Path, workspace_root: Path) -> tuple[list[dict[str, Any]], str | None]:
|
|
1879
|
+
lines_by_id: dict[str, dict[str, Any]] = {}
|
|
1880
|
+
active_ref: str | None = None
|
|
1881
|
+
for candidate in self._snapshot_workspace_candidates(quest_root, workspace_root):
|
|
1882
|
+
paper_root = candidate / "paper"
|
|
1883
|
+
if not paper_root.exists() or not paper_root.is_dir():
|
|
503
1884
|
continue
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
1885
|
+
state_path = paper_root / "paper_line_state.json"
|
|
1886
|
+
payload = read_json(state_path, {}) if state_path.exists() else {}
|
|
1887
|
+
if not isinstance(payload, dict) or not payload:
|
|
1888
|
+
contract = self._paper_contract_payload(quest_root, candidate)
|
|
1889
|
+
if not contract:
|
|
507
1890
|
continue
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
1891
|
+
bundle_manifest = (
|
|
1892
|
+
dict(contract.get("bundle_manifest") or {})
|
|
1893
|
+
if isinstance(contract.get("bundle_manifest"), dict)
|
|
1894
|
+
else {}
|
|
1895
|
+
)
|
|
1896
|
+
payload = {
|
|
1897
|
+
"paper_line_id": slugify(
|
|
1898
|
+
"::".join(
|
|
1899
|
+
[
|
|
1900
|
+
str(contract.get("paper_branch") or "paper").strip() or "paper",
|
|
1901
|
+
str(contract.get("selected_outline_ref") or "outline").strip() or "outline",
|
|
1902
|
+
str(bundle_manifest.get("source_run_id") or "run").strip() or "run",
|
|
1903
|
+
]
|
|
1904
|
+
),
|
|
1905
|
+
"paper-line",
|
|
1906
|
+
),
|
|
1907
|
+
"paper_branch": contract.get("paper_branch"),
|
|
1908
|
+
"paper_root": str(paper_root),
|
|
1909
|
+
"workspace_root": str(candidate),
|
|
1910
|
+
"source_branch": contract.get("source_branch"),
|
|
1911
|
+
"source_run_id": bundle_manifest.get("source_run_id"),
|
|
1912
|
+
"source_idea_id": bundle_manifest.get("source_idea_id"),
|
|
1913
|
+
"selected_outline_ref": contract.get("selected_outline_ref"),
|
|
1914
|
+
"title": contract.get("title"),
|
|
1915
|
+
"required_count": sum(len(item.get("required_items") or []) for item in (contract.get("sections") or [])),
|
|
1916
|
+
"ready_required_count": int((contract.get("evidence_summary") or {}).get("main_text_ready_count") or 0),
|
|
1917
|
+
"section_count": len(contract.get("sections") or []),
|
|
1918
|
+
"ready_section_count": 0,
|
|
1919
|
+
"unmapped_count": int((contract.get("evidence_summary") or {}).get("unmapped_item_count") or 0),
|
|
1920
|
+
"open_supplementary_count": 0,
|
|
1921
|
+
"draft_status": "present" if (paper_root / "draft.md").exists() else "missing",
|
|
1922
|
+
"bundle_status": "present" if (paper_root / "paper_bundle_manifest.json").exists() else "missing",
|
|
1923
|
+
"updated_at": "",
|
|
1924
|
+
}
|
|
1925
|
+
paper_line_id = str(payload.get("paper_line_id") or "").strip()
|
|
1926
|
+
if not paper_line_id:
|
|
1927
|
+
continue
|
|
1928
|
+
payload["paths"] = {
|
|
1929
|
+
"paper_line_state": str(state_path) if state_path.exists() else None,
|
|
1930
|
+
"paper_root": str(paper_root),
|
|
1931
|
+
}
|
|
1932
|
+
current = lines_by_id.get(paper_line_id)
|
|
1933
|
+
if current is None or str(payload.get("updated_at") or "") >= str(current.get("updated_at") or ""):
|
|
1934
|
+
lines_by_id[paper_line_id] = payload
|
|
1935
|
+
if str(candidate) == str(workspace_root):
|
|
1936
|
+
active_ref = paper_line_id
|
|
1937
|
+
lines = sorted(lines_by_id.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
|
1938
|
+
if not active_ref and lines:
|
|
1939
|
+
active_ref = str(lines[0].get("paper_line_id") or "").strip() or None
|
|
1940
|
+
return lines, active_ref
|
|
1941
|
+
|
|
1942
|
+
def _analysis_inventory_payload(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
|
|
1943
|
+
manifest_by_id: dict[str, dict[str, Any]] = {}
|
|
1944
|
+
campaigns_root = quest_root / ".ds" / "analysis_campaigns"
|
|
1945
|
+
if campaigns_root.exists():
|
|
1946
|
+
for path in sorted(campaigns_root.glob("*.json")):
|
|
1947
|
+
payload = read_json(path, {})
|
|
1948
|
+
if not isinstance(payload, dict) or not payload:
|
|
512
1949
|
continue
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
1950
|
+
campaign_id = str(payload.get("campaign_id") or path.stem).strip() or path.stem
|
|
1951
|
+
manifest_by_id[campaign_id] = payload
|
|
1952
|
+
campaigns_by_id: dict[str, dict[str, Any]] = {}
|
|
1953
|
+
for candidate in self._snapshot_workspace_candidates(quest_root, workspace_root):
|
|
1954
|
+
analysis_root = candidate / "experiments" / "analysis-results"
|
|
1955
|
+
if not analysis_root.exists() or not analysis_root.is_dir():
|
|
1956
|
+
continue
|
|
1957
|
+
for campaign_dir in sorted(analysis_root.iterdir()):
|
|
1958
|
+
if not campaign_dir.is_dir():
|
|
1959
|
+
continue
|
|
1960
|
+
campaign_id = campaign_dir.name
|
|
1961
|
+
todo_manifest_path = campaign_dir / "todo_manifest.json"
|
|
1962
|
+
campaign_md_path = campaign_dir / "campaign.md"
|
|
1963
|
+
summary_md_path = campaign_dir / "SUMMARY.md"
|
|
1964
|
+
todo_manifest = read_json(todo_manifest_path, {})
|
|
1965
|
+
todo_manifest = todo_manifest if isinstance(todo_manifest, dict) else {}
|
|
1966
|
+
campaign_manifest = dict(manifest_by_id.get(campaign_id) or {})
|
|
1967
|
+
todo_items = todo_manifest.get("todo_items") if isinstance(todo_manifest.get("todo_items"), list) else []
|
|
1968
|
+
manifest_slices = {
|
|
1969
|
+
str(item.get("slice_id") or "").strip(): dict(item)
|
|
1970
|
+
for item in (campaign_manifest.get("slices") or [])
|
|
1971
|
+
if isinstance(item, dict) and str(item.get("slice_id") or "").strip()
|
|
1972
|
+
}
|
|
1973
|
+
slice_files = []
|
|
1974
|
+
for path in sorted(campaign_dir.glob("*.md")):
|
|
1975
|
+
if path.name in {"campaign.md", "SUMMARY.md"}:
|
|
1976
|
+
continue
|
|
1977
|
+
slice_files.append(path)
|
|
1978
|
+
slices: list[dict[str, Any]] = []
|
|
1979
|
+
for index, path in enumerate(slice_files):
|
|
1980
|
+
matched_todo = todo_items[index] if index < len(todo_items) and isinstance(todo_items[index], dict) else {}
|
|
1981
|
+
slice_id = str(matched_todo.get("slice_id") or path.stem).strip() or path.stem
|
|
1982
|
+
title = str(matched_todo.get("title") or path.stem).strip() or path.stem
|
|
1983
|
+
manifest_slice = dict(manifest_slices.get(slice_id) or {})
|
|
1984
|
+
slices.append(
|
|
1985
|
+
{
|
|
1986
|
+
"slice_id": slice_id,
|
|
1987
|
+
"title": title,
|
|
1988
|
+
"status": str(manifest_slice.get("status") or matched_todo.get("status") or "completed").strip() or "completed",
|
|
1989
|
+
"tier": str(matched_todo.get("tier") or "").strip() or None,
|
|
1990
|
+
"exp_id": str(matched_todo.get("exp_id") or "").strip() or None,
|
|
1991
|
+
"paper_role": str(matched_todo.get("paper_placement") or matched_todo.get("paper_role") or "").strip() or None,
|
|
1992
|
+
"section_id": str(matched_todo.get("section_id") or "").strip() or None,
|
|
1993
|
+
"item_id": str(matched_todo.get("item_id") or "").strip() or None,
|
|
1994
|
+
"claim_links": matched_todo.get("claim_links") if isinstance(matched_todo.get("claim_links"), list) else [],
|
|
1995
|
+
"research_question": str(matched_todo.get("research_question") or "").strip() or None,
|
|
1996
|
+
"experimental_design": str(matched_todo.get("experimental_design") or "").strip() or None,
|
|
1997
|
+
"branch": str(manifest_slice.get("branch") or "").strip() or None,
|
|
1998
|
+
"worktree_root": str(manifest_slice.get("worktree_root") or "").strip() or None,
|
|
1999
|
+
"mapped": bool(
|
|
2000
|
+
str(matched_todo.get("section_id") or "").strip()
|
|
2001
|
+
and str(matched_todo.get("item_id") or "").strip()
|
|
2002
|
+
and str(matched_todo.get("paper_placement") or matched_todo.get("paper_role") or "").strip()
|
|
2003
|
+
),
|
|
2004
|
+
"result_path": str(path),
|
|
2005
|
+
"result_excerpt": self._markdown_excerpt(path, max_lines=6),
|
|
2006
|
+
}
|
|
2007
|
+
)
|
|
2008
|
+
record = {
|
|
2009
|
+
"campaign_id": campaign_id,
|
|
2010
|
+
"title": str((todo_manifest.get("campaign_origin") or {}).get("reason") or campaign_id).strip() or campaign_id,
|
|
2011
|
+
"active_idea_id": str(campaign_manifest.get("active_idea_id") or "").strip() or None,
|
|
2012
|
+
"parent_run_id": str(campaign_manifest.get("parent_run_id") or "").strip() or None,
|
|
2013
|
+
"parent_branch": str(campaign_manifest.get("parent_branch") or "").strip() or None,
|
|
2014
|
+
"paper_line_id": str(campaign_manifest.get("paper_line_id") or "").strip() or None,
|
|
2015
|
+
"paper_line_branch": str(campaign_manifest.get("paper_line_branch") or "").strip() or None,
|
|
2016
|
+
"paper_line_root": str(campaign_manifest.get("paper_line_root") or "").strip() or None,
|
|
2017
|
+
"selected_outline_ref": str(campaign_manifest.get("selected_outline_ref") or todo_manifest.get("selected_outline_ref") or "").strip() or None,
|
|
2018
|
+
"todo_manifest_path": str(todo_manifest_path) if todo_manifest_path.exists() else None,
|
|
2019
|
+
"campaign_path": str(campaign_md_path) if campaign_md_path.exists() else None,
|
|
2020
|
+
"summary_path": str(summary_md_path) if summary_md_path.exists() else None,
|
|
2021
|
+
"summary_excerpt": self._markdown_excerpt(summary_md_path, max_lines=10),
|
|
2022
|
+
"updated_at": str(campaign_manifest.get("updated_at") or "").strip() or None,
|
|
2023
|
+
"slice_count": len(slices),
|
|
2024
|
+
"completed_slice_count": sum(1 for item in slices if str(item.get("status") or "") == "completed"),
|
|
2025
|
+
"mapped_slice_count": sum(1 for item in slices if bool(item.get("mapped"))),
|
|
2026
|
+
"pending_slice_count": sum(1 for item in slices if str(item.get("status") or "") != "completed"),
|
|
2027
|
+
"slices": slices,
|
|
2028
|
+
"_rank": (
|
|
2029
|
+
len(slices),
|
|
2030
|
+
max(
|
|
2031
|
+
self._path_mtime(summary_md_path),
|
|
2032
|
+
self._path_mtime(campaign_md_path),
|
|
2033
|
+
self._path_mtime(todo_manifest_path),
|
|
2034
|
+
self._path_mtime(campaigns_root / f"{campaign_id}.json"),
|
|
2035
|
+
self._path_mtime(campaign_dir),
|
|
2036
|
+
),
|
|
2037
|
+
),
|
|
2038
|
+
}
|
|
2039
|
+
current = campaigns_by_id.get(campaign_id)
|
|
2040
|
+
if current is None or record["_rank"] >= current["_rank"]:
|
|
2041
|
+
campaigns_by_id[campaign_id] = record
|
|
2042
|
+
|
|
2043
|
+
if not campaigns_by_id:
|
|
516
2044
|
return None
|
|
517
|
-
|
|
518
|
-
|
|
2045
|
+
campaigns = []
|
|
2046
|
+
total_slices = 0
|
|
2047
|
+
total_completed = 0
|
|
2048
|
+
total_mapped = 0
|
|
2049
|
+
for item in sorted(
|
|
2050
|
+
campaigns_by_id.values(),
|
|
2051
|
+
key=lambda payload: (payload["_rank"][1], payload["campaign_id"]),
|
|
2052
|
+
reverse=True,
|
|
2053
|
+
):
|
|
2054
|
+
total_slices += int(item.get("slice_count") or 0)
|
|
2055
|
+
total_completed += int(item.get("completed_slice_count") or 0)
|
|
2056
|
+
total_mapped += int(item.get("mapped_slice_count") or 0)
|
|
2057
|
+
campaigns.append({key: value for key, value in item.items() if key != "_rank"})
|
|
2058
|
+
return {
|
|
2059
|
+
"campaign_count": len(campaigns),
|
|
2060
|
+
"slice_count": total_slices,
|
|
2061
|
+
"completed_slice_count": total_completed,
|
|
2062
|
+
"mapped_slice_count": total_mapped,
|
|
2063
|
+
"campaigns": campaigns,
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
def _idea_lines_payload(
|
|
2067
|
+
self,
|
|
2068
|
+
quest_root: Path,
|
|
2069
|
+
*,
|
|
2070
|
+
paper_lines: list[dict[str, Any]],
|
|
2071
|
+
analysis_inventory: dict[str, Any] | None,
|
|
2072
|
+
) -> tuple[list[dict[str, Any]], str | None]:
|
|
2073
|
+
artifacts = self._collect_artifacts(quest_root)
|
|
2074
|
+
research_state = self.read_research_state(quest_root)
|
|
2075
|
+
active_idea_id = str(research_state.get("active_idea_id") or "").strip() or None
|
|
2076
|
+
active_ref: str | None = None
|
|
2077
|
+
lines_by_id: dict[str, dict[str, Any]] = {}
|
|
2078
|
+
|
|
2079
|
+
def ensure_line(idea_id: str) -> dict[str, Any]:
|
|
2080
|
+
current = lines_by_id.get(idea_id)
|
|
2081
|
+
if current is None:
|
|
2082
|
+
current = {
|
|
2083
|
+
"idea_line_id": idea_id,
|
|
2084
|
+
"idea_id": idea_id,
|
|
2085
|
+
"idea_branch": None,
|
|
2086
|
+
"idea_title": None,
|
|
2087
|
+
"lineage_intent": None,
|
|
2088
|
+
"parent_branch": None,
|
|
2089
|
+
"latest_main_run_id": None,
|
|
2090
|
+
"latest_main_run_branch": None,
|
|
2091
|
+
"paper_line_id": None,
|
|
2092
|
+
"paper_branch": None,
|
|
2093
|
+
"selected_outline_ref": None,
|
|
2094
|
+
"analysis_campaign_count": 0,
|
|
2095
|
+
"analysis_slice_count": 0,
|
|
2096
|
+
"completed_analysis_slice_count": 0,
|
|
2097
|
+
"mapped_analysis_slice_count": 0,
|
|
2098
|
+
"required_count": 0,
|
|
2099
|
+
"ready_required_count": 0,
|
|
2100
|
+
"unmapped_count": 0,
|
|
2101
|
+
"open_supplementary_count": 0,
|
|
2102
|
+
"draft_status": None,
|
|
2103
|
+
"bundle_status": None,
|
|
2104
|
+
"updated_at": "",
|
|
2105
|
+
"paths": {
|
|
2106
|
+
"idea_md": None,
|
|
2107
|
+
"idea_draft": None,
|
|
2108
|
+
"paper_line_state": None,
|
|
2109
|
+
},
|
|
2110
|
+
}
|
|
2111
|
+
lines_by_id[idea_id] = current
|
|
2112
|
+
return current
|
|
2113
|
+
|
|
2114
|
+
def updated_rank(value: object) -> str:
|
|
2115
|
+
return str(value or "").strip()
|
|
2116
|
+
|
|
2117
|
+
for artifact in artifacts:
|
|
2118
|
+
kind = str(artifact.get("kind") or "").strip()
|
|
2119
|
+
payload = artifact.get("payload") if isinstance(artifact.get("payload"), dict) else {}
|
|
2120
|
+
if not payload:
|
|
2121
|
+
continue
|
|
2122
|
+
idea_id = str(payload.get("idea_id") or "").strip()
|
|
2123
|
+
if not idea_id:
|
|
2124
|
+
continue
|
|
2125
|
+
entry = ensure_line(idea_id)
|
|
2126
|
+
if kind == "ideas":
|
|
2127
|
+
current_rank = updated_rank(entry.get("updated_at"))
|
|
2128
|
+
candidate_rank = updated_rank(payload.get("updated_at") or payload.get("created_at"))
|
|
2129
|
+
if candidate_rank >= current_rank:
|
|
2130
|
+
details = dict(payload.get("details") or {}) if isinstance(payload.get("details"), dict) else {}
|
|
2131
|
+
paths = dict(payload.get("paths") or {}) if isinstance(payload.get("paths"), dict) else {}
|
|
2132
|
+
entry["idea_branch"] = str(payload.get("branch") or "").strip() or entry.get("idea_branch")
|
|
2133
|
+
entry["idea_title"] = str(details.get("title") or payload.get("title") or "").strip() or entry.get("idea_title")
|
|
2134
|
+
entry["lineage_intent"] = str(payload.get("lineage_intent") or details.get("lineage_intent") or "").strip() or entry.get("lineage_intent")
|
|
2135
|
+
entry["parent_branch"] = str(payload.get("parent_branch") or details.get("parent_branch") or "").strip() or entry.get("parent_branch")
|
|
2136
|
+
entry["updated_at"] = candidate_rank or entry.get("updated_at")
|
|
2137
|
+
entry["paths"] = {
|
|
2138
|
+
**dict(entry.get("paths") or {}),
|
|
2139
|
+
"idea_md": str(paths.get("idea_md") or "").strip() or dict(entry.get("paths") or {}).get("idea_md"),
|
|
2140
|
+
"idea_draft": str(paths.get("idea_draft_md") or details.get("idea_draft_path") or "").strip()
|
|
2141
|
+
or dict(entry.get("paths") or {}).get("idea_draft"),
|
|
2142
|
+
}
|
|
2143
|
+
elif kind == "runs":
|
|
2144
|
+
branch = str(payload.get("branch") or "").strip()
|
|
2145
|
+
run_id = str(payload.get("run_id") or "").strip()
|
|
2146
|
+
run_kind = str(payload.get("run_kind") or "").strip().lower()
|
|
2147
|
+
if not run_id or branch.startswith("analysis/") or branch.startswith("paper/") or run_kind.startswith("analysis"):
|
|
2148
|
+
continue
|
|
2149
|
+
current_rank = updated_rank(entry.get("latest_main_run_updated_at"))
|
|
2150
|
+
candidate_rank = updated_rank(payload.get("updated_at") or payload.get("created_at"))
|
|
2151
|
+
if candidate_rank >= current_rank:
|
|
2152
|
+
entry["latest_main_run_id"] = run_id
|
|
2153
|
+
entry["latest_main_run_branch"] = branch or entry.get("latest_main_run_branch")
|
|
2154
|
+
entry["latest_main_run_updated_at"] = candidate_rank
|
|
2155
|
+
entry["updated_at"] = max(updated_rank(entry.get("updated_at")), candidate_rank)
|
|
2156
|
+
|
|
2157
|
+
for line in paper_lines:
|
|
2158
|
+
idea_id = str(line.get("source_idea_id") or "").strip()
|
|
2159
|
+
if not idea_id:
|
|
2160
|
+
continue
|
|
2161
|
+
entry = ensure_line(idea_id)
|
|
2162
|
+
current_rank = updated_rank(entry.get("paper_line_updated_at"))
|
|
2163
|
+
candidate_rank = updated_rank(line.get("updated_at"))
|
|
2164
|
+
if candidate_rank >= current_rank:
|
|
2165
|
+
entry["paper_line_id"] = str(line.get("paper_line_id") or "").strip() or entry.get("paper_line_id")
|
|
2166
|
+
entry["paper_branch"] = str(line.get("paper_branch") or "").strip() or entry.get("paper_branch")
|
|
2167
|
+
entry["selected_outline_ref"] = str(line.get("selected_outline_ref") or "").strip() or entry.get("selected_outline_ref")
|
|
2168
|
+
entry["required_count"] = int(line.get("required_count") or 0)
|
|
2169
|
+
entry["ready_required_count"] = int(line.get("ready_required_count") or 0)
|
|
2170
|
+
entry["unmapped_count"] = int(line.get("unmapped_count") or 0)
|
|
2171
|
+
entry["open_supplementary_count"] = int(line.get("open_supplementary_count") or 0)
|
|
2172
|
+
entry["draft_status"] = str(line.get("draft_status") or "").strip() or None
|
|
2173
|
+
entry["bundle_status"] = str(line.get("bundle_status") or "").strip() or None
|
|
2174
|
+
entry["paper_line_updated_at"] = candidate_rank
|
|
2175
|
+
entry["updated_at"] = max(updated_rank(entry.get("updated_at")), candidate_rank)
|
|
2176
|
+
entry["paths"] = {
|
|
2177
|
+
**dict(entry.get("paths") or {}),
|
|
2178
|
+
"paper_line_state": str(((line.get("paths") or {}) if isinstance(line.get("paths"), dict) else {}).get("paper_line_state") or "").strip()
|
|
2179
|
+
or dict(entry.get("paths") or {}).get("paper_line_state"),
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
campaigns = list((analysis_inventory or {}).get("campaigns") or []) if isinstance(analysis_inventory, dict) else []
|
|
2183
|
+
for campaign in campaigns:
|
|
2184
|
+
if not isinstance(campaign, dict):
|
|
2185
|
+
continue
|
|
2186
|
+
matched_idea_id = str(campaign.get("active_idea_id") or "").strip()
|
|
2187
|
+
if not matched_idea_id:
|
|
2188
|
+
matched_run_id = str(campaign.get("parent_run_id") or "").strip()
|
|
2189
|
+
matched_branch = str(campaign.get("parent_branch") or "").strip()
|
|
2190
|
+
for candidate in lines_by_id.values():
|
|
2191
|
+
if matched_run_id and matched_run_id == str(candidate.get("latest_main_run_id") or "").strip():
|
|
2192
|
+
matched_idea_id = str(candidate.get("idea_id") or "").strip()
|
|
2193
|
+
break
|
|
2194
|
+
if matched_branch and matched_branch in {
|
|
2195
|
+
str(candidate.get("idea_branch") or "").strip(),
|
|
2196
|
+
str(candidate.get("latest_main_run_branch") or "").strip(),
|
|
2197
|
+
}:
|
|
2198
|
+
matched_idea_id = str(candidate.get("idea_id") or "").strip()
|
|
2199
|
+
break
|
|
2200
|
+
if not matched_idea_id:
|
|
2201
|
+
continue
|
|
2202
|
+
entry = ensure_line(matched_idea_id)
|
|
2203
|
+
entry["analysis_campaign_count"] = int(entry.get("analysis_campaign_count") or 0) + 1
|
|
2204
|
+
entry["analysis_slice_count"] = int(entry.get("analysis_slice_count") or 0) + int(campaign.get("slice_count") or 0)
|
|
2205
|
+
entry["completed_analysis_slice_count"] = int(entry.get("completed_analysis_slice_count") or 0) + int(
|
|
2206
|
+
campaign.get("completed_slice_count") or 0
|
|
2207
|
+
)
|
|
2208
|
+
entry["mapped_analysis_slice_count"] = int(entry.get("mapped_analysis_slice_count") or 0) + int(
|
|
2209
|
+
campaign.get("mapped_slice_count") or 0
|
|
2210
|
+
)
|
|
2211
|
+
if not entry.get("paper_line_id") and str(campaign.get("paper_line_id") or "").strip():
|
|
2212
|
+
entry["paper_line_id"] = str(campaign.get("paper_line_id") or "").strip()
|
|
2213
|
+
entry["paper_branch"] = str(campaign.get("paper_line_branch") or "").strip() or entry.get("paper_branch")
|
|
2214
|
+
entry["selected_outline_ref"] = str(campaign.get("selected_outline_ref") or "").strip() or entry.get("selected_outline_ref")
|
|
2215
|
+
entry["updated_at"] = max(
|
|
2216
|
+
updated_rank(entry.get("updated_at")),
|
|
2217
|
+
updated_rank(campaign.get("updated_at")),
|
|
2218
|
+
)
|
|
2219
|
+
|
|
2220
|
+
lines = sorted(
|
|
2221
|
+
lines_by_id.values(),
|
|
519
2222
|
key=lambda item: (
|
|
520
|
-
str(item.get("
|
|
521
|
-
str(item.get("
|
|
2223
|
+
0 if str(item.get("idea_id") or "").strip() == active_idea_id else 1,
|
|
2224
|
+
str(item.get("updated_at") or ""),
|
|
2225
|
+
str(item.get("idea_line_id") or ""),
|
|
2226
|
+
),
|
|
2227
|
+
)
|
|
2228
|
+
for item in lines:
|
|
2229
|
+
if not item.get("open_supplementary_count"):
|
|
2230
|
+
pending = max(
|
|
2231
|
+
0,
|
|
2232
|
+
int(item.get("analysis_slice_count") or 0) - int(item.get("completed_analysis_slice_count") or 0),
|
|
2233
|
+
)
|
|
2234
|
+
item["open_supplementary_count"] = pending
|
|
2235
|
+
item.pop("latest_main_run_updated_at", None)
|
|
2236
|
+
item.pop("paper_line_updated_at", None)
|
|
2237
|
+
if active_idea_id and active_idea_id in lines_by_id:
|
|
2238
|
+
active_ref = active_idea_id
|
|
2239
|
+
elif lines:
|
|
2240
|
+
active_ref = str(lines[0].get("idea_line_id") or "").strip() or None
|
|
2241
|
+
return lines, active_ref
|
|
2242
|
+
|
|
2243
|
+
def _paper_contract_health_payload(
|
|
2244
|
+
self,
|
|
2245
|
+
*,
|
|
2246
|
+
paper_contract: dict[str, Any] | None,
|
|
2247
|
+
paper_evidence: dict[str, Any] | None,
|
|
2248
|
+
analysis_inventory: dict[str, Any] | None,
|
|
2249
|
+
paper_lines: list[dict[str, Any]],
|
|
2250
|
+
active_paper_line_ref: str | None,
|
|
2251
|
+
) -> dict[str, Any] | None:
|
|
2252
|
+
if not isinstance(paper_contract, dict) or not paper_contract:
|
|
2253
|
+
return None
|
|
2254
|
+
evidence_items = [
|
|
2255
|
+
dict(item) for item in ((paper_evidence or {}).get("items") or []) if isinstance(item, dict)
|
|
2256
|
+
]
|
|
2257
|
+
ledger_by_item = {
|
|
2258
|
+
str(item.get("item_id") or "").strip(): item
|
|
2259
|
+
for item in evidence_items
|
|
2260
|
+
if str(item.get("item_id") or "").strip()
|
|
2261
|
+
}
|
|
2262
|
+
unresolved_required_items: list[dict[str, Any]] = []
|
|
2263
|
+
ready_section_count = 0
|
|
2264
|
+
for section in paper_contract.get("sections") or []:
|
|
2265
|
+
if not isinstance(section, dict):
|
|
2266
|
+
continue
|
|
2267
|
+
required_items = [str(item).strip() for item in (section.get("required_items") or []) if str(item).strip()]
|
|
2268
|
+
section_ready = True
|
|
2269
|
+
for item_id in required_items:
|
|
2270
|
+
ledger_item = ledger_by_item.get(item_id)
|
|
2271
|
+
status = str((ledger_item or {}).get("status") or "").strip().lower()
|
|
2272
|
+
if status not in {"ready", "completed", "analyzed", "written", "recorded", "supported"}:
|
|
2273
|
+
unresolved_required_items.append(
|
|
2274
|
+
{
|
|
2275
|
+
"section_id": str(section.get("section_id") or "").strip() or None,
|
|
2276
|
+
"section_title": str(section.get("title") or "").strip() or None,
|
|
2277
|
+
"item_id": item_id,
|
|
2278
|
+
"status": str((ledger_item or {}).get("status") or "").strip() or None,
|
|
2279
|
+
}
|
|
2280
|
+
)
|
|
2281
|
+
section_ready = False
|
|
2282
|
+
if required_items and section_ready:
|
|
2283
|
+
ready_section_count += 1
|
|
2284
|
+
|
|
2285
|
+
selected_outline_ref = str(paper_contract.get("selected_outline_ref") or "").strip() or None
|
|
2286
|
+
active_line = next(
|
|
2287
|
+
(
|
|
2288
|
+
dict(item)
|
|
2289
|
+
for item in paper_lines
|
|
2290
|
+
if isinstance(item, dict)
|
|
2291
|
+
and str(item.get("paper_line_id") or "").strip()
|
|
2292
|
+
and str(item.get("paper_line_id") or "").strip() == str(active_paper_line_ref or "").strip()
|
|
522
2293
|
),
|
|
2294
|
+
dict(paper_lines[0]) if paper_lines else {},
|
|
2295
|
+
)
|
|
2296
|
+
active_line_id = str(active_line.get("paper_line_id") or "").strip() or None
|
|
2297
|
+
active_line_branch = str(active_line.get("paper_branch") or "").strip() or None
|
|
2298
|
+
|
|
2299
|
+
campaigns = [dict(item) for item in ((analysis_inventory or {}).get("campaigns") or []) if isinstance(item, dict)]
|
|
2300
|
+
relevant_campaigns: list[dict[str, Any]] = []
|
|
2301
|
+
for campaign in campaigns:
|
|
2302
|
+
campaign_outline = str(campaign.get("selected_outline_ref") or "").strip() or None
|
|
2303
|
+
campaign_line_id = str(campaign.get("paper_line_id") or "").strip() or None
|
|
2304
|
+
campaign_line_branch = str(campaign.get("paper_line_branch") or "").strip() or None
|
|
2305
|
+
if active_line_id and campaign_line_id == active_line_id:
|
|
2306
|
+
relevant_campaigns.append(campaign)
|
|
2307
|
+
continue
|
|
2308
|
+
if active_line_branch and campaign_line_branch == active_line_branch:
|
|
2309
|
+
relevant_campaigns.append(campaign)
|
|
2310
|
+
continue
|
|
2311
|
+
if selected_outline_ref and campaign_outline == selected_outline_ref:
|
|
2312
|
+
relevant_campaigns.append(campaign)
|
|
2313
|
+
|
|
2314
|
+
unmapped_completed_items: list[dict[str, Any]] = []
|
|
2315
|
+
blocking_pending_slices: list[dict[str, Any]] = []
|
|
2316
|
+
for campaign in relevant_campaigns:
|
|
2317
|
+
for slice_item in campaign.get("slices") or []:
|
|
2318
|
+
if not isinstance(slice_item, dict):
|
|
2319
|
+
continue
|
|
2320
|
+
status = str(slice_item.get("status") or "").strip().lower()
|
|
2321
|
+
if status == "completed" and not bool(slice_item.get("mapped")):
|
|
2322
|
+
unmapped_completed_items.append(
|
|
2323
|
+
{
|
|
2324
|
+
"campaign_id": str(campaign.get("campaign_id") or "").strip() or None,
|
|
2325
|
+
"slice_id": str(slice_item.get("slice_id") or "").strip() or None,
|
|
2326
|
+
"item_id": str(slice_item.get("item_id") or "").strip() or None,
|
|
2327
|
+
"section_id": str(slice_item.get("section_id") or "").strip() or None,
|
|
2328
|
+
"title": str(slice_item.get("title") or "").strip() or None,
|
|
2329
|
+
}
|
|
2330
|
+
)
|
|
2331
|
+
if status in {"", "pending"}:
|
|
2332
|
+
paper_role = str(slice_item.get("paper_role") or "").strip().lower()
|
|
2333
|
+
tier = str(slice_item.get("tier") or "").strip().lower()
|
|
2334
|
+
if paper_role == "main_text" or tier == "main_required":
|
|
2335
|
+
blocking_pending_slices.append(
|
|
2336
|
+
{
|
|
2337
|
+
"campaign_id": str(campaign.get("campaign_id") or "").strip() or None,
|
|
2338
|
+
"slice_id": str(slice_item.get("slice_id") or "").strip() or None,
|
|
2339
|
+
"item_id": str(slice_item.get("item_id") or "").strip() or None,
|
|
2340
|
+
"section_id": str(slice_item.get("section_id") or "").strip() or None,
|
|
2341
|
+
"title": str(slice_item.get("title") or "").strip() or None,
|
|
2342
|
+
}
|
|
2343
|
+
)
|
|
2344
|
+
|
|
2345
|
+
contract_ok = not unresolved_required_items and not unmapped_completed_items
|
|
2346
|
+
writing_ready = contract_ok and not blocking_pending_slices
|
|
2347
|
+
draft_path = str((paper_contract.get("paths") or {}).get("draft") or "").strip()
|
|
2348
|
+
draft_status = str(active_line.get("draft_status") or "").strip() or ("present" if draft_path else "missing")
|
|
2349
|
+
bundle_status = str(active_line.get("bundle_status") or "").strip() or (
|
|
2350
|
+
"present" if str((paper_contract.get("paths") or {}).get("bundle_manifest") or "").strip() else "missing"
|
|
523
2351
|
)
|
|
2352
|
+
bundle_manifest = (
|
|
2353
|
+
dict(paper_contract.get("bundle_manifest") or {})
|
|
2354
|
+
if isinstance(paper_contract.get("bundle_manifest"), dict)
|
|
2355
|
+
else {}
|
|
2356
|
+
)
|
|
2357
|
+
submission_checklist_path = str(((paper_contract.get("paths") or {}).get("submission_checklist") or "")).strip()
|
|
2358
|
+
submission_checklist = read_json(Path(submission_checklist_path), {}) if submission_checklist_path else {}
|
|
2359
|
+
submission_checklist = submission_checklist if isinstance(submission_checklist, dict) else {}
|
|
2360
|
+
overall_status = str(submission_checklist.get("overall_status") or bundle_manifest.get("status") or "").strip().lower()
|
|
2361
|
+
delivered_at = str(
|
|
2362
|
+
bundle_manifest.get("paper_delivered_to_user_at")
|
|
2363
|
+
or bundle_manifest.get("delivered_at")
|
|
2364
|
+
or submission_checklist.get("paper_delivered_to_user_at")
|
|
2365
|
+
or ""
|
|
2366
|
+
).strip() or None
|
|
2367
|
+
closure_state = "bundle_not_ready"
|
|
2368
|
+
delivery_state = "not_ready"
|
|
2369
|
+
keep_bundle_fixed_by_default = False
|
|
2370
|
+
if bundle_status == "present":
|
|
2371
|
+
closure_state = "delivery_ready"
|
|
2372
|
+
delivery_state = "bundle_ready"
|
|
2373
|
+
if delivered_at or "delivered" in overall_status:
|
|
2374
|
+
delivery_state = "delivered"
|
|
2375
|
+
closure_state = "delivered_continue_research" if "continue" in overall_status else "delivered_parked"
|
|
2376
|
+
keep_bundle_fixed_by_default = True
|
|
2377
|
+
|
|
2378
|
+
if unmapped_completed_items:
|
|
2379
|
+
recommended_next_stage = "write"
|
|
2380
|
+
recommended_action = "sync_paper_contract"
|
|
2381
|
+
elif unresolved_required_items or blocking_pending_slices:
|
|
2382
|
+
recommended_next_stage = "analysis-campaign"
|
|
2383
|
+
recommended_action = "complete_required_supplementary"
|
|
2384
|
+
elif draft_status != "present":
|
|
2385
|
+
recommended_next_stage = "write"
|
|
2386
|
+
recommended_action = "draft_paper"
|
|
2387
|
+
elif bundle_status != "present":
|
|
2388
|
+
recommended_next_stage = "write"
|
|
2389
|
+
recommended_action = "prepare_bundle"
|
|
2390
|
+
else:
|
|
2391
|
+
recommended_next_stage = "finalize"
|
|
2392
|
+
recommended_action = "finalize_paper_line"
|
|
2393
|
+
|
|
2394
|
+
blocking_reasons: list[str] = []
|
|
2395
|
+
if unmapped_completed_items:
|
|
2396
|
+
blocking_reasons.append("completed analysis remains unmapped into the paper contract")
|
|
2397
|
+
if unresolved_required_items:
|
|
2398
|
+
blocking_reasons.append("required outline items are still unresolved")
|
|
2399
|
+
if blocking_pending_slices:
|
|
2400
|
+
blocking_reasons.append("main-text supplementary slices are still pending")
|
|
2401
|
+
|
|
2402
|
+
return {
|
|
2403
|
+
"paper_line_id": active_line_id,
|
|
2404
|
+
"paper_branch": active_line_branch,
|
|
2405
|
+
"selected_outline_ref": selected_outline_ref,
|
|
2406
|
+
"contract_ok": contract_ok,
|
|
2407
|
+
"writing_ready": writing_ready,
|
|
2408
|
+
"finalize_ready": writing_ready and bundle_status == "present",
|
|
2409
|
+
"closure_state": closure_state,
|
|
2410
|
+
"delivery_state": delivery_state,
|
|
2411
|
+
"delivered_at": delivered_at,
|
|
2412
|
+
"keep_bundle_fixed_by_default": keep_bundle_fixed_by_default,
|
|
2413
|
+
"required_count": sum(
|
|
2414
|
+
len(section.get("required_items") or [])
|
|
2415
|
+
for section in (paper_contract.get("sections") or [])
|
|
2416
|
+
if isinstance(section, dict)
|
|
2417
|
+
),
|
|
2418
|
+
"ready_required_count": max(
|
|
2419
|
+
0,
|
|
2420
|
+
sum(
|
|
2421
|
+
len(section.get("required_items") or [])
|
|
2422
|
+
for section in (paper_contract.get("sections") or [])
|
|
2423
|
+
if isinstance(section, dict)
|
|
2424
|
+
)
|
|
2425
|
+
- len(unresolved_required_items),
|
|
2426
|
+
),
|
|
2427
|
+
"section_count": len([section for section in (paper_contract.get("sections") or []) if isinstance(section, dict)]),
|
|
2428
|
+
"ready_section_count": ready_section_count,
|
|
2429
|
+
"ledger_item_count": len(evidence_items),
|
|
2430
|
+
"unresolved_required_count": len(unresolved_required_items),
|
|
2431
|
+
"unmapped_completed_count": len(unmapped_completed_items),
|
|
2432
|
+
"open_supplementary_count": int(active_line.get("open_supplementary_count") or 0),
|
|
2433
|
+
"blocking_open_supplementary_count": len(blocking_pending_slices),
|
|
2434
|
+
"draft_status": draft_status,
|
|
2435
|
+
"bundle_status": bundle_status,
|
|
2436
|
+
"blocking_reasons": blocking_reasons,
|
|
2437
|
+
"recommended_next_stage": recommended_next_stage,
|
|
2438
|
+
"recommended_action": recommended_action,
|
|
2439
|
+
"unresolved_required_items": unresolved_required_items[:12],
|
|
2440
|
+
"unmapped_completed_items": unmapped_completed_items[:12],
|
|
2441
|
+
"blocking_pending_slices": blocking_pending_slices[:12],
|
|
2442
|
+
}
|
|
524
2443
|
|
|
525
2444
|
@staticmethod
|
|
526
2445
|
def _latest_metric_from_payload(payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
@@ -551,14 +2470,8 @@ class QuestService:
|
|
|
551
2470
|
def _quest_id_state_lock(self):
|
|
552
2471
|
lock_path = self._quest_id_lock_path()
|
|
553
2472
|
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)
|
|
2473
|
+
with advisory_file_lock(lock_path):
|
|
2474
|
+
yield
|
|
562
2475
|
|
|
563
2476
|
@contextmanager
|
|
564
2477
|
def _runtime_state_lock(self, quest_root: Path):
|
|
@@ -568,14 +2481,8 @@ class QuestService:
|
|
|
568
2481
|
with thread_lock:
|
|
569
2482
|
lock_path = self._runtime_state_lock_path(quest_root)
|
|
570
2483
|
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)
|
|
2484
|
+
with advisory_file_lock(lock_path):
|
|
2485
|
+
yield
|
|
579
2486
|
|
|
580
2487
|
def _scan_next_numeric_quest_id(self) -> int:
|
|
581
2488
|
max_numeric_id = 0
|
|
@@ -878,9 +2785,26 @@ class QuestService:
|
|
|
878
2785
|
"requested_baseline_ref": quest_yaml.get("requested_baseline_ref"),
|
|
879
2786
|
"startup_contract": quest_yaml.get("startup_contract"),
|
|
880
2787
|
"runner": quest_yaml.get("default_runner", "codex"),
|
|
2788
|
+
"active_workspace_root": str(workspace_root),
|
|
2789
|
+
"research_head_branch": research_state.get("research_head_branch"),
|
|
2790
|
+
"research_head_worktree_root": research_state.get("research_head_worktree_root"),
|
|
2791
|
+
"current_workspace_branch": research_state.get("current_workspace_branch"),
|
|
2792
|
+
"current_workspace_root": research_state.get("current_workspace_root"),
|
|
2793
|
+
"active_idea_id": research_state.get("active_idea_id"),
|
|
881
2794
|
"active_baseline_id": active_baseline_id,
|
|
882
2795
|
"active_baseline_variant_id": active_baseline_variant_id,
|
|
883
2796
|
"active_run_id": runtime_state.get("active_run_id"),
|
|
2797
|
+
"continuation_policy": runtime_state.get("continuation_policy") or "auto",
|
|
2798
|
+
"continuation_anchor": runtime_state.get("continuation_anchor"),
|
|
2799
|
+
"continuation_reason": runtime_state.get("continuation_reason"),
|
|
2800
|
+
"continuation_updated_at": runtime_state.get("continuation_updated_at"),
|
|
2801
|
+
"last_resume_source": runtime_state.get("last_resume_source"),
|
|
2802
|
+
"last_resume_at": runtime_state.get("last_resume_at"),
|
|
2803
|
+
"last_recovery_abandoned_run_id": runtime_state.get("last_recovery_abandoned_run_id"),
|
|
2804
|
+
"last_recovery_summary": runtime_state.get("last_recovery_summary"),
|
|
2805
|
+
"last_stage_fingerprint": runtime_state.get("last_stage_fingerprint"),
|
|
2806
|
+
"last_stage_fingerprint_at": runtime_state.get("last_stage_fingerprint_at"),
|
|
2807
|
+
"same_fingerprint_auto_turn_count": int(runtime_state.get("same_fingerprint_auto_turn_count") or 0),
|
|
884
2808
|
"pending_decisions": pending_decisions,
|
|
885
2809
|
"waiting_interaction_id": waiting_interaction_id,
|
|
886
2810
|
"default_reply_interaction_id": default_reply_interaction_id,
|
|
@@ -1089,7 +3013,7 @@ class QuestService:
|
|
|
1089
3013
|
return entries
|
|
1090
3014
|
|
|
1091
3015
|
def snapshot_fast(self, quest_id: str) -> dict:
|
|
1092
|
-
return self.
|
|
3016
|
+
return self.summary_compact(quest_id)
|
|
1093
3017
|
|
|
1094
3018
|
def snapshot(self, quest_id: str) -> dict:
|
|
1095
3019
|
return self._snapshot(quest_id)
|
|
@@ -1206,6 +3130,22 @@ class QuestService:
|
|
|
1206
3130
|
bash_service = BashExecService(self.home)
|
|
1207
3131
|
bash_summary = bash_service.summary(quest_root)
|
|
1208
3132
|
latest_bash_session = bash_summary.get("latest_session")
|
|
3133
|
+
paper_contract = self._paper_contract_payload(quest_root, workspace_root)
|
|
3134
|
+
paper_evidence = self._paper_evidence_payload(quest_root, workspace_root)
|
|
3135
|
+
analysis_inventory = self._analysis_inventory_payload(quest_root, workspace_root)
|
|
3136
|
+
paper_lines, active_paper_line_ref = self._paper_lines_payload(quest_root, workspace_root)
|
|
3137
|
+
idea_lines, active_idea_line_ref = self._idea_lines_payload(
|
|
3138
|
+
quest_root,
|
|
3139
|
+
paper_lines=paper_lines,
|
|
3140
|
+
analysis_inventory=analysis_inventory,
|
|
3141
|
+
)
|
|
3142
|
+
paper_contract_health = self._paper_contract_health_payload(
|
|
3143
|
+
paper_contract=paper_contract,
|
|
3144
|
+
paper_evidence=paper_evidence,
|
|
3145
|
+
analysis_inventory=analysis_inventory,
|
|
3146
|
+
paper_lines=paper_lines,
|
|
3147
|
+
active_paper_line_ref=active_paper_line_ref,
|
|
3148
|
+
)
|
|
1209
3149
|
paths = {
|
|
1210
3150
|
"brief": str(workspace_root / "brief.md"),
|
|
1211
3151
|
"plan": str(workspace_root / "plan.md"),
|
|
@@ -1277,11 +3217,27 @@ class QuestService:
|
|
|
1277
3217
|
"paper_parent_branch": research_state.get("paper_parent_branch"),
|
|
1278
3218
|
"paper_parent_worktree_root": research_state.get("paper_parent_worktree_root"),
|
|
1279
3219
|
"paper_parent_run_id": research_state.get("paper_parent_run_id"),
|
|
3220
|
+
"idea_lines": idea_lines,
|
|
3221
|
+
"active_idea_line_ref": active_idea_line_ref,
|
|
3222
|
+
"paper_lines": paper_lines,
|
|
3223
|
+
"active_paper_line_ref": active_paper_line_ref,
|
|
3224
|
+
"paper_contract_health": paper_contract_health,
|
|
1280
3225
|
"next_pending_slice_id": research_state.get("next_pending_slice_id"),
|
|
1281
3226
|
"workspace_mode": research_state.get("workspace_mode") or "quest",
|
|
1282
3227
|
"active_baseline_id": active_baseline_id,
|
|
1283
3228
|
"active_baseline_variant_id": active_baseline_variant_id,
|
|
1284
3229
|
"active_run_id": runtime_state.get("active_run_id"),
|
|
3230
|
+
"continuation_policy": runtime_state.get("continuation_policy") or "auto",
|
|
3231
|
+
"continuation_anchor": runtime_state.get("continuation_anchor"),
|
|
3232
|
+
"continuation_reason": runtime_state.get("continuation_reason"),
|
|
3233
|
+
"continuation_updated_at": runtime_state.get("continuation_updated_at"),
|
|
3234
|
+
"last_resume_source": runtime_state.get("last_resume_source"),
|
|
3235
|
+
"last_resume_at": runtime_state.get("last_resume_at"),
|
|
3236
|
+
"last_recovery_abandoned_run_id": runtime_state.get("last_recovery_abandoned_run_id"),
|
|
3237
|
+
"last_recovery_summary": runtime_state.get("last_recovery_summary"),
|
|
3238
|
+
"last_stage_fingerprint": runtime_state.get("last_stage_fingerprint"),
|
|
3239
|
+
"last_stage_fingerprint_at": runtime_state.get("last_stage_fingerprint_at"),
|
|
3240
|
+
"same_fingerprint_auto_turn_count": int(runtime_state.get("same_fingerprint_auto_turn_count") or 0),
|
|
1285
3241
|
"pending_decisions": pending_decisions,
|
|
1286
3242
|
"active_interactions": active_interactions,
|
|
1287
3243
|
"recent_reply_threads": recent_reply_threads,
|
|
@@ -1320,6 +3276,9 @@ class QuestService:
|
|
|
1320
3276
|
"artifact_count": len(artifacts),
|
|
1321
3277
|
"recent_artifacts": artifacts[-5:],
|
|
1322
3278
|
"recent_runs": recent_runs[-5:],
|
|
3279
|
+
"paper_contract": paper_contract,
|
|
3280
|
+
"paper_evidence": paper_evidence,
|
|
3281
|
+
"analysis_inventory": analysis_inventory,
|
|
1323
3282
|
"guidance": guidance,
|
|
1324
3283
|
}
|
|
1325
3284
|
with self._snapshot_cache_lock:
|
|
@@ -1860,97 +3819,7 @@ class QuestService:
|
|
|
1860
3819
|
return self._read_cached_jsonl(self._quest_root(quest_id) / ".ds" / "conversations" / "main.jsonl")[-limit:]
|
|
1861
3820
|
|
|
1862
3821
|
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
|
-
}
|
|
3822
|
+
return self._projected_payload(quest_id, "details")
|
|
1954
3823
|
|
|
1955
3824
|
def events(
|
|
1956
3825
|
self,
|
|
@@ -2077,23 +3946,129 @@ class QuestService:
|
|
|
2077
3946
|
def metrics_timeline(self, quest_id: str) -> dict:
|
|
2078
3947
|
quest_root = self._quest_root(quest_id)
|
|
2079
3948
|
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
|
-
|
|
3949
|
+
state = self._json_compatible_state(self._metrics_timeline_state(quest_root, workspace_root))
|
|
3950
|
+
cache_path = self._metrics_timeline_cache_path(quest_root)
|
|
3951
|
+
cache_schema_version = 2
|
|
3952
|
+
cached = self._read_cached_json(cache_path, {})
|
|
3953
|
+
if (
|
|
3954
|
+
isinstance(cached, dict)
|
|
3955
|
+
and int(cached.get("schema_version") or 0) == cache_schema_version
|
|
3956
|
+
and self._json_compatible_state(cached.get("state")) == state
|
|
3957
|
+
and isinstance(cached.get("payload"), dict)
|
|
3958
|
+
):
|
|
3959
|
+
return dict(cached.get("payload") or {})
|
|
3960
|
+
|
|
3961
|
+
with advisory_file_lock(self._metrics_timeline_cache_lock_path(quest_root)):
|
|
3962
|
+
cached = read_json(cache_path, {})
|
|
3963
|
+
if (
|
|
3964
|
+
isinstance(cached, dict)
|
|
3965
|
+
and int(cached.get("schema_version") or 0) == cache_schema_version
|
|
3966
|
+
and self._json_compatible_state(cached.get("state")) == state
|
|
3967
|
+
and isinstance(cached.get("payload"), dict)
|
|
3968
|
+
):
|
|
3969
|
+
return dict(cached.get("payload") or {})
|
|
3970
|
+
|
|
3971
|
+
attachment = self._active_baseline_attachment(quest_root, workspace_root)
|
|
3972
|
+
baseline_entry = dict(attachment.get("entry") or {}) if isinstance(attachment, dict) else None
|
|
3973
|
+
selected_variant_id = (
|
|
3974
|
+
str(attachment.get("source_variant_id") or "").strip() or None if isinstance(attachment, dict) else None
|
|
3975
|
+
)
|
|
3976
|
+
if not baseline_entry:
|
|
3977
|
+
latest_baseline_payload = None
|
|
3978
|
+
for item in reversed(self._collect_artifacts_raw(quest_root)):
|
|
3979
|
+
if str(item.get("kind") or "").strip() != "baselines":
|
|
3980
|
+
continue
|
|
3981
|
+
payload = item.get("payload") or {}
|
|
3982
|
+
if not isinstance(payload, dict):
|
|
3983
|
+
continue
|
|
3984
|
+
if str(payload.get("status") or "").strip().lower() != "confirmed":
|
|
3985
|
+
continue
|
|
3986
|
+
latest_baseline_payload = payload
|
|
3987
|
+
break
|
|
3988
|
+
if isinstance(latest_baseline_payload, dict) and latest_baseline_payload:
|
|
3989
|
+
baseline_entry = dict(latest_baseline_payload)
|
|
3990
|
+
selected_variant_id = (
|
|
3991
|
+
str(latest_baseline_payload.get("baseline_variant_id") or "").strip() or None
|
|
3992
|
+
)
|
|
3993
|
+
run_records = [
|
|
3994
|
+
item.get("payload") or {}
|
|
3995
|
+
for item in self._collect_run_artifacts_raw(quest_root, run_kind="main_experiment")
|
|
3996
|
+
if isinstance(item.get("payload"), dict)
|
|
3997
|
+
]
|
|
3998
|
+
payload = build_metrics_timeline(
|
|
3999
|
+
quest_id=quest_id,
|
|
4000
|
+
run_records=run_records,
|
|
4001
|
+
baseline_entry=baseline_entry,
|
|
4002
|
+
selected_variant_id=selected_variant_id,
|
|
4003
|
+
)
|
|
4004
|
+
write_json(
|
|
4005
|
+
cache_path,
|
|
4006
|
+
{
|
|
4007
|
+
"schema_version": cache_schema_version,
|
|
4008
|
+
"generated_at": utc_now(),
|
|
4009
|
+
"state": state,
|
|
4010
|
+
"payload": payload,
|
|
4011
|
+
},
|
|
4012
|
+
)
|
|
4013
|
+
return payload
|
|
4014
|
+
|
|
4015
|
+
def baseline_compare(self, quest_id: str) -> dict:
|
|
4016
|
+
quest_root = self._quest_root(quest_id)
|
|
4017
|
+
workspace_root = self.active_workspace_root(quest_root)
|
|
4018
|
+
state = self._json_compatible_state(self._baseline_compare_state(quest_root, workspace_root))
|
|
4019
|
+
cache_path = self._baseline_compare_cache_path(quest_root)
|
|
4020
|
+
cache_schema_version = 1
|
|
4021
|
+
cached = self._read_cached_json(cache_path, {})
|
|
4022
|
+
if (
|
|
4023
|
+
isinstance(cached, dict)
|
|
4024
|
+
and int(cached.get("schema_version") or 0) == cache_schema_version
|
|
4025
|
+
and self._json_compatible_state(cached.get("state")) == state
|
|
4026
|
+
and isinstance(cached.get("payload"), dict)
|
|
4027
|
+
):
|
|
4028
|
+
return dict(cached.get("payload") or {})
|
|
4029
|
+
|
|
4030
|
+
with advisory_file_lock(self._baseline_compare_cache_lock_path(quest_root)):
|
|
4031
|
+
cached = read_json(cache_path, {})
|
|
4032
|
+
if (
|
|
4033
|
+
isinstance(cached, dict)
|
|
4034
|
+
and int(cached.get("schema_version") or 0) == cache_schema_version
|
|
4035
|
+
and self._json_compatible_state(cached.get("state")) == state
|
|
4036
|
+
and isinstance(cached.get("payload"), dict)
|
|
4037
|
+
):
|
|
4038
|
+
return dict(cached.get("payload") or {})
|
|
4039
|
+
|
|
4040
|
+
quest_data = self.read_quest_yaml(quest_root)
|
|
4041
|
+
confirmed_ref = (
|
|
4042
|
+
dict(quest_data.get("confirmed_baseline_ref") or {})
|
|
4043
|
+
if isinstance(quest_data.get("confirmed_baseline_ref"), dict)
|
|
4044
|
+
else {}
|
|
4045
|
+
)
|
|
4046
|
+
attachment = self._active_baseline_attachment(quest_root, workspace_root)
|
|
4047
|
+
active_baseline_id = (
|
|
4048
|
+
str(confirmed_ref.get("baseline_id") or "").strip()
|
|
4049
|
+
or (str(attachment.get("source_baseline_id") or "").strip() if isinstance(attachment, dict) else "")
|
|
4050
|
+
or None
|
|
4051
|
+
)
|
|
4052
|
+
active_variant_id = (
|
|
4053
|
+
str(confirmed_ref.get("variant_id") or "").strip()
|
|
4054
|
+
or (str(attachment.get("source_variant_id") or "").strip() if isinstance(attachment, dict) else "")
|
|
4055
|
+
or None
|
|
4056
|
+
)
|
|
4057
|
+
payload = build_baseline_compare_payload(
|
|
4058
|
+
quest_id=quest_id,
|
|
4059
|
+
baseline_entries=self._baseline_compare_entries(quest_root, workspace_root),
|
|
4060
|
+
active_baseline_id=active_baseline_id,
|
|
4061
|
+
active_variant_id=active_variant_id,
|
|
4062
|
+
)
|
|
4063
|
+
write_json(
|
|
4064
|
+
cache_path,
|
|
4065
|
+
{
|
|
4066
|
+
"schema_version": cache_schema_version,
|
|
4067
|
+
"state": state,
|
|
4068
|
+
"payload": payload,
|
|
4069
|
+
},
|
|
4070
|
+
)
|
|
4071
|
+
return payload
|
|
2097
4072
|
|
|
2098
4073
|
def list_documents(self, quest_id: str) -> list[dict]:
|
|
2099
4074
|
quest_root = self._quest_root(quest_id)
|
|
@@ -2689,6 +4664,17 @@ class QuestService:
|
|
|
2689
4664
|
"last_tool_activity_at": None,
|
|
2690
4665
|
"last_tool_activity_name": None,
|
|
2691
4666
|
"tool_calls_since_last_artifact_interact": 0,
|
|
4667
|
+
"continuation_policy": "auto",
|
|
4668
|
+
"continuation_anchor": None,
|
|
4669
|
+
"continuation_reason": None,
|
|
4670
|
+
"continuation_updated_at": None,
|
|
4671
|
+
"last_resume_source": None,
|
|
4672
|
+
"last_resume_at": None,
|
|
4673
|
+
"last_recovery_abandoned_run_id": None,
|
|
4674
|
+
"last_recovery_summary": None,
|
|
4675
|
+
"last_stage_fingerprint": None,
|
|
4676
|
+
"last_stage_fingerprint_at": None,
|
|
4677
|
+
"same_fingerprint_auto_turn_count": 0,
|
|
2692
4678
|
"pending_user_message_count": pending_count,
|
|
2693
4679
|
"last_delivered_batch_id": None,
|
|
2694
4680
|
"last_delivered_at": None,
|
|
@@ -2754,6 +4740,20 @@ class QuestService:
|
|
|
2754
4740
|
merged = {**defaults, **payload}
|
|
2755
4741
|
merged["pending_user_message_count"] = int(merged.get("pending_user_message_count") or 0)
|
|
2756
4742
|
merged["tool_calls_since_last_artifact_interact"] = int(merged.get("tool_calls_since_last_artifact_interact") or 0)
|
|
4743
|
+
merged["continuation_policy"] = self._normalize_continuation_policy(
|
|
4744
|
+
merged.get("continuation_policy"),
|
|
4745
|
+
default=str(defaults.get("continuation_policy") or "auto"),
|
|
4746
|
+
)
|
|
4747
|
+
merged["continuation_anchor"] = str(merged.get("continuation_anchor") or "").strip() or None
|
|
4748
|
+
merged["continuation_reason"] = str(merged.get("continuation_reason") or "").strip() or None
|
|
4749
|
+
merged["continuation_updated_at"] = str(merged.get("continuation_updated_at") or "").strip() or None
|
|
4750
|
+
merged["last_resume_source"] = str(merged.get("last_resume_source") or "").strip() or None
|
|
4751
|
+
merged["last_resume_at"] = str(merged.get("last_resume_at") or "").strip() or None
|
|
4752
|
+
merged["last_recovery_abandoned_run_id"] = str(merged.get("last_recovery_abandoned_run_id") or "").strip() or None
|
|
4753
|
+
merged["last_recovery_summary"] = str(merged.get("last_recovery_summary") or "").strip() or None
|
|
4754
|
+
merged["last_stage_fingerprint"] = str(merged.get("last_stage_fingerprint") or "").strip() or None
|
|
4755
|
+
merged["last_stage_fingerprint_at"] = str(merged.get("last_stage_fingerprint_at") or "").strip() or None
|
|
4756
|
+
merged["same_fingerprint_auto_turn_count"] = int(merged.get("same_fingerprint_auto_turn_count") or 0)
|
|
2757
4757
|
merged["retry_state"] = dict(merged.get("retry_state") or {}) if isinstance(merged.get("retry_state"), dict) else None
|
|
2758
4758
|
return merged
|
|
2759
4759
|
|
|
@@ -2773,6 +4773,17 @@ class QuestService:
|
|
|
2773
4773
|
last_tool_activity_at: str | None | object = _UNSET,
|
|
2774
4774
|
last_tool_activity_name: str | None | object = _UNSET,
|
|
2775
4775
|
tool_calls_since_last_artifact_interact: int | object = _UNSET,
|
|
4776
|
+
continuation_policy: str | object = _UNSET,
|
|
4777
|
+
continuation_anchor: str | None | object = _UNSET,
|
|
4778
|
+
continuation_reason: str | None | object = _UNSET,
|
|
4779
|
+
continuation_updated_at: str | None | object = _UNSET,
|
|
4780
|
+
last_resume_source: str | None | object = _UNSET,
|
|
4781
|
+
last_resume_at: str | None | object = _UNSET,
|
|
4782
|
+
last_recovery_abandoned_run_id: str | None | object = _UNSET,
|
|
4783
|
+
last_recovery_summary: str | None | object = _UNSET,
|
|
4784
|
+
last_stage_fingerprint: str | None | object = _UNSET,
|
|
4785
|
+
last_stage_fingerprint_at: str | None | object = _UNSET,
|
|
4786
|
+
same_fingerprint_auto_turn_count: int | object = _UNSET,
|
|
2776
4787
|
pending_user_message_count: int | object = _UNSET,
|
|
2777
4788
|
last_delivered_batch_id: str | None | object = _UNSET,
|
|
2778
4789
|
last_delivered_at: str | None | object = _UNSET,
|
|
@@ -2810,6 +4821,43 @@ class QuestService:
|
|
|
2810
4821
|
state["last_tool_activity_name"] = str(last_tool_activity_name).strip() if last_tool_activity_name else None
|
|
2811
4822
|
if tool_calls_since_last_artifact_interact is not _UNSET:
|
|
2812
4823
|
state["tool_calls_since_last_artifact_interact"] = max(0, int(tool_calls_since_last_artifact_interact))
|
|
4824
|
+
continuation_changed = False
|
|
4825
|
+
if continuation_policy is not _UNSET:
|
|
4826
|
+
state["continuation_policy"] = self._normalize_continuation_policy(continuation_policy)
|
|
4827
|
+
continuation_changed = True
|
|
4828
|
+
if continuation_anchor is not _UNSET:
|
|
4829
|
+
normalized_anchor = str(continuation_anchor or "").strip() or None
|
|
4830
|
+
if normalized_anchor is not None:
|
|
4831
|
+
from ..prompts.builder import STANDARD_SKILLS
|
|
4832
|
+
|
|
4833
|
+
if normalized_anchor not in STANDARD_SKILLS:
|
|
4834
|
+
allowed = ", ".join(STANDARD_SKILLS)
|
|
4835
|
+
raise ValueError(
|
|
4836
|
+
f"Unsupported continuation anchor `{normalized_anchor}`. Allowed values: {allowed}."
|
|
4837
|
+
)
|
|
4838
|
+
state["continuation_anchor"] = normalized_anchor
|
|
4839
|
+
continuation_changed = True
|
|
4840
|
+
if continuation_reason is not _UNSET:
|
|
4841
|
+
state["continuation_reason"] = str(continuation_reason or "").strip() or None
|
|
4842
|
+
continuation_changed = True
|
|
4843
|
+
if continuation_updated_at is not _UNSET:
|
|
4844
|
+
state["continuation_updated_at"] = str(continuation_updated_at or "").strip() or None
|
|
4845
|
+
elif continuation_changed:
|
|
4846
|
+
state["continuation_updated_at"] = now
|
|
4847
|
+
if last_resume_source is not _UNSET:
|
|
4848
|
+
state["last_resume_source"] = str(last_resume_source or "").strip() or None
|
|
4849
|
+
if last_resume_at is not _UNSET:
|
|
4850
|
+
state["last_resume_at"] = str(last_resume_at or "").strip() or None
|
|
4851
|
+
if last_recovery_abandoned_run_id is not _UNSET:
|
|
4852
|
+
state["last_recovery_abandoned_run_id"] = str(last_recovery_abandoned_run_id or "").strip() or None
|
|
4853
|
+
if last_recovery_summary is not _UNSET:
|
|
4854
|
+
state["last_recovery_summary"] = str(last_recovery_summary or "").strip() or None
|
|
4855
|
+
if last_stage_fingerprint is not _UNSET:
|
|
4856
|
+
state["last_stage_fingerprint"] = str(last_stage_fingerprint or "").strip() or None
|
|
4857
|
+
if last_stage_fingerprint_at is not _UNSET:
|
|
4858
|
+
state["last_stage_fingerprint_at"] = str(last_stage_fingerprint_at or "").strip() or None
|
|
4859
|
+
if same_fingerprint_auto_turn_count is not _UNSET:
|
|
4860
|
+
state["same_fingerprint_auto_turn_count"] = max(0, int(same_fingerprint_auto_turn_count or 0))
|
|
2813
4861
|
if pending_user_message_count is not _UNSET:
|
|
2814
4862
|
state["pending_user_message_count"] = max(0, int(pending_user_message_count))
|
|
2815
4863
|
if last_delivered_batch_id is not _UNSET:
|
|
@@ -2836,8 +4884,29 @@ class QuestService:
|
|
|
2836
4884
|
quest_data.pop("active_run_id", None)
|
|
2837
4885
|
quest_data["updated_at"] = now
|
|
2838
4886
|
write_yaml(quest_root / "quest.yaml", quest_data)
|
|
4887
|
+
self.schedule_projection_refresh(quest_root, kinds=("details",))
|
|
2839
4888
|
return state
|
|
2840
4889
|
|
|
4890
|
+
@staticmethod
|
|
4891
|
+
def _normalize_continuation_policy(value: object, *, default: str = "auto") -> str:
|
|
4892
|
+
normalized = str(value or "").strip().lower() or default
|
|
4893
|
+
return normalized if normalized in CONTINUATION_POLICIES else default
|
|
4894
|
+
|
|
4895
|
+
def set_continuation_state(
|
|
4896
|
+
self,
|
|
4897
|
+
quest_root: Path,
|
|
4898
|
+
*,
|
|
4899
|
+
policy: str,
|
|
4900
|
+
anchor: str | None = None,
|
|
4901
|
+
reason: str | None = None,
|
|
4902
|
+
) -> dict[str, Any]:
|
|
4903
|
+
return self.update_runtime_state(
|
|
4904
|
+
quest_root=quest_root,
|
|
4905
|
+
continuation_policy=policy,
|
|
4906
|
+
continuation_anchor=anchor,
|
|
4907
|
+
continuation_reason=reason,
|
|
4908
|
+
)
|
|
4909
|
+
|
|
2841
4910
|
def _enqueue_user_message(self, quest_root: Path, record: dict[str, Any]) -> dict[str, Any]:
|
|
2842
4911
|
queue_payload = self._read_message_queue(quest_root)
|
|
2843
4912
|
source = str(record.get("source") or "local")
|
|
@@ -3055,11 +5124,14 @@ class QuestService:
|
|
|
3055
5124
|
artifact_id: str | None,
|
|
3056
5125
|
kind: str,
|
|
3057
5126
|
message: str,
|
|
5127
|
+
summary_preview: str | None = None,
|
|
5128
|
+
dedupe_key: str | None = None,
|
|
3058
5129
|
response_phase: str | None = None,
|
|
3059
5130
|
reply_mode: str | None = None,
|
|
3060
5131
|
surface_actions: list[dict[str, Any]] | None = None,
|
|
3061
5132
|
connector_hints: dict[str, Any] | None = None,
|
|
3062
5133
|
created_at: str | None = None,
|
|
5134
|
+
counts_as_visible: bool = True,
|
|
3063
5135
|
) -> dict[str, Any]:
|
|
3064
5136
|
timestamp = created_at or utc_now()
|
|
3065
5137
|
payload = {
|
|
@@ -3070,6 +5142,8 @@ class QuestService:
|
|
|
3070
5142
|
"artifact_id": artifact_id,
|
|
3071
5143
|
"kind": kind,
|
|
3072
5144
|
"message": message,
|
|
5145
|
+
"summary_preview": str(summary_preview or "").strip() or None,
|
|
5146
|
+
"dedupe_key": str(dedupe_key or "").strip() or None,
|
|
3073
5147
|
"response_phase": response_phase,
|
|
3074
5148
|
"reply_mode": reply_mode,
|
|
3075
5149
|
"surface_actions": [dict(item) for item in (surface_actions or []) if isinstance(item, dict)],
|
|
@@ -3077,15 +5151,17 @@ class QuestService:
|
|
|
3077
5151
|
"created_at": timestamp,
|
|
3078
5152
|
}
|
|
3079
5153
|
append_jsonl(self._interaction_journal_path(quest_root), payload)
|
|
3080
|
-
|
|
3081
|
-
quest_root
|
|
3082
|
-
active_interaction_id
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
5154
|
+
runtime_updates: dict[str, Any] = {
|
|
5155
|
+
"quest_root": quest_root,
|
|
5156
|
+
"active_interaction_id": interaction_id or artifact_id,
|
|
5157
|
+
"last_tool_activity_at": timestamp,
|
|
5158
|
+
"last_tool_activity_name": "artifact.interact",
|
|
5159
|
+
"tool_calls_since_last_artifact_interact": 0,
|
|
5160
|
+
"pending_user_message_count": len((self._read_message_queue(quest_root).get("pending") or [])),
|
|
5161
|
+
}
|
|
5162
|
+
if counts_as_visible:
|
|
5163
|
+
runtime_updates["last_artifact_interact_at"] = timestamp
|
|
5164
|
+
self.update_runtime_state(**runtime_updates)
|
|
3089
5165
|
return payload
|
|
3090
5166
|
|
|
3091
5167
|
def record_tool_activity(
|
|
@@ -3133,13 +5209,25 @@ class QuestService:
|
|
|
3133
5209
|
runtime_state = self._read_runtime_state(quest_root)
|
|
3134
5210
|
last_artifact_interact_at = str(runtime_state.get("last_artifact_interact_at") or "").strip() or None
|
|
3135
5211
|
last_tool_activity_at = str(runtime_state.get("last_tool_activity_at") or "").strip() or None
|
|
5212
|
+
tool_count = int(runtime_state.get("tool_calls_since_last_artifact_interact") or 0)
|
|
5213
|
+
silence_seconds = self._seconds_since_iso_timestamp(last_artifact_interact_at)
|
|
5214
|
+
inspection_due = bool(
|
|
5215
|
+
tool_count >= 25
|
|
5216
|
+
or (
|
|
5217
|
+
tool_count > 0
|
|
5218
|
+
and silence_seconds is not None
|
|
5219
|
+
and silence_seconds >= 30 * 60
|
|
5220
|
+
)
|
|
5221
|
+
)
|
|
3136
5222
|
return {
|
|
3137
5223
|
"last_artifact_interact_at": last_artifact_interact_at,
|
|
3138
|
-
"seconds_since_last_artifact_interact":
|
|
3139
|
-
"tool_calls_since_last_artifact_interact":
|
|
5224
|
+
"seconds_since_last_artifact_interact": silence_seconds,
|
|
5225
|
+
"tool_calls_since_last_artifact_interact": tool_count,
|
|
3140
5226
|
"last_tool_activity_at": last_tool_activity_at,
|
|
3141
5227
|
"seconds_since_last_tool_activity": self._seconds_since_iso_timestamp(last_tool_activity_at),
|
|
3142
5228
|
"last_tool_activity_name": str(runtime_state.get("last_tool_activity_name") or "").strip() or None,
|
|
5229
|
+
"inspection_due": inspection_due,
|
|
5230
|
+
"user_update_due": False,
|
|
3143
5231
|
}
|
|
3144
5232
|
|
|
3145
5233
|
def latest_artifact_interaction_records(self, quest_root: Path, limit: int = 10) -> list[dict[str, Any]]:
|
|
@@ -3935,9 +6023,11 @@ def _tool_output(event: dict, item: dict) -> str:
|
|
|
3935
6023
|
item.get("result"),
|
|
3936
6024
|
item.get("output"),
|
|
3937
6025
|
item.get("content"),
|
|
6026
|
+
item.get("error"),
|
|
3938
6027
|
event.get("result"),
|
|
3939
6028
|
event.get("output"),
|
|
3940
6029
|
event.get("content"),
|
|
6030
|
+
event.get("error"),
|
|
3941
6031
|
item.get("aggregated_output"),
|
|
3942
6032
|
event.get("aggregated_output"),
|
|
3943
6033
|
):
|
|
@@ -3951,11 +6041,13 @@ def _tool_output(event: dict, item: dict) -> str:
|
|
|
3951
6041
|
item.get("output"),
|
|
3952
6042
|
item.get("result"),
|
|
3953
6043
|
item.get("content"),
|
|
6044
|
+
item.get("error"),
|
|
3954
6045
|
event.get("aggregated_output"),
|
|
3955
6046
|
event.get("changes"),
|
|
3956
6047
|
event.get("output"),
|
|
3957
6048
|
event.get("result"),
|
|
3958
6049
|
event.get("content"),
|
|
6050
|
+
event.get("error"),
|
|
3959
6051
|
):
|
|
3960
6052
|
text = _compact_text(value, limit=1200)
|
|
3961
6053
|
if text:
|