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 @@ -41,6 +41,7 @@
"smoke:status-sources": "rm -rf /tmp/loopx-status-source-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-status-source-smoke smoke/status-source-catalog-smoke.ts src/data/ssh-host-catalog.ts src/data/status-source-catalog.ts src/data/local-status-query.ts src/data/status.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-status-source-smoke/apps/presentation/dashboard/smoke/status-source-catalog-smoke.js",
"smoke:task-board-scroll": "node smoke/task-board-scroll-smoke.mjs",
"smoke:usage-progress": "rm -rf /tmp/loopx-usage-progress-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --resolveJsonModule --esModuleInterop --skipLibCheck --strict --outDir /tmp/loopx-usage-progress-smoke smoke/usage-progress-smoke.ts src/features/personal-workspace/personal-workspace-model.ts && node /tmp/loopx-usage-progress-smoke/apps/presentation/dashboard/smoke/usage-progress-smoke.js",
"smoke:action-review-plan": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --skipLibCheck --strict --outDir .local/action-review-plan-smoke smoke/action-review-plan-smoke.ts src/features/personal-workspace/action-review-plan.ts src/vite-env.d.ts && node .local/action-review-plan-smoke/smoke/action-review-plan-smoke.js",
"smoke:attention-details": "tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --resolveJsonModule --esModuleInterop --skipLibCheck --strict --outDir /tmp/loopx-attention-details-smoke smoke/attention-details-smoke.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-attention-details-smoke/apps/presentation/dashboard/smoke/attention-details-smoke.js"
},
"dependencies": {
Expand Down
64 changes: 64 additions & 0 deletions apps/presentation/dashboard/smoke/action-review-plan-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { compileActionReviewPlan, isStaleActionFailure } from "../src/features/personal-workspace/action-review-plan.js";
import { typedActionProposalSchema, type TypedActionProposal } from "../src/data/chat.js";

const proposal: TypedActionProposal = {
schema_version: "loopx_chat_action_proposal_v1", proposal_id: "preview-1",
action_kind: "goal.lifecycle", summary: "Stop sample Goal",
normalized_parameters: { goal_id: "sample-goal", operation: "stop" },
context: { kind: "goal_directory", goal_id: "sample-goal" },
expected_state_fingerprint: "revision-1", permission_classification: "durable_write",
validation_evidence: ["Canonical bounded shape validated"], available_transitions: ["apply", "cancel"],
status: "preview_ready", receipt: null, stale: null, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z",
};
function check(condition: boolean, description: string) { if (!condition) throw new Error(description); }
const compile = (patch: Partial<TypedActionProposal> = {}) => compileActionReviewPlan({ ...proposal, ...patch });
check(compile().interaction === "direct", "A validated ready stop preserves the direct path");
for (const validation_evidence of [[null], [""], [" \t"], [{}], ["valid", null], ["valid", {}], ["valid", ""]]) {
const raw = { ...proposal, validation_evidence };
check(!typedActionProposalSchema.safeParse(raw).success, "Transport must reject every malformed evidence element");
check(compileActionReviewPlan(raw as unknown as TypedActionProposal).interaction !== "direct", "Unparsed malformed evidence cannot become direct");
}
check(typedActionProposalSchema.parse({ ...proposal, validation_evidence: [" Canonical validation "] }).validation_evidence[0] === " Canonical validation ", "Validation preserves evidence text");
for (const operation of ["resume", "delete"]) {
const result = compile({ normalized_parameters: { goal_id: "sample-goal", operation } });
check(result.interaction === "review" && result.canApply, `${operation} requires review`);
}
// Mutate each fact independently. None may preserve direct presentation.
const unsafe: Array<Partial<TypedActionProposal>> = [
{ permission_classification: "protected" }, { permission_classification: "unknown" },
{ action_kind: "goal.update" }, { normalized_parameters: { goal_id: "sample-goal", operation: "unknown" } },
{ normalized_parameters: { operation: "stop" } }, { expected_state_fingerprint: "" },
{ proposal_id: "" }, { validation_evidence: [] }, { available_transitions: ["cancel"] },
{ gate: { kind: "authority_required" } }, { stale: { actual: "revision-2" } },
{ error: { message: "failed validation" } },
{ context: { goal_id: "another-goal" } },
];
for (const patch of unsafe) check(compile(patch).interaction !== "direct", `Unsafe fact must remove direct path: ${JSON.stringify(patch)}`);
for (const status of ["gated", "stale", "applying", "applied", "failed", "rejected", "deferred", "cancelled"] as const) {
check(compile({ status }).interaction !== "direct", `${status} is never direct`);
}
check(compile({ status: "applied", receipt: { projection_verified: true } }).interaction === "completed", "Only verified applied state completes");
for (const receipt of [null, {}, { projection_verified: false }, { projection_verified: "true" }]) {
const result = compile({ status: "applied", receipt });
check(result.interaction === "repair" && !result.canApply, "Unverified receipt cannot complete or repeat apply");
}
check(compile({ status: "preview_ready", receipt: { projection_verified: true } }).interaction !== "completed", "Receipt alone cannot complete");
check(compile({ status: "applied", receipt: { projection_verified: true }, stale: {} }).interaction === "refresh", "Stale wins over nominal success");
check(compile({ status: "applied", receipt: { projection_verified: true }, gate: {} }).interaction === "gated", "Gate wins over nominal success");
for (const action_kind of ["goal.create", "goal.update", "todo.create", "todo.update", "agent.bind", "heartbeat.bind", "monitor.create", "monitor.update", "gate.resolve", "run.correct"] as const) {
for (const status of ["preview_ready", "deferred"] as const) {
const result = compile({ action_kind, status, validation_evidence: [] });
check(result.interaction === "review" && result.canApply, `${action_kind} keeps existing reviewed behavior`);
if (status === "deferred") check(compile({ action_kind, status, gate: { kind: "previous_gate" } }).canApply, "Generic deferred retries retain historical gate without losing the apply path");
}
}
check(compile().sourceFingerprint === "revision-1" && compile().proposalId === "preview-1", "Preserve exact identity and fingerprint");
const frozen = JSON.stringify(proposal);
check(JSON.stringify(compile()) === JSON.stringify(compile()), "Deterministic compilation");
check(JSON.stringify(proposal) === frozen, "No input mutation");
console.log("PASS: action review parity, negative fact mutations, state precedence and verified readback");

check(isStaleActionFailure({ error_code: "action_stale" }), "Typed stale errors offer refresh");
check(isStaleActionFailure({ error_code: "action_conflict" }), "Typed conflicts offer refresh");
check(isStaleActionFailure({ proposal: { status: "stale" } }), "Typed stale proposal survives error wrapping");
check(!isStaleActionFailure({ error_code: "canonical_action_failed", error: "conflict with unrelated external service" }), "Error wording cannot classify source state");
3 changes: 2 additions & 1 deletion apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,8 @@ export const typedActionProposalSchema = z.object({
context: z.record(z.string(), z.unknown()),
expected_state_fingerprint: z.string().min(1),
permission_classification: z.string().min(1),
validation_evidence: z.array(z.unknown()),
// ChatActionService emits textual validation facts for every action kind.
validation_evidence: z.array(z.string().refine((value) => value.trim().length > 0, "Validation evidence must be non-blank text")),
available_transitions: z.array(z.enum(["apply", "cancel", "regenerate", "reject", "defer"])),
status: z.enum(["preview_ready", "applying", "gated", "failed", "rejected", "deferred", "cancelled", "stale", "applied"]),
receipt: z.record(z.string(), z.unknown()).nullable(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/** Presentation only: the Chat proposal and apply service retain write authority. */
export type ActionReviewIdentity = {
schemaVersion: "action_review_plan_v0";
proposalId: string;
sourceFingerprint: string;
};
export type ActionReviewReason =
| "ready_stop" | "resume_review" | "delete_review" | "action_review"
| "protected_action" | "unknown_permission" | "unknown_action"
| "incomplete_proposal" | "authority_gate" | "stale_proposal"
| "apply_pending" | "readback_verified" | "readback_unverified"
| "apply_failed" | "inactive_proposal";

export type ActionReviewPlan = ActionReviewIdentity & (
| { interaction: "direct"; reason: "ready_stop"; canApply: true }
| { interaction: "review"; reason: ActionReviewReason; canApply: boolean }
| { interaction: "gated" | "refresh" | "repair" | "pending" | "completed" | "inactive";
reason: ActionReviewReason; canApply: false }
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { typedActionProposalSchema, type TypedActionProposal } from "../../data/chat.js";

import type { ActionReviewIdentity, ActionReviewPlan, ActionReviewReason } from "./action-review-plan-types";

// These are named presentation rules, not a second Goal legal-action catalog.
const lifecycleReviewReasons = { stop: "ready_stop", resume: "resume_review", delete: "delete_review" } as const;
const hasText = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0;

export function compileActionReviewPlan(proposal: TypedActionProposal): ActionReviewPlan {
const identity: ActionReviewIdentity = {
schemaVersion: "action_review_plan_v0",
proposalId: proposal.proposal_id,
sourceFingerprint: proposal.expected_state_fingerprint,
};
const held = (interaction: "gated" | "refresh" | "repair" | "pending" | "completed" | "inactive", reason: ActionReviewReason): ActionReviewPlan =>
({ ...identity, interaction, reason, canApply: false });
const lifecycle = proposal.action_kind === "goal.lifecycle";
// Lifecycle uses conservative fact precedence. Generic deferred proposals may
// retain a historical gate; their existing status-based retry path is preserved.
if ((lifecycle && proposal.gate != null) || proposal.status === "gated") return held("gated", "authority_gate");
if ((lifecycle && proposal.stale != null) || proposal.status === "stale") return held("refresh", "stale_proposal");
if (proposal.status === "applied") return proposal.receipt?.projection_verified === true
? held("completed", "readback_verified") : held("repair", "readback_unverified");
if (proposal.status === "applying") return held("pending", "apply_pending");
if (proposal.status === "failed" || proposal.error != null) return held("repair", "apply_failed");
if (proposal.status !== "preview_ready" && proposal.status !== "deferred") return held("inactive", "inactive_proposal");
const reviewed = (reason: ActionReviewReason, canApply = true): ActionReviewPlan =>
({ ...identity, interaction: "review", reason, canApply });
// Other action owners keep their existing reviewed path in this lifecycle slice.
if (proposal.action_kind !== "goal.lifecycle") return reviewed(proposal.permission_classification === "protected" ? "protected_action" : "action_review");
const complete = hasText(proposal.proposal_id) && hasText(proposal.expected_state_fingerprint)
&& typedActionProposalSchema.shape.validation_evidence.safeParse(proposal.validation_evidence).success
&& proposal.validation_evidence.length > 0 && proposal.available_transitions.includes("apply");
if (!complete) return held("refresh", "incomplete_proposal");
const { operation, goal_id: goalId } = proposal.normalized_parameters;
if (!hasText(goalId) || (proposal.context.goal_id != null && proposal.context.goal_id !== goalId)) return held("refresh", "incomplete_proposal");
if (operation !== "stop" && operation !== "resume" && operation !== "delete") return reviewed("unknown_action", false);
if (proposal.permission_classification === "protected") return reviewed("protected_action");
if (proposal.permission_classification !== "durable_write") return reviewed("unknown_permission", false);
const reason = lifecycleReviewReasons[operation];
if (reason === "ready_stop" && proposal.status === "preview_ready") return { ...identity, interaction: "direct", reason, canApply: true };
return reviewed(reason === "ready_stop" ? "action_review" : reason);
}

/** The existing Chat error envelope, not translated prose, identifies stale state. */
export function isStaleActionFailure(payload: Record<string, unknown>): boolean {
if (payload.error_code === "action_stale" || payload.error_code === "action_conflict") return true;
const proposal = payload.proposal;
return proposal !== null && typeof proposal === "object" && "status" in proposal && proposal.status === "stale";
}
Original file line number Diff line number Diff line change
Expand Up @@ -961,13 +961,14 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
<small>{selection.item.actionKind} · {selection.item.status}</small>
<h3>{selection.item.title}</h3>
<p>{selection.item.impact}</p>
{selection.item.reviewPlan ? <p className="personal-proposal-explainer" data-action-review={selection.item.reviewPlan.interaction}>{t(`actionReview.${selection.item.reviewPlan.reason}`)}</p> : null}
{selection.item.status === "ready" ? <p className="personal-proposal-explainer">{t("drawer.proposalExplainer")}</p> : null}
<dl>{selection.item.fields.map((field) => <div key={field.key}><dt>{field.label}</dt><dd>{field.value}</dd></div>)}</dl>
</section>
{selection.item.status === "applied" ? <p className="personal-proposal-state is-applied"><Check size={16} />{t("drawer.proposalApplied")}</p> : null}
{selection.item.status === "applied" && 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("drawer.proposalViewGoal")}</button> : null}
{selection.item.status === "stale" ? <p className="personal-proposal-state is-stale">{t("drawer.proposalStale")}</p> : null}
{selection.item.status === "error" ? <div className="personal-proposal-state is-error"><span>{t("drawer.proposalApplyFailed")}</span>{selection.item.errorMessage ? <small>{selection.item.errorMessage}</small> : null}<small>{t("drawer.proposalApplyFailedHint")}</small></div> : null}
{selection.item.status === "error" ? <div className="personal-proposal-state is-error"><span>{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("drawer.proposalApplyFailedHint")}</small></div> : null}
{selection.item.status === "rejected" ? <p className="personal-proposal-state is-error">{t("drawer.proposalRejected")}</p> : null}
{selection.item.status === "deferred" ? <p className="personal-proposal-state is-gated">{t("drawer.proposalDeferred")}</p> : null}
{selection.item.status === "gated" ? <div className="personal-proposal-state is-gated"><span><strong>{t("drawer.gateRequiresHost")}</strong>{t("drawer.gateRequiresHostDescription")}</span>{selection.item.gate?.nextAction ? <small>{selection.item.gate.nextAction}</small> : null}</div> : null}
Expand All @@ -985,8 +986,8 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
);
})() : 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.status === "error" ? <button className="personal-primary-action" onClick={() => void callbacks.onTransitionProposal?.(selection.item, "regenerate")} type="button"><RotateCcw size={17} />{t("drawer.proposalRegenerate")}</button> : !readOnly && selection.item.status !== "gated" ? <button className="personal-primary-action" disabled={!['ready', 'deferred'].includes(selection.item.status)} 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 && ["stale", "gated", "rejected"].includes(selection.item.status) ? <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.status === "error" ? <button className="personal-primary-action" onClick={() => void callbacks.onTransitionProposal?.(selection.item, "regenerate")} type="button"><RotateCcw size={17} />{t("drawer.proposalRegenerate")}</button> : !readOnly && selection.item.status !== "gated" ? <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 && (["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 && ["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
Loading
Loading