Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/presentation/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"smoke:goal-acceptance-contract-browser": "node smoke/goal-acceptance-contract-browser-smoke.mjs",
"smoke:goal-acceptance-contract-packaged": "LOOPX_ACCEPTANCE_CONTRACT_PACKAGED=1 LOOPX_ACCEPTANCE_CONTRACT_PORT=5297 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-acceptance-contract-browser-smoke.mjs",
"smoke:status-projection-contract": "rm -rf /tmp/loopx-status-projection-contract-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-status-projection-contract-smoke smoke/status-projection-contract-smoke.ts src/data/status.ts src/data/status-merge.ts src/data/status-request-fence.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-status-projection-contract-smoke/apps/presentation/dashboard/smoke/status-projection-contract-smoke.js",
"smoke:team-plan-recovery": "vite build --ssr smoke/team-plan-recovery-smoke.mjs --outDir node_modules/.cache/loopx-team-plan-recovery --emptyOutDir && node node_modules/.cache/loopx-team-plan-recovery/team-plan-recovery-smoke.js",
"smoke:team-plan-proposal": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --ignoreDeprecations 6.0 --jsx react-jsx --types node --skipLibCheck --strict --rootDir ../../.. --outDir node_modules/.cache/loopx-team-plan-smoke smoke/team-plan-proposal-smoke.ts src/data/chat.ts src/features/personal-workspace/team-plan-preview.ts src/vite-env.d.ts && node node_modules/.cache/loopx-team-plan-smoke/apps/presentation/dashboard/smoke/team-plan-proposal-smoke.js",
"smoke:status-source-switch-browser": "node ../../../examples/status-source-switch-browser-smoke.mjs",
"smoke:status-source-switch-packaged": "LOOPX_STATUS_SOURCE_SWITCH_PACKAGED=1 LOOPX_STATUS_SOURCE_SWITCH_PORT=5198 node ../../../examples/status-source-switch-browser-smoke.mjs",
Expand Down
50 changes: 50 additions & 0 deletions apps/presentation/dashboard/smoke/team-plan-recovery-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import {createElement} from "react";
import {renderToStaticMarkup} from "react-dom/server";
import {compileActionReviewPlan} from "../../../../loopx/control_plane/presentation/action_review_plan.ts";
import {ContextDrawer} from "../src/features/personal-workspace/context-drawer.tsx";
import {WorkspaceI18nProvider} from "../src/features/personal-workspace/i18n.tsx";

