Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/x-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/tin_lite/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3984,7 +3984,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,
Expand Down
18 changes: 11 additions & 7 deletions src/tin_lite/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3033,6 +3033,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(
Expand All @@ -3043,13 +3049,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),
Expand Down Expand Up @@ -3099,7 +3103,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),
**(
Expand Down
2 changes: 1 addition & 1 deletion src/tin_lite/procedure_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]))
Expand Down
14 changes: 11 additions & 3 deletions src/tin_lite/x_draft.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {}
Expand All @@ -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,
Expand All @@ -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}
51 changes: 50 additions & 1 deletion tests/test_x_draft.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}))
Expand Down Expand Up @@ -307,6 +307,55 @@ async def test_discarding_the_voice_guide_stops_the_draft_instead_of_failing_it(
)


@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]


@pytest.mark.parametrize(
("error", "kind"),
[
Expand Down
9 changes: 8 additions & 1 deletion tests/test_x_draft_temporal.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ async def test_review_sequence_and_replay(mode):
if not binary:
pytest.skip("local Temporal CLI required")
parent, style, compose = (str(uuid4()) for _ in range(3))
failure = {
"run_id": parent,
"reason": "Capture failed",
"kind": "explained",
"detail": "ApplicationError",
}
started = []
calls = []
waiting = asyncio.Event()
Expand Down Expand Up @@ -100,7 +106,7 @@ async def call(argument):
with pytest.raises(WorkflowFailureError):
await handle.result()
assert started == [style]
assert calls[-1] == ("x_draft_failure", parent)
assert calls[-1] == ("x_draft_failure", failure)
else:
await handle.result()
assert started == ([compose] if mode == "reuse" else [style, compose])
Expand All @@ -116,4 +122,5 @@ async def call(argument):
parent,
{"run_id": parent, "step": "style"},
{"run_id": parent, "step": "compose"},
failure,
]
Loading