From db3fdc2e6c44a57000305773a7acab2e4a24732c Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Fri, 29 May 2026 10:17:58 +0530 Subject: [PATCH] feat(skills): scheduled dashboard + run/new pages + [github] preflight gate + composio-only GitHub I/O (#2882) Co-authored-by: sanil-23 Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: sanil-23 Co-authored-by: cyrus@tinyhumans.ai Co-authored-by: Steven Enamakel --- .claude/memory.md | 6 +- app/src-tauri/src/lib.rs | 19 +- app/src/AppRoutes.tsx | 29 + .../settings/panels/DevWorkflowPanel.tsx | 751 ++++++--- .../settings/panels/DeveloperOptionsPanel.tsx | 22 + .../panels/SkillsRunnerPanel.test.tsx | 28 + .../settings/panels/SkillsRunnerPanel.tsx | 31 + .../__tests__/DevWorkflowPanel.test.tsx | 754 ++++++++- app/src/components/skills/CreateSkillForm.tsx | 422 +++++ .../components/skills/CreateSkillModal.tsx | 420 +---- .../skills/ScheduledCronCard.test.tsx | 258 +++ .../components/skills/ScheduledCronCard.tsx | 279 ++++ .../components/skills/SkillsRunnerBody.tsx | 1379 +++++++++++++++++ .../skills/SmartIssuePicker.test.tsx | 83 + .../components/skills/SmartIssuePicker.tsx | 328 ++++ .../skills/__tests__/CreateSkillForm.test.tsx | 277 ++++ .../__tests__/CreateSkillModal.test.tsx | 37 +- .../__tests__/SkillsRunnerBody.test.tsx | 618 ++++++++ .../skills/inputs/BranchPicker.test.tsx | 64 + .../components/skills/inputs/BranchPicker.tsx | 148 ++ .../skills/inputs/RepoPicker.test.tsx | 68 + .../components/skills/inputs/RepoPicker.tsx | 148 ++ .../components/skills/preflightGate.test.ts | 89 ++ app/src/components/skills/preflightGate.ts | 68 + .../components/skills/scheduledCronFormat.ts | 31 + app/src/lib/cron/cronToHuman.test.ts | 87 ++ app/src/lib/cron/cronToHuman.ts | 90 ++ app/src/lib/i18n/chunks/ar-5.ts | 116 +- app/src/lib/i18n/chunks/bn-5.ts | 116 +- app/src/lib/i18n/chunks/de-5.ts | 116 +- app/src/lib/i18n/chunks/en-5.ts | 116 +- app/src/lib/i18n/chunks/es-5.ts | 116 +- app/src/lib/i18n/chunks/fr-5.ts | 116 +- app/src/lib/i18n/chunks/hi-5.ts | 116 +- app/src/lib/i18n/chunks/id-5.ts | 116 +- app/src/lib/i18n/chunks/it-5.ts | 116 +- app/src/lib/i18n/chunks/ko-5.ts | 116 +- app/src/lib/i18n/chunks/pl-5.ts | 116 +- app/src/lib/i18n/chunks/pt-5.ts | 116 +- app/src/lib/i18n/chunks/ru-5.ts | 116 +- app/src/lib/i18n/chunks/zh-CN-5.ts | 116 +- app/src/lib/i18n/en.ts | 120 +- app/src/pages/Settings.tsx | 2 + app/src/pages/SkillNew.test.tsx | 92 ++ app/src/pages/SkillNew.tsx | 100 ++ app/src/pages/Skills.tsx | 26 +- app/src/pages/SkillsDashboard.test.tsx | 218 +++ app/src/pages/SkillsDashboard.tsx | 286 ++++ app/src/pages/SkillsRun.test.tsx | 36 + app/src/pages/SkillsRun.tsx | 53 + app/src/services/api/skillsApi.test.ts | 123 ++ app/src/services/api/skillsApi.ts | 170 ++ app/src/utils/tauriCommands/cron.test.ts | 96 ++ app/src/utils/tauriCommands/cron.ts | 22 + docs/skills-runner-unification.md | 181 +++ src/core/jsonrpc.rs | 4 + .../agent/agents/code_executor/agent.toml | 7 +- .../agent/agents/code_executor/prompt.md | 33 + .../agent/agents/orchestrator/agent.toml | 9 + .../agent/agents/orchestrator/prompt.md | 2 +- .../agent/agents/tools_agent/agent.toml | 2 +- .../harness/subagent_runner/autonomous.rs | 29 + .../agent/harness/subagent_runner/mod.rs | 2 + .../agent/harness/subagent_runner/ops.rs | 8 +- src/openhuman/codegraph/index.rs | 829 ++++++++++ src/openhuman/codegraph/mod.rs | 29 + src/openhuman/codegraph/search.rs | 289 ++++ src/openhuman/codegraph/store.rs | 332 ++++ src/openhuman/composio/identity.rs | 232 +++ src/openhuman/composio/mod.rs | 2 + src/openhuman/cron/schemas.rs | 198 ++- .../desktop_companion/pipeline_tests.rs | 10 +- src/openhuman/desktop_companion/session.rs | 17 + .../desktop_companion/session_tests.rs | 10 +- src/openhuman/embeddings/mod.rs | 1 + src/openhuman/embeddings/rpc.rs | 23 + src/openhuman/mod.rs | 1 + .../skills/defaults/dev-workflow/SKILL.md | 149 ++ .../skills/defaults/dev-workflow/skill.toml | 49 + .../defaults/github-issue-crusher/SKILL.md | 104 ++ .../defaults/github-issue-crusher/skill.toml | 50 + .../defaults/pr-review-shepherd/SKILL.md | 140 ++ .../defaults/pr-review-shepherd/skill.toml | 54 + src/openhuman/skills/mod.rs | 3 + src/openhuman/skills/ops.rs | 2 +- src/openhuman/skills/ops_create.rs | 205 ++- src/openhuman/skills/ops_tests.rs | 138 ++ src/openhuman/skills/preflight.rs | 632 ++++++++ src/openhuman/skills/registry.rs | 464 ++++++ src/openhuman/skills/run_log.rs | 656 ++++++++ src/openhuman/skills/schemas.rs | 601 ++++++- src/openhuman/tools/impl/agent/mod.rs | 2 + src/openhuman/tools/impl/agent/run_skill.rs | 166 ++ src/openhuman/tools/impl/codegraph/mod.rs | 331 ++++ src/openhuman/tools/impl/mod.rs | 2 + src/openhuman/tools/impl/network/url_guard.rs | 19 + src/openhuman/tools/ops.rs | 16 + 97 files changed, 14866 insertions(+), 669 deletions(-) create mode 100644 app/src/components/settings/panels/SkillsRunnerPanel.test.tsx create mode 100644 app/src/components/settings/panels/SkillsRunnerPanel.tsx create mode 100644 app/src/components/skills/CreateSkillForm.tsx create mode 100644 app/src/components/skills/ScheduledCronCard.test.tsx create mode 100644 app/src/components/skills/ScheduledCronCard.tsx create mode 100644 app/src/components/skills/SkillsRunnerBody.tsx create mode 100644 app/src/components/skills/SmartIssuePicker.test.tsx create mode 100644 app/src/components/skills/SmartIssuePicker.tsx create mode 100644 app/src/components/skills/__tests__/CreateSkillForm.test.tsx create mode 100644 app/src/components/skills/__tests__/SkillsRunnerBody.test.tsx create mode 100644 app/src/components/skills/inputs/BranchPicker.test.tsx create mode 100644 app/src/components/skills/inputs/BranchPicker.tsx create mode 100644 app/src/components/skills/inputs/RepoPicker.test.tsx create mode 100644 app/src/components/skills/inputs/RepoPicker.tsx create mode 100644 app/src/components/skills/preflightGate.test.ts create mode 100644 app/src/components/skills/preflightGate.ts create mode 100644 app/src/components/skills/scheduledCronFormat.ts create mode 100644 app/src/lib/cron/cronToHuman.test.ts create mode 100644 app/src/lib/cron/cronToHuman.ts create mode 100644 app/src/pages/SkillNew.test.tsx create mode 100644 app/src/pages/SkillNew.tsx create mode 100644 app/src/pages/SkillsDashboard.test.tsx create mode 100644 app/src/pages/SkillsDashboard.tsx create mode 100644 app/src/pages/SkillsRun.test.tsx create mode 100644 app/src/pages/SkillsRun.tsx create mode 100644 app/src/services/api/skillsApi.test.ts create mode 100644 app/src/utils/tauriCommands/cron.test.ts create mode 100644 docs/skills-runner-unification.md create mode 100644 src/openhuman/agent/harness/subagent_runner/autonomous.rs create mode 100644 src/openhuman/codegraph/index.rs create mode 100644 src/openhuman/codegraph/mod.rs create mode 100644 src/openhuman/codegraph/search.rs create mode 100644 src/openhuman/codegraph/store.rs create mode 100644 src/openhuman/composio/identity.rs create mode 100644 src/openhuman/skills/defaults/dev-workflow/SKILL.md create mode 100644 src/openhuman/skills/defaults/dev-workflow/skill.toml create mode 100644 src/openhuman/skills/defaults/github-issue-crusher/SKILL.md create mode 100644 src/openhuman/skills/defaults/github-issue-crusher/skill.toml create mode 100644 src/openhuman/skills/defaults/pr-review-shepherd/SKILL.md create mode 100644 src/openhuman/skills/defaults/pr-review-shepherd/skill.toml create mode 100644 src/openhuman/skills/preflight.rs create mode 100644 src/openhuman/skills/registry.rs create mode 100644 src/openhuman/skills/run_log.rs create mode 100644 src/openhuman/tools/impl/agent/run_skill.rs create mode 100644 src/openhuman/tools/impl/codegraph/mod.rs diff --git a/.claude/memory.md b/.claude/memory.md index 3731748cb..1094a341d 100644 --- a/.claude/memory.md +++ b/.claude/memory.md @@ -208,7 +208,11 @@ Quick reference for anyone starting with Claude on this project. Updated by the - **Core port** — `7788` (default; in-process inside Tauri host). Check with `lsof -i :7788`. - **`pnpm core:stage`** — no-op (sidecar removed in PR #1061). Use `pnpm dev:app` for full Tauri+core dev. - **Kill stuck processes** — `lsof -i :7788` then `kill `. Useful when `dev:app` reports a stale listener and you want to force a fresh boot rather than relying on the handle's auto-recovery. -- **Skills runtime removed** — the QuickJS / `rquickjs` runtime is gone; `src/openhuman/skills/` is metadata-only ("Legacy skill metadata helpers retained after QuickJS runtime removal"). Skill execution surfaces are being rebuilt; don't assume a `.skill` can run end-to-end without checking the current code. +- **Skills runtime rebuilt (PR #2707)** — QuickJS is gone, but skills now run as orchestrator-focused agents via `skills_run` RPC. Default skills live in `src/openhuman/skills/defaults//` with `skill.toml` + `SKILL.md`, registered in `registry.rs` `DEFAULT_SKILLS` const. Seeded into `/skills/` on boot (idempotent, non-destructive). Bundled defaults: `github-issue-crusher`, `dev-workflow`. Skills run with 200 iteration cap and full web access. +- **Codegraph tools (PR #2707)** — `codegraph_index` and `codegraph_search` registered in `src/openhuman/tools/ops.rs`. Implementation in `src/openhuman/codegraph/` — tree-sitter extraction, SQLite FTS5, dense embeddings, RRF fusion. Auto-indexes on first search. +- **Tool names are exact** — Always check `src/openhuman/tools/ops.rs` for authoritative names. Key ones: `edit` (not `edit_file`), `composio` (not `composio_execute`), `codegraph_index`, `codegraph_search`. +- **`cron_add` RPC** — Was missing from `schemas.rs` (only existed as agent tool). Now exposed as `openhuman.cron_add`. Frontend wrapper: `openhumanCronAdd()` in `app/src/utils/tauriCommands/cron.ts`. +- **Worktree `pnpm build` rolldown fix** — Worktrees can miss `@rolldown/binding-darwin-arm64`. Fix: `pnpm install --force`. ## Artifacts Domain (Issue #2776) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 78b0f61b2..c1f28753e 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -2023,10 +2023,25 @@ fn append_platform_cef_gpu_workarounds(args: &mut Vec, os: &s #[cfg(target_os = "linux")] { let uid = nix::unistd::getuid().as_raw(); - if os == "linux" && linux_is_root_uid(uid) { + // Dev-only: also honor OPENHUMAN_CEF_NO_SANDBOX=1 so a non-root headless + // box (no sudo to chown chrome-sandbox root:4755) can launch over RDP. + // + // SECURITY: gated to debug builds only. Disabling Chromium's process + // sandbox in a release binary would let anyone with env-write access + // on the host silently turn off a meaningful defense-in-depth layer + // (graycyrus review on PR #2875). In release builds `forced` stays + // false so only the `linux_is_root_uid` path can opt in (uid=0 + // already implies root-equivalent trust). + #[cfg(debug_assertions)] + let forced = std::env::var("OPENHUMAN_CEF_NO_SANDBOX") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + #[cfg(not(debug_assertions))] + let forced = false; + if os == "linux" && (linux_is_root_uid(uid) || forced) { args.push(("--no-sandbox", None)); log::info!( - "[cef-startup] running as root (uid=0) on Linux: adding --no-sandbox \ + "[cef-startup] Linux: adding --no-sandbox (root uid or OPENHUMAN_CEF_NO_SANDBOX) \ (OPENHUMAN-TAURI-K1)" ); } diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index 0dce108a3..62c6cb3eb 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -16,7 +16,9 @@ import Onboarding from './pages/onboarding/Onboarding'; import Rewards from './pages/Rewards'; import Routines from './pages/Routines'; import Settings from './pages/Settings'; +import SkillNew from './pages/SkillNew'; import Skills from './pages/Skills'; +import SkillsRun from './pages/SkillsRun'; import WebCallbackPage from './pages/WebCallbackPage'; import Welcome from './pages/Welcome'; @@ -80,6 +82,33 @@ const AppRoutes = () => { } /> + {/* Skills lives at /skills with its 4 sub-tabs (Composio / Channels / + MCP Servers / Runners). The scheduled-skills dashboard concept + composes INSIDE the Runners sub-tab, not as a separate top-level + page — the bottom-bar "Connections" entry has always pointed at + /skills to surface Composio integrations + MCP, and that muscle + memory is restored here. + `/skills/new` is the create-a-skill authoring page. + Order matters: keep `/skills/new` before `/skills` so it wins the + prefix match. */} + + + + } + /> + + + + + } + /> + { @@ -81,13 +61,11 @@ const DevWorkflowPanel = () => { const [reposLoading, setReposLoading] = useState(false); const [reposError, setReposError] = useState(null); - // Lazy-initialised state from persisted config - const initialConfig = loadSavedConfig(); - const [savedConfig, setSavedConfig] = useState(initialConfig); - const [selectedRepo, setSelectedRepo] = useState(initialConfig?.repoFullName ?? ''); - const [forkInfo, setForkInfo] = useState(initialConfig?.forkInfo ?? null); - const [targetBranch, setTargetBranch] = useState(initialConfig?.targetBranch ?? ''); - const [schedule, setSchedule] = useState(initialConfig?.schedule ?? SCHEDULE_PRESETS[0].value); + // Form state + const [selectedRepo, setSelectedRepo] = useState(''); + const [forkInfo, setForkInfo] = useState(null); + const [targetBranch, setTargetBranch] = useState(''); + const [schedule, setSchedule] = useState(SCHEDULE_PRESETS[0].value); // Fork detection loading const [forkLoading, setForkLoading] = useState(false); @@ -99,6 +77,78 @@ const DevWorkflowPanel = () => { // Save state const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle'); + // Cron job state + const [existingJob, setExistingJob] = useState(null); + const [cronLoading, setCronLoading] = useState(false); + const [runHistory, setRunHistory] = useState([]); + const [historyExpanded, setHistoryExpanded] = useState(false); + const [expandedRunId, setExpandedRunId] = useState(null); + const [running, setRunning] = useState(false); + + // ── Load existing cron job on mount ───────────────────────────────── + const loadExistingJob = useCallback(async () => { + setCronLoading(true); + try { + const res = await openhumanCronList(); + // RPC returns { result: CronJob[], logs: [...] } + const jobs = (res as { result?: CoreCronJob[] }).result ?? []; + const jobList = Array.isArray(jobs) ? jobs : []; + const found = jobList.find((j: CoreCronJob) => j.name?.startsWith('dev-workflow') ?? false); + if (found) { + setExistingJob(found); + + // Restore form state from the stored job so returning to the panel + // with an active job doesn't show blank dropdowns. + const restored: { repo?: string; schedule?: string; branch?: string } = {}; + + // Schedule: prefer the structured cron expr, fall back to `expression`. + const scheduleExpr = + (found.schedule?.kind === 'cron' ? found.schedule.expr : undefined) ?? + found.expression ?? + undefined; + if (scheduleExpr) { + setSchedule(scheduleExpr); + restored.schedule = scheduleExpr; + } + + // Repo: encoded in the job name as `dev-workflow--` + // where the original `/` separator became the first `-` after the prefix. + const repoSlug = found.name?.replace(/^dev-workflow-/, '') ?? ''; + if (repoSlug) { + // Re-derive `owner/repo` by replacing only the first `-` with `/`. + const fullName = repoSlug.replace('-', '/'); + setSelectedRepo(fullName); + restored.repo = fullName; + } + + // Target branch: recoverable from the prompt, which embeds + // `PRs target \`\``. + const branchMatch = found.prompt?.match(/PRs target `([^`]+)`/); + if (branchMatch?.[1]) { + setTargetBranch(branchMatch[1]); + restored.branch = branchMatch[1]; + } + + log( + 'found existing dev-workflow cron job: %s, restored form state: %o', + found.id, + restored + ); + } else { + setExistingJob(null); + log('no existing dev-workflow cron job found'); + } + } catch (err) { + log('failed to load existing cron job: %s', err); + } finally { + setCronLoading(false); + } + }, []); + + useEffect(() => { + void loadExistingJob(); + }, [loadExistingJob]); + // ── Fetch repos via composio_execute ──────────────────────────────── const loadRepos = useCallback(async () => { setReposLoading(true); @@ -289,40 +339,162 @@ const DevWorkflowPanel = () => { [repos] ); + // ── Load run history ─────────────────────────────────────────────── + const loadRunHistory = useCallback(async () => { + if (!existingJob) return; + try { + const res = await openhumanCronRuns(existingJob.id, 5); + // RPC returns { result: { runs: CronRun[] }, logs: [...] } + const raw = (res as { result?: { runs?: CoreCronRun[] } }).result; + const runs = raw?.runs ?? []; + setRunHistory(Array.isArray(runs) ? runs : []); + log( + 'loaded %d run history entries for job %s', + Array.isArray(runs) ? runs.length : 0, + existingJob.id + ); + } catch (err) { + log('failed to load run history: %s', err); + } + }, [existingJob]); + + useEffect(() => { + if (existingJob) { + void loadRunHistory(); + } + }, [existingJob, loadRunHistory]); + // ── Save config ──────────────────────────────────────────────────── - const handleSave = () => { + const handleSave = useCallback(async () => { if (!selectedRepo || !targetBranch) return; - const [owner, repo] = selectedRepo.split('/'); - const config: DevWorkflowConfig = { - repoFullName: selectedRepo, - repoOwner: owner, - repoName: repo, - forkInfo, - targetBranch, - schedule, + const [owner] = selectedRepo.split('/'); + const upstreamName = forkInfo ? forkInfo.upstreamFullName : selectedRepo; + + const repoName = upstreamName.split('/')[1] ?? selectedRepo.split('/')[1] ?? ''; + const skillPrompt = [ + `You are running the dev-workflow skill. Follow these guidelines exactly.`, + ``, + `# Dev Workflow — Autonomous Issue Crusher`, + ``, + `Find a GitHub issue on \`${upstreamName}\`, implement a fix, and deliver a PR.`, + ``, + `## Repos`, + `- **Upstream** = \`${upstreamName}\` — issues live here, PRs target \`${targetBranch}\`.`, + `- **Fork** = \`${owner}/${repoName}\` — push the fix branch here.`, + `- Commit through the GitHub API — no local git push.`, + ``, + `## Issue Selection (smart fallback)`, + `1. **First**: Look for open issues assigned to \`${owner}\` on \`${upstreamName}\` with no linked PR.`, + `2. **If none assigned**: Find unassigned open issues. Prefer issues labeled \`good first issue\`, \`bug\`, \`help wanted\`, or \`easy\`. Prefer issues with detailed descriptions (>500 chars). Skip issues that already have an open PR linked.`, + `3. **Self-assign**: Once you pick an unassigned issue, assign it to \`${owner}\` using GITHUB_ADD_ASSIGNEES so no one else picks it up concurrently.`, + `4. **If no suitable issues at all**: Exit cleanly — report "no suitable issues found".`, + ``, + `## Implementation Steps`, + `1. Read the full issue body, comments, and labels.`, + `2. Ensure fork \`${owner}/${repoName}\` exists (create if needed).`, + `3. Clone \`${upstreamName}\` locally, branch \`dev-workflow/-\` off \`${targetBranch}\`.`, + `4. Run \`codegraph_index\` on the repo.`, + `5. Use \`codegraph_search\` to find relevant code. Fall back to grep/glob if coverage isn't full.`, + `6. Implement the minimal correct fix. Re-read files and git diff — don't trust memory.`, + `7. Run tests. Iterate until green.`, + `8. Push via GitHub API (blob → tree → commit → update-ref). Do NOT git push.`, + `9. Open cross-repo PR: \`${upstreamName}:${targetBranch}\` ← \`${owner}:\`. Body: Closes #N + summary + how you verified.`, + ``, + `## Rules`, + `- One PR per run, then stop.`, + `- Only fix the picked issue — no unrelated changes.`, + `- codegraph is an accelerant, not a gate — fall back to grep if cold.`, + `- If too large/risky (would touch >20 files or needs multi-system changes), comment on the issue explaining why and skip.`, + `- Never force-push or push to upstream directly.`, + ].join('\n'); + + const cronParams: CronAddParams = { + name: `dev-workflow-${selectedRepo.replace('/', '-')}`, + schedule: { kind: 'cron', expr: schedule }, + job_type: 'agent', + prompt: skillPrompt, + session_target: 'isolated', + delivery: { mode: 'proactive', best_effort: true }, }; - saveConfig(config); - setSavedConfig(config); - setSaveStatus('saved'); - log('saved dev workflow config: %o', config); + log( + 'saving dev-workflow cron job: existingJob=%s, repo=%s', + existingJob?.id ?? 'none', + selectedRepo + ); - setTimeout(() => setSaveStatus('idle'), 3000); - }; + try { + if (existingJob) { + // Update existing job + await openhumanCronUpdate(existingJob.id, { + name: cronParams.name, + schedule: cronParams.schedule, + prompt: cronParams.prompt, + }); + log('updated cron job %s', existingJob.id); + } else { + // Create new job + await openhumanCronAdd(cronParams); + log('created new dev-workflow cron job for repo=%s', selectedRepo); + } + setSaveStatus('saved'); + void loadExistingJob(); // Refresh + setTimeout(() => setSaveStatus('idle'), 3000); + } catch (err) { + log('save error: %s', err); + setSaveStatus('error'); + } + }, [selectedRepo, targetBranch, forkInfo, schedule, existingJob, loadExistingJob]); // ── Remove config ────────────────────────────────────────────────── - const handleRemove = () => { - clearConfig(); - setSavedConfig(null); - setSelectedRepo(''); - setForkInfo(null); - setBranches([]); - setTargetBranch(''); - setSchedule(SCHEDULE_PRESETS[0].value); - setSaveStatus('idle'); - log('removed dev workflow config'); - }; + const handleRemove = useCallback(async () => { + if (!existingJob) return; + log('removing dev-workflow cron job %s', existingJob.id); + try { + await openhumanCronRemove(existingJob.id); + setExistingJob(null); + setSelectedRepo(''); + setForkInfo(null); + setBranches([]); + setTargetBranch(''); + setSchedule(SCHEDULE_PRESETS[0].value); + setSaveStatus('idle'); + setRunHistory([]); + log('removed dev workflow cron job'); + } catch (err) { + log('remove error: %s', err); + } + }, [existingJob]); + + // ── Toggle enable/disable ────────────────────────────────────────── + const handleToggle = useCallback(async () => { + if (!existingJob) return; + const newEnabled = !existingJob.enabled; + log('toggling cron job %s enabled=%s', existingJob.id, newEnabled); + try { + await openhumanCronUpdate(existingJob.id, { enabled: newEnabled }); + void loadExistingJob(); + } catch (err) { + log('toggle error: %s', err); + } + }, [existingJob, loadExistingJob]); + + // ── Run Now ──────────────────────────────────────────────────────── + const handleRunNow = useCallback(async () => { + if (!existingJob) return; + setRunning(true); + log('running cron job %s now', existingJob.id); + try { + await openhumanCronRun(existingJob.id); + void loadExistingJob(); + void loadRunHistory(); + } catch (err) { + log('run now error: %s', err); + } finally { + setRunning(false); + } + }, [existingJob, loadExistingJob, loadRunHistory]); // ── Render ───────────────────────────────────────────────────────── const canSave = selectedRepo && targetBranch && schedule; @@ -342,188 +514,313 @@ const DevWorkflowPanel = () => { {t('settings.developerMenu.devWorkflow.panelDesc')}

- {/* Repo selector */} -
- - {reposError && ( -
- {reposError} -
- )} - -
- - {/* Fork info */} - {forkLoading && ( + {/* Active config summary — shown at top regardless of repo loading */} + {cronLoading && (
- {t('settings.devWorkflow.detectingForkInfo')} + {t('settings.devWorkflow.loadingRepositories')}
)} - {forkInfo && ( -
-
- {t('settings.devWorkflow.forkDetected')} -
-
- {t('settings.devWorkflow.upstream')}{' '} - {forkInfo.upstreamFullName} -
-
- {t('settings.devWorkflow.forkPrNote')} -
-
- )} - {selectedRepo && !forkLoading && !forkInfo && ( -
-
- {t('settings.devWorkflow.notForkNote')} -
-
- )} - - {/* Branch selector */} - {branches.length > 0 && ( -
- -

- {t('settings.devWorkflow.targetBranchNote')} - {forkInfo ? ` on ${forkInfo.upstreamFullName}` : ''}. -

- -
- )} - {branchesLoading && ( -
- {t('settings.devWorkflow.loadingBranches')} -
- )} - - {/* Schedule */} - {selectedRepo && ( -
- -

- {t('settings.devWorkflow.runFrequencyNote')} -

- -
- )} - - {/* Actions */} - {selectedRepo && ( -
- - {savedConfig && ( - + {existingJob && ( +
+ {/* Running indicator */} + {running && ( +
+ + + {t('settings.devWorkflow.runningStatus')} + +
)} - {saveStatus === 'saved' && ( - - {t('settings.devWorkflow.saved')} - - )} -
- )} - - {/* Active config summary */} - {savedConfig && ( -
-
- {t('settings.devWorkflow.activeConfiguration')} +
+
+ {t('settings.devWorkflow.activeConfiguration')} +
+
+ + + {existingJob.enabled + ? t('settings.devWorkflow.enabled') + : t('settings.devWorkflow.paused')} + +
{t('settings.devWorkflow.activeConfigRepository')}
- {savedConfig.repoFullName} -
- {savedConfig.forkInfo && ( - <> -
- {t('settings.devWorkflow.activeConfigUpstream')} -
-
- {savedConfig.forkInfo.upstreamFullName} -
- - )} -
- {t('settings.devWorkflow.activeConfigTargetBranch')} -
-
- {savedConfig.targetBranch} + {existingJob.name?.replace(/^dev-workflow-/, '') ?? '—'}
{t('settings.devWorkflow.activeConfigSchedule')}
- {SCHEDULE_PRESETS.find(p => p.value === savedConfig.schedule) != null - ? t(SCHEDULE_PRESETS.find(p => p.value === savedConfig.schedule)!.labelKey) - : savedConfig.schedule} + {SCHEDULE_PRESETS.find(p => p.value === existingJob.expression) + ? t(SCHEDULE_PRESETS.find(p => p.value === existingJob.expression)!.labelKey) + : existingJob.expression}
+
+ {t('settings.devWorkflow.nextRun')} +
+
+ {existingJob.next_run ? new Date(existingJob.next_run).toLocaleString() : '—'} +
+ {existingJob.last_run && ( + <> +
+ {t('settings.devWorkflow.lastRun')} +
+
+ {new Date(existingJob.last_run).toLocaleString()} + {existingJob.last_status && ( + + {existingJob.last_status} + + )} +
+ + )}
-

- {t('settings.devWorkflow.phase2Note')} -

+ +
+ + +
+ + {existingJob.last_output && ( +
+
+ {t('settings.devWorkflow.lastOutput')} +
+
+                  {existingJob.last_output}
+                
+
+ )} + + {runHistory.length > 0 && ( +
+ + {historyExpanded && ( +
+ {runHistory.map(run => ( +
+ + {expandedRunId === run.id && run.output && ( +
+                            {run.output}
+                          
+ )} + {expandedRunId === run.id && !run.output && ( +
+ {t('settings.devWorkflow.noOutput')} +
+ )} +
+ ))} +
+ )} +
+ )}
)} + + {/* Setup form — only shown when no active config exists */} + {!existingJob && ( + <> +
+ + {reposError && ( +
+ {reposError} +
+ )} + +
+ + {/* Fork info */} + {forkLoading && ( +
+ {t('settings.devWorkflow.detectingForkInfo')} +
+ )} + {forkInfo && ( +
+
+ {t('settings.devWorkflow.forkDetected')} +
+
+ {t('settings.devWorkflow.upstream')}{' '} + {forkInfo.upstreamFullName} +
+
+ {t('settings.devWorkflow.forkPrNote')} +
+
+ )} + {selectedRepo && !forkLoading && !forkInfo && ( +
+
+ {t('settings.devWorkflow.notForkNote')} +
+
+ )} + + {/* Branch selector */} + {branches.length > 0 && ( +
+ +

+ {t('settings.devWorkflow.targetBranchNote')} + {forkInfo ? ` on ${forkInfo.upstreamFullName}` : ''}. +

+ +
+ )} + {branchesLoading && ( +
+ {t('settings.devWorkflow.loadingBranches')} +
+ )} + + {/* Schedule */} + {selectedRepo && ( +
+ +

+ {t('settings.devWorkflow.runFrequencyNote')} +

+ +
+ )} + + {/* Actions */} + {selectedRepo && ( +
+ + {saveStatus === 'saved' && ( + + {t('settings.devWorkflow.saved')} + + )} + {saveStatus === 'error' && ( + + {t('settings.devWorkflow.cronSaveError')} + + )} +
+ )} + + )}
); diff --git a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx index 3529af612..9c74dacac 100644 --- a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx +++ b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx @@ -74,6 +74,28 @@ const developerItems = [ ), }, + // Settings → Developer → Skills Runner is commented out: the same UX + // (and more) now lives at /skills (Connections → Runners sub-tab) as + // the scheduled-skills dashboard, with /skills/run for ad-hoc runs. + // The route + panel component remain wired (Settings.tsx:458 keeps the + // /settings/skills-runner route), so deep links and bookmarks still + // resolve — only the menu entry is hidden. + // { + // id: 'skills-runner', + // titleKey: 'settings.developerMenu.skillsRunner.title', + // descriptionKey: 'settings.developerMenu.skillsRunner.desc', + // route: 'skills-runner', + // icon: ( + // + // + // + // ), + // }, { id: 'dev-workflow', titleKey: 'settings.developerMenu.devWorkflow.title', diff --git a/app/src/components/settings/panels/SkillsRunnerPanel.test.tsx b/app/src/components/settings/panels/SkillsRunnerPanel.test.tsx new file mode 100644 index 000000000..ce5dad825 --- /dev/null +++ b/app/src/components/settings/panels/SkillsRunnerPanel.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; + +import SkillsRunnerPanel from './SkillsRunnerPanel'; + +vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); +vi.mock('../../skills/SkillsRunnerBody', () => ({ + default: () =>
, +})); +vi.mock('../components/SettingsHeader', () => ({ + default: ({ title }: { title: string }) =>
{title}
, +})); +vi.mock('../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }), +})); + +describe('SkillsRunnerPanel', () => { + it('renders the settings header and runner body', () => { + render( + + + + ); + expect(screen.getByTestId('settings-header')).toBeInTheDocument(); + expect(screen.getByTestId('skills-runner-body')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/panels/SkillsRunnerPanel.tsx b/app/src/components/settings/panels/SkillsRunnerPanel.tsx new file mode 100644 index 000000000..2656cef80 --- /dev/null +++ b/app/src/components/settings/panels/SkillsRunnerPanel.tsx @@ -0,0 +1,31 @@ +// Settings → Developer Options → Skills Runner — thin wrapper around the +// reusable `` so the settings shell (header + back +// button + breadcrumbs) stays consistent with other panels. The actual +// picker / Run / Schedule / Recent Runs UX lives in +// `app/src/components/skills/SkillsRunnerBody.tsx`, shared with the +// top-level /skills page's "Runners" tab. +import { useT } from '../../../lib/i18n/I18nContext'; +import SkillsRunnerBody from '../../skills/SkillsRunnerBody'; +import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; + +const SkillsRunnerPanel = () => { + const { t } = useT(); + const { navigateBack, breadcrumbs } = useSettingsNavigation(); + + return ( +
+ +
+ +
+
+ ); +}; + +export default SkillsRunnerPanel; diff --git a/app/src/components/settings/panels/__tests__/DevWorkflowPanel.test.tsx b/app/src/components/settings/panels/__tests__/DevWorkflowPanel.test.tsx index 78389c881..0c8049a86 100644 --- a/app/src/components/settings/panels/__tests__/DevWorkflowPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/DevWorkflowPanel.test.tsx @@ -4,15 +4,33 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { renderWithProviders } from '../../../../test/test-utils'; // [dev-workflow] Unit tests for DevWorkflowPanel.tsx — covers repo loading, -// not-connected error, fork detection, branch population, and save/clear wiring. +// not-connected error, fork detection, branch population, and cron job wiring. -const hoisted = vi.hoisted(() => ({ composioExecute: vi.fn(), listConnections: vi.fn() })); +const hoisted = vi.hoisted(() => ({ + composioExecute: vi.fn(), + listConnections: vi.fn(), + cronAdd: vi.fn(), + cronList: vi.fn(), + cronRemove: vi.fn(), + cronUpdate: vi.fn(), + cronRun: vi.fn(), + cronRuns: vi.fn(), +})); vi.mock('../../../../lib/composio/composioApi', () => ({ execute: hoisted.composioExecute, listConnections: hoisted.listConnections, })); +vi.mock('../../../../utils/tauriCommands/cron', () => ({ + openhumanCronAdd: hoisted.cronAdd, + openhumanCronList: hoisted.cronList, + openhumanCronRemove: hoisted.cronRemove, + openhumanCronUpdate: hoisted.cronUpdate, + openhumanCronRun: hoisted.cronRun, + openhumanCronRuns: hoisted.cronRuns, +})); + // Stable t function — creating a new function object on every render // would cause useCallback([t]) to re-create on every render, triggering // the loadRepos useEffect in an infinite loop. @@ -32,7 +50,7 @@ vi.mock('../../components/SettingsHeader', () => ({ })); // Import once — DevWorkflowPanel state is managed via API mocks and -// localStorage, not module-level vars, so a single import is sufficient. +// cron RPC, not module-level vars, so a single import is sufficient. async function importPanel() { const mod = await import('../DevWorkflowPanel'); return mod.default; @@ -82,9 +100,15 @@ const branchesResponse = { describe('DevWorkflowPanel', () => { beforeEach(() => { vi.clearAllMocks(); - localStorage.clear(); hoisted.listConnections.mockResolvedValue(githubConnection); hoisted.composioExecute.mockResolvedValue(reposResponse); + hoisted.cronList.mockResolvedValue({ result: [], logs: [] }); + hoisted.cronAdd.mockResolvedValue({ + result: { id: 'cron-1', name: 'dev-workflow-user-repo1' }, + logs: [], + }); + hoisted.cronRemove.mockResolvedValue({ result: { job_id: 'cron-1', removed: true }, logs: [] }); + hoisted.cronRuns.mockResolvedValue({ result: { runs: [] }, logs: [] }); }); test('renders header immediately and populates repo dropdown on successful fetch', async () => { @@ -183,7 +207,7 @@ describe('DevWorkflowPanel', () => { }); }); - test('save button stores config in localStorage', async () => { + test('save button creates a cron job via openhumanCronAdd', async () => { // Call sequence: LIST_REPOS → GET_A_REPO (non-fork) → LIST_BRANCHES hoisted.composioExecute .mockResolvedValueOnce(reposResponse) @@ -209,50 +233,57 @@ describe('DevWorkflowPanel', () => { // Click save const saveBtn = screen.getByRole('button', { - name: /settings\.devWorkflow\.(save|update)Configuration/, + name: /settings\.devWorkflow\.saveConfiguration/, }); fireEvent.click(saveBtn); - // Verify localStorage was written - const raw = localStorage.getItem('openhuman:dev-workflow-config'); - expect(raw).not.toBeNull(); - const stored = JSON.parse(raw!); - expect(stored.repoFullName).toBe('user/repo1'); - expect(stored.repoOwner).toBe('user'); - expect(stored.repoName).toBe('repo1'); - expect(stored.targetBranch).toBe('main'); - expect(typeof stored.schedule).toBe('string'); - - // Saved status indicator - expect(screen.getByText('settings.devWorkflow.saved')).toBeInTheDocument(); + // Verify cron_add was called + await waitFor(() => { + expect(hoisted.cronAdd).toHaveBeenCalledTimes(1); + }); + const addCall = hoisted.cronAdd.mock.calls[0][0]; + expect(addCall.name).toBe('dev-workflow-user-repo1'); + expect(addCall.schedule).toEqual({ kind: 'cron', expr: '*/30 * * * *' }); + expect(addCall.job_type).toBe('agent'); + expect(addCall.prompt).toContain('dev-workflow'); + expect(addCall.prompt).toContain('user/repo1'); }); - test('remove button clears localStorage config', async () => { - // Pre-populate localStorage so savedConfig is non-null on mount - const existingConfig = { - repoFullName: 'user/repo1', - repoOwner: 'user', - repoName: 'repo1', - forkInfo: null, - targetBranch: 'main', - schedule: '*/30 * * * *', + test('remove button deletes cron job via openhumanCronRemove', async () => { + // Pre-populate cron list so existingJob is set on mount + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', }; - localStorage.setItem('openhuman:dev-workflow-config', JSON.stringify(existingConfig)); + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); const Panel = await importPanel(); renderWithProviders(); - // Active config summary is shown immediately (initialised from localStorage) - expect(screen.getByText('settings.devWorkflow.activeConfiguration')).toBeInTheDocument(); + // Active config card shows at top regardless of repo loading + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.activeConfiguration')).toBeInTheDocument(); + }); - // Remove button is visible because savedConfig is set + // Remove button is in the active config card const removeBtn = screen.getByRole('button', { name: 'settings.devWorkflow.remove' }); fireEvent.click(removeBtn); - // localStorage should be cleared - expect(localStorage.getItem('openhuman:dev-workflow-config')).toBeNull(); - // Active config summary gone - expect(screen.queryByText('settings.devWorkflow.activeConfiguration')).toBeNull(); + // Verify cron_remove was called + await waitFor(() => { + expect(hoisted.cronRemove).toHaveBeenCalledWith('cron-1'); + }); }); test('shows branches fetched from upstream when fork is detected', async () => { @@ -297,4 +328,657 @@ describe('DevWorkflowPanel', () => { expect(screen.getByText('network error')).toBeInTheDocument(); }); }); + + test('toggle button calls openhumanCronUpdate with enabled flag', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + hoisted.cronUpdate.mockResolvedValue({ data: { ...existingCronJob, enabled: false } }); + + const Panel = await importPanel(); + renderWithProviders(); + + // Wait for active config with toggle + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.enabled')).toBeInTheDocument(); + }); + + // Click the toggle button (the switch element) + const toggleBtn = screen.getByText('settings.devWorkflow.enabled').previousElementSibling; + if (toggleBtn) fireEvent.click(toggleBtn); + + await waitFor(() => { + expect(hoisted.cronUpdate).toHaveBeenCalledWith('cron-1', { enabled: false }); + }); + }); + + test('run now button calls openhumanCronRun', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + hoisted.cronRun.mockResolvedValue({ + data: { job_id: 'cron-1', status: 'ok', duration_ms: 100, output: 'done' }, + }); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.runNow')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('settings.devWorkflow.runNow')); + + await waitFor(() => { + expect(hoisted.cronRun).toHaveBeenCalledWith('cron-1'); + }); + }); + + test('shows run history when cron runs are available', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + last_run: '2026-01-01T00:30:00Z', + last_status: 'ok', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + hoisted.cronRuns.mockResolvedValue({ + result: { + runs: [ + { + id: 1, + job_id: 'cron-1', + started_at: '2026-01-01T00:30:00Z', + finished_at: '2026-01-01T00:31:00Z', + status: 'ok', + duration_ms: 60000, + }, + ], + }, + logs: [], + }); + + const Panel = await importPanel(); + renderWithProviders(); + + // Wait for the recent runs toggle to appear + await waitFor(() => { + expect(screen.getByText(/settings\.devWorkflow\.recentRuns/)).toBeInTheDocument(); + }); + + // Expand history + fireEvent.click(screen.getByText(/settings\.devWorkflow\.recentRuns/)); + + // Run entry should be visible + await waitFor(() => { + expect(screen.getByText('60.0s')).toBeInTheDocument(); + }); + }); + + test('shows last run status badge when job has last_status', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + last_run: '2026-01-01T00:30:00Z', + last_status: 'error', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('error')).toBeInTheDocument(); + }); + }); + + test('handles save error gracefully', async () => { + hoisted.composioExecute + .mockResolvedValueOnce(reposResponse) + .mockResolvedValueOnce(repoMetaNonFork) + .mockResolvedValueOnce(branchesResponse); + hoisted.cronAdd.mockRejectedValue(new Error('save failed')); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole('option', { name: /user\/repo1/ })).toBeInTheDocument(); + }); + + const repoSelect = screen.getAllByRole('combobox')[0]; + fireEvent.change(repoSelect, { target: { value: 'user/repo1' } }); + + await waitFor(() => { + expect(screen.getByRole('option', { name: 'main' })).toBeInTheDocument(); + }); + + const saveBtn = screen.getByRole('button', { + name: /settings\.devWorkflow\.saveConfiguration/, + }); + fireEvent.click(saveBtn); + + // Error status should appear + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.cronSaveError')).toBeInTheDocument(); + }); + }); + + test('loadExistingJob handles cronList error gracefully', async () => { + hoisted.cronList.mockRejectedValue(new Error('cron list failed')); + + const Panel = await importPanel(); + renderWithProviders(); + + // Panel should still render despite cronList failure + expect(screen.getByTestId('settings-header')).toBeInTheDocument(); + + // Repos should still load + await waitFor(() => { + expect(screen.getByRole('option', { name: /user\/repo1/ })).toBeInTheDocument(); + }); + }); + + // ── Run Now simulation tests ────────────────────────────────────────── + + test('run now shows running indicator then refreshes on completion', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + + // cronRun resolves after a tick (simulates async execution) + let resolveRun: (v: unknown) => void = () => {}; + hoisted.cronRun.mockImplementation( + () => + new Promise(resolve => { + resolveRun = resolve; + }) + ); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.runNow')).toBeInTheDocument(); + }); + + // Click Run Now + fireEvent.click(screen.getByText('settings.devWorkflow.runNow')); + + // Running indicator should appear + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.running')).toBeInTheDocument(); + expect(screen.getByText('settings.devWorkflow.runningStatus')).toBeInTheDocument(); + }); + + // Button should be disabled while running + const btn = screen.getByText('settings.devWorkflow.running'); + expect(btn.closest('button')).toHaveAttribute('disabled'); + + // Simulate run completion + resolveRun({ + result: { job_id: 'cron-1', status: 'ok', duration_ms: 5000, output: 'Fixed issue #42' }, + }); + + // After completion, button should return to normal + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.runNow')).toBeInTheDocument(); + }); + + // cronRun was called + expect(hoisted.cronRun).toHaveBeenCalledWith('cron-1'); + // loadExistingJob should have been called to refresh + expect(hoisted.cronList).toHaveBeenCalledTimes(2); // initial + refresh + }); + + test('run now handles error and resets running state', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + hoisted.cronRun.mockRejectedValue(new Error('agent crashed')); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.runNow')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('settings.devWorkflow.runNow')); + + // After error, button should return to normal (not stuck in running) + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.runNow')).toBeInTheDocument(); + }); + }); + + test('shows last_output in active config when present', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + last_run: '2026-01-01T00:30:00Z', + last_status: 'ok', + last_output: 'No open issues assigned. Exiting cleanly.', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.lastOutput')).toBeInTheDocument(); + }); + expect(screen.getByText('No open issues assigned. Exiting cleanly.')).toBeInTheDocument(); + }); + + test('expandable run history shows output when clicked', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + hoisted.cronRuns.mockResolvedValue({ + result: { + runs: [ + { + id: 1, + job_id: 'cron-1', + started_at: '2026-01-01T00:30:00Z', + finished_at: '2026-01-01T00:31:00Z', + status: 'ok', + duration_ms: 60000, + output: 'Picked issue #42. Opened PR #99.', + }, + ], + }, + logs: [], + }); + + const Panel = await importPanel(); + renderWithProviders(); + + // Expand history + await waitFor(() => { + expect(screen.getByText(/settings\.devWorkflow\.recentRuns/)).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText(/settings\.devWorkflow\.recentRuns/)); + + // Click on the run entry to expand output + await waitFor(() => { + expect(screen.getByText('60.0s')).toBeInTheDocument(); + }); + + // Find the run row button and click it + const runRow = screen.getByText('60.0s').closest('button'); + if (runRow) fireEvent.click(runRow); + + // Output should be visible + await waitFor(() => { + expect(screen.getByText('Picked issue #42. Opened PR #99.')).toBeInTheDocument(); + }); + }); + + test('expandable run history shows no-output message when run has no output', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + hoisted.cronRuns.mockResolvedValue({ + result: { + runs: [ + { + id: 1, + job_id: 'cron-1', + started_at: '2026-01-01T00:30:00Z', + finished_at: '2026-01-01T00:31:00Z', + status: 'error', + duration_ms: 1000, + output: null, + }, + ], + }, + logs: [], + }); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText(/settings\.devWorkflow\.recentRuns/)).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText(/settings\.devWorkflow\.recentRuns/)); + + await waitFor(() => { + expect(screen.getByText('1.0s')).toBeInTheDocument(); + }); + + const runRow = screen.getByText('1.0s').closest('button'); + if (runRow) fireEvent.click(runRow); + + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.noOutput')).toBeInTheDocument(); + }); + }); + + test('setup form is hidden when existing job is present', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + + const Panel = await importPanel(); + renderWithProviders(); + + // Active config shows + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.activeConfiguration')).toBeInTheDocument(); + }); + + // Repo selector should NOT be visible + expect(screen.queryByText('settings.devWorkflow.githubRepository')).not.toBeInTheDocument(); + expect(screen.queryByText('settings.devWorkflow.selectRepository')).not.toBeInTheDocument(); + }); + + test('setup form shows when no existing job', async () => { + hoisted.cronList.mockResolvedValue({ result: [], logs: [] }); + + const Panel = await importPanel(); + renderWithProviders(); + + // Repo selector should be visible + await waitFor(() => { + expect(screen.getByRole('option', { name: /user\/repo1/ })).toBeInTheDocument(); + }); + + // No active config card + expect(screen.queryByText('settings.devWorkflow.activeConfiguration')).not.toBeInTheDocument(); + }); + + test('schedule preset label shows in active config', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + // Schedule preset matches — should show the label key + expect(screen.getByText('settings.devWorkflow.schedule.every30min')).toBeInTheDocument(); + }); + }); + + test('paused state shows when job is disabled', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: false, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.paused')).toBeInTheDocument(); + }); + }); + + test('save with fork detected includes upstream in prompt', async () => { + hoisted.composioExecute + .mockResolvedValueOnce(reposResponse) + .mockResolvedValueOnce(repoMetaFork) + .mockResolvedValueOnce(branchesResponse); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole('option', { name: /user\/repo1/ })).toBeInTheDocument(); + }); + + const repoSelect = screen.getAllByRole('combobox')[0]; + fireEvent.change(repoSelect, { target: { value: 'user/repo1' } }); + + await waitFor(() => { + expect(screen.getByRole('option', { name: 'main' })).toBeInTheDocument(); + }); + + const saveBtn = screen.getByRole('button', { + name: /settings\.devWorkflow\.saveConfiguration/, + }); + fireEvent.click(saveBtn); + + await waitFor(() => { + expect(hoisted.cronAdd).toHaveBeenCalledTimes(1); + }); + const addCall = hoisted.cronAdd.mock.calls[0][0]; + // Fork detected — prompt should reference upstream repo + expect(addCall.prompt).toContain('upstream/repo'); + expect(addCall.prompt).toContain('Self-assign'); + expect(addCall.prompt).toContain('unassigned'); + }); + + test('remove on existing job calls cronRemove (not cronUpdate/cronAdd)', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + // First call returns existing job, second call (after remove+re-render) returns empty + hoisted.cronList + .mockResolvedValueOnce({ result: [existingCronJob], logs: [] }) + .mockResolvedValue({ result: [], logs: [] }); + + const Panel = await importPanel(); + renderWithProviders(); + + // Wait for active config to show + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.activeConfiguration')).toBeInTheDocument(); + }); + + // Remove the existing job so setup form appears + const removeBtn = screen.getByRole('button', { name: 'settings.devWorkflow.remove' }); + fireEvent.click(removeBtn); + + await waitFor(() => { + expect(hoisted.cronRemove).toHaveBeenCalledWith('cron-1'); + }); + // Remove must not persist via the add/update RPCs. + expect(hoisted.cronAdd).not.toHaveBeenCalled(); + expect(hoisted.cronUpdate).not.toHaveBeenCalled(); + }); + + test('toggling an existing job persists via cronUpdate and never cronAdd', async () => { + const existingCronJob = { + id: 'cron-42', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + hoisted.cronUpdate.mockResolvedValue({ data: { ...existingCronJob, enabled: false } }); + + const Panel = await importPanel(); + renderWithProviders(); + + // Wait for the active config card (existing-job UI) to render. + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.activeConfiguration')).toBeInTheDocument(); + }); + + // Trigger the persist-on-existing-job action (the enable/disable switch). + const toggleBtn = screen.getByText('settings.devWorkflow.enabled').previousElementSibling; + if (toggleBtn) fireEvent.click(toggleBtn); + + // cronUpdate is called with the existing job id and the toggled payload. + await waitFor(() => { + expect(hoisted.cronUpdate).toHaveBeenCalledWith('cron-42', { enabled: false }); + }); + // The update path must never fall through to cronAdd. + expect(hoisted.cronAdd).not.toHaveBeenCalled(); + }); }); diff --git a/app/src/components/skills/CreateSkillForm.tsx b/app/src/components/skills/CreateSkillForm.tsx new file mode 100644 index 000000000..793a28f17 --- /dev/null +++ b/app/src/components/skills/CreateSkillForm.tsx @@ -0,0 +1,422 @@ +/** + * CreateSkillForm + * ---------------- + * + * Body of the "create a new SKILL.md" flow, shared between + * `CreateSkillModal` (modal chrome) and the `/skills/new` page wrapper. + * + * Owns: + * - All form fields (name, description, scope, license, author, + * tags, allowed-tools). + * - Slug preview + validation (name and description required). + * - Submit handler that calls `skillsApi.createSkill` and surfaces + * the result via `onCreated(skill)` / error string via inline + * `
`. + * + * Does NOT own: + * - The submit/cancel buttons (the wrapper provides them so the + * modal can use a footer bar and the page can render a top-right + * primary action). + * - Modal-specific concerns (focus capture, Escape-to-close, + * backdrop click). Those stay in `CreateSkillModal`. + * + * The wrapper drives submission by either calling the imperative + * handle exposed via a ref (`` → + * `ref.current.submit()`) OR by reading `formValid` + `submitting` + * from the props the form raises and wiring its own submit button to + * the underlying `
` via the standard `form="..."` attribute. + * Both modal and page use the latter, so the form mounts a real + * `` and they bind `