const failed = {
schema_version: "loopx_chat_action_proposal_v1", proposal_id: "assignment-1",
action_kind: "team.plan", status: "failed", permission_classification: "durable_write",
expected_state_fingerprint: "source-1", available_transitions: ["apply", "cancel"],
failure: {retry_safe: true}, context: {kind: "manager", goal_id: "goal-1"},
normalized_parameters: {goal_id: "goal-1", plan: {
schema_version: "steward_team_plan_preview_v0", kind: "steward_team_plan_preview",
goal_id: "goal-1", objective: "Inspect the contract", applies: false,
stop_condition: "Return evidence", quota_envelope: {slots: 1}, gaps: [],
lanes: [{lane_id: "inspect", agent_id: "alpha", staffing: "ready",
acceptance: "Return verified evidence", first_todo: {text: "Inspect the contract",
priority: "P1", task_class: "advancement_task", action_kind: "implement"}}],
}},
};
function render(raw, patch = {}) {
const reviewPlan = compileActionReviewPlan(raw);
const item = {actionKind: raw.action_kind, previewId: raw.proposal_id, goalId: "goal-1",
title: "Assign inspection", fields: [], status: raw.status === "stale" ? "stale" : "error",
reviewPlan, ...patch};
return renderToStaticMarkup(createElement(WorkspaceI18nProvider, null,
createElement(ContextDrawer, {agents: [], callbacks: {}, onClose() {},
selection: {kind: "proposal", item}})));
}
assert.match(render(failed), /<button[^>]*>.*?Retry original operation<\/button>/s,
"A retry-safe original assignment has a recovery action");
for (const patch of [
{failure: {retry_safe: false}}, {failure: null},
{normalized_parameters: {...failed.normalized_parameters, goal_id: "another-goal"}},
{permission_classification: "protected"}, {available_transitions: ["cancel"]},
]) {
const html = render({...failed, ...patch});
assert.doesNotMatch(html, /Retry original operation|Retry assignment/,
"An unsafe or mismatched assignment cannot expose original apply");
assert.match(html, /does not permit retrying the assignment/);
}
assert.doesNotMatch(render(failed, {reviewPlan: {...compileActionReviewPlan(failed), canApply: false}}),
/Retry original operation<\/button>/, "Even a retained recovery flag cannot bypass disabled apply");
const stale = render({...failed, status: "stale", stale: {current: "source-2"}});
assert.doesNotMatch(stale, /Retry original operation|Recover operation result/);
assert.match(stale, /Recheck against latest state/);
assert.match(render({...failed, action_kind: "todo.create", failure: null}), /Retry with latest state/,
"Unrelated failed previews retain their existing regeneration action");
console.log("PASS: rendered Team Plan recovery respects typed admission and stale state");
Original file line number Diff line number Diff line change
Expand Up @@ -1149,7 +1149,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
: selection.item.status === "applied" && selection.item.actionKind !== "team.plan" ? <p className={`personal-proposal-state ${selection.item.actionKind === "operation.execute" && selection.item.reviewPlan?.reason === "readback_unverified" ? "is-gated" : "is-applied"}`}><Check size={16} />{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : t("drawer.proposalApplied")}</p> : null}
{selection.item.status === "applied" && selection.item.actionKind !== "operation.execute" && selection.item.goalId ? <button className="personal-primary-action" onClick={() => { const goalId = selection.item.goalId!; onClose(); void callbacks.onOpenGoal?.(goalId); }} type="button"><ExternalLink size={16} />{selection.item.actionKind === "goal.create" ? t("drawer.proposalEnterGoal") : t(selection.item.actionKind === "team.plan" ? "proposal.teamPlan.openGoal" : "drawer.proposalViewGoal")}</button> : null}
{selection.item.status === "stale" ? <p className="personal-proposal-state is-stale">{t("drawer.proposalStale")}</p> : null}
{selection.item.status === "error" && !selection.item.reviewPlan?.retryOriginal ? <div className="personal-proposal-state is-error"><span>{operationUnknown ? t("proposal.operationState.submission_unknown") : selection.item.reviewPlan?.reason === "readback_unverified" ? t("actionReview.readback_unverified") : t("drawer.proposalApplyFailed")}</span>{selection.item.errorMessage ? <small>{selection.item.errorMessage}</small> : null}<small>{t(operationUnknown ? "actionReview.operation_reconcile_original" : selection.item.actionKind === "team.plan" ? "proposal.teamPlan.retryHint" : "drawer.proposalApplyFailedHint")}</small></div> : null}
{selection.item.status === "error" && !selection.item.reviewPlan?.retryOriginal ? <div className="personal-proposal-state is-error"><span>{operationUnknown ? t("proposal.operationState.submission_unknown") : selection.item.actionKind === "team.plan" || selection.item.reviewPlan?.reason === "readback_unverified" ? t("actionReview.readback_unverified") : t("drawer.proposalApplyFailed")}</span>{selection.item.errorMessage ? <small>{selection.item.errorMessage}</small> : null}<small>{t(operationUnknown ? "actionReview.operation_reconcile_original" : selection.item.actionKind === "team.plan" ? "proposal.teamPlan.retryUnavailable" : "drawer.proposalApplyFailedHint")}</small></div> : null}
{selection.item.status === "rejected" ? <p className="personal-proposal-state is-error">{t("drawer.proposalRejected")}</p> : null}
{selection.item.reviewPlan?.operationFrame?.kind === "result"
&& selection.item.reviewPlan.operationFrame.resultKind === "cancelled"
Expand All @@ -1158,7 +1158,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
{selection.item.status === "gated" && selection.item.actionKind === "gate.resolve" ? <div className="personal-proposal-state is-gated"><span><strong>{t("drawer.gateDecisionRecheckTitle")}</strong>{t("drawer.gateDecisionRecheck")}</span></div>
: selection.item.status === "gated" ? <div className="personal-proposal-state is-gated"><span><strong>{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : selection.item.workspaceCandidates?.length ? selection.item.title : t("drawer.gateRequiresHost")}</strong>{selection.item.actionKind === "operation.execute" || selection.item.workspaceCandidates?.length ? selection.item.impact : t("drawer.gateRequiresHostDescription")}</span>{selection.item.gate?.nextAction ? <small>{selection.item.gate.nextAction}</small> : null}</div> : null}
{!readOnly && selection.item.workspaceCandidates?.length ? <div className="personal-workspace-candidates" aria-label={t("drawer.workspaceCandidates")}>{selection.item.workspaceCandidates.map((candidate) => <button key={candidate.workspaceRef} onClick={() => void callbacks.onSelectWorkspaceCandidate?.(selection.item, candidate.workspaceRef)} type="button"><strong>{candidate.label}</strong><small>{candidate.workspaceRef}</small></button>)}</div> : null}
{!readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status === "error" ? <button className="personal-primary-action" onClick={() => void ((selection.item.actionKind === "team.plan" || selection.item.reviewPlan?.retryOriginal) ? callbacks.onApplyProposal?.(selection.item) : callbacks.onTransitionProposal?.(selection.item, "regenerate"))} type="button"><RotateCcw size={17} />{t(selection.item.reviewPlan?.retryOriginal ? "drawer.retryOriginal" : selection.item.actionKind === "team.plan" ? "proposal.teamPlan.retry" : "drawer.proposalRegenerate")}</button> : !readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status !== "gated" && !((selection.item.actionKind === "team.plan" || decisionFrame) && selection.item.status === "applied") ? <button className="personal-primary-action" disabled={!['ready', 'deferred'].includes(selection.item.status) || selection.item.reviewPlan?.canApply === false} onClick={() => void callbacks.onApplyProposal?.(selection.item)} type="button"><Check size={17} />{selection.item.status === "applying" ? t("drawer.applying") : selection.item.primaryLabel ?? t("drawer.apply")}</button> : null}
{!readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status === "error" && (selection.item.actionKind !== "team.plan" || (selection.item.reviewPlan?.retryOriginal && selection.item.reviewPlan.canApply)) ? <button className="personal-primary-action" onClick={() => void (selection.item.reviewPlan?.retryOriginal ? callbacks.onApplyProposal?.(selection.item) : callbacks.onTransitionProposal?.(selection.item, "regenerate"))} type="button"><RotateCcw size={17} />{t(selection.item.reviewPlan?.retryOriginal ? "drawer.retryOriginal" : "drawer.proposalRegenerate")}</button> : !readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status !== "gated" && !(selection.item.actionKind === "team.plan" && ["error", "stale"].includes(selection.item.status)) && !((selection.item.actionKind === "team.plan" || decisionFrame) && selection.item.status === "applied") ? <button className="personal-primary-action" disabled={!['ready', 'deferred'].includes(selection.item.status) || selection.item.reviewPlan?.canApply === false} onClick={() => void callbacks.onApplyProposal?.(selection.item)} type="button"><Check size={17} />{selection.item.status === "applying" ? t("drawer.applying") : selection.item.primaryLabel ?? t("drawer.apply")}</button> : null}
{!readOnly && selection.item.actionKind !== "operation.execute" && (["stale", "gated", "rejected"].includes(selection.item.status) || (selection.item.status === "ready" && selection.item.reviewPlan?.canApply === false)) ? <button className="personal-secondary-action" onClick={() => void callbacks.onTransitionProposal?.(selection.item, "regenerate")} type="button"><RotateCcw size={16} />{t("drawer.proposalRecheck")}</button> : null}
{!readOnly && selection.item.actionKind !== "operation.execute" && selection.item.actionKind !== "gate.resolve" && ["ready", "gated"].includes(selection.item.status) ? <div className="personal-drawer-action-grid"><button className="personal-secondary-action" onClick={() => void callbacks.onTransitionProposal?.(selection.item, "defer")} type="button">{t("drawer.proposalDefer")}</button><button className="personal-secondary-action" onClick={() => void callbacks.onTransitionProposal?.(selection.item, "reject")} type="button">{t("drawer.decisionReject")}</button></div> : null}
{!["applied", "applying"].includes(selection.item.status) ? <button className="personal-secondary-action" onClick={onClose} type="button">{t("drawer.proposalClose")}</button> : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,7 @@ const en = {
"actionReview.canonical_update_retry": "The edit is not yet verified. Retry this operation to recover its result.",
"actionReview.goal_creation_retry": "Goal initialization is incomplete. Retry the original creation; its recorded storage choice is preserved.",
"actionReview.canonical_update_projection_pending": "The operation was committed; display delivery is pending. Retry this operation to restore the current view.",
"actionReview.team_plan_retry": "Retry this assignment using the original plan. Current state is checked again, and any tasks already created are recovered without duplication.",
"actionReview.apply_failed": "Execution did not complete. Check the failure and regenerate the preview before retrying.",
"actionReview.inactive_proposal": "This proposal is no longer ready to execute. Recheck it before continuing.",
"proposal.gate.default": "Host confirmation required",
Expand Down Expand Up @@ -869,7 +870,7 @@ const en = {
"proposal.teamPlan.openGoal": "Open Goal",
"proposal.teamPlan.resultTitle": "Assignment result",
"proposal.teamPlan.retry": "Retry assignment",
"proposal.teamPlan.retryHint": "Retry this assignment to recover its result without duplicating tasks.",
"proposal.teamPlan.retryUnavailable": "This proposal does not permit retrying the assignment. Check the recorded failure before continuing.",
"proposal.summary.teamPlan": "Assign {count} tasks for {goal}",
"proposal.impact.teamPlan": "Confirm to assign the ready tasks. Unavailable assignments stay pending.",
"proposal.primary.teamPlan": "Confirm assignment",
Expand Down Expand Up @@ -2122,6 +2123,7 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"actionReview.canonical_update_retry": "编辑结果尚未确认。重试此操作以恢复原结果。",
"actionReview.goal_creation_retry": "Goal 初始化尚未完成。请重试原创建操作,已记录的存储选择会保留。",
"actionReview.canonical_update_projection_pending": "操作已提交,展示尚未同步。重试此操作以恢复当前视图。",
"actionReview.team_plan_retry": "可以从同一提案重试分配。系统会重新核验当前状态,并恢复已记录的结果,不会重复创建任务。",
"actionReview.apply_failed": "执行未完成。请检查失败原因并重新生成预览后再试。",
"actionReview.inactive_proposal": "此提案当前不可执行。请重新检查后再继续。",
"proposal.gate.default": "需要宿主确认",
Expand Down Expand Up @@ -2190,7 +2192,7 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"proposal.teamPlan.openGoal": "打开目标",
"proposal.teamPlan.resultTitle": "分配结果",
"proposal.teamPlan.retry": "重试分配",
"proposal.teamPlan.retryHint": "重试会恢复本次分配结果,不会重复创建任务。",
"proposal.teamPlan.retryUnavailable": "当前提案不允许重试分配。请先核对已记录的失败原因。",
"proposal.summary.teamPlan": "为 {goal} 分配 {count} 项任务",
"proposal.impact.teamPlan": "确认后分配可安排的任务,其余保留为待安排。",
"proposal.primary.teamPlan": "确认分配",
Expand Down
Loading
Loading