feat(pmflow-os): add execution progress writeback
This commit is contained in:
parent
2956906f19
commit
a7008bb3cc
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 或本地桥回写。" />;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
Loading…
Reference in New Issue