Spaces:
Running
Running
Step 4A-6-C-R3.2.4 Live Progress UX & Terminal Notification Fix
#14
by GuXiaopeng - opened
- .gitattributes +0 -1
- app.py +97 -29
- core/generation_runner.py +27 -8
- smartpaper_restore_bridge/frontend/index.html +91 -0
- task_executor.py +9 -6
.gitattributes
CHANGED
|
@@ -33,4 +33,3 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
-
core/__pycache__/pipeline_agent.cpython-313.pyc filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
app.py
CHANGED
|
@@ -30,6 +30,7 @@ from i18n import (
|
|
| 30 |
LANGUAGE_OPTIONS, get_amj_csv_template, get_asq_txt_template,
|
| 31 |
get_output_suffix, get_target_flag, get_target_language, tr,
|
| 32 |
)
|
|
|
|
| 33 |
|
| 34 |
st.set_page_config(
|
| 35 |
page_title="Article Agent",
|
|
@@ -267,6 +268,7 @@ for key, default in {
|
|
| 267 |
"sp_terminal_notice": None,
|
| 268 |
"sp_task_recovery_initialized": False,
|
| 269 |
"sp_last_ui_task_status": "",
|
|
|
|
| 270 |
}.items():
|
| 271 |
if key not in st.session_state:
|
| 272 |
st.session_state[key] = default
|
|
@@ -494,7 +496,7 @@ def _process_pending_resume() -> None:
|
|
| 494 |
st.rerun()
|
| 495 |
_launch_executor(task_id, force=True)
|
| 496 |
st.session_state["sp_pending_resume_task_id"] = ""
|
| 497 |
-
st.session_state["
|
| 498 |
st.rerun()
|
| 499 |
|
| 500 |
|
|
@@ -541,7 +543,7 @@ def _process_pending_launch(lang_code: str) -> None:
|
|
| 541 |
st.rerun()
|
| 542 |
st.session_state["sp_pending_launch"] = None
|
| 543 |
st.session_state["sp_payment_required"] = False
|
| 544 |
-
st.session_state["
|
| 545 |
st.rerun()
|
| 546 |
|
| 547 |
|
|
@@ -564,26 +566,80 @@ os.makedirs(_user_workspace, exist_ok=True)
|
|
| 564 |
|
| 565 |
|
| 566 |
ACTIVE_TASK_STATUSES = {"created", "running", "resumable", "cancelling"}
|
|
|
|
| 567 |
|
| 568 |
def _render_monitor_box(task: dict, lang_code: str) -> None:
|
|
|
|
| 569 |
status = str(task.get("status") or "")
|
| 570 |
node = str(task.get("current_node") or status or "")
|
| 571 |
progress = max(0, min(100, int(task.get("progress_pct") or 0)))
|
| 572 |
-
|
| 573 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
st.markdown(f"""
|
| 575 |
-
|
| 576 |
-
|
|
|
|
|
|
|
|
|
|
| 577 |
<div><b>{tr(lang_code, 'monitor_compute')}:</b></div>
|
| 578 |
-
<div style='display:
|
| 579 |
-
<span style='background: #fef3c7;
|
| 580 |
-
<span style='background: #dbeafe;
|
| 581 |
-
<span style='background: #d1fae5;
|
| 582 |
</div>
|
| 583 |
</div>
|
| 584 |
""", unsafe_allow_html=True)
|
| 585 |
|
| 586 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
def _render_task_status_once(lang_code: str) -> None:
|
| 588 |
task = st.session_state.get("sp_generation_task") or {}
|
| 589 |
task_id = str(task.get("task_id") or "").strip()
|
|
@@ -591,7 +647,7 @@ def _render_task_status_once(lang_code: str) -> None:
|
|
| 591 |
return
|
| 592 |
previous_status = str(st.session_state.get("sp_last_ui_task_status") or str(task.get("status") or ""))
|
| 593 |
status = str(task.get("status") or "")
|
| 594 |
-
if status in
|
| 595 |
try:
|
| 596 |
result = generation_task_status(task_id)
|
| 597 |
task = dict(result.get("task") or task)
|
|
@@ -601,7 +657,9 @@ def _render_task_status_once(lang_code: str) -> None:
|
|
| 601 |
logging.warning("[SP_UI] status_poll_error task_id=%s error=%s", task_id, type(exc).__name__)
|
| 602 |
st.session_state["sp_last_ui_task_status"] = status
|
| 603 |
_render_monitor_box(task, lang_code)
|
| 604 |
-
if status
|
|
|
|
|
|
|
| 605 |
output_path = _task_output_path(task)
|
| 606 |
if output_path and output_path.is_file():
|
| 607 |
st.success(tr(lang_code, "success"))
|
|
@@ -615,27 +673,35 @@ def _render_task_status_once(lang_code: str) -> None:
|
|
| 615 |
if detail:
|
| 616 |
st.warning(detail)
|
| 617 |
|
| 618 |
-
# Only
|
| 619 |
-
#
|
| 620 |
if previous_status and status != previous_status and status in {"resumable", "completed", "failed", "cancelled"}:
|
| 621 |
st.rerun()
|
| 622 |
|
| 623 |
|
| 624 |
-
def
|
| 625 |
-
"""One-time
|
| 626 |
-
if not bool(st.session_state.get("
|
| 627 |
return
|
| 628 |
-
st.session_state["
|
|
|
|
|
|
|
| 629 |
components.html(
|
| 630 |
-
"""
|
| 631 |
<script>
|
| 632 |
-
(() => {
|
| 633 |
const parentDocument = window.parent.document;
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 639 |
</script>
|
| 640 |
""",
|
| 641 |
height=0,
|
|
@@ -702,6 +768,7 @@ def render_workspace_controls(user):
|
|
| 702 |
template = get_amj_csv_template(lang_code)
|
| 703 |
st.code(template, language="csv")
|
| 704 |
st.download_button(label=tr(lang_code, "download_amj_template"), data=template, file_name="AMJ_Data_Template.csv", mime="text/csv", use_container_width=True)
|
|
|
|
| 705 |
if st.button(tr(lang_code, "start_amj"), disabled=active, key="btn_amj"):
|
| 706 |
st.session_state["run_mode"] = "amj"
|
| 707 |
st.session_state["sp_pending_launch"] = {"mode": "amj", "idempotency_key": _new_generation_idempotency_key()}
|
|
@@ -717,6 +784,7 @@ def render_workspace_controls(user):
|
|
| 717 |
template = get_asq_txt_template(lang_code)
|
| 718 |
st.code(template, language="text")
|
| 719 |
st.download_button(label=tr(lang_code, "download_asq_template"), data=template, file_name="ASQ_Interview_Template.txt", mime="text/plain", use_container_width=True)
|
|
|
|
| 720 |
if st.button(tr(lang_code, "start_asq"), disabled=active, key="btn_asq"):
|
| 721 |
st.session_state["run_mode"] = "asq"
|
| 722 |
st.session_state["sp_pending_launch"] = {"mode": "asq", "idempotency_key": _new_generation_idempotency_key()}
|
|
@@ -739,13 +807,13 @@ _process_pending_launch(lang_code)
|
|
| 739 |
if st.session_state.get("sp_payment_required"):
|
| 740 |
render_payment_purchase(lang_code)
|
| 741 |
|
| 742 |
-
# R3.2
|
| 743 |
-
#
|
| 744 |
-
#
|
| 745 |
_task_for_monitor = st.session_state.get("sp_generation_task") or {}
|
| 746 |
if str(_task_for_monitor.get("task_id") or "").strip():
|
| 747 |
st.markdown("<div id='sp-task-monitor-anchor'></div>", unsafe_allow_html=True)
|
| 748 |
_render_task_status_once(lang_code)
|
| 749 |
-
|
| 750 |
|
| 751 |
|
|
|
|
| 30 |
LANGUAGE_OPTIONS, get_amj_csv_template, get_asq_txt_template,
|
| 31 |
get_output_suffix, get_target_flag, get_target_language, tr,
|
| 32 |
)
|
| 33 |
+
from smartpaper_restore_bridge import edge_bridge
|
| 34 |
|
| 35 |
st.set_page_config(
|
| 36 |
page_title="Article Agent",
|
|
|
|
| 268 |
"sp_terminal_notice": None,
|
| 269 |
"sp_task_recovery_initialized": False,
|
| 270 |
"sp_last_ui_task_status": "",
|
| 271 |
+
"sp_scroll_to_generation_action": False,
|
| 272 |
}.items():
|
| 273 |
if key not in st.session_state:
|
| 274 |
st.session_state[key] = default
|
|
|
|
| 496 |
st.rerun()
|
| 497 |
_launch_executor(task_id, force=True)
|
| 498 |
st.session_state["sp_pending_resume_task_id"] = ""
|
| 499 |
+
st.session_state["sp_scroll_to_generation_action"] = True
|
| 500 |
st.rerun()
|
| 501 |
|
| 502 |
|
|
|
|
| 543 |
st.rerun()
|
| 544 |
st.session_state["sp_pending_launch"] = None
|
| 545 |
st.session_state["sp_payment_required"] = False
|
| 546 |
+
st.session_state["sp_scroll_to_generation_action"] = True
|
| 547 |
st.rerun()
|
| 548 |
|
| 549 |
|
|
|
|
| 566 |
|
| 567 |
|
| 568 |
ACTIVE_TASK_STATUSES = {"created", "running", "resumable", "cancelling"}
|
| 569 |
+
LIVE_TASK_STATUSES = {"created", "running", "cancelling"}
|
| 570 |
|
| 571 |
def _render_monitor_box(task: dict, lang_code: str) -> None:
|
| 572 |
+
"""Render the approved monitor location with DOM targets for live updates."""
|
| 573 |
status = str(task.get("status") or "")
|
| 574 |
node = str(task.get("current_node") or status or "")
|
| 575 |
progress = max(0, min(100, int(task.get("progress_pct") or 0)))
|
| 576 |
+
checkpoint_seq = int(task.get("checkpoint_seq") or 0)
|
| 577 |
+
checkpoint_stage = str(task.get("checkpoint_stage") or "")
|
| 578 |
+
ds_calls = int(task.get("ds_calls") or 0)
|
| 579 |
+
s2_calls = int(task.get("s2_calls") or 0)
|
| 580 |
+
oa_calls = int(task.get("oa_calls") or 0)
|
| 581 |
+
|
| 582 |
+
progress_markup = ""
|
| 583 |
+
if status in ACTIVE_TASK_STATUSES:
|
| 584 |
+
progress_markup = f"""
|
| 585 |
+
<div id='sp-live-progress-wrap' style='margin:0 0 12px 0;'>
|
| 586 |
+
<div id='sp-live-progress-text' style='margin-bottom:7px;'>{progress}% — {node}</div>
|
| 587 |
+
<div style='width:100%;height:10px;background:rgba(128,128,128,.24);border-radius:999px;overflow:hidden;'>
|
| 588 |
+
<div id='sp-live-progress-fill' style='width:{progress}%;height:100%;background:currentColor;border-radius:999px;transition:width .35s ease;'></div>
|
| 589 |
+
</div>
|
| 590 |
+
</div>
|
| 591 |
+
"""
|
| 592 |
+
|
| 593 |
st.markdown(f"""
|
| 594 |
+
{progress_markup}
|
| 595 |
+
<div class='log-box' id='sp-live-monitor'>
|
| 596 |
+
<div><b>{tr(lang_code, 'monitor_pipeline')}:</b> <span id='sp-live-status'>{status}</span></div>
|
| 597 |
+
<div><b>{tr(lang_code, 'monitor_node')}:</b> <span id='sp-live-node'>{node}</span></div>
|
| 598 |
+
<div><b>{tr(lang_code, 'monitor_log')}:</b> checkpoint <span id='sp-live-checkpoint-seq'>{checkpoint_seq}</span> · <span id='sp-live-checkpoint-stage'>{checkpoint_stage}</span></div><hr>
|
| 599 |
<div><b>{tr(lang_code, 'monitor_compute')}:</b></div>
|
| 600 |
+
<div style='display:flex;gap:8px;'>
|
| 601 |
+
<span style='background: #fef3c7;padding:4px 8px;border-radius:4px;color:black;'>🧠 DeepSeek: <b id='sp-live-ds'>{ds_calls}</b></span>
|
| 602 |
+
<span style='background: #dbeafe;padding:4px 8px;border-radius:4px;color:black;'>📚 S2: <b id='sp-live-s2'>{s2_calls}</b></span>
|
| 603 |
+
<span style='background: #d1fae5;padding:4px 8px;border-radius:4px;color:black;'>🌐 OpenAlex: <b id='sp-live-oa'>{oa_calls}</b></span>
|
| 604 |
</div>
|
| 605 |
</div>
|
| 606 |
""", unsafe_allow_html=True)
|
| 607 |
|
| 608 |
|
| 609 |
+
def _watch_task_status_live(task: dict) -> None:
|
| 610 |
+
"""Start browser-only read polling for active task UI state.
|
| 611 |
+
|
| 612 |
+
The component updates monitor DOM locally. It sends a Streamlit value only
|
| 613 |
+
when a terminal task state is observed, causing exactly one boundary rerun.
|
| 614 |
+
It never sends heartbeat, claim, finish, or cancellation commands.
|
| 615 |
+
"""
|
| 616 |
+
task_id = str(task.get("task_id") or "").strip()
|
| 617 |
+
status = str(task.get("status") or "")
|
| 618 |
+
if not task_id or status not in LIVE_TASK_STATUSES:
|
| 619 |
+
return
|
| 620 |
+
api_origin = str(st.session_state.get("sp_auth_api_origin") or "").strip()
|
| 621 |
+
if not api_origin:
|
| 622 |
+
logging.warning("[SP_UI] live_watch_missing_api_origin task_id=%s", task_id)
|
| 623 |
+
return
|
| 624 |
+
try:
|
| 625 |
+
watch_result = edge_bridge(
|
| 626 |
+
action="task_watch",
|
| 627 |
+
command_id=f"watch:{task_id}",
|
| 628 |
+
api_origin=api_origin,
|
| 629 |
+
payload={"task_id": task_id, "poll_interval_ms": 2500},
|
| 630 |
+
key=f"sp_task_watch_{_safe_key(task_id)}",
|
| 631 |
+
)
|
| 632 |
+
boundary_task = watch_result.get("task") if isinstance(watch_result, dict) and watch_result.get("boundary") else None
|
| 633 |
+
if isinstance(boundary_task, dict) and str(boundary_task.get("task_id") or "") == task_id:
|
| 634 |
+
# Consume the watcher's authoritative boundary snapshot directly so
|
| 635 |
+
# the ordinary 5-second task-status cache cannot delay terminal UI.
|
| 636 |
+
st.session_state["sp_generation_task"] = dict(boundary_task)
|
| 637 |
+
st.session_state["sp_last_ui_task_status"] = str(boundary_task.get("status") or "")
|
| 638 |
+
st.rerun()
|
| 639 |
+
except Exception as exc:
|
| 640 |
+
logging.warning("[SP_UI] live_watch_error task_id=%s error=%s", task_id, type(exc).__name__)
|
| 641 |
+
|
| 642 |
+
|
| 643 |
def _render_task_status_once(lang_code: str) -> None:
|
| 644 |
task = st.session_state.get("sp_generation_task") or {}
|
| 645 |
task_id = str(task.get("task_id") or "").strip()
|
|
|
|
| 647 |
return
|
| 648 |
previous_status = str(st.session_state.get("sp_last_ui_task_status") or str(task.get("status") or ""))
|
| 649 |
status = str(task.get("status") or "")
|
| 650 |
+
if status in ACTIVE_TASK_STATUSES:
|
| 651 |
try:
|
| 652 |
result = generation_task_status(task_id)
|
| 653 |
task = dict(result.get("task") or task)
|
|
|
|
| 657 |
logging.warning("[SP_UI] status_poll_error task_id=%s error=%s", task_id, type(exc).__name__)
|
| 658 |
st.session_state["sp_last_ui_task_status"] = status
|
| 659 |
_render_monitor_box(task, lang_code)
|
| 660 |
+
if status in LIVE_TASK_STATUSES:
|
| 661 |
+
_watch_task_status_live(task)
|
| 662 |
+
elif status == "completed":
|
| 663 |
output_path = _task_output_path(task)
|
| 664 |
if output_path and output_path.is_file():
|
| 665 |
st.success(tr(lang_code, "success"))
|
|
|
|
| 673 |
if detail:
|
| 674 |
st.warning(detail)
|
| 675 |
|
| 676 |
+
# Only terminal/resume boundaries may rerun the whole Streamlit app. Live
|
| 677 |
+
# progress is DOM-only and therefore cannot reintroduce periodic flicker.
|
| 678 |
if previous_status and status != previous_status and status in {"resumable", "completed", "failed", "cancelled"}:
|
| 679 |
st.rerun()
|
| 680 |
|
| 681 |
|
| 682 |
+
def _scroll_to_generation_action_once() -> None:
|
| 683 |
+
"""One-time post-launch/resume scroll to the paper-generation action area."""
|
| 684 |
+
if not bool(st.session_state.get("sp_scroll_to_generation_action")):
|
| 685 |
return
|
| 686 |
+
st.session_state["sp_scroll_to_generation_action"] = False
|
| 687 |
+
mode = str(st.session_state.get("run_mode") or "amj").lower()
|
| 688 |
+
target_id = "sp-generation-action-asq" if mode == "asq" else "sp-generation-action-amj"
|
| 689 |
components.html(
|
| 690 |
+
f"""
|
| 691 |
<script>
|
| 692 |
+
(() => {{
|
| 693 |
const parentDocument = window.parent.document;
|
| 694 |
+
const targetId = {json.dumps(target_id)};
|
| 695 |
+
let done = false;
|
| 696 |
+
const locate = () => {{
|
| 697 |
+
if (done) return;
|
| 698 |
+
const target = parentDocument.getElementById(targetId);
|
| 699 |
+
if (!target) return;
|
| 700 |
+
done = true;
|
| 701 |
+
target.scrollIntoView({{behavior:'auto', block:'center'}});
|
| 702 |
+
}};
|
| 703 |
+
[60, 160, 320, 650, 1100].forEach((delay) => window.parent.setTimeout(locate, delay));
|
| 704 |
+
}})();
|
| 705 |
</script>
|
| 706 |
""",
|
| 707 |
height=0,
|
|
|
|
| 768 |
template = get_amj_csv_template(lang_code)
|
| 769 |
st.code(template, language="csv")
|
| 770 |
st.download_button(label=tr(lang_code, "download_amj_template"), data=template, file_name="AMJ_Data_Template.csv", mime="text/csv", use_container_width=True)
|
| 771 |
+
st.markdown("<div id='sp-generation-action-amj'></div>", unsafe_allow_html=True)
|
| 772 |
if st.button(tr(lang_code, "start_amj"), disabled=active, key="btn_amj"):
|
| 773 |
st.session_state["run_mode"] = "amj"
|
| 774 |
st.session_state["sp_pending_launch"] = {"mode": "amj", "idempotency_key": _new_generation_idempotency_key()}
|
|
|
|
| 784 |
template = get_asq_txt_template(lang_code)
|
| 785 |
st.code(template, language="text")
|
| 786 |
st.download_button(label=tr(lang_code, "download_asq_template"), data=template, file_name="ASQ_Interview_Template.txt", mime="text/plain", use_container_width=True)
|
| 787 |
+
st.markdown("<div id='sp-generation-action-asq'></div>", unsafe_allow_html=True)
|
| 788 |
if st.button(tr(lang_code, "start_asq"), disabled=active, key="btn_asq"):
|
| 789 |
st.session_state["run_mode"] = "asq"
|
| 790 |
st.session_state["sp_pending_launch"] = {"mode": "asq", "idempotency_key": _new_generation_idempotency_key()}
|
|
|
|
| 807 |
if st.session_state.get("sp_payment_required"):
|
| 808 |
render_payment_purchase(lang_code)
|
| 809 |
|
| 810 |
+
# R3.2.4 keeps the approved task-monitor location. Active progress is updated
|
| 811 |
+
# inside the browser by read-only edge polling; Streamlit reruns only on a
|
| 812 |
+
# user action or a terminal/resumable boundary, so there is no periodic flicker.
|
| 813 |
_task_for_monitor = st.session_state.get("sp_generation_task") or {}
|
| 814 |
if str(_task_for_monitor.get("task_id") or "").strip():
|
| 815 |
st.markdown("<div id='sp-task-monitor-anchor'></div>", unsafe_allow_html=True)
|
| 816 |
_render_task_status_once(lang_code)
|
| 817 |
+
_scroll_to_generation_action_once()
|
| 818 |
|
| 819 |
|
core/generation_runner.py
CHANGED
|
@@ -74,16 +74,30 @@ class GenerationRunner:
|
|
| 74 |
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 75 |
self.agent = None
|
| 76 |
|
| 77 |
-
def
|
|
|
|
| 78 |
agent = self.agent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
self.progress_callback(
|
| 80 |
progress_pct=max(0, min(100, int(pct))),
|
| 81 |
current_node=str(node or "")[:240],
|
| 82 |
checkpoint_seq=checkpoint_seq,
|
| 83 |
checkpoint_stage=checkpoint_stage,
|
| 84 |
-
ds_calls=
|
| 85 |
-
s2_calls=
|
| 86 |
-
oa_calls=
|
| 87 |
)
|
| 88 |
|
| 89 |
def _checkpoint(self, stage: str, state: dict[str, Any], pct: int) -> None:
|
|
@@ -527,10 +541,15 @@ Output only final academic prose. Do not report the word count."""
|
|
| 527 |
return {"output_filename": output_filename, "output_path": str(final)}
|
| 528 |
|
| 529 |
def _progress_activity(self) -> None:
|
| 530 |
-
#
|
| 531 |
-
#
|
| 532 |
-
|
| 533 |
try:
|
| 534 |
-
self.progress_callback(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
except TypeError:
|
| 536 |
pass
|
|
|
|
| 74 |
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 75 |
self.agent = None
|
| 76 |
|
| 77 |
+
def _compute_request_counts(self) -> tuple[int, int, int]:
|
| 78 |
+
"""Return one consistent UI metric: actual outbound request attempts."""
|
| 79 |
agent = self.agent
|
| 80 |
+
if agent is None:
|
| 81 |
+
return 0, 0, 0
|
| 82 |
+
metrics = getattr(agent, "source_metrics", {}) or {}
|
| 83 |
+
s2_metric = metrics.get("s2", {}) if isinstance(metrics, dict) else {}
|
| 84 |
+
oa_metric = metrics.get("oa", {}) if isinstance(metrics, dict) else {}
|
| 85 |
+
return (
|
| 86 |
+
int(getattr(agent, "ds_calls", 0) or 0),
|
| 87 |
+
int(s2_metric.get("request_count", 0) or 0),
|
| 88 |
+
int(oa_metric.get("request_count", 0) or 0),
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
def _progress(self, pct: int, node: str, *, checkpoint_seq=None, checkpoint_stage=None) -> None:
|
| 92 |
+
ds_calls, s2_calls, oa_calls = self._compute_request_counts()
|
| 93 |
self.progress_callback(
|
| 94 |
progress_pct=max(0, min(100, int(pct))),
|
| 95 |
current_node=str(node or "")[:240],
|
| 96 |
checkpoint_seq=checkpoint_seq,
|
| 97 |
checkpoint_stage=checkpoint_stage,
|
| 98 |
+
ds_calls=ds_calls,
|
| 99 |
+
s2_calls=s2_calls,
|
| 100 |
+
oa_calls=oa_calls,
|
| 101 |
)
|
| 102 |
|
| 103 |
def _checkpoint(self, stage: str, state: dict[str, Any], pct: int) -> None:
|
|
|
|
| 541 |
return {"output_filename": output_filename, "output_path": str(final)}
|
| 542 |
|
| 543 |
def _progress_activity(self) -> None:
|
| 544 |
+
# Activity remains Executor-owned. We also snapshot actual request
|
| 545 |
+
# counters so the next Executor heartbeat can persist live UI metrics.
|
| 546 |
+
ds_calls, s2_calls, oa_calls = self._compute_request_counts()
|
| 547 |
try:
|
| 548 |
+
self.progress_callback(
|
| 549 |
+
activity_only=True,
|
| 550 |
+
ds_calls=ds_calls,
|
| 551 |
+
s2_calls=s2_calls,
|
| 552 |
+
oa_calls=oa_calls,
|
| 553 |
+
)
|
| 554 |
except TypeError:
|
| 555 |
pass
|
smartpaper_restore_bridge/frontend/index.html
CHANGED
|
@@ -19,7 +19,9 @@
|
|
| 19 |
const MAX_TOKEN_LENGTH = 4096;
|
| 20 |
const MAX_RESULT_AGE_MS = 10 * 60 * 1000;
|
| 21 |
const REQUEST_TIMEOUT_MS = 20000;
|
|
|
|
| 22 |
let lastResponseSignature = "";
|
|
|
|
| 23 |
|
| 24 |
const ACTION_PATHS = Object.freeze({
|
| 25 |
task_start: "/edge/task/start",
|
|
@@ -172,6 +174,90 @@
|
|
| 172 |
writeJson(RESULT_PREFIX + commandId, { saved_at:Date.now(), value });
|
| 173 |
}
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
async function handleEdgeAction(action, commandId, args) {
|
| 176 |
const path = ACTION_PATHS[action];
|
| 177 |
if (!path) return { ok:false, error:"unsupported_edge_action", action, command_id:commandId };
|
|
@@ -196,6 +282,11 @@
|
|
| 196 |
const args = message.args || {};
|
| 197 |
const action = typeof args.action === "string" ? args.action : "read";
|
| 198 |
const commandId = typeof args.command_id === "string" ? args.command_id : "";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
let response;
|
| 200 |
try {
|
| 201 |
response = ["read","write","delete"].includes(action)
|
|
|
|
| 19 |
const MAX_TOKEN_LENGTH = 4096;
|
| 20 |
const MAX_RESULT_AGE_MS = 10 * 60 * 1000;
|
| 21 |
const REQUEST_TIMEOUT_MS = 20000;
|
| 22 |
+
const TERMINAL_TASK_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
| 23 |
let lastResponseSignature = "";
|
| 24 |
+
let activeTaskWatch = null;
|
| 25 |
|
| 26 |
const ACTION_PATHS = Object.freeze({
|
| 27 |
task_start: "/edge/task/start",
|
|
|
|
| 174 |
writeJson(RESULT_PREFIX + commandId, { saved_at:Date.now(), value });
|
| 175 |
}
|
| 176 |
|
| 177 |
+
function asCount(value) {
|
| 178 |
+
const number = Number(value || 0);
|
| 179 |
+
return Number.isFinite(number) ? Math.max(0, Math.trunc(number)) : 0;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
function updateText(parentDocument, id, value) {
|
| 183 |
+
const element = parentDocument.getElementById(id);
|
| 184 |
+
if (element) element.textContent = String(value ?? "");
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
function updateTaskMonitor(task) {
|
| 188 |
+
let parentDocument;
|
| 189 |
+
try { parentDocument = window.parent.document; }
|
| 190 |
+
catch (_) { return; }
|
| 191 |
+
const status = String(task && task.status || "");
|
| 192 |
+
const node = String(task && task.current_node || status || "");
|
| 193 |
+
const progress = Math.max(0, Math.min(100, asCount(task && task.progress_pct)));
|
| 194 |
+
updateText(parentDocument, "sp-live-status", status);
|
| 195 |
+
updateText(parentDocument, "sp-live-node", node);
|
| 196 |
+
updateText(parentDocument, "sp-live-checkpoint-seq", asCount(task && task.checkpoint_seq));
|
| 197 |
+
updateText(parentDocument, "sp-live-checkpoint-stage", String(task && task.checkpoint_stage || ""));
|
| 198 |
+
updateText(parentDocument, "sp-live-ds", asCount(task && task.ds_calls));
|
| 199 |
+
updateText(parentDocument, "sp-live-s2", asCount(task && task.s2_calls));
|
| 200 |
+
updateText(parentDocument, "sp-live-oa", asCount(task && task.oa_calls));
|
| 201 |
+
updateText(parentDocument, "sp-live-progress-text", `${progress}% — ${node}`);
|
| 202 |
+
const fill = parentDocument.getElementById("sp-live-progress-fill");
|
| 203 |
+
if (fill) fill.style.width = `${progress}%`;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
function stopTaskWatch() {
|
| 207 |
+
const watch = activeTaskWatch;
|
| 208 |
+
activeTaskWatch = null;
|
| 209 |
+
if (watch && watch.timer) window.clearTimeout(watch.timer);
|
| 210 |
+
if (watch && watch.controller) {
|
| 211 |
+
try { watch.controller.abort(); } catch (_) {}
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
function startTaskWatch(commandId, args) {
|
| 216 |
+
stopTaskWatch();
|
| 217 |
+
const apiOrigin = normalizeOrigin(args.api_origin || "");
|
| 218 |
+
const payload = args.payload && typeof args.payload === "object" ? args.payload : {};
|
| 219 |
+
const taskId = typeof payload.task_id === "string" ? payload.task_id.trim() : "";
|
| 220 |
+
const requestedInterval = Number(payload.poll_interval_ms || 2500);
|
| 221 |
+
const intervalMs = Math.max(1500, Math.min(10000, Number.isFinite(requestedInterval) ? requestedInterval : 2500));
|
| 222 |
+
if (!apiOrigin || !taskId) {
|
| 223 |
+
sendValue({ ok:false, action:"task_watch", command_id:commandId, error:"invalid_task_watch" });
|
| 224 |
+
return;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
const watch = { commandId, taskId, apiOrigin, intervalMs, timer:null, controller:null, stopped:false };
|
| 228 |
+
activeTaskWatch = watch;
|
| 229 |
+
|
| 230 |
+
const schedule = () => {
|
| 231 |
+
if (activeTaskWatch !== watch || watch.stopped) return;
|
| 232 |
+
watch.timer = window.setTimeout(poll, watch.intervalMs);
|
| 233 |
+
};
|
| 234 |
+
|
| 235 |
+
const poll = async () => {
|
| 236 |
+
if (activeTaskWatch !== watch || watch.stopped) return;
|
| 237 |
+
const result = await postJson(watch.apiOrigin, ACTION_PATHS.task_status, { task_id:watch.taskId });
|
| 238 |
+
if (activeTaskWatch !== watch || watch.stopped) return;
|
| 239 |
+
const task = result && result.task && typeof result.task === "object" ? result.task : null;
|
| 240 |
+
if (result.ok && task) {
|
| 241 |
+
updateTaskMonitor(task);
|
| 242 |
+
const status = String(task.status || "");
|
| 243 |
+
if (TERMINAL_TASK_STATUSES.has(status) || status === "resumable") {
|
| 244 |
+
watch.stopped = true;
|
| 245 |
+
activeTaskWatch = null;
|
| 246 |
+
sendValue(Object.assign({}, result, {
|
| 247 |
+
action:"task_watch",
|
| 248 |
+
command_id:commandId,
|
| 249 |
+
terminal:TERMINAL_TASK_STATUSES.has(status),
|
| 250 |
+
boundary:true
|
| 251 |
+
}));
|
| 252 |
+
return;
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
schedule();
|
| 256 |
+
};
|
| 257 |
+
|
| 258 |
+
poll();
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
async function handleEdgeAction(action, commandId, args) {
|
| 262 |
const path = ACTION_PATHS[action];
|
| 263 |
if (!path) return { ok:false, error:"unsupported_edge_action", action, command_id:commandId };
|
|
|
|
| 282 |
const args = message.args || {};
|
| 283 |
const action = typeof args.action === "string" ? args.action : "read";
|
| 284 |
const commandId = typeof args.command_id === "string" ? args.command_id : "";
|
| 285 |
+
if (action === "task_watch") {
|
| 286 |
+
setFrameHeight();
|
| 287 |
+
startTaskWatch(commandId, args);
|
| 288 |
+
return;
|
| 289 |
+
}
|
| 290 |
let response;
|
| 291 |
try {
|
| 292 |
response = ["read","write","delete"].includes(action)
|
task_executor.py
CHANGED
|
@@ -276,6 +276,15 @@ class ExecutionState:
|
|
| 276 |
def touch(self, *, progress_pct=None, current_node=None, checkpoint_seq=None, checkpoint_stage=None, ds_calls=None, s2_calls=None, oa_calls=None, activity_only=False):
|
| 277 |
with self._lock:
|
| 278 |
self._last_activity = time.monotonic()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
if not activity_only:
|
| 280 |
if progress_pct is not None:
|
| 281 |
self._progress_pct = max(self._progress_pct, max(0, min(100, int(progress_pct))))
|
|
@@ -284,12 +293,6 @@ class ExecutionState:
|
|
| 284 |
if checkpoint_seq is not None and int(checkpoint_seq) >= self._checkpoint_seq:
|
| 285 |
self._checkpoint_seq = int(checkpoint_seq)
|
| 286 |
self._checkpoint_stage = str(checkpoint_stage or self._checkpoint_stage)[:120]
|
| 287 |
-
if ds_calls is not None:
|
| 288 |
-
self._ds_calls = max(self._ds_calls, int(ds_calls))
|
| 289 |
-
if s2_calls is not None:
|
| 290 |
-
self._s2_calls = max(self._s2_calls, int(s2_calls))
|
| 291 |
-
if oa_calls is not None:
|
| 292 |
-
self._oa_calls = max(self._oa_calls, int(oa_calls))
|
| 293 |
|
| 294 |
def cancelled(self) -> bool:
|
| 295 |
with self._lock:
|
|
|
|
| 276 |
def touch(self, *, progress_pct=None, current_node=None, checkpoint_seq=None, checkpoint_stage=None, ds_calls=None, s2_calls=None, oa_calls=None, activity_only=False):
|
| 277 |
with self._lock:
|
| 278 |
self._last_activity = time.monotonic()
|
| 279 |
+
# Request counters are observational metrics, not task-liveness
|
| 280 |
+
# authority. They may advance on activity-only callbacks while
|
| 281 |
+
# progress/node/checkpoint ownership remains unchanged.
|
| 282 |
+
if ds_calls is not None:
|
| 283 |
+
self._ds_calls = max(self._ds_calls, int(ds_calls))
|
| 284 |
+
if s2_calls is not None:
|
| 285 |
+
self._s2_calls = max(self._s2_calls, int(s2_calls))
|
| 286 |
+
if oa_calls is not None:
|
| 287 |
+
self._oa_calls = max(self._oa_calls, int(oa_calls))
|
| 288 |
if not activity_only:
|
| 289 |
if progress_pct is not None:
|
| 290 |
self._progress_pct = max(self._progress_pct, max(0, min(100, int(progress_pct))))
|
|
|
|
| 293 |
if checkpoint_seq is not None and int(checkpoint_seq) >= self._checkpoint_seq:
|
| 294 |
self._checkpoint_seq = int(checkpoint_seq)
|
| 295 |
self._checkpoint_stage = str(checkpoint_stage or self._checkpoint_stage)[:120]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
|
| 297 |
def cancelled(self) -> bool:
|
| 298 |
with self._lock:
|