diff --git a/run.json b/run.json index 21cdcfaeb..a754744ea 100644 --- a/run.json +++ b/run.json @@ -505,7 +505,7 @@ "kind": "running" }, "status_updated_at": "2026-05-29T18:30:49.079402Z", - "last_event_at": "2026-05-29T18:46:10.747704Z", + "last_event_at": "2026-05-29T18:50:38.426359Z", "pending_control": null, "checkpoints": [ { @@ -789,9 +789,9 @@ } }, { - "seq": 0, + "seq": 347, "checkpoint": { - "timestamp": "2026-05-29T18:46:10.842209Z", + "timestamp": "2026-05-29T18:46:15.327017Z", "current_node": "implement", "completed_nodes": [ "start", @@ -802,33 +802,223 @@ ], "node_retries": {}, "context_values": { - "internal.work_dir": "/home/daytona/workspace/fabro", + "graph.goal": "# Create Automation From Run Prefill Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Add a frontend-only flow that lets users start a new automation form from an existing run.\n\n**Architecture:** Reuse the existing `/automations/new` route with an optional `from_run` search parameter. The route derives initial form values from existing run and run-settings queries, then mounts a keyed form child so source data initializes local form state without direct React effects. The run actions menu links to the prefill route for ordinary runs and to the existing automation detail page for automation-created runs.\n\n**Tech Stack:** React 19, React Router, SWR, TypeScript, existing Fabro web API clients, Bun tests.\n\n---\n\n## Decisions\n\n- Add only frontend behavior. Do not change backend routes, OpenAPI, generated clients, or automation persistence.\n- Use `/automations/new?from_run=` as the public UI interface.\n- Treat `from_run` as a draft initializer only. Submitting still calls the existing `automationsApi.createAutomation`.\n- If a run already has `run.automation.id`, show `View automation` instead of `Create automation from run`.\n- Do not infer schedules from runs. Prefilled automations use manual/API trigger enabled and schedule disabled.\n- Do not use direct `useEffect` in route/component code; follow `docs/internal/react-effects-policy.md`.\n\n## Files\n\n- Modify `apps/fabro-web/app/routes/run-detail.tsx` for the actions menu navigation.\n- Modify `apps/fabro-web/app/routes/run-detail.test.ts` for run-action coverage.\n- Modify `apps/fabro-web/app/routes/automations-new.tsx` for query-param parsing, data loading, and keyed form initialization.\n- Modify `apps/fabro-web/app/components/automation-form.tsx` for a pure prefill helper built on existing `AutomationFormValues`, `kebabify`, `snakeify`, and `EMPTY_AUTOMATION_FORM`.\n- Create `apps/fabro-web/app/routes/automations-new.test.tsx` for route-level prefill behavior.\n\n## Implementation Tasks\n\n### Task 1: Add A Pure Prefill Helper\n\n- [ ] In `apps/fabro-web/app/components/automation-form.tsx`, add an exported helper named `automationFormValuesFromRun(run, settings)`.\n- [ ] The helper should return a complete `AutomationFormValues` object:\n - `name`: run title if present, otherwise workflow name, graph name, slug, or `\"New automation\"`.\n - `id`: `kebabify(name)`.\n - `description`: empty string.\n - `enabled`: `true`.\n - `repository`: prefer `settings.run.scm.owner` plus `settings.run.scm.repository`; otherwise use a GitHub-looking `run.repository.name`; otherwise parse GitHub `origin_url`; otherwise empty string.\n - `ref`: prefer `sandboxRuntime(run.sandbox)?.clone_branch`; otherwise `\"main\"`.\n - `workflow`: prefer `run.workflow.slug`; otherwise `snakeify(workflow name, graph name, or name)`.\n - `manualEnabled`: `true`.\n - `scheduleEnabled`: `false`.\n - `cron`: preserve `EMPTY_AUTOMATION_FORM.cron`.\n- [ ] Keep repository parsing intentionally narrow: only produce `owner/repo` for GitHub-style values. Leave non-GitHub or unknown repositories blank so users can edit them.\n\n### Task 2: Wire `/automations/new?from_run=...`\n\n- [ ] In `apps/fabro-web/app/routes/automations-new.tsx`, import `useSearchParams`, `useRun`, and `useRunSettings`.\n- [ ] Split the route into a wrapper and a keyed form child:\n - Wrapper reads `from_run`.\n - Wrapper calls `useRun(fromRunId)` and `useRunSettings(fromRunId)` only when `from_run` is present.\n - Wrapper derives initial values during render.\n - Form child owns `useState(initialValues)` exactly as the current route does.\n- [ ] For the blank path, preserve current behavior and render immediately with `EMPTY_AUTOMATION_FORM`.\n- [ ] For `from_run`, render a small loading placeholder until the run query resolves.\n- [ ] If the source run cannot be loaded, render the normal empty form with a non-blocking `ErrorMessage` explaining that the source run could not be loaded and the automation can be filled manually.\n- [ ] On cancel, continue navigating to `/automations`.\n- [ ] On submit success, keep the current toast and navigation to `/automations`.\n\n### Task 3: Add Run Detail Actions\n\n- [ ] In `apps/fabro-web/app/routes/run-detail.tsx`, add an operations action after `Preview` and before interrupt/steering actions.\n- [ ] If `summary.automation?.id` is present:\n - key: `view-automation`\n - label: `View automation`\n - onSelect: navigate to `/automations/${encodeURIComponent(summary.automation.id)}`\n- [ ] Otherwise:\n - key: `create-automation`\n - label: `Create automation from run`\n - onSelect: navigate to `/automations/new?from_run=${encodeURIComponent(params.id)}`\n- [ ] Do not disable the action for terminal, active, archived, or demo runs. It is only navigation.\n\n### Task 4: Add Focused Tests\n\n- [ ] Add `apps/fabro-web/app/routes/automations-new.test.tsx`.\n- [ ] Mock `../lib/queries` so `useRun` and `useRunSettings` can return controlled data.\n- [ ] Test `/automations/new` still renders empty form values.\n- [ ] Test `/automations/new?from_run=run_1` pre-populates name, slug, repository, branch, workflow, manual trigger, and disabled schedule from mocked run/settings data.\n- [ ] Test missing source-run data renders the form with an error message and empty editable fields.\n- [ ] Extend `apps/fabro-web/app/routes/run-detail.test.ts`:\n - Unlinked run shows `Create automation from run`; selecting it navigates to `/automations/new?from_run=run_1`.\n - Linked run shows `View automation`; selecting it navigates to `/automations/`.\n\n## Verification\n\n- [ ] Run the focused tests:\n\n```bash\ncd apps/fabro-web && bun test app/routes/automations-new.test.tsx app/routes/run-detail.test.ts\n```\n\n- [ ] Run type checking:\n\n```bash\ncd apps/fabro-web && bun run typecheck\n```\n\n- [ ] If focused tests expose shared test setup issues, run the full web test suite before finishing:\n\n```bash\ncd apps/fabro-web && bun test\n```\n\n## Acceptance Criteria\n\n- From an ordinary run detail page, the actions menu includes `Create automation from run`.\n- Selecting it opens `/automations/new?from_run=` with editable prefilled automation fields.\n- From a run that already has automation metadata, the actions menu includes `View automation` and does not offer a duplicate-create action.\n- Creating the automation still uses the existing create automation API and persists the same automation shape as manual creation.\n- No backend, OpenAPI, generated client, or scheduler files are changed.\n", + "graph.rankdir": "LR", + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "current_node": "implement", + "internal.retry_count.preflight_compile": 0, + "outcome": "succeeded", + "internal.retry_count.toolchain": 0, + "internal.run_id": "01KSTG15CJNFARS9XGNYZTHZP6", + "failure_signature": "", + "internal.thread_id": "preflight_lint", "response.implement": "Implemented the frontend-only “create automation from run” prefill flow.\n\nChanges made:\n- Added `automationFormValuesFromRun(run, settings)` in `automation-form.tsx`.\n - Prefills name, slug, repo, branch, workflow, and trigger defaults.\n - Keeps repo parsing intentionally GitHub-only/narrow.\n- Wired `/automations/new?from_run=` in `automations-new.tsx`.\n - Uses `useSearchParams`, `useRun`, and `useRunSettings`.\n - Splits route wrapper from keyed form child.\n - Preserves blank form behavior.\n - Shows loading while source run loads.\n - Falls back to editable empty form with a non-blocking error if source run can’t load.\n- Added run detail actions:\n - Ordinary runs: `Create automation from run`\n - Automation-linked runs: `View automation`\n- Added route-level prefill tests in `automations-new.test.tsx`.\n- Extended `run-detail.test.ts` for create/view automation navigation.\n\nVerification:\n- `cd apps/fabro-web && bun test app/routes/automations-new.test.tsx app/routes/run-detail.test.ts` ✅\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `cd apps/fabro-web && bun test` ✅ — 508 tests passed\n\nNo backend, OpenAPI, generated client, or scheduler files were changed.", + "internal.retry_count.start": 0, + "last_response": "Implemented the frontend-only “create automation from run” prefill flow.\n\nChanges made:\n- Added `automationFormValuesFromRun(run, settings)` in `automation-form.tsx`.\n - Prefills name, slug, repo", + "thread.preflight_lint.current_node": "implement", + "thread.toolchain.current_node": "preflight_compile", + "internal.retry_count.implement": 0, + "last_stage": "implement", + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "internal.retry_count.preflight_lint": 0, + "internal.fidelity": "compact", + "thread.start.current_node": "toolchain", + "failure_class": "", + "thread.preflight_compile.current_node": "preflight_lint", + "internal.node_visit_count": 1, + "internal.work_dir": "/home/daytona/workspace/fabro" + }, + "node_outcomes": { + "preflight_lint": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 151325, + "active_time_ms": 151325 + } + }, + "start": { + "status": "succeeded", + "usage": null + }, + "toolchain": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/fc14b2ba2d770e5cd3169df7a29525c962adfc4cfa3097b9098c63ebd61a748c" + }, + "notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 6761, + "active_time_ms": 6761 + } + }, + "preflight_compile": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo check -q --workspace 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 131279, + "active_time_ms": 131279 + } + }, + "implement": { + "status": "succeeded", + "context_updates": { + "last_stage": "implement", + "response.implement": "Implemented the frontend-only “create automation from run” prefill flow.\n\nChanges made:\n- Added `automationFormValuesFromRun(run, settings)` in `automation-form.tsx`.\n - Prefills name, slug, repo, branch, workflow, and trigger defaults.\n - Keeps repo parsing intentionally GitHub-only/narrow.\n- Wired `/automations/new?from_run=` in `automations-new.tsx`.\n - Uses `useSearchParams`, `useRun`, and `useRunSettings`.\n - Splits route wrapper from keyed form child.\n - Preserves blank form behavior.\n - Shows loading while source run loads.\n - Falls back to editable empty form with a non-blocking error if source run can’t load.\n- Added run detail actions:\n - Ordinary runs: `Create automation from run`\n - Automation-linked runs: `View automation`\n- Added route-level prefill tests in `automations-new.test.tsx`.\n- Extended `run-detail.test.ts` for create/view automation navigation.\n\nVerification:\n- `cd apps/fabro-web && bun test app/routes/automations-new.test.tsx app/routes/run-detail.test.ts` ✅\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `cd apps/fabro-web && bun test` ✅ — 508 tests passed\n\nNo backend, OpenAPI, generated client, or scheduler files were changed.", + "last_response": "Implemented the frontend-only “create automation from run” prefill flow.\n\nChanges made:\n- Added `automationFormValuesFromRun(run, settings)` in `automation-form.tsx`.\n - Prefills name, slug, repo" + }, + "notes": "Stage completed: implement", + "usage": { + "input": { + "usage": { + "model": { + "provider": "openai", + "model_id": "gpt-5.5" + }, + "tokens": { + "input_tokens": 1099856, + "output_tokens": 10986, + "reasoning_tokens": 10513, + "cache_read_tokens": 3378688, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "openai" + } + }, + "total_usd_micros": 7833594 + }, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 557096, + "tool_time_ms": 52550, + "active_time_ms": 609646 + } + } + }, + "next_node_id": "simplify_opus", + "git_commit_sha": "95e8f6f4f30d42b89879217055d7b7115b800593", + "node_visits": { + "preflight_compile": 1, + "start": 1, + "toolchain": 1, + "preflight_lint": 1, + "implement": 1 + } + }, + "diff": { + "patch": "diff --git a/apps/fabro-web/app/components/automation-form.tsx b/apps/fabro-web/app/components/automation-form.tsx\nindex 5f79f4a78..7b2b669e5 100644\n--- a/apps/fabro-web/app/components/automation-form.tsx\n+++ b/apps/fabro-web/app/components/automation-form.tsx\n@@ -1,9 +1,15 @@\n import { useRef, type ReactNode } from \"react\";\n import { Switch } from \"@headlessui/react\";\n-import type { Automation, AutomationTrigger } from \"@qltysh/fabro-api-client\";\n+import type {\n+ Automation,\n+ AutomationTrigger,\n+ Run,\n+ WorkflowSettings,\n+} from \"@qltysh/fabro-api-client\";\n \n import { Panel, Row } from \"./settings-panel\";\n import { INPUT_CLASS } from \"./ui\";\n+import { sandboxRuntime } from \"../lib/run-sandbox-lifecycle\";\n \n export interface AutomationFormValues {\n id: string;\n@@ -55,6 +61,40 @@ export function automationToFormValues(automation: Automation): AutomationFormVa\n };\n }\n \n+export function automationFormValuesFromRun(\n+ run: Run,\n+ settings?: WorkflowSettings | null,\n+): AutomationFormValues {\n+ const name = firstPresentString(\n+ run.title,\n+ run.workflow.name,\n+ run.workflow.graph_name,\n+ run.workflow.slug,\n+ \"New automation\",\n+ );\n+ const workflowName = firstPresentString(\n+ run.workflow.name,\n+ run.workflow.graph_name,\n+ name,\n+ );\n+ return {\n+ ...EMPTY_AUTOMATION_FORM,\n+ id: kebabify(name),\n+ name,\n+ description: \"\",\n+ enabled: true,\n+ repository: githubRepositoryFromSettings(settings)\n+ ?? githubRepositoryName(run.repository?.name)\n+ ?? githubRepositoryFromOriginUrl(run.repository?.origin_url)\n+ ?? \"\",\n+ ref: sandboxRuntime(run.sandbox)?.clone_branch ?? EMPTY_AUTOMATION_FORM.ref,\n+ workflow: run.workflow.slug?.trim() || snakeify(workflowName),\n+ manualEnabled: true,\n+ scheduleEnabled: false,\n+ cron: EMPTY_AUTOMATION_FORM.cron,\n+ };\n+}\n+\n export function triggersFromFormValues(values: AutomationFormValues): AutomationTrigger[] {\n const triggers: AutomationTrigger[] = [];\n if (values.manualEnabled) {\n@@ -98,6 +138,54 @@ export function snakeify(value: string): string {\n .replace(/^_|_$/g, \"\");\n }\n \n+function firstPresentString(...values: Array): string {\n+ for (const value of values) {\n+ const trimmed = value?.trim();\n+ if (trimmed) return trimmed;\n+ }\n+ return \"\";\n+}\n+\n+function githubRepositoryFromSettings(\n+ settings?: WorkflowSettings | null,\n+): string | null {\n+ const owner = settings?.run?.scm?.owner;\n+ const repository = settings?.run?.scm?.repository;\n+ if (!owner || !repository) return null;\n+ return githubRepositoryName(`${owner}/${repository}`);\n+}\n+\n+function githubRepositoryName(value: string | null | undefined): string | null {\n+ const trimmed = value?.trim().replace(/\\.git$/i, \"\");\n+ if (!trimmed) return null;\n+\n+ const match = trimmed.match(/^([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\\/([A-Za-z0-9._-]+)$/);\n+ if (!match) return null;\n+ return `${match[1]}/${match[2]}`;\n+}\n+\n+function githubRepositoryFromOriginUrl(value: string | null | undefined): string | null {\n+ const trimmed = value?.trim();\n+ if (!trimmed) return null;\n+\n+ const scpLikeMatch = trimmed.match(\n+ /^git@github\\.com:([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\\/([A-Za-z0-9._-]+?)(?:\\.git)?$/i,\n+ );\n+ if (scpLikeMatch) {\n+ return githubRepositoryName(`${scpLikeMatch[1]}/${scpLikeMatch[2]}`);\n+ }\n+\n+ try {\n+ const url = new URL(trimmed);\n+ if (url.hostname.toLowerCase() !== \"github.com\") return null;\n+ const parts = url.pathname.split(\"/\").filter(Boolean);\n+ if (parts.length !== 2) return null;\n+ return githubRepositoryName(`${parts[0]}/${parts[1]}`);\n+ } catch {\n+ return null;\n+ }\n+}\n+\n function describeCron(expression: string): string {\n const trimmed = expression.trim();\n const preset = CRON_PRESETS.find((p) => p.value === trimmed);\ndiff --git a/apps/fabro-web/app/routes/automations-new.test.tsx b/apps/fabro-web/app/routes/automations-new.test.tsx\nnew file mode 100644\nindex 000000000..47f471abf\n--- /dev/null\n+++ b/apps/fabro-web/app/routes/automations-new.test.tsx\n@@ -0,0 +1,300 @@\n+import { afterEach, beforeEach, describe, expect, mock, test } from \"bun:test\";\n+import { createElement } from \"react\";\n+import TestRenderer, { act } from \"react-test-renderer\";\n+import { createMemoryRouter, RouterProvider } from \"react-router\";\n+\n+import { ToastProvider } from \"../components/toast\";\n+import { setupReactTestEnv } from \"../lib/test-utils\";\n+\n+let currentRun: any = null;\n+let currentRunError: unknown = null;\n+let currentRunLoading = false;\n+let currentRunSettings: any = null;\n+const queryCalls: Array<{ hook: string; id: string | undefined }> = [];\n+const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];\n+let teardownReactEnv: (() => void) | undefined;\n+\n+const createAutomationMock = mock((_payload: unknown) =>\n+ Promise.resolve({ data: {} }),\n+);\n+const swrMutateMock = mock((_key: unknown) => Promise.resolve(undefined));\n+\n+mock.module(\"@headlessui/react\", () => ({\n+ Dialog: ({ open, children }: any) =>\n+ open ? createElement(\"div\", { role: \"dialog\" }, children) : null,\n+ DialogPanel: ({ children, ...props }: any) =>\n+ createElement(\"div\", props, children),\n+ DialogTitle: ({ children, ...props }: any) =>\n+ createElement(\"h2\", props, children),\n+ Switch: ({ checked, onChange, children, ...props }: any) =>\n+ createElement(\n+ \"button\",\n+ {\n+ ...props,\n+ type: \"button\",\n+ role: \"switch\",\n+ \"aria-checked\": checked,\n+ onClick: () => onChange(!checked),\n+ },\n+ children,\n+ ),\n+}));\n+\n+mock.module(\"../lib/queries\", () => ({\n+ useRun: (id: string | undefined) => {\n+ queryCalls.push({ hook: \"useRun\", id });\n+ return {\n+ data: currentRun,\n+ error: currentRunError,\n+ isLoading: currentRunLoading,\n+ };\n+ },\n+ useRunSettings: (id: string | undefined) => {\n+ queryCalls.push({ hook: \"useRunSettings\", id });\n+ return {\n+ data: currentRunSettings,\n+ error: null,\n+ isLoading: false,\n+ };\n+ },\n+}));\n+\n+mock.module(\"../lib/api-client\", () => ({\n+ ApiError: class ApiError extends Error {\n+ readonly status: number;\n+ readonly requestId: string | null;\n+ readonly body: unknown;\n+\n+ constructor({\n+ status,\n+ message,\n+ requestId,\n+ body,\n+ }: {\n+ status: number;\n+ message: string;\n+ requestId: string | null;\n+ body: unknown;\n+ }) {\n+ super(message);\n+ this.name = \"ApiError\";\n+ this.status = status;\n+ this.requestId = requestId;\n+ this.body = body;\n+ }\n+ },\n+ apiData: async function apiData(\n+ call: () => Promise<{ data: T }>,\n+ ): Promise {\n+ const response = await call();\n+ return response.data;\n+ },\n+ automationsApi: {\n+ createAutomation: createAutomationMock,\n+ },\n+}));\n+\n+mock.module(\"swr\", () => ({\n+ useSWRConfig: () => ({ mutate: swrMutateMock }),\n+}));\n+\n+const { default: AutomationsNew } = await import(\"./automations-new\");\n+mock.restore();\n+\n+function makeRun(overrides: Record = {}) {\n+ return {\n+ id: \"run_1\",\n+ children_count: 0,\n+ goal: \"Fix CI\",\n+ title: \"Fix failing tests\",\n+ workflow: {\n+ slug: \"fix_ci\",\n+ name: \"Fix CI\",\n+ graph_name: \"ci_graph\",\n+ node_count: 0,\n+ edge_count: 0,\n+ },\n+ automation: null,\n+ repository: {\n+ name: \"fallback/repo\",\n+ origin_url: \"https://github.com/fallback/repo.git\",\n+ provider: \"github\",\n+ },\n+ created_by: null,\n+ origin: { kind: \"api\" },\n+ labels: {},\n+ lifecycle: {\n+ status: { kind: \"succeeded\", reason: \"completed\" },\n+ approval: null,\n+ pending_control: null,\n+ queue_position: null,\n+ error: null,\n+ archived: false,\n+ archived_at: null,\n+ },\n+ sandbox: {\n+ kind: \"ready\",\n+ plan: { provider: \"docker\", image: null, snapshot: null },\n+ instance: {\n+ provider: \"docker\",\n+ image: null,\n+ snapshot: null,\n+ runtime: {\n+ id: \"container_1\",\n+ working_directory: \"/workspace\",\n+ repo_cloned: true,\n+ clone_origin_url: \"https://github.com/qltysh/fabro.git\",\n+ clone_branch: \"feature/from-run\",\n+ },\n+ },\n+ },\n+ models: [],\n+ source_directory: null,\n+ timestamps: {\n+ created_at: \"2026-04-20T12:00:00Z\",\n+ started_at: null,\n+ last_event_at: null,\n+ completed_at: null,\n+ },\n+ timing: null,\n+ billing: null,\n+ size: \"XS\",\n+ ask_fabro: {\n+ available: false,\n+ unavailable_reason: \"no_sandbox\",\n+ default_model: null,\n+ },\n+ diff: null,\n+ pull_request: null,\n+ current_question: null,\n+ superseded_by: null,\n+ retried_from: null,\n+ links: { web: null },\n+ ...overrides,\n+ };\n+}\n+\n+function makeRunSettings() {\n+ return {\n+ run: {\n+ scm: {\n+ provider: \"github\",\n+ owner: \"qltysh\",\n+ repository: \"fabro\",\n+ github: null,\n+ },\n+ },\n+ };\n+}\n+\n+async function renderAutomationsNew(initialEntry: string) {\n+ const router = createMemoryRouter(\n+ [\n+ {\n+ path: \"/automations\",\n+ element:
Automations
,\n+ },\n+ {\n+ path: \"/automations/new\",\n+ element: ,\n+ },\n+ ],\n+ { initialEntries: [initialEntry] },\n+ );\n+\n+ let renderer!: TestRenderer.ReactTestRenderer;\n+ await act(async () => {\n+ renderer = TestRenderer.create(\n+ \n+ \n+ ,\n+ );\n+ });\n+ mountedRenderers.push(renderer);\n+ return { renderer, router };\n+}\n+\n+function fieldValue(renderer: TestRenderer.ReactTestRenderer, label: string) {\n+ return renderer.root.findByProps({ \"aria-label\": label }).props.value;\n+}\n+\n+function switchChecked(renderer: TestRenderer.ReactTestRenderer, label: string) {\n+ const props = renderer.root.findByProps({ \"aria-label\": label }).props;\n+ return props[\"aria-checked\"] ?? props.checked;\n+}\n+\n+function textFromNode(\n+ node: ReturnType,\n+): string {\n+ if (!node) return \"\";\n+ if (typeof node === \"string\") return node;\n+ if (Array.isArray(node)) return node.map(textFromNode).join(\" \");\n+ return (node.children ?? []).map(textFromNode).join(\" \");\n+}\n+\n+beforeEach(() => {\n+ teardownReactEnv = setupReactTestEnv();\n+ currentRun = null;\n+ currentRunError = null;\n+ currentRunLoading = false;\n+ currentRunSettings = null;\n+ queryCalls.length = 0;\n+ createAutomationMock.mockClear();\n+ swrMutateMock.mockClear();\n+});\n+\n+afterEach(() => {\n+ for (const renderer of mountedRenderers.splice(0)) {\n+ act(() => renderer.unmount());\n+ }\n+ teardownReactEnv?.();\n+ teardownReactEnv = undefined;\n+});\n+\n+describe(\"AutomationsNew\", () => {\n+ test(\"/automations/new renders empty form values\", async () => {\n+ const { renderer } = await renderAutomationsNew(\"/automations/new\");\n+\n+ expect(fieldValue(renderer, \"Automation name\")).toBe(\"\");\n+ expect(fieldValue(renderer, \"Automation slug\")).toBe(\"\");\n+ expect(fieldValue(renderer, \"Repository\")).toBe(\"\");\n+ expect(fieldValue(renderer, \"Default branch\")).toBe(\"main\");\n+ expect(fieldValue(renderer, \"Workflow slug\")).toBe(\"\");\n+ expect(switchChecked(renderer, \"Enable manual and API triggers\")).toBe(true);\n+ expect(switchChecked(renderer, \"Enable scheduled triggers\")).toBe(false);\n+ });\n+\n+ test(\"/automations/new?from_run=run_1 pre-populates from run and settings data\", async () => {\n+ currentRun = makeRun();\n+ currentRunSettings = makeRunSettings();\n+\n+ const { renderer } = await renderAutomationsNew(\"/automations/new?from_run=run_1\");\n+\n+ expect(fieldValue(renderer, \"Automation name\")).toBe(\"Fix failing tests\");\n+ expect(fieldValue(renderer, \"Automation slug\")).toBe(\"fix-failing-tests\");\n+ expect(fieldValue(renderer, \"Repository\")).toBe(\"qltysh/fabro\");\n+ expect(fieldValue(renderer, \"Default branch\")).toBe(\"feature/from-run\");\n+ expect(fieldValue(renderer, \"Workflow slug\")).toBe(\"fix_ci\");\n+ expect(switchChecked(renderer, \"Enable manual and API triggers\")).toBe(true);\n+ expect(switchChecked(renderer, \"Enable scheduled triggers\")).toBe(false);\n+ expect(\n+ renderer.root.findAllByProps({ \"aria-label\": \"Cron expression\" }),\n+ ).toHaveLength(0);\n+ expect(queryCalls).toContainEqual({ hook: \"useRun\", id: \"run_1\" });\n+ expect(queryCalls).toContainEqual({ hook: \"useRunSettings\", id: \"run_1\" });\n+ });\n+\n+ test(\"missing source run data renders an editable empty form with a non-blocking error\", async () => {\n+ currentRun = null;\n+ currentRunError = new Error(\"not found\");\n+\n+ const { renderer } = await renderAutomationsNew(\"/automations/new?from_run=run_1\");\n+\n+ expect(textFromNode(renderer.toJSON())).toContain(\"could not be loaded\");\n+ expect(textFromNode(renderer.toJSON())).toContain(\"fill it out manually\");\n+ expect(fieldValue(renderer, \"Automation name\")).toBe(\"\");\n+ expect(fieldValue(renderer, \"Repository\")).toBe(\"\");\n+ expect(fieldValue(renderer, \"Default branch\")).toBe(\"main\");\n+ expect(fieldValue(renderer, \"Workflow slug\")).toBe(\"\");\n+ });\n+});\ndiff --git a/apps/fabro-web/app/routes/automations-new.tsx b/apps/fabro-web/app/routes/automations-new.tsx\nindex 1ee221c35..75454693a 100644\n--- a/apps/fabro-web/app/routes/automations-new.tsx\n+++ b/apps/fabro-web/app/routes/automations-new.tsx\n@@ -1,13 +1,15 @@\n import { useState } from \"react\";\n-import { Link, useNavigate } from \"react-router\";\n+import { Link, useNavigate, useSearchParams } from \"react-router\";\n import { useSWRConfig } from \"swr\";\n import { ChevronRightIcon } from \"@heroicons/react/20/solid\";\n \n import { ApiError, apiData, automationsApi } from \"../lib/api-client\";\n import { queryKeys } from \"../lib/query-keys\";\n+import { useRun, useRunSettings } from \"../lib/queries\";\n import {\n AutomationFormFields,\n EMPTY_AUTOMATION_FORM,\n+ automationFormValuesFromRun,\n isFormValid,\n triggersFromFormValues,\n type AutomationFormValues,\n@@ -26,10 +28,65 @@ export function meta() {\n export const handle = { hideHeader: true };\n \n export default function AutomationsNew() {\n+ const [searchParams] = useSearchParams();\n+ const fromRunId = searchParams.get(\"from_run\")?.trim() || undefined;\n+ const runQuery = useRun(fromRunId);\n+ const settingsQuery = useRunSettings(fromRunId);\n+\n+ if (!fromRunId) {\n+ return (\n+ \n+ );\n+ }\n+\n+ if (runQuery.isLoading && !runQuery.data) {\n+ return (\n+
\n+ \n+

\n+ Loading source run…\n+

\n+
\n+ );\n+ }\n+\n+ if (!runQuery.data) {\n+ return (\n+ \n+ );\n+ }\n+\n+ const initialValues = automationFormValuesFromRun(\n+ runQuery.data,\n+ settingsQuery.data ?? null,\n+ );\n+\n+ return (\n+ \n+ );\n+}\n+\n+function AutomationCreateForm({\n+ initialValues,\n+ sourceError = null,\n+}: {\n+ initialValues: AutomationFormValues;\n+ sourceError?: string | null;\n+}) {\n const navigate = useNavigate();\n const { mutate } = useSWRConfig();\n const toast = useToast();\n- const [values, setValues] = useState(EMPTY_AUTOMATION_FORM);\n+ const [values, setValues] = useState(initialValues);\n const [submitting, setSubmitting] = useState(false);\n const [error, setError] = useState(null);\n \n@@ -75,6 +132,7 @@ export default function AutomationsNew() {\n \n \n \n+ {sourceError ? : null}\n {error ? : null}\n \n \ndiff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts\nindex 844321220..012ba96a9 100644\n--- a/apps/fabro-web/app/routes/run-detail.test.ts\n+++ b/apps/fabro-web/app/routes/run-detail.test.ts\n@@ -50,6 +50,10 @@ mock.module(\"../lib/queries\", () => ({\n data: currentRunSummary,\n isLoading: false,\n }),\n+ useRunSettings: () => ({\n+ data: null,\n+ isLoading: false,\n+ }),\n useRunQuestions: () => ({\n data: currentQuestions,\n }),\n@@ -190,6 +194,7 @@ function makeRunSummary(\n pullRequest: any = null,\n title = \"Run 1\",\n askFabro: any = null,\n+ automation: any = null,\n ) {\n const apiStatus =\n status === \"succeeded\"\n@@ -207,7 +212,7 @@ function makeRunSummary(\n goal: \"Run 1\",\n title,\n workflow: { slug: \"default\", name: \"Default\", graph_name: null, node_count: 0, edge_count: 0 },\n- automation: null,\n+ automation,\n repository: { name: \"fabro\", origin_url: null, provider: \"unknown\" },\n created_by: null,\n origin: { kind: \"api\" },\n@@ -269,6 +274,7 @@ async function renderRunDetailHarness({\n pullRequest = null,\n title,\n askFabro = null,\n+ automation = null,\n }: {\n initialEntry: string;\n status?: string;\n@@ -277,8 +283,9 @@ async function renderRunDetailHarness({\n pullRequest?: any;\n title?: string;\n askFabro?: any;\n+ automation?: any;\n }) {\n- currentRunSummary = makeRunSummary(status, diffSummary, pullRequest, title, askFabro);\n+ currentRunSummary = makeRunSummary(status, diffSummary, pullRequest, title, askFabro, automation);\n currentQuestions = questions;\n (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;\n \n@@ -303,6 +310,14 @@ async function renderRunDetailHarness({\n },\n ],\n },\n+ {\n+ path: \"/automations/new\",\n+ element: h(\"div\", { \"data-route\": \"automations-new\" }, \"New automation\"),\n+ },\n+ {\n+ path: \"/automations/:automationId\",\n+ element: h(\"div\", { \"data-route\": \"automation-detail\" }, \"Automation detail\"),\n+ },\n ],\n { initialEntries: [initialEntry] },\n );\n@@ -373,6 +388,15 @@ function findButtonByText(\n )[0];\n }\n \n+function findButtonsByText(\n+ renderer: TestRenderer.ReactTestRenderer,\n+ text: string,\n+) {\n+ return renderer.root.findAll(\n+ (node) => node.type === \"button\" && textFromTestNode(node).includes(text),\n+ );\n+}\n+\n function deferred() {\n let resolve!: (value: T | PromiseLike) => void;\n let reject!: (reason?: unknown) => void;\n@@ -782,6 +806,38 @@ describe(\"RunDetail full-height child routes\", () => {\n expect(tabCountBadges(renderer)).toHaveLength(0);\n });\n \n+ test(\"ordinary runs can navigate to create an automation from the run\", async () => {\n+ const { renderer, router } = await renderRunDetailHarness({\n+ initialEntry: \"/runs/run_1\",\n+ });\n+\n+ expect(findButtonsByText(renderer, \"Create automation from run\")).toHaveLength(1);\n+ expect(findButtonsByText(renderer, \"View automation\")).toHaveLength(0);\n+\n+ await act(async () => {\n+ findButtonByText(renderer, \"Create automation from run\")!.props.onClick();\n+ });\n+\n+ expect(router.state.location.pathname).toBe(\"/automations/new\");\n+ expect(router.state.location.search).toBe(\"?from_run=run_1\");\n+ });\n+\n+ test(\"automation-created runs navigate to the existing automation instead of duplicate creation\", async () => {\n+ const { renderer, router } = await renderRunDetailHarness({\n+ initialEntry: \"/runs/run_1\",\n+ automation: { id: \"fix build\", name: \"Fix Build\", trigger_id: \"manual\" },\n+ });\n+\n+ expect(findButtonsByText(renderer, \"View automation\")).toHaveLength(1);\n+ expect(findButtonsByText(renderer, \"Create automation from run\")).toHaveLength(0);\n+\n+ await act(async () => {\n+ findButtonByText(renderer, \"View automation\")!.props.onClick();\n+ });\n+\n+ expect(router.state.location.pathname).toBe(\"/automations/fix%20build\");\n+ });\n+\n test(\"confirms deleting an archived run and navigates back to runs\", async () => {\n const deletion = deferred();\n deleteRunApiResult = deletion.promise;\ndiff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx\nindex b97da4026..ad75da0e0 100644\n--- a/apps/fabro-web/app/routes/run-detail.tsx\n+++ b/apps/fabro-web/app/routes/run-detail.tsx\n@@ -200,6 +200,18 @@ export default function RunDetail({ params }: { params: { id: string } }) {\n }\n };\n const hasPendingQuestions = isBlocked && pendingQuestions.length > 0;\n+ const automationId = summary.automation?.id ?? null;\n+ const automationAction = automationId\n+ ? {\n+ key: \"view-automation\",\n+ label: \"View automation\",\n+ onSelect: () => navigate(`/automations/${encodeURIComponent(automationId)}`),\n+ }\n+ : {\n+ key: \"create-automation\",\n+ label: \"Create automation from run\",\n+ onSelect: () => navigate(`/automations/new?from_run=${encodeURIComponent(params.id)}`),\n+ };\n const actionGroups: ActionGroups = {\n operations: [\n ...(hasSandbox\n@@ -211,6 +223,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {\n onSelect: () => void handlePreview(),\n }]\n : []),\n+ automationAction,\n {\n key: \"interrupt\",\n label: \"Send interrupt\",\n", + "summary": { + "files_changed": 5, + "additions": 536, + "deletions": 5 + } + } + }, + { + "seq": 0, + "checkpoint": { + "timestamp": "2026-05-29T18:50:38.762290Z", + "current_node": "simplify_opus", + "completed_nodes": [ + "start", + "toolchain", + "preflight_compile", + "preflight_lint", + "implement", + "simplify_opus" + ], + "node_retries": {}, + "context_values": { "failure_class": "", "internal.retry_count.implement": 0, "graph.goal": "# Create Automation From Run Prefill Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Add a frontend-only flow that lets users start a new automation form from an existing run.\n\n**Architecture:** Reuse the existing `/automations/new` route with an optional `from_run` search parameter. The route derives initial form values from existing run and run-settings queries, then mounts a keyed form child so source data initializes local form state without direct React effects. The run actions menu links to the prefill route for ordinary runs and to the existing automation detail page for automation-created runs.\n\n**Tech Stack:** React 19, React Router, SWR, TypeScript, existing Fabro web API clients, Bun tests.\n\n---\n\n## Decisions\n\n- Add only frontend behavior. Do not change backend routes, OpenAPI, generated clients, or automation persistence.\n- Use `/automations/new?from_run=` as the public UI interface.\n- Treat `from_run` as a draft initializer only. Submitting still calls the existing `automationsApi.createAutomation`.\n- If a run already has `run.automation.id`, show `View automation` instead of `Create automation from run`.\n- Do not infer schedules from runs. Prefilled automations use manual/API trigger enabled and schedule disabled.\n- Do not use direct `useEffect` in route/component code; follow `docs/internal/react-effects-policy.md`.\n\n## Files\n\n- Modify `apps/fabro-web/app/routes/run-detail.tsx` for the actions menu navigation.\n- Modify `apps/fabro-web/app/routes/run-detail.test.ts` for run-action coverage.\n- Modify `apps/fabro-web/app/routes/automations-new.tsx` for query-param parsing, data loading, and keyed form initialization.\n- Modify `apps/fabro-web/app/components/automation-form.tsx` for a pure prefill helper built on existing `AutomationFormValues`, `kebabify`, `snakeify`, and `EMPTY_AUTOMATION_FORM`.\n- Create `apps/fabro-web/app/routes/automations-new.test.tsx` for route-level prefill behavior.\n\n## Implementation Tasks\n\n### Task 1: Add A Pure Prefill Helper\n\n- [ ] In `apps/fabro-web/app/components/automation-form.tsx`, add an exported helper named `automationFormValuesFromRun(run, settings)`.\n- [ ] The helper should return a complete `AutomationFormValues` object:\n - `name`: run title if present, otherwise workflow name, graph name, slug, or `\"New automation\"`.\n - `id`: `kebabify(name)`.\n - `description`: empty string.\n - `enabled`: `true`.\n - `repository`: prefer `settings.run.scm.owner` plus `settings.run.scm.repository`; otherwise use a GitHub-looking `run.repository.name`; otherwise parse GitHub `origin_url`; otherwise empty string.\n - `ref`: prefer `sandboxRuntime(run.sandbox)?.clone_branch`; otherwise `\"main\"`.\n - `workflow`: prefer `run.workflow.slug`; otherwise `snakeify(workflow name, graph name, or name)`.\n - `manualEnabled`: `true`.\n - `scheduleEnabled`: `false`.\n - `cron`: preserve `EMPTY_AUTOMATION_FORM.cron`.\n- [ ] Keep repository parsing intentionally narrow: only produce `owner/repo` for GitHub-style values. Leave non-GitHub or unknown repositories blank so users can edit them.\n\n### Task 2: Wire `/automations/new?from_run=...`\n\n- [ ] In `apps/fabro-web/app/routes/automations-new.tsx`, import `useSearchParams`, `useRun`, and `useRunSettings`.\n- [ ] Split the route into a wrapper and a keyed form child:\n - Wrapper reads `from_run`.\n - Wrapper calls `useRun(fromRunId)` and `useRunSettings(fromRunId)` only when `from_run` is present.\n - Wrapper derives initial values during render.\n - Form child owns `useState(initialValues)` exactly as the current route does.\n- [ ] For the blank path, preserve current behavior and render immediately with `EMPTY_AUTOMATION_FORM`.\n- [ ] For `from_run`, render a small loading placeholder until the run query resolves.\n- [ ] If the source run cannot be loaded, render the normal empty form with a non-blocking `ErrorMessage` explaining that the source run could not be loaded and the automation can be filled manually.\n- [ ] On cancel, continue navigating to `/automations`.\n- [ ] On submit success, keep the current toast and navigation to `/automations`.\n\n### Task 3: Add Run Detail Actions\n\n- [ ] In `apps/fabro-web/app/routes/run-detail.tsx`, add an operations action after `Preview` and before interrupt/steering actions.\n- [ ] If `summary.automation?.id` is present:\n - key: `view-automation`\n - label: `View automation`\n - onSelect: navigate to `/automations/${encodeURIComponent(summary.automation.id)}`\n- [ ] Otherwise:\n - key: `create-automation`\n - label: `Create automation from run`\n - onSelect: navigate to `/automations/new?from_run=${encodeURIComponent(params.id)}`\n- [ ] Do not disable the action for terminal, active, archived, or demo runs. It is only navigation.\n\n### Task 4: Add Focused Tests\n\n- [ ] Add `apps/fabro-web/app/routes/automations-new.test.tsx`.\n- [ ] Mock `../lib/queries` so `useRun` and `useRunSettings` can return controlled data.\n- [ ] Test `/automations/new` still renders empty form values.\n- [ ] Test `/automations/new?from_run=run_1` pre-populates name, slug, repository, branch, workflow, manual trigger, and disabled schedule from mocked run/settings data.\n- [ ] Test missing source-run data renders the form with an error message and empty editable fields.\n- [ ] Extend `apps/fabro-web/app/routes/run-detail.test.ts`:\n - Unlinked run shows `Create automation from run`; selecting it navigates to `/automations/new?from_run=run_1`.\n - Linked run shows `View automation`; selecting it navigates to `/automations/`.\n\n## Verification\n\n- [ ] Run the focused tests:\n\n```bash\ncd apps/fabro-web && bun test app/routes/automations-new.test.tsx app/routes/run-detail.test.ts\n```\n\n- [ ] Run type checking:\n\n```bash\ncd apps/fabro-web && bun run typecheck\n```\n\n- [ ] If focused tests expose shared test setup issues, run the full web test suite before finishing:\n\n```bash\ncd apps/fabro-web && bun test\n```\n\n## Acceptance Criteria\n\n- From an ordinary run detail page, the actions menu includes `Create automation from run`.\n- Selecting it opens `/automations/new?from_run=` with editable prefilled automation fields.\n- From a run that already has automation metadata, the actions menu includes `View automation` and does not offer a duplicate-create action.\n- Creating the automation still uses the existing create automation API and persists the same automation shape as manual creation.\n- No backend, OpenAPI, generated client, or scheduler files are changed.\n", - "thread.toolchain.current_node": "preflight_compile", - "failure_signature": "", "internal.retry_count.preflight_compile": 0, - "internal.thread_id": "preflight_lint", "internal.retry_count.start": 0, "internal.retry_count.toolchain": 0, "thread.preflight_compile.current_node": "preflight_lint", "graph.rankdir": "LR", + "last_response": "## Summary of cleanup\n\nAggregated findings from three parallel review agents and fixed the meaningful issues:\n\n**High-priority fixes:**\n\n1. **Race condition causing lost edits** (`automations-new.tsx`", + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "thread.preflight_lint.current_node": "implement", + "internal.run_id": "01KSTG15CJNFARS9XGNYZTHZP6", + "response.implement": "Implemented the frontend-only “create automation from run” prefill flow.\n\nChanges made:\n- Added `automationFormValuesFromRun(run, settings)` in `automation-form.tsx`.\n - Prefills name, slug, repo, branch, workflow, and trigger defaults.\n - Keeps repo parsing intentionally GitHub-only/narrow.\n- Wired `/automations/new?from_run=` in `automations-new.tsx`.\n - Uses `useSearchParams`, `useRun`, and `useRunSettings`.\n - Splits route wrapper from keyed form child.\n - Preserves blank form behavior.\n - Shows loading while source run loads.\n - Falls back to editable empty form with a non-blocking error if source run can’t load.\n- Added run detail actions:\n - Ordinary runs: `Create automation from run`\n - Automation-linked runs: `View automation`\n- Added route-level prefill tests in `automations-new.test.tsx`.\n- Extended `run-detail.test.ts` for create/view automation navigation.\n\nVerification:\n- `cd apps/fabro-web && bun test app/routes/automations-new.test.tsx app/routes/run-detail.test.ts` ✅\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `cd apps/fabro-web && bun test` ✅ — 508 tests passed\n\nNo backend, OpenAPI, generated client, or scheduler files were changed.", + "internal.fidelity": "compact", + "internal.work_dir": "/home/daytona/workspace/fabro", + "response.simplify_opus": "## Summary of cleanup\n\nAggregated findings from three parallel review agents and fixed the meaningful issues:\n\n**High-priority fixes:**\n\n1. **Race condition causing lost edits** (`automations-new.tsx`) — Both query agents flagged that `sourceFormKey` keyed on derived `name/repository/ref/workflow` plus a `hasSettings` flag would remount the form (and discard user edits) when the parallel `useRunSettings` query resolved after `useRun`. Fixed by:\n - Waiting for both queries to settle before mounting the form\n - Reducing the key to `from-run:${fromRunId}` so SWR revalidation can't trigger spurious remounts\n - Deleted the `sourceFormKey` helper\n\n2. **Parameter sprawl in `makeRunSummary`** (`run-detail.test.ts`) — The function had grown to 6 positional args (most `any`-typed and defaulting to `null`). Converted to an options bag and updated all call sites.\n\n**Medium-priority fixes:**\n\n3. **Redundant default fields in `automationFormValuesFromRun`** (`automation-form.tsx`) — Removed `description`, `enabled`, `manualEnabled`, `scheduleEnabled`, `cron`, and `ref` fallback since they're already provided by the `...EMPTY_AUTOMATION_FORM` spread. The function is now significantly tighter.\n\n4. **Duplicated test helper** (`run-detail.test.ts`) — Collapsed `findButtonByText` to delegate to `findButtonsByText` instead of duplicating the `findAll` predicate.\n\n**Skipped:**\n\n- Extracting GitHub URL parsing to a shared lib — no other callers exist; keeping it co-located with its single consumer is fine for greenfield.\n- `firstPresentString` helper — borderline; agent confirmed no existing equivalent and inlining would hurt readability.\n- Test fixtures still use `any` — pre-existing pattern; not worth widening this diff.\n\n**Verification:** All 31 tests pass in the focused test suite, `tsc` clean.", + "internal.retry_count.simplify_opus": 0, + "thread.toolchain.current_node": "preflight_compile", + "failure_signature": "", + "internal.thread_id": "implement", "internal.node_visit_count": 1, "thread.start.current_node": "toolchain", "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "thread.implement.current_node": "simplify_opus", "outcome": "succeeded", - "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", "internal.retry_count.preflight_lint": 0, - "thread.preflight_lint.current_node": "implement", - "last_response": "Implemented the frontend-only “create automation from run” prefill flow.\n\nChanges made:\n- Added `automationFormValuesFromRun(run, settings)` in `automation-form.tsx`.\n - Prefills name, slug, repo", - "current_node": "implement", - "internal.run_id": "01KSTG15CJNFARS9XGNYZTHZP6", - "internal.fidelity": "compact", - "last_stage": "implement" + "current_node": "simplify_opus", + "last_stage": "simplify_opus" }, "node_outcomes": { + "simplify_opus": { + "status": "succeeded", + "context_updates": { + "last_response": "## Summary of cleanup\n\nAggregated findings from three parallel review agents and fixed the meaningful issues:\n\n**High-priority fixes:**\n\n1. **Race condition causing lost edits** (`automations-new.tsx`", + "last_stage": "simplify_opus", + "response.simplify_opus": "## Summary of cleanup\n\nAggregated findings from three parallel review agents and fixed the meaningful issues:\n\n**High-priority fixes:**\n\n1. **Race condition causing lost edits** (`automations-new.tsx`) — Both query agents flagged that `sourceFormKey` keyed on derived `name/repository/ref/workflow` plus a `hasSettings` flag would remount the form (and discard user edits) when the parallel `useRunSettings` query resolved after `useRun`. Fixed by:\n - Waiting for both queries to settle before mounting the form\n - Reducing the key to `from-run:${fromRunId}` so SWR revalidation can't trigger spurious remounts\n - Deleted the `sourceFormKey` helper\n\n2. **Parameter sprawl in `makeRunSummary`** (`run-detail.test.ts`) — The function had grown to 6 positional args (most `any`-typed and defaulting to `null`). Converted to an options bag and updated all call sites.\n\n**Medium-priority fixes:**\n\n3. **Redundant default fields in `automationFormValuesFromRun`** (`automation-form.tsx`) — Removed `description`, `enabled`, `manualEnabled`, `scheduleEnabled`, `cron`, and `ref` fallback since they're already provided by the `...EMPTY_AUTOMATION_FORM` spread. The function is now significantly tighter.\n\n4. **Duplicated test helper** (`run-detail.test.ts`) — Collapsed `findButtonByText` to delegate to `findButtonsByText` instead of duplicating the `findAll` predicate.\n\n**Skipped:**\n\n- Extracting GitHub URL parsing to a shared lib — no other callers exist; keeping it co-located with its single consumer is fine for greenfield.\n- `firstPresentString` helper — borderline; agent confirmed no existing equivalent and inlining would hurt readability.\n- Test fixtures still use `any` — pre-existing pattern; not worth widening this diff.\n\n**Verification:** All 31 tests pass in the focused test suite, `tsc` clean." + }, + "notes": "Stage completed: simplify_opus", + "usage": { + "input": { + "usage": { + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "tokens": { + "input_tokens": 44098, + "output_tokens": 9707, + "reasoning_tokens": 0, + "cache_read_tokens": 1075713, + "cache_write_tokens": 185242 + } + }, + "facts": { + "algorithm": "anthropic", + "cache_write_5m_tokens": 185242, + "cache_write_1h_tokens": 0 + } + }, + "total_usd_micros": 2158783 + }, + "files_touched": [ + "/home/daytona/workspace/fabro/apps/fabro-web/app/components/automation-form.tsx", + "/home/daytona/workspace/fabro/apps/fabro-web/app/routes/automations-new.tsx", + "/home/daytona/workspace/fabro/apps/fabro-web/app/routes/run-detail.test.ts" + ], + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 145167, + "tool_time_ms": 117342, + "active_time_ms": 262509 + } + }, "implement": { "status": "succeeded", "context_updates": { @@ -912,10 +1102,11 @@ } } }, - "next_node_id": "simplify_opus", + "next_node_id": "simplify_gpt", "node_visits": { "start": 1, "implement": 1, + "simplify_opus": 1, "preflight_compile": 1, "toolchain": 1, "preflight_lint": 1 @@ -1002,7 +1193,12 @@ "first_event_seq": 52, "prompt": null, "response": null, - "completion": null, + "completion": { + "outcome": "succeeded", + "notes": "Stage completed: implement", + "failure_reason": null, + "timestamp": "2026-05-29T18:46:10.841760Z" + }, "provider_used": { "mode": "agent", "provider": "openai", @@ -1016,6 +1212,12 @@ "output": null, "started_at": "2026-05-29T18:35:59.860224Z", "handler": "agent", + "timing": { + "wall_time_ms": 610978, + "inference_time_ms": 557096, + "tool_time_ms": 52550, + "active_time_ms": 609646 + }, "usage": { "input_tokens": 1099856, "output_tokens": 10986, @@ -1243,7 +1445,7 @@ ], "warnings": [] }, - "state": "running" + "state": "succeeded" }, "toolchain@1": { "first_event_seq": 22, @@ -1374,6 +1576,265 @@ "cache_write_tokens": 0 }, "state": "succeeded" + }, + "simplify_opus@1": { + "first_event_seq": 350, + "prompt": null, + "response": null, + "completion": null, + "provider_used": { + "mode": "agent", + "provider": "anthropic", + "model": "claude-opus-4-7" + }, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, + "started_at": "2026-05-29T18:46:15.329385Z", + "handler": "agent", + "usage": { + "input_tokens": 44098, + "output_tokens": 9707, + "total_tokens": 1314760, + "reasoning_tokens": 0, + "cache_read_tokens": 1075713, + "cache_write_tokens": 185242, + "total_usd_micros": 2158783 + }, + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "subagents": [ + { + "agent_id": "1876699d", + "depth": 1, + "task": "You are reviewing a code diff for **CODE REUSE** issues in the Fabro web app (React/TypeScript).\n\nThe diff is in `/tmp/changes.diff`. Read it first.\n\nWorking directory: /home/daytona/workspace/fabro\n\nFocus on:\n1. Search for existing utilities and helpers in `apps/fabro-web/app/lib/` and `apps/fabro-web/app/components/` that could replace newly written code in the diff.\n2. Specifically check for existing helpers for:\n - GitHub URL parsing / repository name extraction (e.g., search for \"github.com\", \"origin_url\", \"repository.name\" patterns in app/lib)\n - \"First present string\" / coalescing helpers\n - Existing query patterns using `useRun` + `useRunSettings` together\n - Existing patterns for keyed form initialization\n - Existing PageHeader / loading placeholder components\n3. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead.\n4. Flag inline logic that could use an existing utility.\n\nUse grep extensively. Examples:\n- `grep -rn \"github.com\" apps/fabro-web/app/lib apps/fabro-web/app/components`\n- `grep -rn \"origin_url\" apps/fabro-web/app`\n- `grep -rn \"clone_branch\" apps/fabro-web/app`\n- `grep -rn \"useRun.*useRunSettings\" apps/fabro-web/app`\n- `grep -rn \"kebabify\\|snakeify\" apps/fabro-web/app`\n\nNote: This is a greenfield app, focus on simplicity. Report findings concisely with file:line references.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 44 + } + }, + { + "agent_id": "93c0e6d0", + "depth": 1, + "task": "You are reviewing a code diff for **CODE QUALITY** issues in the Fabro web app (React/TypeScript).\n\nThe diff is in `/tmp/changes.diff`. Read it first.\n\nWorking directory: /home/daytona/workspace/fabro\n\nFocus on hacky patterns:\n1. Redundant state: state that duplicates existing state, cached values that could be derived\n2. Direct useEffect that should be derived state (the project has `docs/internal/react-effects-policy.md` — direct useEffect is discouraged)\n3. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring\n4. Copy-paste with slight variation: near-duplicate code blocks that should be unified\n5. Leaky abstractions: exposing internal details that should be encapsulated\n6. Stringly-typed code: using raw strings where constants, enums, or branded types exist\n7. Overly defensive null/undefined handling\n8. The `sourceFormKey` function looks suspicious — does it need to include all those fields, or is `fromRunId + hasSettings` sufficient?\n9. The diff modifies `makeRunSummary` in tests to add an `automation` positional parameter — this is parameter sprawl. Should be an object.\n10. Inspect the test for `makeRunSummary` signature — already has 5 positional args; adding a 6th is a smell.\n\nRead `apps/fabro-web/app/components/automation-form.tsx`, `apps/fabro-web/app/routes/automations-new.tsx`, `apps/fabro-web/app/routes/run-detail.tsx`, and `apps/fabro-web/app/routes/run-detail.test.ts` to assess the changes in context.\n\nNote: This is a greenfield app, be aggressive in optimizing quality. Report findings concisely with file:line references and suggested fixes.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 29 + } + }, + { + "agent_id": "f26888a7", + "depth": 1, + "task": "You are reviewing a code diff for **EFFICIENCY** issues in the Fabro web app (React/TypeScript).\n\nThe diff is in `/tmp/changes.diff`. Read it first.\n\nWorking directory: /home/daytona/workspace/fabro\n\nFocus on:\n1. Unnecessary work: redundant computations, repeated reads, duplicate API calls\n2. Missed concurrency: independent operations sequential when they could parallelize\n3. Hot-path bloat: new blocking work in per-render hot paths\n4. Per-render re-computation: things in `AutomationsNew` component body that re-run every render unnecessarily\n5. Whether `useRun(undefined)` and `useRunSettings(undefined)` make API calls when there's no `from_run` param — that would be wasteful\n6. Whether `automationFormValuesFromRun` is called inside render but should be memoized (it's pure but only used as initial state, so memoization may not matter)\n7. The `sourceFormKey` joins many strings — does that cause excessive remounts when data progressively loads (run loads first, then settings loads, key changes, form remounts and loses user edits)?\n\nRead `apps/fabro-web/app/routes/automations-new.tsx`, `apps/fabro-web/app/lib/queries.ts` (or similar) to understand how `useRun`/`useRunSettings` behave when id is undefined.\n\nNote: Report findings concisely with file:line references and suggested fixes. This is a greenfield React app.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 14 + } + } + ], + "permission_level": "full", + "agent_tools": [ + { + "name": "AskUserQuestion", + "description": "Ask the human one or more questions and wait for their answers before continuing this stage.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "TaskCreate", + "description": "Create pending tasks in the current session. Use concise subjects, descriptions, optional activeForm text, and metadata. Check TaskList first to avoid duplicate tasks.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "TaskGet", + "description": "Get one task by taskId, including subject, status, description, owner, blockedBy, and blocks.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "TaskList", + "description": "List tasks for the current session, including status, owner, and blocking dependencies. Use TaskGet with a taskId for full description and dependency details.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "TaskUpdate", + "description": "Update an existing task's status, text, owner, metadata, or dependencies. Valid statuses are pending, in_progress, completed, and deleted. After completing a task, call TaskList to find newly unblocked work.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "close_agent", + "description": "Close a running subagent that is no longer needed.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "edit_file", + "description": "Edit a file by replacing an exact string. The old_string must be an exact match and unique unless replace_all is true; include surrounding context when needed. Read the file first and preserve existing indentation.", + "source": { + "kind": "native" + }, + "category": "write", + "invoked": true + }, + { + "name": "glob", + "description": "Find files by file names using a glob pattern. Use path to choose the search root. Prefer this over shell find or ls when locating repository files.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": true + }, + { + "name": "grep", + "description": "Search file contents with a regex pattern. Use path to choose the search root, glob_filter to limit matching files, case_insensitive for case folding, and max_results to cap output.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": true + }, + { + "name": "read_file", + "description": "Read files before editing them. Returns line-numbered text and supports offset/limit for large files. Use this instead of shell cat, head, tail, or sed when inspecting repository files.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": true + }, + { + "name": "send_input", + "description": "Send a follow-up message to a running subagent when new information or corrected instructions are needed.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "shell", + "description": "Execute shell commands for terminal operations, package managers, tests and builds. Use dedicated tools for file reads, file edits, filename searches, and content searches. Provide timeout_ms for long-running commands.", + "source": { + "kind": "native" + }, + "category": "shell", + "invoked": true + }, + { + "name": "spawn_agent", + "description": "Spawn a subagent for independent work or context isolation. Use it for tasks that can proceed separately, and avoid duplicating the same work in the parent session.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": true + }, + { + "name": "wait", + "description": "Wait for a subagent to complete, then use the result to synthesize the outcome for the user.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": true + }, + { + "name": "web_fetch", + "description": "Fetch content from a URL that starts with http:// or https://. Pass a prompt to extract specific information or summarize the page; omit prompt to return the page content.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "web_search", + "description": "Search the web using Brave Search when current external information is needed. Returns result titles, URLs, and descriptions; use web_fetch for a specific URL.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "write_file", + "description": "Create new files, or overwrite an existing file only when replacement is explicitly intended. Prefer edit_file for targeted changes to existing files because write_file overwrites the full file content.", + "source": { + "kind": "native" + }, + "category": "write", + "invoked": false + } + ], + "context_window": { + "provider": "anthropic", + "model": "claude-opus-4-7", + "context_window_tokens": 1000000, + "input_tokens": 56454, + "usage_percent": 5.6454, + "count_method": "response_usage_scaled_breakdown", + "staleness": "live", + "generated_at": "2026-05-29T18:50:38.425799Z", + "event_seq": 610, + "breakdown": [ + { + "category": "system_prompt", + "tokens": 2555, + "usage_percent": 0.2555 + }, + { + "category": "tools", + "tokens": 2891, + "usage_percent": 0.2891 + }, + { + "category": "memory", + "tokens": 6147, + "usage_percent": 0.6147 + }, + { + "category": "conversation", + "tokens": 44854, + "usage_percent": 4.4854 + }, + { + "category": "other", + "tokens": 7, + "usage_percent": 0.0007 + } + ], + "warnings": [] + }, + "state": "running" } } } \ No newline at end of file diff --git a/stages/005-implement@1/diff.patch b/stages/005-implement@1/diff.patch new file mode 100644 index 000000000..fb91a3e51 --- /dev/null +++ b/stages/005-implement@1/diff.patch @@ -0,0 +1,694 @@ +diff --git a/apps/fabro-web/app/components/automation-form.tsx b/apps/fabro-web/app/components/automation-form.tsx +index 5f79f4a78..7b2b669e5 100644 +--- a/apps/fabro-web/app/components/automation-form.tsx ++++ b/apps/fabro-web/app/components/automation-form.tsx +@@ -1,9 +1,15 @@ + import { useRef, type ReactNode } from "react"; + import { Switch } from "@headlessui/react"; +-import type { Automation, AutomationTrigger } from "@qltysh/fabro-api-client"; ++import type { ++ Automation, ++ AutomationTrigger, ++ Run, ++ WorkflowSettings, ++} from "@qltysh/fabro-api-client"; + + import { Panel, Row } from "./settings-panel"; + import { INPUT_CLASS } from "./ui"; ++import { sandboxRuntime } from "../lib/run-sandbox-lifecycle"; + + export interface AutomationFormValues { + id: string; +@@ -55,6 +61,40 @@ export function automationToFormValues(automation: Automation): AutomationFormVa + }; + } + ++export function automationFormValuesFromRun( ++ run: Run, ++ settings?: WorkflowSettings | null, ++): AutomationFormValues { ++ const name = firstPresentString( ++ run.title, ++ run.workflow.name, ++ run.workflow.graph_name, ++ run.workflow.slug, ++ "New automation", ++ ); ++ const workflowName = firstPresentString( ++ run.workflow.name, ++ run.workflow.graph_name, ++ name, ++ ); ++ return { ++ ...EMPTY_AUTOMATION_FORM, ++ id: kebabify(name), ++ name, ++ description: "", ++ enabled: true, ++ repository: githubRepositoryFromSettings(settings) ++ ?? githubRepositoryName(run.repository?.name) ++ ?? githubRepositoryFromOriginUrl(run.repository?.origin_url) ++ ?? "", ++ ref: sandboxRuntime(run.sandbox)?.clone_branch ?? EMPTY_AUTOMATION_FORM.ref, ++ workflow: run.workflow.slug?.trim() || snakeify(workflowName), ++ manualEnabled: true, ++ scheduleEnabled: false, ++ cron: EMPTY_AUTOMATION_FORM.cron, ++ }; ++} ++ + export function triggersFromFormValues(values: AutomationFormValues): AutomationTrigger[] { + const triggers: AutomationTrigger[] = []; + if (values.manualEnabled) { +@@ -98,6 +138,54 @@ export function snakeify(value: string): string { + .replace(/^_|_$/g, ""); + } + ++function firstPresentString(...values: Array): string { ++ for (const value of values) { ++ const trimmed = value?.trim(); ++ if (trimmed) return trimmed; ++ } ++ return ""; ++} ++ ++function githubRepositoryFromSettings( ++ settings?: WorkflowSettings | null, ++): string | null { ++ const owner = settings?.run?.scm?.owner; ++ const repository = settings?.run?.scm?.repository; ++ if (!owner || !repository) return null; ++ return githubRepositoryName(`${owner}/${repository}`); ++} ++ ++function githubRepositoryName(value: string | null | undefined): string | null { ++ const trimmed = value?.trim().replace(/\.git$/i, ""); ++ if (!trimmed) return null; ++ ++ const match = trimmed.match(/^([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\/([A-Za-z0-9._-]+)$/); ++ if (!match) return null; ++ return `${match[1]}/${match[2]}`; ++} ++ ++function githubRepositoryFromOriginUrl(value: string | null | undefined): string | null { ++ const trimmed = value?.trim(); ++ if (!trimmed) return null; ++ ++ const scpLikeMatch = trimmed.match( ++ /^git@github\.com:([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\/([A-Za-z0-9._-]+?)(?:\.git)?$/i, ++ ); ++ if (scpLikeMatch) { ++ return githubRepositoryName(`${scpLikeMatch[1]}/${scpLikeMatch[2]}`); ++ } ++ ++ try { ++ const url = new URL(trimmed); ++ if (url.hostname.toLowerCase() !== "github.com") return null; ++ const parts = url.pathname.split("/").filter(Boolean); ++ if (parts.length !== 2) return null; ++ return githubRepositoryName(`${parts[0]}/${parts[1]}`); ++ } catch { ++ return null; ++ } ++} ++ + function describeCron(expression: string): string { + const trimmed = expression.trim(); + const preset = CRON_PRESETS.find((p) => p.value === trimmed); +diff --git a/apps/fabro-web/app/routes/automations-new.test.tsx b/apps/fabro-web/app/routes/automations-new.test.tsx +new file mode 100644 +index 000000000..47f471abf +--- /dev/null ++++ b/apps/fabro-web/app/routes/automations-new.test.tsx +@@ -0,0 +1,300 @@ ++import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; ++import { createElement } from "react"; ++import TestRenderer, { act } from "react-test-renderer"; ++import { createMemoryRouter, RouterProvider } from "react-router"; ++ ++import { ToastProvider } from "../components/toast"; ++import { setupReactTestEnv } from "../lib/test-utils"; ++ ++let currentRun: any = null; ++let currentRunError: unknown = null; ++let currentRunLoading = false; ++let currentRunSettings: any = null; ++const queryCalls: Array<{ hook: string; id: string | undefined }> = []; ++const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; ++let teardownReactEnv: (() => void) | undefined; ++ ++const createAutomationMock = mock((_payload: unknown) => ++ Promise.resolve({ data: {} }), ++); ++const swrMutateMock = mock((_key: unknown) => Promise.resolve(undefined)); ++ ++mock.module("@headlessui/react", () => ({ ++ Dialog: ({ open, children }: any) => ++ open ? createElement("div", { role: "dialog" }, children) : null, ++ DialogPanel: ({ children, ...props }: any) => ++ createElement("div", props, children), ++ DialogTitle: ({ children, ...props }: any) => ++ createElement("h2", props, children), ++ Switch: ({ checked, onChange, children, ...props }: any) => ++ createElement( ++ "button", ++ { ++ ...props, ++ type: "button", ++ role: "switch", ++ "aria-checked": checked, ++ onClick: () => onChange(!checked), ++ }, ++ children, ++ ), ++})); ++ ++mock.module("../lib/queries", () => ({ ++ useRun: (id: string | undefined) => { ++ queryCalls.push({ hook: "useRun", id }); ++ return { ++ data: currentRun, ++ error: currentRunError, ++ isLoading: currentRunLoading, ++ }; ++ }, ++ useRunSettings: (id: string | undefined) => { ++ queryCalls.push({ hook: "useRunSettings", id }); ++ return { ++ data: currentRunSettings, ++ error: null, ++ isLoading: false, ++ }; ++ }, ++})); ++ ++mock.module("../lib/api-client", () => ({ ++ ApiError: class ApiError extends Error { ++ readonly status: number; ++ readonly requestId: string | null; ++ readonly body: unknown; ++ ++ constructor({ ++ status, ++ message, ++ requestId, ++ body, ++ }: { ++ status: number; ++ message: string; ++ requestId: string | null; ++ body: unknown; ++ }) { ++ super(message); ++ this.name = "ApiError"; ++ this.status = status; ++ this.requestId = requestId; ++ this.body = body; ++ } ++ }, ++ apiData: async function apiData( ++ call: () => Promise<{ data: T }>, ++ ): Promise { ++ const response = await call(); ++ return response.data; ++ }, ++ automationsApi: { ++ createAutomation: createAutomationMock, ++ }, ++})); ++ ++mock.module("swr", () => ({ ++ useSWRConfig: () => ({ mutate: swrMutateMock }), ++})); ++ ++const { default: AutomationsNew } = await import("./automations-new"); ++mock.restore(); ++ ++function makeRun(overrides: Record = {}) { ++ return { ++ id: "run_1", ++ children_count: 0, ++ goal: "Fix CI", ++ title: "Fix failing tests", ++ workflow: { ++ slug: "fix_ci", ++ name: "Fix CI", ++ graph_name: "ci_graph", ++ node_count: 0, ++ edge_count: 0, ++ }, ++ automation: null, ++ repository: { ++ name: "fallback/repo", ++ origin_url: "https://github.com/fallback/repo.git", ++ provider: "github", ++ }, ++ created_by: null, ++ origin: { kind: "api" }, ++ labels: {}, ++ lifecycle: { ++ status: { kind: "succeeded", reason: "completed" }, ++ approval: null, ++ pending_control: null, ++ queue_position: null, ++ error: null, ++ archived: false, ++ archived_at: null, ++ }, ++ sandbox: { ++ kind: "ready", ++ plan: { provider: "docker", image: null, snapshot: null }, ++ instance: { ++ provider: "docker", ++ image: null, ++ snapshot: null, ++ runtime: { ++ id: "container_1", ++ working_directory: "/workspace", ++ repo_cloned: true, ++ clone_origin_url: "https://github.com/qltysh/fabro.git", ++ clone_branch: "feature/from-run", ++ }, ++ }, ++ }, ++ models: [], ++ source_directory: null, ++ timestamps: { ++ created_at: "2026-04-20T12:00:00Z", ++ started_at: null, ++ last_event_at: null, ++ completed_at: null, ++ }, ++ timing: null, ++ billing: null, ++ size: "XS", ++ ask_fabro: { ++ available: false, ++ unavailable_reason: "no_sandbox", ++ default_model: null, ++ }, ++ diff: null, ++ pull_request: null, ++ current_question: null, ++ superseded_by: null, ++ retried_from: null, ++ links: { web: null }, ++ ...overrides, ++ }; ++} ++ ++function makeRunSettings() { ++ return { ++ run: { ++ scm: { ++ provider: "github", ++ owner: "qltysh", ++ repository: "fabro", ++ github: null, ++ }, ++ }, ++ }; ++} ++ ++async function renderAutomationsNew(initialEntry: string) { ++ const router = createMemoryRouter( ++ [ ++ { ++ path: "/automations", ++ element:
Automations
, ++ }, ++ { ++ path: "/automations/new", ++ element: , ++ }, ++ ], ++ { initialEntries: [initialEntry] }, ++ ); ++ ++ let renderer!: TestRenderer.ReactTestRenderer; ++ await act(async () => { ++ renderer = TestRenderer.create( ++ ++ ++ , ++ ); ++ }); ++ mountedRenderers.push(renderer); ++ return { renderer, router }; ++} ++ ++function fieldValue(renderer: TestRenderer.ReactTestRenderer, label: string) { ++ return renderer.root.findByProps({ "aria-label": label }).props.value; ++} ++ ++function switchChecked(renderer: TestRenderer.ReactTestRenderer, label: string) { ++ const props = renderer.root.findByProps({ "aria-label": label }).props; ++ return props["aria-checked"] ?? props.checked; ++} ++ ++function textFromNode( ++ node: ReturnType, ++): string { ++ if (!node) return ""; ++ if (typeof node === "string") return node; ++ if (Array.isArray(node)) return node.map(textFromNode).join(" "); ++ return (node.children ?? []).map(textFromNode).join(" "); ++} ++ ++beforeEach(() => { ++ teardownReactEnv = setupReactTestEnv(); ++ currentRun = null; ++ currentRunError = null; ++ currentRunLoading = false; ++ currentRunSettings = null; ++ queryCalls.length = 0; ++ createAutomationMock.mockClear(); ++ swrMutateMock.mockClear(); ++}); ++ ++afterEach(() => { ++ for (const renderer of mountedRenderers.splice(0)) { ++ act(() => renderer.unmount()); ++ } ++ teardownReactEnv?.(); ++ teardownReactEnv = undefined; ++}); ++ ++describe("AutomationsNew", () => { ++ test("/automations/new renders empty form values", async () => { ++ const { renderer } = await renderAutomationsNew("/automations/new"); ++ ++ expect(fieldValue(renderer, "Automation name")).toBe(""); ++ expect(fieldValue(renderer, "Automation slug")).toBe(""); ++ expect(fieldValue(renderer, "Repository")).toBe(""); ++ expect(fieldValue(renderer, "Default branch")).toBe("main"); ++ expect(fieldValue(renderer, "Workflow slug")).toBe(""); ++ expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true); ++ expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false); ++ }); ++ ++ test("/automations/new?from_run=run_1 pre-populates from run and settings data", async () => { ++ currentRun = makeRun(); ++ currentRunSettings = makeRunSettings(); ++ ++ const { renderer } = await renderAutomationsNew("/automations/new?from_run=run_1"); ++ ++ expect(fieldValue(renderer, "Automation name")).toBe("Fix failing tests"); ++ expect(fieldValue(renderer, "Automation slug")).toBe("fix-failing-tests"); ++ expect(fieldValue(renderer, "Repository")).toBe("qltysh/fabro"); ++ expect(fieldValue(renderer, "Default branch")).toBe("feature/from-run"); ++ expect(fieldValue(renderer, "Workflow slug")).toBe("fix_ci"); ++ expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true); ++ expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false); ++ expect( ++ renderer.root.findAllByProps({ "aria-label": "Cron expression" }), ++ ).toHaveLength(0); ++ expect(queryCalls).toContainEqual({ hook: "useRun", id: "run_1" }); ++ expect(queryCalls).toContainEqual({ hook: "useRunSettings", id: "run_1" }); ++ }); ++ ++ test("missing source run data renders an editable empty form with a non-blocking error", async () => { ++ currentRun = null; ++ currentRunError = new Error("not found"); ++ ++ const { renderer } = await renderAutomationsNew("/automations/new?from_run=run_1"); ++ ++ expect(textFromNode(renderer.toJSON())).toContain("could not be loaded"); ++ expect(textFromNode(renderer.toJSON())).toContain("fill it out manually"); ++ expect(fieldValue(renderer, "Automation name")).toBe(""); ++ expect(fieldValue(renderer, "Repository")).toBe(""); ++ expect(fieldValue(renderer, "Default branch")).toBe("main"); ++ expect(fieldValue(renderer, "Workflow slug")).toBe(""); ++ }); ++}); +diff --git a/apps/fabro-web/app/routes/automations-new.tsx b/apps/fabro-web/app/routes/automations-new.tsx +index 1ee221c35..75454693a 100644 +--- a/apps/fabro-web/app/routes/automations-new.tsx ++++ b/apps/fabro-web/app/routes/automations-new.tsx +@@ -1,13 +1,15 @@ + import { useState } from "react"; +-import { Link, useNavigate } from "react-router"; ++import { Link, useNavigate, useSearchParams } from "react-router"; + import { useSWRConfig } from "swr"; + import { ChevronRightIcon } from "@heroicons/react/20/solid"; + + import { ApiError, apiData, automationsApi } from "../lib/api-client"; + import { queryKeys } from "../lib/query-keys"; ++import { useRun, useRunSettings } from "../lib/queries"; + import { + AutomationFormFields, + EMPTY_AUTOMATION_FORM, ++ automationFormValuesFromRun, + isFormValid, + triggersFromFormValues, + type AutomationFormValues, +@@ -26,10 +28,65 @@ export function meta() { + export const handle = { hideHeader: true }; + + export default function AutomationsNew() { ++ const [searchParams] = useSearchParams(); ++ const fromRunId = searchParams.get("from_run")?.trim() || undefined; ++ const runQuery = useRun(fromRunId); ++ const settingsQuery = useRunSettings(fromRunId); ++ ++ if (!fromRunId) { ++ return ( ++ ++ ); ++ } ++ ++ if (runQuery.isLoading && !runQuery.data) { ++ return ( ++
++ ++

++ Loading source run… ++

++
++ ); ++ } ++ ++ if (!runQuery.data) { ++ return ( ++ ++ ); ++ } ++ ++ const initialValues = automationFormValuesFromRun( ++ runQuery.data, ++ settingsQuery.data ?? null, ++ ); ++ ++ return ( ++ ++ ); ++} ++ ++function AutomationCreateForm({ ++ initialValues, ++ sourceError = null, ++}: { ++ initialValues: AutomationFormValues; ++ sourceError?: string | null; ++}) { + const navigate = useNavigate(); + const { mutate } = useSWRConfig(); + const toast = useToast(); +- const [values, setValues] = useState(EMPTY_AUTOMATION_FORM); ++ const [values, setValues] = useState(initialValues); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + +@@ -75,6 +132,7 @@ export default function AutomationsNew() { + + + ++ {sourceError ? : null} + {error ? : null} + + +diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts +index 844321220..012ba96a9 100644 +--- a/apps/fabro-web/app/routes/run-detail.test.ts ++++ b/apps/fabro-web/app/routes/run-detail.test.ts +@@ -50,6 +50,10 @@ mock.module("../lib/queries", () => ({ + data: currentRunSummary, + isLoading: false, + }), ++ useRunSettings: () => ({ ++ data: null, ++ isLoading: false, ++ }), + useRunQuestions: () => ({ + data: currentQuestions, + }), +@@ -190,6 +194,7 @@ function makeRunSummary( + pullRequest: any = null, + title = "Run 1", + askFabro: any = null, ++ automation: any = null, + ) { + const apiStatus = + status === "succeeded" +@@ -207,7 +212,7 @@ function makeRunSummary( + goal: "Run 1", + title, + workflow: { slug: "default", name: "Default", graph_name: null, node_count: 0, edge_count: 0 }, +- automation: null, ++ automation, + repository: { name: "fabro", origin_url: null, provider: "unknown" }, + created_by: null, + origin: { kind: "api" }, +@@ -269,6 +274,7 @@ async function renderRunDetailHarness({ + pullRequest = null, + title, + askFabro = null, ++ automation = null, + }: { + initialEntry: string; + status?: string; +@@ -277,8 +283,9 @@ async function renderRunDetailHarness({ + pullRequest?: any; + title?: string; + askFabro?: any; ++ automation?: any; + }) { +- currentRunSummary = makeRunSummary(status, diffSummary, pullRequest, title, askFabro); ++ currentRunSummary = makeRunSummary(status, diffSummary, pullRequest, title, askFabro, automation); + currentQuestions = questions; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +@@ -303,6 +310,14 @@ async function renderRunDetailHarness({ + }, + ], + }, ++ { ++ path: "/automations/new", ++ element: h("div", { "data-route": "automations-new" }, "New automation"), ++ }, ++ { ++ path: "/automations/:automationId", ++ element: h("div", { "data-route": "automation-detail" }, "Automation detail"), ++ }, + ], + { initialEntries: [initialEntry] }, + ); +@@ -373,6 +388,15 @@ function findButtonByText( + )[0]; + } + ++function findButtonsByText( ++ renderer: TestRenderer.ReactTestRenderer, ++ text: string, ++) { ++ return renderer.root.findAll( ++ (node) => node.type === "button" && textFromTestNode(node).includes(text), ++ ); ++} ++ + function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; +@@ -782,6 +806,38 @@ describe("RunDetail full-height child routes", () => { + expect(tabCountBadges(renderer)).toHaveLength(0); + }); + ++ test("ordinary runs can navigate to create an automation from the run", async () => { ++ const { renderer, router } = await renderRunDetailHarness({ ++ initialEntry: "/runs/run_1", ++ }); ++ ++ expect(findButtonsByText(renderer, "Create automation from run")).toHaveLength(1); ++ expect(findButtonsByText(renderer, "View automation")).toHaveLength(0); ++ ++ await act(async () => { ++ findButtonByText(renderer, "Create automation from run")!.props.onClick(); ++ }); ++ ++ expect(router.state.location.pathname).toBe("/automations/new"); ++ expect(router.state.location.search).toBe("?from_run=run_1"); ++ }); ++ ++ test("automation-created runs navigate to the existing automation instead of duplicate creation", async () => { ++ const { renderer, router } = await renderRunDetailHarness({ ++ initialEntry: "/runs/run_1", ++ automation: { id: "fix build", name: "Fix Build", trigger_id: "manual" }, ++ }); ++ ++ expect(findButtonsByText(renderer, "View automation")).toHaveLength(1); ++ expect(findButtonsByText(renderer, "Create automation from run")).toHaveLength(0); ++ ++ await act(async () => { ++ findButtonByText(renderer, "View automation")!.props.onClick(); ++ }); ++ ++ expect(router.state.location.pathname).toBe("/automations/fix%20build"); ++ }); ++ + test("confirms deleting an archived run and navigates back to runs", async () => { + const deletion = deferred(); + deleteRunApiResult = deletion.promise; +diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx +index b97da4026..ad75da0e0 100644 +--- a/apps/fabro-web/app/routes/run-detail.tsx ++++ b/apps/fabro-web/app/routes/run-detail.tsx +@@ -200,6 +200,18 @@ export default function RunDetail({ params }: { params: { id: string } }) { + } + }; + const hasPendingQuestions = isBlocked && pendingQuestions.length > 0; ++ const automationId = summary.automation?.id ?? null; ++ const automationAction = automationId ++ ? { ++ key: "view-automation", ++ label: "View automation", ++ onSelect: () => navigate(`/automations/${encodeURIComponent(automationId)}`), ++ } ++ : { ++ key: "create-automation", ++ label: "Create automation from run", ++ onSelect: () => navigate(`/automations/new?from_run=${encodeURIComponent(params.id)}`), ++ }; + const actionGroups: ActionGroups = { + operations: [ + ...(hasSandbox +@@ -211,6 +223,7 @@ export default function RunDetail({ params }: { params: { id: string } }) { + onSelect: () => void handlePreview(), + }] + : []), ++ automationAction, + { + key: "interrupt", + label: "Send interrupt", diff --git a/stages/005-implement@1/status.json b/stages/005-implement@1/status.json new file mode 100644 index 000000000..6c09dd013 --- /dev/null +++ b/stages/005-implement@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "succeeded", + "notes": "Stage completed: implement", + "failure_reason": null, + "timestamp": "2026-05-29T18:46:10.841760Z" +} \ No newline at end of file diff --git a/stages/006-simplify_opus@1/prompt.md b/stages/006-simplify_opus@1/prompt.md new file mode 100644 index 000000000..708831f5e --- /dev/null +++ b/stages/006-simplify_opus@1/prompt.md @@ -0,0 +1,181 @@ +Goal: # Create Automation From Run Prefill Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a frontend-only flow that lets users start a new automation form from an existing run. + +**Architecture:** Reuse the existing `/automations/new` route with an optional `from_run` search parameter. The route derives initial form values from existing run and run-settings queries, then mounts a keyed form child so source data initializes local form state without direct React effects. The run actions menu links to the prefill route for ordinary runs and to the existing automation detail page for automation-created runs. + +**Tech Stack:** React 19, React Router, SWR, TypeScript, existing Fabro web API clients, Bun tests. + +--- + +## Decisions + +- Add only frontend behavior. Do not change backend routes, OpenAPI, generated clients, or automation persistence. +- Use `/automations/new?from_run=` as the public UI interface. +- Treat `from_run` as a draft initializer only. Submitting still calls the existing `automationsApi.createAutomation`. +- If a run already has `run.automation.id`, show `View automation` instead of `Create automation from run`. +- Do not infer schedules from runs. Prefilled automations use manual/API trigger enabled and schedule disabled. +- Do not use direct `useEffect` in route/component code; follow `docs/internal/react-effects-policy.md`. + +## Files + +- Modify `apps/fabro-web/app/routes/run-detail.tsx` for the actions menu navigation. +- Modify `apps/fabro-web/app/routes/run-detail.test.ts` for run-action coverage. +- Modify `apps/fabro-web/app/routes/automations-new.tsx` for query-param parsing, data loading, and keyed form initialization. +- Modify `apps/fabro-web/app/components/automation-form.tsx` for a pure prefill helper built on existing `AutomationFormValues`, `kebabify`, `snakeify`, and `EMPTY_AUTOMATION_FORM`. +- Create `apps/fabro-web/app/routes/automations-new.test.tsx` for route-level prefill behavior. + +## Implementation Tasks + +### Task 1: Add A Pure Prefill Helper + +- [ ] In `apps/fabro-web/app/components/automation-form.tsx`, add an exported helper named `automationFormValuesFromRun(run, settings)`. +- [ ] The helper should return a complete `AutomationFormValues` object: + - `name`: run title if present, otherwise workflow name, graph name, slug, or `"New automation"`. + - `id`: `kebabify(name)`. + - `description`: empty string. + - `enabled`: `true`. + - `repository`: prefer `settings.run.scm.owner` plus `settings.run.scm.repository`; otherwise use a GitHub-looking `run.repository.name`; otherwise parse GitHub `origin_url`; otherwise empty string. + - `ref`: prefer `sandboxRuntime(run.sandbox)?.clone_branch`; otherwise `"main"`. + - `workflow`: prefer `run.workflow.slug`; otherwise `snakeify(workflow name, graph name, or name)`. + - `manualEnabled`: `true`. + - `scheduleEnabled`: `false`. + - `cron`: preserve `EMPTY_AUTOMATION_FORM.cron`. +- [ ] Keep repository parsing intentionally narrow: only produce `owner/repo` for GitHub-style values. Leave non-GitHub or unknown repositories blank so users can edit them. + +### Task 2: Wire `/automations/new?from_run=...` + +- [ ] In `apps/fabro-web/app/routes/automations-new.tsx`, import `useSearchParams`, `useRun`, and `useRunSettings`. +- [ ] Split the route into a wrapper and a keyed form child: + - Wrapper reads `from_run`. + - Wrapper calls `useRun(fromRunId)` and `useRunSettings(fromRunId)` only when `from_run` is present. + - Wrapper derives initial values during render. + - Form child owns `useState(initialValues)` exactly as the current route does. +- [ ] For the blank path, preserve current behavior and render immediately with `EMPTY_AUTOMATION_FORM`. +- [ ] For `from_run`, render a small loading placeholder until the run query resolves. +- [ ] If the source run cannot be loaded, render the normal empty form with a non-blocking `ErrorMessage` explaining that the source run could not be loaded and the automation can be filled manually. +- [ ] On cancel, continue navigating to `/automations`. +- [ ] On submit success, keep the current toast and navigation to `/automations`. + +### Task 3: Add Run Detail Actions + +- [ ] In `apps/fabro-web/app/routes/run-detail.tsx`, add an operations action after `Preview` and before interrupt/steering actions. +- [ ] If `summary.automation?.id` is present: + - key: `view-automation` + - label: `View automation` + - onSelect: navigate to `/automations/${encodeURIComponent(summary.automation.id)}` +- [ ] Otherwise: + - key: `create-automation` + - label: `Create automation from run` + - onSelect: navigate to `/automations/new?from_run=${encodeURIComponent(params.id)}` +- [ ] Do not disable the action for terminal, active, archived, or demo runs. It is only navigation. + +### Task 4: Add Focused Tests + +- [ ] Add `apps/fabro-web/app/routes/automations-new.test.tsx`. +- [ ] Mock `../lib/queries` so `useRun` and `useRunSettings` can return controlled data. +- [ ] Test `/automations/new` still renders empty form values. +- [ ] Test `/automations/new?from_run=run_1` pre-populates name, slug, repository, branch, workflow, manual trigger, and disabled schedule from mocked run/settings data. +- [ ] Test missing source-run data renders the form with an error message and empty editable fields. +- [ ] Extend `apps/fabro-web/app/routes/run-detail.test.ts`: + - Unlinked run shows `Create automation from run`; selecting it navigates to `/automations/new?from_run=run_1`. + - Linked run shows `View automation`; selecting it navigates to `/automations/`. + +## Verification + +- [ ] Run the focused tests: + +```bash +cd apps/fabro-web && bun test app/routes/automations-new.test.tsx app/routes/run-detail.test.ts +``` + +- [ ] Run type checking: + +```bash +cd apps/fabro-web && bun run typecheck +``` + +- [ ] If focused tests expose shared test setup issues, run the full web test suite before finishing: + +```bash +cd apps/fabro-web && bun test +``` + +## Acceptance Criteria + +- From an ordinary run detail page, the actions menu includes `Create automation from run`. +- Selecting it opens `/automations/new?from_run=` with editable prefilled automation fields. +- From a run that already has automation metadata, the actions menu includes `View automation` and does not offer a duplicate-create action. +- Creating the automation still uses the existing create automation API and persists the same automation shape as manual creation. +- No backend, OpenAPI, generated client, or scheduler files are changed. + + +## Completed stages +- **toolchain**: succeeded + - Script: `command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1` + - Output: + ``` + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + ``` +- **preflight_compile**: succeeded + - Script: `cargo check -q --workspace 2>&1` + - Output: (empty) +- **preflight_lint**: succeeded + - Script: `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1` + - Output: (empty) +- **implement**: succeeded + - Model: gpt-5.5, 1.1m tokens in / 21.5k out + + +# Simplify: Code Review and Cleanup + +Review changes vs. origin for reuse, quality, and efficiency. Fix any issues found. + +## Phase 1: Identify Changes + +Run git diff (or git diff HEAD if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation. + +## Phase 2: Launch Three Review Agents in Parallel + +Use the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context. + +### Agent 1: Code Reuse Review + +For each change: + +1. Search for existing utilities and helpers that could replace newly written code. Use Grep to find similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones. +2. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead. +3. Flag any inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates. + +Note: This is a greenfield app, so focus on maximizing simplicity and don't worry about changing things to achieve it. + +### Agent 2: Code Quality Review + +Review the same changes for hacky patterns: + +1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls +2. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring existing ones +3. Copy-paste with slight variation: near-duplicate code blocks that should be unified with a shared abstraction +4. Leaky abstractions: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries +5. Stringly-typed code: using raw strings where constants, enums (string unions), or branded types already exist in the codebase + +Note: This is a greenfield app, so be aggressive in optimizing quality. + +### Agent 3: Efficiency Review + +Review the same changes for efficiency: + +1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns +2. Missed concurrency: independent operations run sequentially when they could run in parallel +3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths +4. Unnecessary existence checks: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error +5. Memory: unbounded data structures, missing cleanup, event listener leaks +6. Overly broad operations: reading entire files when only a portion is needed, loading all items when filtering for one + +## Phase 3: Fix Issues + +Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it. + +When done, briefly summarize what was fixed (or confirm the code was already clean). \ No newline at end of file diff --git a/stages/006-simplify_opus@1/provider_used.json b/stages/006-simplify_opus@1/provider_used.json new file mode 100644 index 000000000..672cc50e5 --- /dev/null +++ b/stages/006-simplify_opus@1/provider_used.json @@ -0,0 +1,5 @@ +{ + "mode": "agent", + "provider": "anthropic", + "model": "claude-opus-4-7" +} \ No newline at end of file diff --git a/stages/006-simplify_opus@1/response.md b/stages/006-simplify_opus@1/response.md new file mode 100644 index 000000000..464a18adb --- /dev/null +++ b/stages/006-simplify_opus@1/response.md @@ -0,0 +1,26 @@ +## Summary of cleanup + +Aggregated findings from three parallel review agents and fixed the meaningful issues: + +**High-priority fixes:** + +1. **Race condition causing lost edits** (`automations-new.tsx`) — Both query agents flagged that `sourceFormKey` keyed on derived `name/repository/ref/workflow` plus a `hasSettings` flag would remount the form (and discard user edits) when the parallel `useRunSettings` query resolved after `useRun`. Fixed by: + - Waiting for both queries to settle before mounting the form + - Reducing the key to `from-run:${fromRunId}` so SWR revalidation can't trigger spurious remounts + - Deleted the `sourceFormKey` helper + +2. **Parameter sprawl in `makeRunSummary`** (`run-detail.test.ts`) — The function had grown to 6 positional args (most `any`-typed and defaulting to `null`). Converted to an options bag and updated all call sites. + +**Medium-priority fixes:** + +3. **Redundant default fields in `automationFormValuesFromRun`** (`automation-form.tsx`) — Removed `description`, `enabled`, `manualEnabled`, `scheduleEnabled`, `cron`, and `ref` fallback since they're already provided by the `...EMPTY_AUTOMATION_FORM` spread. The function is now significantly tighter. + +4. **Duplicated test helper** (`run-detail.test.ts`) — Collapsed `findButtonByText` to delegate to `findButtonsByText` instead of duplicating the `findAll` predicate. + +**Skipped:** + +- Extracting GitHub URL parsing to a shared lib — no other callers exist; keeping it co-located with its single consumer is fine for greenfield. +- `firstPresentString` helper — borderline; agent confirmed no existing equivalent and inlining would hurt readability. +- Test fixtures still use `any` — pre-existing pattern; not worth widening this diff. + +**Verification:** All 31 tests pass in the focused test suite, `tsc` clean. \ No newline at end of file