feat(pmflow-os): add execution progress writeback

This commit is contained in:
Albert 2026-04-26 10:51:14 +08:00
parent 2956906f19
commit a7008bb3cc
8 changed files with 620 additions and 9 deletions

View File

@ -15,6 +15,7 @@ from ...schemas.pmflow_os import (
PMFlowChangeSetCommitRequest,
PMFlowExecutionHandoffConsumeRequest,
PMFlowExecutionHandoffRequest,
PMFlowExecutionProgressEventRequest,
PMFlowExtractionCreateRequest,
PMFlowThreadMessageCreateRequest,
)
@ -285,6 +286,39 @@ async def create_pmflow_os_execution_handoff(
return success_response(payload.model_dump(mode="json"))
@router.post("/projects/{project_id}/execution-progress-events")
async def create_pmflow_os_execution_progress_event(
project_id: int,
body: PMFlowExecutionProgressEventRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.record_execution_progress_event(
project_id=int(project_id),
body=body,
current_user_id=int(current_user.id),
)
except ValueError as exc:
code = str(exc)
if code == "task_not_found":
return JSONResponse(status_code=404, content=error_response(404, "task_not_found"))
if code == "repo_binding_not_found":
return JSONResponse(status_code=422, content=error_response(422, "repo_binding_not_found"))
if code == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload.model_dump(mode="json"))
@router.post("/projects/{project_id}/execution-handoffs/{prompt_package_id}/consume")
async def consume_pmflow_os_execution_handoff(
project_id: int,

View File

@ -13,6 +13,20 @@ PMFlowRiskLevel = Literal["low", "medium", "high"]
PMFlowExecutionStatus = Literal["queued", "pushed", "coding", "pr_open", "testing", "synced", "failed"]
PMFlowReleaseStatus = Literal["check_pending", "ready", "shipping", "live", "watch"]
PMFlowExecutionRuntimeTarget = Literal["local_bridge", "codex", "github_agent", "manual"]
PMFlowExecutionProgressSource = Literal["local_bridge", "codex", "github_agent", "manual"]
PMFlowExecutionProgressEventType = Literal[
"branch_created",
"commit",
"pr_opened",
"pr_synced",
"test_passed",
"test_failed",
"checks_passed",
"blocked",
"merged",
"accepted",
"manual_progress",
]
ProjectLoopStage = Literal["command_idle", "ai_extracting", "extraction_ready", "review_open", "writeback_success"]
PMFlowChangeGroup = Literal["new_object", "field_update", "risk", "decision"]
PMFlowChangeStatus = Literal["pending_review", "accepted", "edited", "deferred", "committed", "skipped"]
@ -100,6 +114,21 @@ class PMFlowExecutionHandoffConsumeRequest(BaseModel):
note: Optional[str] = Field(default=None, max_length=4000)
class PMFlowExecutionProgressEventRequest(BaseModel):
task_id: str = Field(min_length=1)
event_type: PMFlowExecutionProgressEventType
source: PMFlowExecutionProgressSource = "manual"
repo_binding_id: Optional[str] = None
branch_name: Optional[str] = Field(default=None, max_length=255)
commit_sha: Optional[str] = Field(default=None, max_length=255)
pr_number: Optional[int] = Field(default=None, ge=1)
test_summary: Optional[str] = Field(default=None, max_length=4000)
workflow_state: Optional[str] = Field(default=None, max_length=120)
summary: Optional[str] = Field(default=None, max_length=4000)
event_source_id: Optional[str] = Field(default=None, max_length=255)
occurred_at: Optional[str] = None
class PMFlowChangeSetDetailResponse(BaseModel):
changeSet: dict[str, Any]
items: list[dict[str, Any]] = Field(default_factory=list)

View File

@ -39,6 +39,7 @@ from ..schemas.pmflow_os import (
PMFlowDecision,
PMFlowExecutionHandoffConsumeRequest,
PMFlowExecutionHandoffRequest,
PMFlowExecutionProgressEventRequest,
PMFlowExecutionContext,
PMFlowFeedEvent,
PMFlowMetricSignal,
@ -322,6 +323,33 @@ class PMFlowOSProjectionService:
await self.db.commit()
return await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
async def record_execution_progress_event(
self,
*,
project_id: int,
body: PMFlowExecutionProgressEventRequest,
current_user_id: Optional[int] = None,
) -> ExecutionSummaryProjection:
await self._load_project(project_id=project_id)
task = await self._load_project_task(project_id=project_id, task_id=body.task_id)
repo = await self._load_project_repo_binding(project_id=project_id, repo_binding_id=body.repo_binding_id)
payload = self._progress_event_payload(body=body, repo=repo)
summary = self._progress_event_summary(body=body, task=task, repo=repo)
control = AIProjectControlService(self.db, tenant_id=self.tenant_id)
await control.create_or_apply_progress_event(
task_id=int(task.id),
project_id=int(project_id),
event_type=body.event_type,
source=body.source,
payload=payload,
summary=summary,
user_id=int(current_user_id) if current_user_id is not None else None,
repo_binding_id=int(repo.id) if repo is not None else None,
event_source_id=body.event_source_id,
occurred_at=body.occurred_at,
)
return await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
async def _load_project(self, *, project_id: int) -> Project:
result = await self.db.execute(
select(Project).where(and_(Project.id == int(project_id), Project.tenant_id == self.tenant_id))
@ -1108,18 +1136,18 @@ class PMFlowOSProjectionService:
workflow_state = str((event or {}).get("workflow_state") or "").lower()
derived = str((event or {}).get("derived_task_status") or task_status or "").lower()
combined = " ".join([event_type, workflow_state, derived])
if "failed" in combined or "blocked" in combined:
return "failed"
if "merged" in combined or "accepted" in combined or "done" in combined or "completed" in combined or "synced" in combined:
return "synced"
if "test" in combined:
return "testing"
if "pull_request" in combined or "pr" in combined or "review" in combined:
return "pr_open"
if "commit" in combined or "coding" in combined or "in_progress" in combined:
return "coding"
if "pushed" in combined or "queued" in combined:
if "branch" in combined or "pushed" in combined or "queued" in combined:
return "pushed"
if "done" in combined or "completed" in combined or "synced" in combined:
return "synced"
if "failed" in combined or "blocked" in combined:
return "failed"
return "queued"
def _pick_execution_task_id(
@ -1173,7 +1201,9 @@ class PMFlowOSProjectionService:
def _project_test_summary(self, events: list[dict[str, Any]]) -> str:
for event in events:
if "test" in str(event.get("event_type") or "").lower():
event_type = str(event.get("event_type") or "").lower()
workflow_state = str(event.get("workflow_state") or "").lower()
if "test" in event_type or "check" in event_type or workflow_state == "testing":
return str(event.get("summary") or event.get("workflow_state") or "test event recorded")
return "pending"
@ -1370,6 +1400,62 @@ class PMFlowOSProjectionService:
def _handoff_consumed_summary(self, *, task: Task, runtime_target: str, consumer_label: str) -> str:
return f"执行交接已领取:{task.title} -> {runtime_target} / {consumer_label}"
def _progress_event_payload(
self,
*,
body: PMFlowExecutionProgressEventRequest,
repo: Optional[RepoBinding],
) -> dict[str, Any]:
payload: dict[str, Any] = {
"workflow_state": body.workflow_state or self._default_workflow_state(body.event_type),
"source": body.source,
"boundary": "PMFlow records execution result metadata only; command execution stays in Local Bridge, Codex, GitHub agent, or manual runtime.",
}
for key, value in {
"branch_name": body.branch_name,
"commit_sha": body.commit_sha,
"pr_number": body.pr_number,
"test_summary": body.test_summary,
"event_source_id": body.event_source_id,
}.items():
if value is not None and str(value).strip():
payload[key] = value
if repo is not None:
payload["repo_binding_id"] = str(repo.id)
payload["repository_full_name"] = f"{repo.owner}/{repo.repo_name}"
return payload
def _progress_event_summary(
self,
*,
body: PMFlowExecutionProgressEventRequest,
task: Task,
repo: Optional[RepoBinding],
) -> str:
if body.summary:
return body.summary
if body.test_summary:
return body.test_summary
repo_label = f"{repo.owner}/{repo.repo_name}" if repo is not None else "未绑定仓库"
detail = body.commit_sha or (f"PR #{body.pr_number}" if body.pr_number else body.branch_name) or self._default_workflow_state(body.event_type)
return f"执行结果回写:{task.title} / {body.event_type} / {repo_label} / {detail}"
def _default_workflow_state(self, event_type: str) -> str:
value = str(event_type or "").strip().lower()
if value == "branch_created":
return "pushed"
if value == "commit":
return "coding"
if value in {"pr_opened", "pr_synced"}:
return "in_review"
if value in {"test_passed", "test_failed", "checks_passed"}:
return "testing"
if value in {"merged", "accepted"}:
return "synced"
if value == "blocked":
return "failed"
return "manual_progress"
def _latest_iso(self, values: list[Optional[datetime]]) -> Optional[str]:
valid = [value for value in values if value is not None]
if not valid:

View File

@ -599,6 +599,179 @@ async def test_execution_handoff_consume_records_pickup_event(db_env) -> None:
assert event_types == ["handoff_created", "handoff_consumed"]
@pytest.mark.asyncio
async def test_execution_progress_writeback_records_branch_pr_commit_and_test(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
charter = TruthNode(
tenant_id=1,
node_type="charter",
project_id=project.id,
created_by=user.id,
title="执行回写宪章",
content={"business_goal": "把运行端结果回流到执行摘要"},
version=1,
status="aligned",
)
session.add(charter)
await session.flush()
task = Task(
tenant_id=1,
truth_node_id=charter.id,
project_id=project.id,
assignee_id=user.id,
ai_recommended_assignees=None,
ai_breakdown_payload={"acceptance_criteria": ["回流 branch/commit/PR/test"]},
manual_overrides=None,
title="执行结果写回",
description="Codex 或 Local Bridge 写回进展",
required_skill_tag_ids=[],
status="confirmed",
priority="high",
)
repo = RepoBinding(
tenant_id=1,
project_id=project.id,
provider="github",
owner="pmflow",
repo_name="writeback",
default_branch="main",
installation_id="install-4",
status="active",
)
session.add_all([task, repo])
await session.commit()
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
commit_response = await client.post(
f"/pmflow-os/projects/{project.id}/execution-progress-events",
json={
"task_id": str(task.id),
"repo_binding_id": str(repo.id),
"event_type": "commit",
"source": "codex",
"branch_name": "codex/main-flow-d3",
"commit_sha": "abc123def456",
"workflow_state": "coding",
},
)
pr_response = await client.post(
f"/pmflow-os/projects/{project.id}/execution-progress-events",
json={
"task_id": str(task.id),
"repo_binding_id": str(repo.id),
"event_type": "pr_opened",
"source": "github_agent",
"branch_name": "codex/main-flow-d3",
"pr_number": 77,
},
)
test_response = await client.post(
f"/pmflow-os/projects/{project.id}/execution-progress-events",
json={
"task_id": str(task.id),
"repo_binding_id": str(repo.id),
"event_type": "test_failed",
"source": "local_bridge",
"branch_name": "codex/main-flow-d3",
"test_summary": "单测失败:ExecutionBridgePage 回写表单缺少断言",
},
)
assert commit_response.status_code == 200
assert commit_response.json()["data"]["execution"]["status"] == "coding"
assert pr_response.status_code == 200
pr_payload = pr_response.json()["data"]
assert pr_payload["execution"]["status"] == "pr_open"
assert pr_payload["execution"]["prStatus"] == "PR #77 · linked"
assert test_response.status_code == 200
test_payload = test_response.json()["data"]
assert test_payload["execution"]["status"] == "failed"
assert test_payload["execution"]["testSummary"] == "单测失败:ExecutionBridgePage 回写表单缺少断言"
assert test_payload["recentProgressEvents"][0]["event_type"] == "test_failed"
assert test_payload["recentProgressEvents"][0]["repo"]["full_name"] == "pmflow/writeback"
link = (await session.execute(select(TaskCodeLink).where(TaskCodeLink.task_id == task.id))).scalar_one()
assert link.branch_name == "codex/main-flow-d3"
assert link.pr_number == 77
assert link.last_commit_sha == "abc123def456"
events = (await session.execute(select(ProgressEvent).where(ProgressEvent.task_id == task.id))).scalars().all()
assert [event.event_type for event in events] == ["commit", "pr_opened", "test_failed"]
@pytest.mark.asyncio
async def test_execution_progress_writeback_rejects_repo_outside_project(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
other_project = Project(
tenant_id=1,
name="其它回写项目",
title="其它回写项目",
description="不能被当前项目进展事件引用",
owner_id=user.id,
status="active",
assigned_role="BUILDER",
signal_count=0,
)
session.add(other_project)
await session.flush()
session.add(UserProject(tenant_id=1, user_id=user.id, project_id=other_project.id))
charter = TruthNode(
tenant_id=1,
node_type="charter",
project_id=project.id,
created_by=user.id,
title="当前项目宪章",
content={"business_goal": "校验 repo project 边界"},
version=1,
status="aligned",
)
repo = RepoBinding(
tenant_id=1,
project_id=other_project.id,
provider="github",
owner="pmflow",
repo_name="other-writeback",
default_branch="main",
status="active",
)
session.add_all([charter, repo])
await session.flush()
task = Task(
tenant_id=1,
truth_node_id=charter.id,
project_id=project.id,
assignee_id=user.id,
ai_recommended_assignees=None,
ai_breakdown_payload=None,
manual_overrides=None,
title="当前项目任务",
description="不能绑定其它项目 repo",
required_skill_tag_ids=[],
status="confirmed",
priority="medium",
)
session.add(task)
await session.commit()
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.post(
f"/pmflow-os/projects/{project.id}/execution-progress-events",
json={
"task_id": str(task.id),
"repo_binding_id": str(repo.id),
"event_type": "commit",
"source": "codex",
"branch_name": "codex/wrong-repo",
},
)
assert response.status_code == 422
assert response.json()["message"] == "repo_binding_not_found"
@pytest.mark.asyncio
async def test_execution_handoff_rejects_task_outside_project(db_env) -> None:
session = db_env

View File

@ -23,6 +23,19 @@ export interface PMFlowExecutionSummary {
}
export type PMFlowExecutionRuntimeTarget = "local_bridge" | "codex" | "github_agent" | "manual";
export type PMFlowExecutionProgressSource = "local_bridge" | "codex" | "github_agent" | "manual";
export type PMFlowExecutionProgressEventType =
| "branch_created"
| "commit"
| "pr_opened"
| "pr_synced"
| "test_passed"
| "test_failed"
| "checks_passed"
| "blocked"
| "merged"
| "accepted"
| "manual_progress";
export interface PMFlowExecutionHandoffRequest {
task_id: string;
@ -37,6 +50,21 @@ export interface PMFlowExecutionHandoffConsumeRequest {
note?: string | null;
}
export interface PMFlowExecutionProgressEventRequest {
task_id: string;
event_type: PMFlowExecutionProgressEventType;
source?: PMFlowExecutionProgressSource;
repo_binding_id?: string | null;
branch_name?: string | null;
commit_sha?: string | null;
pr_number?: number | null;
test_summary?: string | null;
workflow_state?: string | null;
summary?: string | null;
event_source_id?: string | null;
occurred_at?: string | null;
}
export interface PMFlowPromptPackage {
id: string;
project_id: string;
@ -195,6 +223,17 @@ export async function consumePMFlowExecutionHandoff(
return unwrap(data);
}
export async function createPMFlowExecutionProgressEvent(
projectId: string,
payload: PMFlowExecutionProgressEventRequest
): Promise<PMFlowExecutionSummary> {
const { data } = await api.post<ApiEnvelope<PMFlowExecutionSummary>>(
`/pmflow-os/projects/${projectId}/execution-progress-events`,
payload
);
return unwrap(data);
}
export async function createPMFlowThreadMessage(
projectId: string,
threadId: string,

View File

@ -1,3 +1,4 @@
import { useState } from "react";
import {
AlertTriangle,
CheckCircle2,
@ -17,6 +18,8 @@ import {
import type {
PMFlowDevTask,
PMFlowExecutionProgressEventType,
PMFlowExecutionProgressSource,
PMFlowExecutionSummary,
PMFlowExecutionRuntimeTarget,
PMFlowProgressEvent,
@ -54,6 +57,7 @@ export default function ExecutionBridgePage() {
refetchExecutionSummary,
createExecutionHandoff,
consumeExecutionHandoff,
recordExecutionProgress,
advanceExecution,
} = usePMFlowOsStore();
const demoSummary = dataSourceMode === "demo" ? createDemoExecutionSummary(state) : null;
@ -111,7 +115,10 @@ export default function ExecutionBridgePage() {
onConsumeHandoff={isDemo ? undefined : consumeExecutionHandoff}
onDemoPush={isDemo ? advanceExecution : undefined}
/>
<ExecutionReviewPanel summary={summary} />
<ExecutionReviewPanel
summary={summary}
onRecordProgress={isDemo ? undefined : recordExecutionProgress}
/>
</div>
<ExecutionProgressPanel summary={summary} />
@ -313,7 +320,24 @@ function RepoBindingList({ repos }: { repos: PMFlowRepoBinding[] }) {
);
}
function ExecutionReviewPanel({ summary }: { summary: PMFlowExecutionSummary }) {
function ExecutionReviewPanel({
summary,
onRecordProgress,
}: {
summary: PMFlowExecutionSummary;
onRecordProgress?: (payload: {
task_id: string;
event_type: PMFlowExecutionProgressEventType;
source?: PMFlowExecutionProgressSource;
repo_binding_id?: string | null;
branch_name?: string | null;
commit_sha?: string | null;
pr_number?: number | null;
test_summary?: string | null;
workflow_state?: string | null;
summary?: string | null;
}) => void;
}) {
return (
<section className="min-w-0 rounded-xl border border-[#e2e9f5] bg-white shadow-[0_1px_2px_rgba(15,23,42,0.03)]">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#edf2f8] px-5 py-4">
@ -326,7 +350,10 @@ function ExecutionReviewPanel({ summary }: { summary: PMFlowExecutionSummary })
<div className="grid gap-4 p-5 xl:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)]">
<ActiveTaskList tasks={summary.activeDevTasks} />
<ExecutionFacts summary={summary} />
<div className="space-y-4">
<ExecutionFacts summary={summary} />
{onRecordProgress ? <ExecutionWritebackPanel summary={summary} onRecordProgress={onRecordProgress} /> : null}
</div>
</div>
</section>
);
@ -388,6 +415,159 @@ function ExecutionFacts({ summary }: { summary: PMFlowExecutionSummary }) {
);
}
function ExecutionWritebackPanel({
summary,
onRecordProgress,
}: {
summary: PMFlowExecutionSummary;
onRecordProgress: (payload: {
task_id: string;
event_type: PMFlowExecutionProgressEventType;
source?: PMFlowExecutionProgressSource;
repo_binding_id?: string | null;
branch_name?: string | null;
commit_sha?: string | null;
pr_number?: number | null;
test_summary?: string | null;
workflow_state?: string | null;
summary?: string | null;
}) => void;
}) {
const task = summary.activeDevTasks.find((item) => item.id === summary.execution.taskId) || summary.activeDevTasks[0] || null;
const repo = task?.code_link?.repo || summary.repoBindings[0] || null;
const [eventType, setEventType] = useState<PMFlowExecutionProgressEventType>("commit");
const [source, setSource] = useState<PMFlowExecutionProgressSource>("codex");
const [branchName, setBranchName] = useState(task?.code_link?.branch_name || "");
const [commitSha, setCommitSha] = useState(task?.code_link?.last_commit_sha || "");
const [prNumber, setPrNumber] = useState(task?.code_link?.pr_number ? String(task.code_link.pr_number) : "");
const [testSummary, setTestSummary] = useState("");
const [workflowState, setWorkflowState] = useState("");
const canSubmit = Boolean(task);
return (
<section className="rounded-xl border border-[#e8eef8] bg-white p-4">
<div className="flex items-start gap-3">
<GitBranch className="mt-0.5 h-4 w-4 shrink-0 text-[#2463EB]" />
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-slate-900">执行结果回写</h3>
<p className="mt-1 text-xs leading-5 text-slate-500">
记录 Codex / Local Bridge / GitHub agent 已产生的结果;这里只写 progress event,不执行命令。
</p>
{!task ? (
<div className="mt-3 text-xs leading-5 text-amber-700">缺少 activeDevTasks,无法写回执行结果。</div>
) : null}
{task && !repo ? (
<div className="mt-3 text-xs leading-5 text-amber-700">当前没有 repoBinding,将只记录进展事件,不绑定 branch/PR/commit 到仓库。</div>
) : null}
<div className="mt-4 grid gap-3">
<label className="grid gap-1.5 text-xs font-semibold text-slate-600">
事件类型
<select
className="h-9 rounded-lg border border-[#dfe7f3] bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-[#2463EB]"
value={eventType}
onChange={(event) => setEventType(event.target.value as PMFlowExecutionProgressEventType)}
>
<option value="branch_created">branch_created</option>
<option value="commit">commit</option>
<option value="pr_opened">pr_opened</option>
<option value="pr_synced">pr_synced</option>
<option value="test_passed">test_passed</option>
<option value="test_failed">test_failed</option>
<option value="checks_passed">checks_passed</option>
<option value="blocked">blocked</option>
<option value="merged">merged</option>
<option value="accepted">accepted</option>
<option value="manual_progress">manual_progress</option>
</select>
</label>
<label className="grid gap-1.5 text-xs font-semibold text-slate-600">
来源
<select
className="h-9 rounded-lg border border-[#dfe7f3] bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-[#2463EB]"
value={source}
onChange={(event) => setSource(event.target.value as PMFlowExecutionProgressSource)}
>
<option value="codex">codex</option>
<option value="local_bridge">local_bridge</option>
<option value="github_agent">github_agent</option>
<option value="manual">manual</option>
</select>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<WritebackInput label="分支" value={branchName} onChange={setBranchName} placeholder="codex/main-flow-d3" />
<WritebackInput label="Commit SHA" value={commitSha} onChange={setCommitSha} placeholder="abcdef123456" />
</div>
<div className="grid gap-3 sm:grid-cols-2">
<WritebackInput label="PR 编号" value={prNumber} onChange={setPrNumber} placeholder="128" />
<WritebackInput label="workflow_state" value={workflowState} onChange={setWorkflowState} placeholder="coding / testing / synced" />
</div>
<label className="grid gap-1.5 text-xs font-semibold text-slate-600">
测试摘要
<textarea
className="min-h-20 resize-y rounded-lg border border-[#dfe7f3] bg-white px-3 py-2 text-sm font-medium leading-6 text-slate-800 outline-none transition placeholder:text-slate-400 focus:border-[#2463EB]"
value={testSummary}
onChange={(event) => setTestSummary(event.target.value)}
placeholder="测试通过、失败原因或检查结果"
/>
</label>
</div>
<button
type="button"
className={cn(
"mt-4 inline-flex w-full items-center justify-center gap-2 rounded-lg px-5 py-2 text-sm font-semibold transition",
canSubmit ? "bg-[#2463EB] text-white hover:bg-[#1f58d5]" : "cursor-not-allowed bg-slate-100 text-slate-400"
)}
disabled={!canSubmit}
onClick={() => {
if (!task) return;
const parsedPr = prNumber.trim() ? Number(prNumber) : null;
onRecordProgress({
task_id: task.id,
event_type: eventType,
source,
repo_binding_id: repo?.id || null,
branch_name: branchName.trim() || null,
commit_sha: commitSha.trim() || null,
pr_number: Number.isFinite(parsedPr) ? parsedPr : null,
test_summary: testSummary.trim() || null,
workflow_state: workflowState.trim() || null,
summary: testSummary.trim() || null,
});
}}
>
<GitBranch className="h-4 w-4" />
写回进展事件
</button>
</div>
</div>
</section>
);
}
function WritebackInput({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
}) {
return (
<label className="grid gap-1.5 text-xs font-semibold text-slate-600">
{label}
<input
className="h-9 rounded-lg border border-[#dfe7f3] bg-white px-3 text-sm font-medium text-slate-800 outline-none transition placeholder:text-slate-400 focus:border-[#2463EB]"
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
/>
</label>
);
}
function DeliveryList({ deliveries }: { deliveries: PMFlowRecentDelivery[] }) {
if (!deliveries.length) {
return <ExecutionEmptyState title="没有近期交付回流" summary="recentDeliveries 为空,尚未收到 GitHub webhook delivery 或本地桥回写。" />;

View File

@ -8,6 +8,7 @@ import {
commitPMFlowChangeSet,
consumePMFlowExecutionHandoff,
createPMFlowExecutionHandoff,
createPMFlowExecutionProgressEvent,
createPMFlowThreadMessage,
deferPMFlowChangeItem,
editPMFlowChangeItem,
@ -28,6 +29,7 @@ vi.mock("@/api/pmflowOs", () => ({
commitPMFlowChangeSet: vi.fn(),
consumePMFlowExecutionHandoff: vi.fn(),
createPMFlowExecutionHandoff: vi.fn(),
createPMFlowExecutionProgressEvent: vi.fn(),
editPMFlowChangeItem: vi.fn(),
deferPMFlowChangeItem: vi.fn(),
}));
@ -37,6 +39,7 @@ const getExecutionSummaryMock = vi.mocked(getPMFlowExecutionSummary);
const createThreadMessageMock = vi.mocked(createPMFlowThreadMessage);
const createExecutionHandoffMock = vi.mocked(createPMFlowExecutionHandoff);
const consumeExecutionHandoffMock = vi.mocked(consumePMFlowExecutionHandoff);
const createExecutionProgressEventMock = vi.mocked(createPMFlowExecutionProgressEvent);
const acceptItemMock = vi.mocked(acceptPMFlowChangeItem);
const commitChangeSetMock = vi.mocked(commitPMFlowChangeSet);
const editItemMock = vi.mocked(editPMFlowChangeItem);
@ -265,6 +268,45 @@ describe("PMFlowOsProvider real change review actions", () => {
recentDeliveries: [],
warnings: [],
});
createExecutionProgressEventMock.mockResolvedValue({
projectId: "123",
execution: {
taskId: "task-99",
status: "pr_open",
prompt: "真实 prompt package",
commitLog: ["执行结果回写"],
prStatus: "PR #88 · linked",
testSummary: "pending",
writebackStatus: "in_review",
},
devSync: { active_task_count: 1, recent_event_count: 3 },
promptPackage: {
id: "501",
project_id: "123",
charter_id: "301",
task_id: "task-99",
status: "active",
package_kind: "execution_handoff",
package_text: "真实 prompt package",
package_payload: {},
created_at: "2026-04-26T00:00:00",
},
repoBindings: [],
activeDevTasks: [],
recentProgressEvents: [
{
id: "703",
event_type: "pr_opened",
source: "github_agent",
summary: "执行结果回写",
workflow_state: "in_review",
derived_task_status: "review",
pr_number: 88,
},
],
recentDeliveries: [],
warnings: [],
});
});
afterEach(() => {
@ -323,6 +365,23 @@ describe("PMFlowOsProvider real change review actions", () => {
)
);
act(() =>
result.current.recordExecutionProgress({
task_id: "task-99",
repo_binding_id: "601",
event_type: "pr_opened",
source: "github_agent",
branch_name: "codex/main-flow-d3",
pr_number: 88,
})
);
await waitFor(() =>
expect(createExecutionProgressEventMock).toHaveBeenCalledWith(
"123",
expect.objectContaining({ task_id: "task-99", event_type: "pr_opened", source: "github_agent", pr_number: 88 })
)
);
expect(result.current.state.requirements).toEqual([]);
});
@ -347,6 +406,7 @@ describe("PMFlowOsProvider real change review actions", () => {
expect(acceptItemMock).not.toHaveBeenCalled();
expect(createExecutionHandoffMock).not.toHaveBeenCalled();
expect(consumeExecutionHandoffMock).not.toHaveBeenCalled();
expect(createExecutionProgressEventMock).not.toHaveBeenCalled();
expect(getExecutionSummaryMock).not.toHaveBeenCalled();
expect(result.current.state.changeItems.find((item) => item.id === pending!.id)?.status).toBe("accepted");
});

View File

@ -5,6 +5,7 @@ import {
commitPMFlowChangeSet,
consumePMFlowExecutionHandoff,
createPMFlowExecutionHandoff,
createPMFlowExecutionProgressEvent,
createPMFlowExtraction,
createPMFlowThreadMessage,
deferPMFlowChangeItem,
@ -12,6 +13,7 @@ import {
getPMFlowExecutionSummary,
getPMFlowTruthState,
type PMFlowExecutionHandoffRequest,
type PMFlowExecutionProgressEventRequest,
type PMFlowExecutionSummary,
type PMFlowProposedChangeItem,
} from "@/api/pmflowOs";
@ -534,6 +536,7 @@ interface PMFlowOsStoreValue {
pushTaskToExecution: (taskId: string) => void;
createExecutionHandoff: (payload: PMFlowExecutionHandoffRequest) => void;
consumeExecutionHandoff: (promptPackageId: string) => void;
recordExecutionProgress: (payload: PMFlowExecutionProgressEventRequest) => void;
advanceExecution: () => void;
writeBackExecution: (outcome: "passed" | "failed") => void;
publishRelease: () => void;
@ -757,6 +760,13 @@ export function PMFlowOsProvider({ children }: { children: ReactNode }) {
}
mutateDemoOnly((current) => advanceExecutionMutation(current));
},
recordExecutionProgress: (payload) => {
if (dataSource.mode === "api" && dataSource.projectId) {
runApiAction(() => createPMFlowExecutionProgressEvent(dataSource.projectId || "", payload));
return;
}
mutateDemoOnly((current) => advanceExecutionMutation(current));
},
advanceExecution: () => mutateDemoOnly((current) => advanceExecutionMutation(current)),
writeBackExecution: (outcome) => mutateDemoOnly((current) => writeBackExecutionMutation(current, outcome)),
publishRelease: () => mutateDemoOnly((current) => publishReleaseMutation(current)),