diff --git a/docs/keiko-editor/1491-production-readiness-audit.md b/docs/keiko-editor/1491-production-readiness-audit.md new file mode 100644 index 00000000..19f36e73 --- /dev/null +++ b/docs/keiko-editor/1491-production-readiness-audit.md @@ -0,0 +1,72 @@ +# Epic #1491 production readiness audit + +Audit timestamp: 2026-06-26T12:13:23Z + +Scope: + +- Epic #1491: Keiko Agent-Native Editor Foundation and Runtime Governance. +- Final child issue #1389: Runtime, Git, and command UX with audit evidence. +- Integrated dependency: Epic #470 governed Git delivery, as merged into the #1491 feature branch by PR #1551. + +## Current delivery state + +- #470 remains closed as completed and is treated as an integrated dependency, not as an open workstream. +- PR #1551 is merged into `feat/keiko-agent-native-editor-foundation-and-runtime`. +- #1389 implementation is present on the #1491 feature branch, but the issue must stay open until this audit PR is merged and closure evidence is posted. +- #1491 is not yet merged to `dev`; epic closure must wait for a green PR from the feature branch to `dev`. + +## Audit findings + +The audit did not find a second Git write path, a browser shell escape, or a container execution bypass. +The editor runtime composes existing governed surfaces: + +- Read-only repository state stays on `/api/git/status` and `/api/git/diff`. +- Git mutations stay on `/api/git-delivery/*` and the governed Git, pull request, and merge windows. +- Command execution uses server-discovered task ids only. +- Container execution uses the server-frozen catalog only. + +Two production hardening gaps were found and fixed: + +1. Command and container run controls could accept rapid duplicate submits before React rerendered the `running` state. + The widgets now use the synchronous in-flight ref as the immediate guard, and the run buttons are truly disabled during execution. +2. Runtime-linked windows could retain stale project paths when an existing window was reconfigured from the Runtime hub. + The Runtime hub and command/container windows now synchronize their project path from updated window configuration. + +## Verification matrix + +Local targeted verification: + +- Runtime hub, command, container, and widget registry tests passed. +- Git delivery evidence check passed. +- Editor documentation link check passed. + +Required full verification before merge: + +- `npm run typecheck` +- `npm run lint` +- `npm run arch:check` +- `npm run arch:check:negative` +- `npm run test:coverage:quality` +- `npm run build --workspace @oscharko-dev/keiko-ui` +- `npm run check:git-delivery-evidence` +- `npm run check:editor-doc-links` +- `npm audit --audit-level=moderate --workspace @oscharko-dev/keiko-ui` + +Additional browser verification should be run only if this audit PR expands beyond the current UI hardening and documentation changes. + +## Closure requirements + +#1389 can be closed after this audit PR is merged and the issue receives a closure comment linking: + +- the merged implementation PR #1551, +- this audit PR, +- the local and GitHub check evidence, +- the retained Git Delivery boundary. + +#1491 can be closed only after: + +- every migrated child issue is closed with correct status labels, +- #1389 is closed, +- this audit evidence is merged, +- a PR from `feat/keiko-agent-native-editor-foundation-and-runtime` to `dev` is merged green, +- the epic receives a final closeout comment. diff --git a/packages/keiko-ui/src/app/components/desktop/widgets/cards/CommandsWidget.test.tsx b/packages/keiko-ui/src/app/components/desktop/widgets/cards/CommandsWidget.test.tsx index e91fd034..1590654d 100644 --- a/packages/keiko-ui/src/app/components/desktop/widgets/cards/CommandsWidget.test.tsx +++ b/packages/keiko-ui/src/app/components/desktop/widgets/cards/CommandsWidget.test.tsx @@ -2,7 +2,7 @@ // the UI through the same paths a real BFF would. Covers: catalog fetch, run-button POST, failure // surface, cancel via SSE-captured runId, SSE event display, and an axe a11y smoke. -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { axe } from "jest-axe"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -107,6 +107,17 @@ describe("CommandsWidget", () => { expect(screen.getByRole("button", { name: /run task/i })).toBeInTheDocument(); }); + it("reloads the discovered task catalog when the window project path changes", async () => { + const view = render(); + await screen.findByRole("combobox", { name: /task/i }); + vi.mocked(fetchCommandCatalog).mockClear(); + + view.rerender(); + + await waitFor(() => expect(fetchCommandCatalog).toHaveBeenCalledWith("/proj-next")); + expect(screen.getByLabelText(/project path/i)).toHaveValue("/proj-next"); + }); + it("populates the task dropdown from the discovered catalog", async () => { const user = userEvent.setup(); render(); @@ -139,6 +150,20 @@ describe("CommandsWidget", () => { }); }); + it("guards against duplicate submissions before React rerenders the running state", async () => { + vi.mocked(createCommandRun).mockImplementation(() => new Promise(() => undefined)); + render(); + await screen.findByRole("combobox", { name: /task/i }); + const runButton = screen.getByRole("button", { name: /run task/i }); + await waitFor(() => expect(runButton).toHaveAttribute("aria-disabled", "false")); + + fireEvent.click(runButton); + fireEvent.click(runButton); + + expect(createCommandRun).toHaveBeenCalledTimes(1); + expect(runButton).toBeDisabled(); + }); + it("surfaces a failure reason badge for a non-zero exit", async () => { vi.mocked(createCommandRun).mockResolvedValue({ ...RESULT, diff --git a/packages/keiko-ui/src/app/components/desktop/widgets/cards/CommandsWidget.tsx b/packages/keiko-ui/src/app/components/desktop/widgets/cards/CommandsWidget.tsx index 8e0268d6..f98fa85a 100644 --- a/packages/keiko-ui/src/app/components/desktop/widgets/cards/CommandsWidget.tsx +++ b/packages/keiko-ui/src/app/components/desktop/widgets/cards/CommandsWidget.tsx @@ -90,6 +90,10 @@ function resultSummary(result: CommandTaskRunResult): string { export function CommandsWidget(props: CommandsWidgetProps): ReactNode { const [projectInput, setProjectInput] = useState(props.projectPath ?? ""); + useEffect(() => { + setProjectInput(props.projectPath ?? ""); + }, [props.projectPath]); + const [tasks, setTasks] = useState([]); const [taskId, setTaskId] = useState(""); const [running, setRunning] = useState(false); @@ -169,7 +173,7 @@ export function CommandsWidget(props: CommandsWidgetProps): ReactNode { const onSubmit = useCallback( async (e: FormEvent): Promise => { e.preventDefault(); - if (running || taskId.length === 0) return; + if (running || runningRef.current || taskId.length === 0) return; setError(null); setResult(null); setInFlightRunId(null); @@ -236,7 +240,7 @@ export function CommandsWidget(props: CommandsWidgetProps): ReactNode { className="tm-action" data-primary="true" ref={runBtnRef} - disabled={tasks.length === 0} + disabled={running || tasks.length === 0 || taskId.length === 0} aria-disabled={running || tasks.length === 0 || taskId.length === 0} > {running ? "Running…" : "Run task"} @@ -245,6 +249,7 @@ export function CommandsWidget(props: CommandsWidgetProps): ReactNode {