From 04e3f60dd23ad0653e37ba099ab4c6c64580307d Mon Sep 17 00:00:00 2001 From: Hamit Arslan Date: Mon, 12 Oct 2026 12:12:51 +0300 Subject: [PATCH] fix: surface pending child reviews on X draft runs --- docs/x-workflow.md | 5 ++- src/tin_lite/api.py | 4 ++- src/tin_lite/mcp_server.py | 18 ++++++----- src/tin_lite/procedure_control.py | 2 +- src/tin_lite/x_draft.py | 14 +++++++-- tests/test_x_draft.py | 51 ++++++++++++++++++++++++++++++- 6 files changed, 80 insertions(+), 14 deletions(-) diff --git a/docs/x-workflow.md b/docs/x-workflow.md index 4b6b6269..088d6c0c 100644 --- a/docs/x-workflow.md +++ b/docs/x-workflow.md @@ -136,7 +136,10 @@ run receipt under `reports/x/`, with the public post link. Use normal workflow discovery and `start_workflow` with `social.x_draft` for the full drafting request, or select a step directly when that is what you need. `get_run` returns the voice choice and child run IDs/statuses from Postgres; a waiting style child uses -the existing review action. +the existing review action. While that child waits, the parent’s MCP status label says +“waiting for you” and `result_links` points to the child’s guide. The HTTP/MCP +`x_draft.review_links` field holds the same links. Approve the child, not the parent; +a stopped parent advertises no review link. Then use `read_x_drafts`, `save_x_draft`, `preview_x_post` and `publish_x_post` to edit and deliver the selected post. Publishing consumes the preview token and a stable request ID; retry the same request after a lost acknowledgement. Show the exact preview and obtain diff --git a/src/tin_lite/api.py b/src/tin_lite/api.py index 4e124aae..71dd0ead 100644 --- a/src/tin_lite/api.py +++ b/src/tin_lite/api.py @@ -3902,7 +3902,9 @@ async def get_run( delivery = await delivery_service(runtime).status(run) return RunView.model_validate(run).model_copy( update={ - "x_draft": await x_draft_facts(runtime.database, run) + "x_draft": await x_draft_facts( + runtime.database, run, settings=request.app.state.settings + ) if run.executor == "social.x_draft" else None, "content_delivery": delivery, diff --git a/src/tin_lite/mcp_server.py b/src/tin_lite/mcp_server.py index 99bef1c1..3e79ca51 100644 --- a/src/tin_lite/mcp_server.py +++ b/src/tin_lite/mcp_server.py @@ -3032,6 +3032,12 @@ async def get_run(run_id: str) -> dict[str, Any]: run = await require_run(_mcp_uuid(run_id, field="run_id"), token, tool_name="get_run") from tin_lite.x_draft import facts as x_draft_facts + draft = ( + await x_draft_facts(runtime().database, run, settings=settings) + if run.executor == "social.x_draft" + else None + ) + review_links = draft["review_links"] if draft else [] review_summary = await _review_summary(runtime().database, run) delivery = await delivery_service(runtime()).status(run) selected_sources = await selected_run_sources( @@ -3042,13 +3048,11 @@ async def get_run(run_id: str) -> dict[str, Any]: "project_id": str(run.project_id), "workflow_id": str(run.workflow_id), "workflow": run.workflow_name, - **( - {"x_draft": await x_draft_facts(runtime().database, run)} - if run.executor == "social.x_draft" - else {} - ), + **({"x_draft": draft} if draft else {}), "status": run.status.value, - "status_label": STATUS_LABELS.get(run.status.value, run.status.value), + "status_label": STATUS_LABELS["needs_input"] + if review_links + else STATUS_LABELS.get(run.status.value, run.status.value), "artifact_path": run.artifact_path, "artifact_revision": run.canonical_commit_sha, "related_documents": await related_output_documents(runtime().database, run), @@ -3096,7 +3100,7 @@ async def get_run(run_id: str) -> dict[str, Any]: }, "service": _service_view(), "links": _project_links(settings, run.project_id), - "result_links": result_links(settings, run), + "result_links": result_links(settings, run) + review_links, "delivery_destination": delivery_destination(settings, run.project_id), **await _founder_words_for_run(run, review_summary), **( diff --git a/src/tin_lite/procedure_control.py b/src/tin_lite/procedure_control.py index c360980e..b3c15cc5 100644 --- a/src/tin_lite/procedure_control.py +++ b/src/tin_lite/procedure_control.py @@ -106,7 +106,7 @@ async def stop_x_draft(*, runtime, run, actor): run_id=run.id, project_id=run.project_id, actor=actor, workflow_key=x_draft.KEY ) waiting = None - for row in (await x_draft.facts(db, stopped))["steps"]: + for row in (await x_draft.facts(db, stopped, settings=None))["steps"]: if not row["run_id"]: continue child = await db.get_run(UUID(row["run_id"])) diff --git a/src/tin_lite/x_draft.py b/src/tin_lite/x_draft.py index 2c07936b..d222c5e8 100644 --- a/src/tin_lite/x_draft.py +++ b/src/tin_lite/x_draft.py @@ -3,6 +3,7 @@ from uuid import UUID from tin_lite import x_style +from tin_lite.onboarding_experience import result_links KEY = "social.x_draft" WORKFLOW_ID = UUID("13a31bac-6a89-4789-9d66-c0ab03a5da0d") @@ -84,11 +85,11 @@ def matching_guide(guide, account): return bool(owner and (owner == account if account else True)) -async def facts(database, run): +async def facts(database, run, *, settings): """The same small Postgres projection for HTTP and MCP; never reads Temporal.""" prepared = await database.get_effect(f"x-draft:{run.id}:prepare") source = prepared.result if prepared and prepared.status == "completed" else {} - steps = [] + steps, review_links = [], [] for step, workflow_key in STEPS.items(): receipt = await database.get_effect(f"x-draft:{run.id}:{step}") value = receipt.result if receipt and receipt.status == "completed" else {} @@ -98,6 +99,13 @@ async def facts(database, run): child.project_id != run.project_id or not template or child.workflow_id != template.id ): raise ValueError("X drafting child does not belong to this recipe") + if ( + run.status.value in {"pending", "running"} + and child + and child.status.value == "needs_input" + and child.review_required + ): + review_links.extend(result_links(settings, child)) steps.append( { "step": step, @@ -107,4 +115,4 @@ async def facts(database, run): "artifact_path": child.artifact_path if child else None, } ) - return {"voice": source.get("voice"), "steps": steps} + return {"voice": source.get("voice"), "steps": steps, "review_links": review_links} diff --git a/tests/test_x_draft.py b/tests/test_x_draft.py index 1680903a..135d0c4a 100644 --- a/tests/test_x_draft.py +++ b/tests/test_x_draft.py @@ -197,7 +197,7 @@ async def test_parent_projects_existing_draft_and_http_mcp_agree(publication_db, ) == 1 ) - facts = await x_draft.facts(f.db, done) + facts = await x_draft.facts(f.db, done, settings=f.settings) assert facts["steps"][0]["status"] == "skipped" assert facts["steps"][1]["status"] == "succeeded" mcp_view = structured(await server.call_tool("get_run", {"run_id": str(run.id)})) @@ -305,3 +305,52 @@ async def test_discarding_the_voice_guide_stops_the_draft_instead_of_failing_it( "SELECT count(*) FROM activity_events WHERE run_id=$1 AND event_type='x_draft_stopped'", run.id, ) + + +@pytest.mark.parametrize("parent_status", ["running", "stopped"]) +async def test_parent_advertises_child_review_only_while_active( + publication_db, monkeypatch, parent_status +): + f = await fixture(publication_db) + parent = await start( + f, supplied_samples="A useful update with clear technical details and a short ending." + ) + await f.activities.prepare(str(parent.id)) + style = await step(f, parent, "style") + child_id = UUID(style["run_id"]) + path = "style/proposals/x-writing-style.md" + sha = f.storage.repo.edit({path: b"# X writing style\n\nWrite clear short updates.\n"}) + await f.db.pool.execute( + "UPDATE workflow_runs SET status='needs_input', artifact_path=$2, " + "canonical_commit_sha=$3 WHERE id=$1", + child_id, + path, + sha, + ) + await f.db.pool.execute( + "UPDATE workflow_runs SET status=$2 WHERE id=$1", parent.id, parent_status + ) + view = structured(await mcp(f, monkeypatch).call_tool("get_run", {"run_id": str(parent.id)})) + assert view["status"] == parent_status + assert view["status_label"] == ("waiting for you" if parent_status == "running" else "stopped") + links = view["x_draft"]["review_links"] + if parent_status == "running": + assert len(links) == 1 + assert links[0]["run_id"] == str(child_id) and links[0]["kind"] == "review" + assert str(child_id) in links[0]["url"] + assert links[0] in view["result_links"] + assert not view["review_required"] and "approve" not in view["allowed_actions"] + else: + assert links == [] and view["result_links"] == [] + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app(f)), base_url="https://tin.test" + ) as client: + response = await client.get(f"/api/workflows/runs/{parent.id}") + assert response.status_code == 200 + assert response.json()["x_draft"] == view["x_draft"] + + from tin_lite.mcp_public import public_result + + public = public_result("get_run", view, {}) + assert public["status_label"] == view["status_label"] + assert [item["url"] for item in public["documents"]] == [item["url"] for item in links]