diff --git a/.claude/memory.md b/.claude/memory.md index 3731748cb8..1094a341d0 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/components/settings/panels/DevWorkflowPanel.tsx b/app/src/components/settings/panels/DevWorkflowPanel.tsx index 30f6b0af6c..cdec2b63ea 100644 --- a/app/src/components/settings/panels/DevWorkflowPanel.tsx +++ b/app/src/components/settings/panels/DevWorkflowPanel.tsx @@ -3,6 +3,17 @@ import { useCallback, useEffect, useState } from 'react'; import { execute as composioExecute, listConnections } from '../../../lib/composio/composioApi'; import { useT } from '../../../lib/i18n/I18nContext'; +import { + CoreCronJob, + CoreCronRun, + CronAddParams, + openhumanCronAdd, + openhumanCronList, + openhumanCronRemove, + openhumanCronRun, + openhumanCronRuns, + openhumanCronUpdate, +} from '../../../utils/tauriCommands/cron'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; @@ -31,17 +42,6 @@ interface GhBranch { name: string; } -interface DevWorkflowConfig { - repoFullName: string; - repoOwner: string; - repoName: string; - forkInfo: ForkInfo | null; - targetBranch: string; - schedule: string; -} - -const STORAGE_KEY = 'openhuman:dev-workflow-config'; - const SCHEDULE_PRESETS = [ { labelKey: 'settings.devWorkflow.schedule.every30min' as const, value: '*/30 * * * *' }, { labelKey: 'settings.devWorkflow.schedule.everyHour' as const, value: '0 * * * *' }, @@ -50,26 +50,6 @@ const SCHEDULE_PRESETS = [ { labelKey: 'settings.devWorkflow.schedule.onceDaily' as const, value: '0 9 * * *' }, ]; -// ── Helpers ──────────────────────────────────────────────────────────── - -function loadSavedConfig(): DevWorkflowConfig | null { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return null; - return JSON.parse(raw) as DevWorkflowConfig; - } catch { - return null; - } -} - -function saveConfig(config: DevWorkflowConfig) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); -} - -function clearConfig() { - localStorage.removeItem(STORAGE_KEY); -} - // ── Component ────────────────────────────────────────────────────────── const DevWorkflowPanel = () => { @@ -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,41 @@ 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); + log('found existing dev-workflow cron job: %s', found.id); + } 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 +302,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 +477,313 @@ const DevWorkflowPanel = () => { {t('settings.developerMenu.devWorkflow.panelDesc')}

- {/* Repo selector */} -
- - {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 && ( + {/* Active config summary — shown at top regardless of repo loading */} + {cronLoading && (
- {t('settings.devWorkflow.loadingBranches')} + {t('settings.devWorkflow.loadingRepositories')}
)} - - {/* Schedule */} - {selectedRepo && ( -
- -

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

- -
- )} - - {/* Actions */} - {selectedRepo && ( -
- - {savedConfig && ( - - )} - {saveStatus === 'saved' && ( - - {t('settings.devWorkflow.saved')} - + {existingJob && ( +
+ {/* Running indicator */} + {running && ( +
+ + + {t('settings.devWorkflow.runningStatus')} + +
)} -
- )} - - {/* 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} + {existingJob.name?.replace(/^dev-workflow-/, '') ?? '—'}
- {savedConfig.forkInfo && ( - <> -
- {t('settings.devWorkflow.activeConfigUpstream')} -
-
- {savedConfig.forkInfo.upstreamFullName} -
- - )}
- {t('settings.devWorkflow.activeConfigTargetBranch')} + {t('settings.devWorkflow.activeConfigSchedule')}
-
- {savedConfig.targetBranch} +
+ {SCHEDULE_PRESETS.find(p => p.value === existingJob.expression) + ? t(SCHEDULE_PRESETS.find(p => p.value === existingJob.expression)!.labelKey) + : existingJob.expression}
- {t('settings.devWorkflow.activeConfigSchedule')} + {t('settings.devWorkflow.nextRun')}
- {SCHEDULE_PRESETS.find(p => p.value === savedConfig.schedule) != null - ? t(SCHEDULE_PRESETS.find(p => p.value === savedConfig.schedule)!.labelKey) - : savedConfig.schedule} + {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/__tests__/DevWorkflowPanel.test.tsx b/app/src/components/settings/panels/__tests__/DevWorkflowPanel.test.tsx index 78389c881e..de927d3795 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. - -const hoisted = vi.hoisted(() => ({ composioExecute: vi.fn(), listConnections: vi.fn() })); +// not-connected error, fork detection, branch population, and cron job wiring. + +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,615 @@ 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('update existing job calls cronUpdate instead of 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'); + }); + }); }); diff --git a/app/src/lib/i18n/chunks/ar-5.ts b/app/src/lib/i18n/chunks/ar-5.ts index 1d57ab9578..a8e86d842d 100644 --- a/app/src/lib/i18n/chunks/ar-5.ts +++ b/app/src/lib/i18n/chunks/ar-5.ts @@ -204,8 +204,21 @@ const ar5: TranslationMap = { 'settings.devWorkflow.activeConfigUpstream': 'Upstream:', 'settings.devWorkflow.activeConfigTargetBranch': 'Target branch:', 'settings.devWorkflow.activeConfigSchedule': 'Schedule:', - 'settings.devWorkflow.phase2Note': - 'Phase 2: This will automatically create a cron job to pick issues and raise PRs.', + 'settings.devWorkflow.enabled': 'Enabled', + 'settings.devWorkflow.paused': 'Paused', + 'settings.devWorkflow.enableToggle': 'Enable scheduled workflow', + 'settings.devWorkflow.pauseToggle': 'Pause scheduled workflow', + 'settings.devWorkflow.targetBranchOn': 'on', + 'settings.devWorkflow.nextRun': 'Next run', + 'settings.devWorkflow.lastRun': 'Last run', + 'settings.devWorkflow.runNow': 'Run now', + 'settings.devWorkflow.running': 'Running…', + 'settings.devWorkflow.recentRuns': 'Recent runs', + 'settings.devWorkflow.cronSaveError': 'Failed to save configuration', + 'settings.devWorkflow.lastOutput': 'Last output', + 'settings.devWorkflow.noOutput': 'No output captured', + 'settings.devWorkflow.runningStatus': + 'Agent is running — picking an issue and working on a fix...', 'settings.devWorkflow.errorNotConnected': 'GitHub is not connected. Please connect GitHub via Settings > Advanced > Composio first.', 'settings.devWorkflow.errorToolNotEnabled': diff --git a/app/src/lib/i18n/chunks/bn-5.ts b/app/src/lib/i18n/chunks/bn-5.ts index 77c38e82bc..22014d307f 100644 --- a/app/src/lib/i18n/chunks/bn-5.ts +++ b/app/src/lib/i18n/chunks/bn-5.ts @@ -209,8 +209,21 @@ const bn5: TranslationMap = { 'settings.devWorkflow.activeConfigUpstream': 'Upstream:', 'settings.devWorkflow.activeConfigTargetBranch': 'Target branch:', 'settings.devWorkflow.activeConfigSchedule': 'Schedule:', - 'settings.devWorkflow.phase2Note': - 'Phase 2: This will automatically create a cron job to pick issues and raise PRs.', + 'settings.devWorkflow.enabled': 'Enabled', + 'settings.devWorkflow.paused': 'Paused', + 'settings.devWorkflow.enableToggle': 'Enable scheduled workflow', + 'settings.devWorkflow.pauseToggle': 'Pause scheduled workflow', + 'settings.devWorkflow.targetBranchOn': 'on', + 'settings.devWorkflow.nextRun': 'Next run', + 'settings.devWorkflow.lastRun': 'Last run', + 'settings.devWorkflow.runNow': 'Run now', + 'settings.devWorkflow.running': 'Running…', + 'settings.devWorkflow.recentRuns': 'Recent runs', + 'settings.devWorkflow.cronSaveError': 'Failed to save configuration', + 'settings.devWorkflow.lastOutput': 'Last output', + 'settings.devWorkflow.noOutput': 'No output captured', + 'settings.devWorkflow.runningStatus': + 'Agent is running — picking an issue and working on a fix...', 'settings.devWorkflow.errorNotConnected': 'GitHub is not connected. Please connect GitHub via Settings > Advanced > Composio first.', 'settings.devWorkflow.errorToolNotEnabled': diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index 60fba5daac..e2f9051375 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -217,8 +217,21 @@ const de5: TranslationMap = { 'settings.devWorkflow.activeConfigUpstream': 'Upstream:', 'settings.devWorkflow.activeConfigTargetBranch': 'Target branch:', 'settings.devWorkflow.activeConfigSchedule': 'Schedule:', - 'settings.devWorkflow.phase2Note': - 'Phase 2: This will automatically create a cron job to pick issues and raise PRs.', + 'settings.devWorkflow.enabled': 'Enabled', + 'settings.devWorkflow.paused': 'Paused', + 'settings.devWorkflow.enableToggle': 'Enable scheduled workflow', + 'settings.devWorkflow.pauseToggle': 'Pause scheduled workflow', + 'settings.devWorkflow.targetBranchOn': 'on', + 'settings.devWorkflow.nextRun': 'Next run', + 'settings.devWorkflow.lastRun': 'Last run', + 'settings.devWorkflow.runNow': 'Run now', + 'settings.devWorkflow.running': 'Running…', + 'settings.devWorkflow.recentRuns': 'Recent runs', + 'settings.devWorkflow.cronSaveError': 'Failed to save configuration', + 'settings.devWorkflow.lastOutput': 'Last output', + 'settings.devWorkflow.noOutput': 'No output captured', + 'settings.devWorkflow.runningStatus': + 'Agent is running — picking an issue and working on a fix...', 'settings.devWorkflow.errorNotConnected': 'GitHub is not connected. Please connect GitHub via Settings > Advanced > Composio first.', 'settings.devWorkflow.errorToolNotEnabled': diff --git a/app/src/lib/i18n/chunks/en-5.ts b/app/src/lib/i18n/chunks/en-5.ts index 28c7d35444..42be1b480e 100644 --- a/app/src/lib/i18n/chunks/en-5.ts +++ b/app/src/lib/i18n/chunks/en-5.ts @@ -208,8 +208,21 @@ const en5: TranslationMap = { 'settings.devWorkflow.activeConfigUpstream': 'Upstream:', 'settings.devWorkflow.activeConfigTargetBranch': 'Target branch:', 'settings.devWorkflow.activeConfigSchedule': 'Schedule:', - 'settings.devWorkflow.phase2Note': - 'Phase 2: This will automatically create a cron job to pick issues and raise PRs.', + 'settings.devWorkflow.enabled': 'Enabled', + 'settings.devWorkflow.paused': 'Paused', + 'settings.devWorkflow.enableToggle': 'Enable scheduled workflow', + 'settings.devWorkflow.pauseToggle': 'Pause scheduled workflow', + 'settings.devWorkflow.targetBranchOn': 'on', + 'settings.devWorkflow.nextRun': 'Next run', + 'settings.devWorkflow.lastRun': 'Last run', + 'settings.devWorkflow.runNow': 'Run now', + 'settings.devWorkflow.running': 'Running…', + 'settings.devWorkflow.recentRuns': 'Recent runs', + 'settings.devWorkflow.cronSaveError': 'Failed to save configuration', + 'settings.devWorkflow.lastOutput': 'Last output', + 'settings.devWorkflow.noOutput': 'No output captured', + 'settings.devWorkflow.runningStatus': + 'Agent is running — picking an issue and working on a fix...', 'settings.devWorkflow.errorNotConnected': 'GitHub is not connected. Please connect GitHub via Settings > Advanced > Composio first.', 'settings.devWorkflow.errorToolNotEnabled': diff --git a/app/src/lib/i18n/chunks/es-5.ts b/app/src/lib/i18n/chunks/es-5.ts index bdefbe22ba..6f36692691 100644 --- a/app/src/lib/i18n/chunks/es-5.ts +++ b/app/src/lib/i18n/chunks/es-5.ts @@ -212,8 +212,21 @@ const es5: TranslationMap = { 'settings.devWorkflow.activeConfigUpstream': 'Upstream:', 'settings.devWorkflow.activeConfigTargetBranch': 'Target branch:', 'settings.devWorkflow.activeConfigSchedule': 'Schedule:', - 'settings.devWorkflow.phase2Note': - 'Phase 2: This will automatically create a cron job to pick issues and raise PRs.', + 'settings.devWorkflow.enabled': 'Enabled', + 'settings.devWorkflow.paused': 'Paused', + 'settings.devWorkflow.enableToggle': 'Enable scheduled workflow', + 'settings.devWorkflow.pauseToggle': 'Pause scheduled workflow', + 'settings.devWorkflow.targetBranchOn': 'on', + 'settings.devWorkflow.nextRun': 'Next run', + 'settings.devWorkflow.lastRun': 'Last run', + 'settings.devWorkflow.runNow': 'Run now', + 'settings.devWorkflow.running': 'Running…', + 'settings.devWorkflow.recentRuns': 'Recent runs', + 'settings.devWorkflow.cronSaveError': 'Failed to save configuration', + 'settings.devWorkflow.lastOutput': 'Last output', + 'settings.devWorkflow.noOutput': 'No output captured', + 'settings.devWorkflow.runningStatus': + 'Agent is running — picking an issue and working on a fix...', 'settings.devWorkflow.errorNotConnected': 'GitHub is not connected. Please connect GitHub via Settings > Advanced > Composio first.', 'settings.devWorkflow.errorToolNotEnabled': diff --git a/app/src/lib/i18n/chunks/fr-5.ts b/app/src/lib/i18n/chunks/fr-5.ts index 67011fcdcc..af33bc49e4 100644 --- a/app/src/lib/i18n/chunks/fr-5.ts +++ b/app/src/lib/i18n/chunks/fr-5.ts @@ -214,8 +214,21 @@ const fr5: TranslationMap = { 'settings.devWorkflow.activeConfigUpstream': 'Upstream:', 'settings.devWorkflow.activeConfigTargetBranch': 'Target branch:', 'settings.devWorkflow.activeConfigSchedule': 'Schedule:', - 'settings.devWorkflow.phase2Note': - 'Phase 2: This will automatically create a cron job to pick issues and raise PRs.', + 'settings.devWorkflow.enabled': 'Enabled', + 'settings.devWorkflow.paused': 'Paused', + 'settings.devWorkflow.enableToggle': 'Enable scheduled workflow', + 'settings.devWorkflow.pauseToggle': 'Pause scheduled workflow', + 'settings.devWorkflow.targetBranchOn': 'on', + 'settings.devWorkflow.nextRun': 'Next run', + 'settings.devWorkflow.lastRun': 'Last run', + 'settings.devWorkflow.runNow': 'Run now', + 'settings.devWorkflow.running': 'Running…', + 'settings.devWorkflow.recentRuns': 'Recent runs', + 'settings.devWorkflow.cronSaveError': 'Failed to save configuration', + 'settings.devWorkflow.lastOutput': 'Last output', + 'settings.devWorkflow.noOutput': 'No output captured', + 'settings.devWorkflow.runningStatus': + 'Agent is running — picking an issue and working on a fix...', 'settings.devWorkflow.errorNotConnected': 'GitHub is not connected. Please connect GitHub via Settings > Advanced > Composio first.', 'settings.devWorkflow.errorToolNotEnabled': diff --git a/app/src/lib/i18n/chunks/hi-5.ts b/app/src/lib/i18n/chunks/hi-5.ts index 0ebb70319e..f70be71398 100644 --- a/app/src/lib/i18n/chunks/hi-5.ts +++ b/app/src/lib/i18n/chunks/hi-5.ts @@ -209,8 +209,21 @@ const hi5: TranslationMap = { 'settings.devWorkflow.activeConfigUpstream': 'Upstream:', 'settings.devWorkflow.activeConfigTargetBranch': 'Target branch:', 'settings.devWorkflow.activeConfigSchedule': 'Schedule:', - 'settings.devWorkflow.phase2Note': - 'Phase 2: This will automatically create a cron job to pick issues and raise PRs.', + 'settings.devWorkflow.enabled': 'Enabled', + 'settings.devWorkflow.paused': 'Paused', + 'settings.devWorkflow.enableToggle': 'Enable scheduled workflow', + 'settings.devWorkflow.pauseToggle': 'Pause scheduled workflow', + 'settings.devWorkflow.targetBranchOn': 'on', + 'settings.devWorkflow.nextRun': 'Next run', + 'settings.devWorkflow.lastRun': 'Last run', + 'settings.devWorkflow.runNow': 'Run now', + 'settings.devWorkflow.running': 'Running…', + 'settings.devWorkflow.recentRuns': 'Recent runs', + 'settings.devWorkflow.cronSaveError': 'Failed to save configuration', + 'settings.devWorkflow.lastOutput': 'Last output', + 'settings.devWorkflow.noOutput': 'No output captured', + 'settings.devWorkflow.runningStatus': + 'Agent is running — picking an issue and working on a fix...', 'settings.devWorkflow.errorNotConnected': 'GitHub is not connected. Please connect GitHub via Settings > Advanced > Composio first.', 'settings.devWorkflow.errorToolNotEnabled': diff --git a/app/src/lib/i18n/chunks/id-5.ts b/app/src/lib/i18n/chunks/id-5.ts index ffbf267a4a..afe8a7e0f8 100644 --- a/app/src/lib/i18n/chunks/id-5.ts +++ b/app/src/lib/i18n/chunks/id-5.ts @@ -210,8 +210,21 @@ const id5: TranslationMap = { 'settings.devWorkflow.activeConfigUpstream': 'Upstream:', 'settings.devWorkflow.activeConfigTargetBranch': 'Target branch:', 'settings.devWorkflow.activeConfigSchedule': 'Schedule:', - 'settings.devWorkflow.phase2Note': - 'Phase 2: This will automatically create a cron job to pick issues and raise PRs.', + 'settings.devWorkflow.enabled': 'Enabled', + 'settings.devWorkflow.paused': 'Paused', + 'settings.devWorkflow.enableToggle': 'Enable scheduled workflow', + 'settings.devWorkflow.pauseToggle': 'Pause scheduled workflow', + 'settings.devWorkflow.targetBranchOn': 'on', + 'settings.devWorkflow.nextRun': 'Next run', + 'settings.devWorkflow.lastRun': 'Last run', + 'settings.devWorkflow.runNow': 'Run now', + 'settings.devWorkflow.running': 'Running…', + 'settings.devWorkflow.recentRuns': 'Recent runs', + 'settings.devWorkflow.cronSaveError': 'Failed to save configuration', + 'settings.devWorkflow.lastOutput': 'Last output', + 'settings.devWorkflow.noOutput': 'No output captured', + 'settings.devWorkflow.runningStatus': + 'Agent is running — picking an issue and working on a fix...', 'settings.devWorkflow.errorNotConnected': 'GitHub is not connected. Please connect GitHub via Settings > Advanced > Composio first.', 'settings.devWorkflow.errorToolNotEnabled': diff --git a/app/src/lib/i18n/chunks/it-5.ts b/app/src/lib/i18n/chunks/it-5.ts index 999c69c4ff..f85935755e 100644 --- a/app/src/lib/i18n/chunks/it-5.ts +++ b/app/src/lib/i18n/chunks/it-5.ts @@ -212,8 +212,21 @@ const it5: TranslationMap = { 'settings.devWorkflow.activeConfigUpstream': 'Upstream:', 'settings.devWorkflow.activeConfigTargetBranch': 'Target branch:', 'settings.devWorkflow.activeConfigSchedule': 'Schedule:', - 'settings.devWorkflow.phase2Note': - 'Phase 2: This will automatically create a cron job to pick issues and raise PRs.', + 'settings.devWorkflow.enabled': 'Enabled', + 'settings.devWorkflow.paused': 'Paused', + 'settings.devWorkflow.enableToggle': 'Enable scheduled workflow', + 'settings.devWorkflow.pauseToggle': 'Pause scheduled workflow', + 'settings.devWorkflow.targetBranchOn': 'on', + 'settings.devWorkflow.nextRun': 'Next run', + 'settings.devWorkflow.lastRun': 'Last run', + 'settings.devWorkflow.runNow': 'Run now', + 'settings.devWorkflow.running': 'Running…', + 'settings.devWorkflow.recentRuns': 'Recent runs', + 'settings.devWorkflow.cronSaveError': 'Failed to save configuration', + 'settings.devWorkflow.lastOutput': 'Last output', + 'settings.devWorkflow.noOutput': 'No output captured', + 'settings.devWorkflow.runningStatus': + 'Agent is running — picking an issue and working on a fix...', 'settings.devWorkflow.errorNotConnected': 'GitHub is not connected. Please connect GitHub via Settings > Advanced > Composio first.', 'settings.devWorkflow.errorToolNotEnabled': diff --git a/app/src/lib/i18n/chunks/ko-5.ts b/app/src/lib/i18n/chunks/ko-5.ts index 0be7a598c7..d7c30a0eea 100644 --- a/app/src/lib/i18n/chunks/ko-5.ts +++ b/app/src/lib/i18n/chunks/ko-5.ts @@ -541,8 +541,21 @@ const ko5: TranslationMap = { 'settings.devWorkflow.activeConfigUpstream': 'Upstream:', 'settings.devWorkflow.activeConfigTargetBranch': 'Target branch:', 'settings.devWorkflow.activeConfigSchedule': 'Schedule:', - 'settings.devWorkflow.phase2Note': - 'Phase 2: This will automatically create a cron job to pick issues and raise PRs.', + 'settings.devWorkflow.enabled': 'Enabled', + 'settings.devWorkflow.paused': 'Paused', + 'settings.devWorkflow.enableToggle': 'Enable scheduled workflow', + 'settings.devWorkflow.pauseToggle': 'Pause scheduled workflow', + 'settings.devWorkflow.targetBranchOn': 'on', + 'settings.devWorkflow.nextRun': 'Next run', + 'settings.devWorkflow.lastRun': 'Last run', + 'settings.devWorkflow.runNow': 'Run now', + 'settings.devWorkflow.running': 'Running…', + 'settings.devWorkflow.recentRuns': 'Recent runs', + 'settings.devWorkflow.cronSaveError': 'Failed to save configuration', + 'settings.devWorkflow.lastOutput': 'Last output', + 'settings.devWorkflow.noOutput': 'No output captured', + 'settings.devWorkflow.runningStatus': + 'Agent is running — picking an issue and working on a fix...', 'settings.devWorkflow.errorNotConnected': 'GitHub is not connected. Please connect GitHub via Settings > Advanced > Composio first.', 'settings.devWorkflow.errorToolNotEnabled': diff --git a/app/src/lib/i18n/chunks/pl-5.ts b/app/src/lib/i18n/chunks/pl-5.ts index 1f2479157a..3874858860 100644 --- a/app/src/lib/i18n/chunks/pl-5.ts +++ b/app/src/lib/i18n/chunks/pl-5.ts @@ -222,8 +222,21 @@ const pl5: TranslationMap = { 'settings.devWorkflow.activeConfigUpstream': 'Upstream:', 'settings.devWorkflow.activeConfigTargetBranch': 'Target branch:', 'settings.devWorkflow.activeConfigSchedule': 'Schedule:', - 'settings.devWorkflow.phase2Note': - 'Phase 2: This will automatically create a cron job to pick issues and raise PRs.', + 'settings.devWorkflow.enabled': 'Enabled', + 'settings.devWorkflow.paused': 'Paused', + 'settings.devWorkflow.enableToggle': 'Enable scheduled workflow', + 'settings.devWorkflow.pauseToggle': 'Pause scheduled workflow', + 'settings.devWorkflow.targetBranchOn': 'on', + 'settings.devWorkflow.nextRun': 'Next run', + 'settings.devWorkflow.lastRun': 'Last run', + 'settings.devWorkflow.runNow': 'Run now', + 'settings.devWorkflow.running': 'Running…', + 'settings.devWorkflow.recentRuns': 'Recent runs', + 'settings.devWorkflow.cronSaveError': 'Failed to save configuration', + 'settings.devWorkflow.lastOutput': 'Last output', + 'settings.devWorkflow.noOutput': 'No output captured', + 'settings.devWorkflow.runningStatus': + 'Agent is running — picking an issue and working on a fix...', 'settings.devWorkflow.errorNotConnected': 'GitHub is not connected. Please connect GitHub via Settings > Advanced > Composio first.', 'settings.devWorkflow.errorToolNotEnabled': diff --git a/app/src/lib/i18n/chunks/pt-5.ts b/app/src/lib/i18n/chunks/pt-5.ts index 41a5c36ea9..c9779d7e65 100644 --- a/app/src/lib/i18n/chunks/pt-5.ts +++ b/app/src/lib/i18n/chunks/pt-5.ts @@ -213,8 +213,21 @@ const pt5: TranslationMap = { 'settings.devWorkflow.activeConfigUpstream': 'Upstream:', 'settings.devWorkflow.activeConfigTargetBranch': 'Target branch:', 'settings.devWorkflow.activeConfigSchedule': 'Schedule:', - 'settings.devWorkflow.phase2Note': - 'Phase 2: This will automatically create a cron job to pick issues and raise PRs.', + 'settings.devWorkflow.enabled': 'Enabled', + 'settings.devWorkflow.paused': 'Paused', + 'settings.devWorkflow.enableToggle': 'Enable scheduled workflow', + 'settings.devWorkflow.pauseToggle': 'Pause scheduled workflow', + 'settings.devWorkflow.targetBranchOn': 'on', + 'settings.devWorkflow.nextRun': 'Next run', + 'settings.devWorkflow.lastRun': 'Last run', + 'settings.devWorkflow.runNow': 'Run now', + 'settings.devWorkflow.running': 'Running…', + 'settings.devWorkflow.recentRuns': 'Recent runs', + 'settings.devWorkflow.cronSaveError': 'Failed to save configuration', + 'settings.devWorkflow.lastOutput': 'Last output', + 'settings.devWorkflow.noOutput': 'No output captured', + 'settings.devWorkflow.runningStatus': + 'Agent is running — picking an issue and working on a fix...', 'settings.devWorkflow.errorNotConnected': 'GitHub is not connected. Please connect GitHub via Settings > Advanced > Composio first.', 'settings.devWorkflow.errorToolNotEnabled': diff --git a/app/src/lib/i18n/chunks/ru-5.ts b/app/src/lib/i18n/chunks/ru-5.ts index 239751e4f0..013a64554c 100644 --- a/app/src/lib/i18n/chunks/ru-5.ts +++ b/app/src/lib/i18n/chunks/ru-5.ts @@ -210,8 +210,21 @@ const ru5: TranslationMap = { 'settings.devWorkflow.activeConfigUpstream': 'Upstream:', 'settings.devWorkflow.activeConfigTargetBranch': 'Target branch:', 'settings.devWorkflow.activeConfigSchedule': 'Schedule:', - 'settings.devWorkflow.phase2Note': - 'Phase 2: This will automatically create a cron job to pick issues and raise PRs.', + 'settings.devWorkflow.enabled': 'Enabled', + 'settings.devWorkflow.paused': 'Paused', + 'settings.devWorkflow.enableToggle': 'Enable scheduled workflow', + 'settings.devWorkflow.pauseToggle': 'Pause scheduled workflow', + 'settings.devWorkflow.targetBranchOn': 'on', + 'settings.devWorkflow.nextRun': 'Next run', + 'settings.devWorkflow.lastRun': 'Last run', + 'settings.devWorkflow.runNow': 'Run now', + 'settings.devWorkflow.running': 'Running…', + 'settings.devWorkflow.recentRuns': 'Recent runs', + 'settings.devWorkflow.cronSaveError': 'Failed to save configuration', + 'settings.devWorkflow.lastOutput': 'Last output', + 'settings.devWorkflow.noOutput': 'No output captured', + 'settings.devWorkflow.runningStatus': + 'Agent is running — picking an issue and working on a fix...', 'settings.devWorkflow.errorNotConnected': 'GitHub is not connected. Please connect GitHub via Settings > Advanced > Composio first.', 'settings.devWorkflow.errorToolNotEnabled': diff --git a/app/src/lib/i18n/chunks/zh-CN-5.ts b/app/src/lib/i18n/chunks/zh-CN-5.ts index 23a5c1dff0..6d4a00538a 100644 --- a/app/src/lib/i18n/chunks/zh-CN-5.ts +++ b/app/src/lib/i18n/chunks/zh-CN-5.ts @@ -199,8 +199,21 @@ const zhCN5: TranslationMap = { 'settings.devWorkflow.activeConfigUpstream': 'Upstream:', 'settings.devWorkflow.activeConfigTargetBranch': 'Target branch:', 'settings.devWorkflow.activeConfigSchedule': 'Schedule:', - 'settings.devWorkflow.phase2Note': - 'Phase 2: This will automatically create a cron job to pick issues and raise PRs.', + 'settings.devWorkflow.enabled': 'Enabled', + 'settings.devWorkflow.paused': 'Paused', + 'settings.devWorkflow.enableToggle': 'Enable scheduled workflow', + 'settings.devWorkflow.pauseToggle': 'Pause scheduled workflow', + 'settings.devWorkflow.targetBranchOn': 'on', + 'settings.devWorkflow.nextRun': 'Next run', + 'settings.devWorkflow.lastRun': 'Last run', + 'settings.devWorkflow.runNow': 'Run now', + 'settings.devWorkflow.running': 'Running…', + 'settings.devWorkflow.recentRuns': 'Recent runs', + 'settings.devWorkflow.cronSaveError': 'Failed to save configuration', + 'settings.devWorkflow.lastOutput': 'Last output', + 'settings.devWorkflow.noOutput': 'No output captured', + 'settings.devWorkflow.runningStatus': + 'Agent is running — picking an issue and working on a fix...', 'settings.devWorkflow.errorNotConnected': 'GitHub is not connected. Please connect GitHub via Settings > Advanced > Composio first.', 'settings.devWorkflow.errorToolNotEnabled': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a37792c680..1876d2a29d 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3167,8 +3167,21 @@ const en: TranslationMap = { 'settings.devWorkflow.activeConfigUpstream': 'Upstream:', 'settings.devWorkflow.activeConfigTargetBranch': 'Target branch:', 'settings.devWorkflow.activeConfigSchedule': 'Schedule:', - 'settings.devWorkflow.phase2Note': - 'Phase 2: This will automatically create a cron job to pick issues and raise PRs.', + 'settings.devWorkflow.enabled': 'Enabled', + 'settings.devWorkflow.paused': 'Paused', + 'settings.devWorkflow.enableToggle': 'Enable scheduled workflow', + 'settings.devWorkflow.pauseToggle': 'Pause scheduled workflow', + 'settings.devWorkflow.targetBranchOn': 'on', + 'settings.devWorkflow.nextRun': 'Next run', + 'settings.devWorkflow.lastRun': 'Last run', + 'settings.devWorkflow.runNow': 'Run now', + 'settings.devWorkflow.running': 'Running…', + 'settings.devWorkflow.recentRuns': 'Recent runs', + 'settings.devWorkflow.cronSaveError': 'Failed to save configuration', + 'settings.devWorkflow.lastOutput': 'Last output', + 'settings.devWorkflow.noOutput': 'No output captured', + 'settings.devWorkflow.runningStatus': + 'Agent is running — picking an issue and working on a fix...', 'settings.devWorkflow.errorNotConnected': 'GitHub is not connected. Please connect GitHub via Settings > Advanced > Composio first.', 'settings.devWorkflow.errorToolNotEnabled': diff --git a/app/src/services/rpcMethods.ts b/app/src/services/rpcMethods.ts index 0086744b9e..80d4689cc6 100644 --- a/app/src/services/rpcMethods.ts +++ b/app/src/services/rpcMethods.ts @@ -76,6 +76,8 @@ export const LEGACY_METHOD_ALIASES: Record = { 'openhuman.local_ai_presets': CORE_RPC_METHODS.inferencePresets, 'openhuman.providers_list_models': CORE_RPC_METHODS.inferenceListModels, 'openhuman.inference_embed': CORE_RPC_METHODS.embeddingsEmbed, + // bare `health_snapshot` (no namespace prefix) was used by older clients + // before the canonical `openhuman.health_snapshot` form was established. health_snapshot: CORE_RPC_METHODS.healthSnapshot, }; diff --git a/app/src/utils/tauriCommands/cron.ts b/app/src/utils/tauriCommands/cron.ts index 25a06cf5a1..2c1bb7a1a9 100644 --- a/app/src/utils/tauriCommands/cron.ts +++ b/app/src/utils/tauriCommands/cron.ts @@ -52,6 +52,28 @@ export interface CoreCronRun { duration_ms?: number | null; } +export interface CronAddParams { + name?: string; + schedule: CoreCronSchedule; + job_type?: 'shell' | 'agent'; + command?: string; + prompt?: string; + session_target?: 'isolated' | 'main'; + model?: string; + agent_id?: string; + delivery?: { mode: string; channel?: string | null; to?: string | null; best_effort?: boolean }; + delete_after_run?: boolean; +} + +export async function openhumanCronAdd( + params: CronAddParams +): Promise> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc>({ method: 'openhuman.cron_add', params }); +} + export async function openhumanCronList(): Promise> { if (!isTauri()) { throw new Error('Not running in Tauri'); diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 0c6d3c5fe9..f0fab30f4d 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -1364,6 +1364,10 @@ async fn run_server_inner( ), Err(e) => log::warn!("[boot] whatsapp_data::global init failed: {e}"), } + // Seed bundled default skills into /skills/ so they + // ship with the system — discoverable (skills_list) and runnable + // — without a manual drop. Idempotent; never clobbers user edits. + crate::openhuman::skills::registry::seed_default_skills(&cfg.workspace_dir); } Err(e) => { log::error!( diff --git a/src/core/legacy_aliases.rs b/src/core/legacy_aliases.rs index fcc605b59b..d97abc2f7f 100644 --- a/src/core/legacy_aliases.rs +++ b/src/core/legacy_aliases.rs @@ -200,6 +200,20 @@ mod tests { rest[..value_end].to_string() } + /// Extract a JS object key that may be either quoted (`'foo'` / `"foo"`) + /// or an unquoted bare identifier (`foo`). Prettier removes quotes from + /// identifiers that don't require them, so both forms appear in practice. + fn extract_key(text: &str) -> String { + let t = text.trim(); + // Quoted key — extract the inner value. + if t.starts_with('\'') || t.starts_with('"') { + return quoted_value(t); + } + // Bare identifier — strip trailing punctuation (commas, whitespace) and return. + t.trim_end_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_') + .to_string() + } + fn parse_core_rpc_methods(source: &str) -> BTreeMap { let body = object_body_after_marker(source, "export const CORE_RPC_METHODS", "} as const;"); let mut methods = BTreeMap::new(); @@ -220,10 +234,25 @@ mod tests { core_methods: &BTreeMap, ) -> BTreeMap { let body = object_body_after_marker(source, "export const LEGACY_METHOD_ALIASES", "};"); + // Strip `// …` comments from each line before joining so inline + // comments (and standalone comment lines) don't contaminate entries. + // Keys may appear as quoted strings (`'foo'`) or bare identifiers + // (`foo`) after Prettier removes unnecessary quotes from valid + // JS identifiers. let compact = body .lines() .map(str::trim) .filter(|line| !line.is_empty() && !line.starts_with("//")) + .map(|line| { + // Strip inline `// …` trailing comments. Object values + // (`'openhuman.x'`, `CORE_RPC_METHODS.y`) never contain `//`. + if let Some(pos) = line.find("//") { + &line[..pos] + } else { + line + } + }) + .filter(|line| !line.trim().is_empty()) .collect::>() .join(" "); let mut aliases = BTreeMap::new(); @@ -235,14 +264,7 @@ mod tests { let (legacy, target_expr) = entry .split_once(':') .unwrap_or_else(|| panic!("expected legacy alias entry, got `{entry}`")); - // Prettier strips quotes from keys that are valid JS identifiers - // (e.g. `health_snapshot`), so accept both `'foo':` and bare `foo:`. - let legacy_trimmed = legacy.trim(); - let legacy = if legacy_trimmed.starts_with('\'') || legacy_trimmed.starts_with('"') { - quoted_value(legacy) - } else { - legacy_trimmed.to_string() - }; + let legacy = extract_key(legacy); let target_expr = target_expr.trim(); let canonical = if let Some(key) = target_expr.strip_prefix("CORE_RPC_METHODS.") { core_methods diff --git a/src/openhuman/agent/harness/subagent_runner/autonomous.rs b/src/openhuman/agent/harness/subagent_runner/autonomous.rs new file mode 100644 index 0000000000..3c4964fcb4 --- /dev/null +++ b/src/openhuman/agent/harness/subagent_runner/autonomous.rs @@ -0,0 +1,29 @@ +//! Autonomous skill-run overrides. +//! +//! `skills_run` runs the orchestrator (and any sub-agents it spawns) as an +//! unattended background tree: it isn't approval-gated (background turns carry +//! no `APPROVAL_CHAT_CONTEXT`), and the per-agent iteration cap is lifted so the +//! run continues until it's done or the repeated-failure circuit breaker trips. +//! +//! The lifted cap rides a `tokio` task-local set around the orchestrator's +//! `run_single`. Sub-agent inner loops are awaited *inline* within that scope +//! (`run_subagent` does not detach), so the task-local reaches them too — one +//! switch covers the whole tree. + +use std::future::Future; + +tokio::task_local! { + static AUTONOMOUS_ITER_CAP: usize; +} + +/// The active autonomous iteration cap, if a skill run scoped one. +pub fn autonomous_iter_cap() -> Option { + AUTONOMOUS_ITER_CAP.try_with(|c| *c).ok() +} + +/// Run `fut` with an autonomous iteration cap in scope. The cap propagates to +/// every agentic loop awaited within — the orchestrator turn and the inline +/// sub-agent loops. +pub async fn with_autonomous_iter_cap(cap: usize, fut: F) -> F::Output { + AUTONOMOUS_ITER_CAP.scope(cap, fut).await +} diff --git a/src/openhuman/agent/harness/subagent_runner/mod.rs b/src/openhuman/agent/harness/subagent_runner/mod.rs index c00d17f10c..41feabe265 100644 --- a/src/openhuman/agent/harness/subagent_runner/mod.rs +++ b/src/openhuman/agent/harness/subagent_runner/mod.rs @@ -30,6 +30,7 @@ //! | `extract_tool.rs` | `extract_from_result` tool (direct provider extraction) | //! | `tool_prep.rs` | Tool filtering + prompt loading + text-mode protocol block | +mod autonomous; mod extract_tool; mod handoff; mod ops; @@ -37,6 +38,7 @@ mod tool_prep; mod types; // Public API — the entry point and the shapes it returns. +pub use autonomous::{autonomous_iter_cap, with_autonomous_iter_cap}; pub use ops::run_subagent; pub use types::{SubagentMode, SubagentRunError, SubagentRunOptions, SubagentRunOutcome}; diff --git a/src/openhuman/agent/harness/subagent_runner/ops.rs b/src/openhuman/agent/harness/subagent_runner/ops.rs index 9b4d7aa4d2..963ec198b9 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops.rs @@ -1230,7 +1230,13 @@ async fn run_inner_loop( handoff_cache: Option<&ResultHandoffCache>, parent: &ParentExecutionContext, ) -> Result<(String, usize, AggregatedUsage), SubagentRunError> { - let max_iterations = max_iterations.max(1); + // An autonomous skill run (set via `with_autonomous_iter_cap`) lifts the + // per-agent cap so sub-agents run until done / the circuit breaker trips. + // Take the larger of the two so a sub-agent that already wants more keeps it. + let max_iterations = super::autonomous::autonomous_iter_cap() + .map(|cap| cap.max(max_iterations)) + .unwrap_or(max_iterations) + .max(1); // Compiled digest of this sub-agent run's tool calls + results, for a // graceful checkpoint if it hits the iteration cap (mirrors the main diff --git a/src/openhuman/codegraph/index.rs b/src/openhuman/codegraph/index.rs new file mode 100644 index 0000000000..301c76768c --- /dev/null +++ b/src/openhuman/codegraph/index.rs @@ -0,0 +1,829 @@ +//! Indexing: enumerate a git tree's blobs → for each unseen `(content, model)` +//! extract a structural-aug doc + BM25 tokens, embed it, and cache by blob SHA; +//! then write the `(repo, ref)` manifest. Content-addressed + incremental: a +//! branch switch / new commit / pull only (re)embeds the blobs that changed. +//! +//! The structural extractor here is a dependency-free heuristic (signatures + +//! imports + call identifiers + leading doc/comments) — the same *content* the +//! validated prototype's `ast` pass produced. A tree-sitter upgrade (better +//! extraction + the repo-map call graph) slots in behind [`structural_doc`]. +//! +//! The embedder is injected (`&dyn EmbeddingProvider`) so the flow unit-tests +//! with a fake; production passes the configured (cloud-default) provider, and +//! its `signature()` becomes the blob cache's `model` key. + +use anyhow::{Context, Result}; +use std::path::Path; +use std::process::Command; + +use crate::openhuman::embeddings::EmbeddingProvider; + +use super::store::CodegraphStore; + +const CODE_EXTS: &[&str] = &[ + "rs", "py", "js", "jsx", "ts", "tsx", "go", "java", "rb", "c", "cc", "cpp", "h", "hpp", "cs", + "php", "kt", "swift", "scala", "sh", +]; +const MAX_FILE_BYTES: u64 = 100_000; +const MAX_CALLS: usize = 200; +/// Structural docs embedded per provider call. One call per file would be one +/// network round-trip per file against a cloud embedder; batching collapses a +/// repo into a handful of calls. +const EMBED_BATCH: usize = 128; + +/// Cache `model` key for a lexical-only (BM25, no embedding) index. Kept +/// separate from any embedder signature so a later dense pass indexes fresh +/// under its own key rather than colliding with these embedding-less rows. +pub const LEXICAL_MODEL: &str = "codegraph:lexical:v1"; + +/// What to build for a `(repo, ref)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IndexMode { + /// BM25 tokens only — no embedding calls. Cheap; enough for small repos + /// where recall saturates anyway. + Lexical, + /// Structural-aug dense vectors + BM25 tokens — the full seed. Worth its + /// embedding cost on larger repos. + Dense, +} + +impl IndexMode { + /// The blob-cache `model` key this mode writes/reads under. + pub fn model_key(self, embedder: &dyn EmbeddingProvider) -> String { + match self { + IndexMode::Lexical => LEXICAL_MODEL.to_string(), + IndexMode::Dense => embedder.signature(), + } + } +} + +/// Count tracked code files at the checkout — the cheap signal (`git ls-files`, +/// no reads/embeds) used to choose [`IndexMode`] before indexing. +pub fn count_code_files(repo_dir: &Path) -> Result { + Ok(tree_blobs(repo_dir)?.len()) +} + +/// Per-index outcome. On a branch switch, `computed` is just the changed blobs. +#[derive(Debug, Clone, serde::Serialize)] +pub struct IndexReport { + pub repo_id: String, + pub git_ref: String, + pub files: usize, + pub computed: usize, + pub cached: usize, + pub skipped: usize, +} + +fn git(repo_dir: &Path, args: &[&str]) -> Result { + let out = Command::new("git") + .arg("-C") + .arg(repo_dir) + .args(args) + .output() + .with_context(|| format!("git {args:?}"))?; + if !out.status.success() { + anyhow::bail!( + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +/// Branch name if on a branch, else the short commit SHA (detached). +pub fn current_ref(repo_dir: &Path) -> Result { + if let Ok(s) = git(repo_dir, &["symbolic-ref", "--quiet", "--short", "HEAD"]) { + let s = s.trim(); + if !s.is_empty() { + return Ok(s.to_string()); + } + } + Ok(git(repo_dir, &["rev-parse", "--short", "HEAD"])? + .trim() + .to_string()) +} + +/// `(path, blob_sha)` for tracked code files at the current checkout. +fn tree_blobs(repo_dir: &Path) -> Result> { + let mut out = Vec::new(); + for line in git(repo_dir, &["ls-files", "-s"])?.lines() { + // ` \t` + let (meta, path) = match line.split_once('\t') { + Some(p) => p, + None => continue, + }; + let sha = match meta.split_whitespace().nth(1) { + Some(s) => s, + None => continue, + }; + let ext = Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + if CODE_EXTS.contains(&ext) { + out.push((path.to_string(), sha.to_string())); + } + } + Ok(out) +} + +/// Lexical tokens with identifier splitting (camelCase / snake_case), so the +/// BM25 arm matches `__floordiv__` and `floordiv`/`floor`/`div` alike. +pub fn code_tokens(text: &str) -> Vec { + let mut toks = Vec::new(); + for raw in text.split(|c: char| !c.is_ascii_alphanumeric()) { + if raw.is_empty() { + continue; + } + let low = raw.to_ascii_lowercase(); + toks.push(low.clone()); + // split camelCase / snake (already split on non-alnum) into sub-words + let mut cur = String::new(); + let mut prev_lower = false; + for ch in raw.chars() { + if ch.is_ascii_uppercase() && prev_lower && !cur.is_empty() { + toks.push(cur.to_ascii_lowercase()); + cur.clear(); + } + cur.push(ch); + prev_lower = ch.is_ascii_lowercase(); + } + let sub = cur.to_ascii_lowercase(); + if !sub.is_empty() && sub != low { + toks.push(sub); + } + } + toks +} + +/// Heuristic, content-only "intent" text: definition signatures + imports + +/// called-symbol identifiers + leading doc/comment lines. Path is excluded so +/// the result is purely content-derived (cacheable by blob SHA). +pub fn structural_doc(text: &str) -> String { + let mut sigs = Vec::new(); + let mut imports = Vec::new(); + let mut docs = Vec::new(); + let mut calls: Vec = Vec::new(); + let mut seen_calls = std::collections::HashSet::new(); + + for line in text.lines() { + let t = line.trim(); + if t.is_empty() { + continue; + } + let lead = t.split_whitespace().next().unwrap_or(""); + match lead { + // definition keywords across the supported languages + "fn" | "def" | "class" | "struct" | "impl" | "trait" | "enum" | "interface" + | "type" | "func" | "function" | "module" | "public" | "private" | "protected" + | "pub" | "async" | "export" | "const" => { + sigs.push(t.trim_end_matches('{').trim().to_string()); + } + "import" | "use" | "from" | "require" | "#include" | "package" => { + imports.push(t.to_string()); + } + _ => {} + } + if t.starts_with("//") + || t.starts_with("///") + || t.starts_with('#') + || t.starts_with('*') + || t.starts_with("\"\"\"") + { + docs.push(t.trim_start_matches(['/', '#', '*', ' ', '"']).to_string()); + } + // naive call extraction: `ident(` + for (i, _) in line.match_indices('(') { + let prefix = &line[..i]; + let name: String = prefix + .chars() + .rev() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect::() + .chars() + .rev() + .collect(); + if name.len() >= 2 && seen_calls.insert(name.clone()) && calls.len() < MAX_CALLS { + calls.push(name); + } + } + } + + let mut parts = Vec::new(); + if !sigs.is_empty() { + parts.push(format!("symbols: {}", sigs.join(" "))); + } + if !imports.is_empty() { + parts.push(format!("imports: {}", imports.join(" "))); + } + if !calls.is_empty() { + parts.push(format!("calls: {}", calls.join(" "))); + } + if !docs.is_empty() { + parts.push(format!("docs: {}", docs.join(" "))); + } + parts.join("\n") +} + +fn l2_normalize(v: &mut [f32]) { + let norm = v.iter().map(|x| x * x).sum::().sqrt(); + if norm > 0.0 { + for x in v.iter_mut() { + *x /= norm; + } + } +} + +/// (Re)index the checkout at `repo_dir` under `(repo_id, ref)`. Only blobs not +/// already cached for this `mode`'s key are read + (in `Dense`) embedded; the +/// rest are cache hits. Then the ref's manifest is rewritten to the current +/// tree. In `Lexical` mode no embedder call is made — tokens only. +pub async fn index_ref( + store: &mut CodegraphStore, + repo_id: &str, + repo_dir: &Path, + git_ref: Option<&str>, + embedder: &dyn EmbeddingProvider, + mode: IndexMode, +) -> Result { + let git_ref = match git_ref { + Some(r) => r.to_string(), + None => current_ref(repo_dir)?, + }; + let model = mode.model_key(embedder); + let blobs = tree_blobs(repo_dir)?; + let (mut cached, mut skipped) = (0usize, 0usize); + + // Phase 1 — read + extract every *uncached, unique* blob. No DB writes and + // no embedding yet, so phases 2 and 3 can batch both. A content SHA seen + // twice in the tree (identical file) is extracted once. + let mut seen = std::collections::HashSet::new(); + let mut pend_sha: Vec = Vec::new(); + let mut pend_tokens: Vec> = Vec::new(); + let mut pend_docs: Vec = Vec::new(); + for (path, sha) in &blobs { + if !seen.insert(sha.clone()) || store.has_blob(sha, &model)? { + cached += 1; + continue; + } + let full = repo_dir.join(path); + match std::fs::metadata(&full) { + Ok(m) if m.len() > MAX_FILE_BYTES => { + skipped += 1; + continue; + } + Err(_) => { + skipped += 1; + continue; + } + _ => {} + } + let text = match std::fs::read_to_string(&full) { + Ok(t) => t, + Err(_) => { + skipped += 1; + continue; + } + }; + let tokens = code_tokens(&text); + if mode == IndexMode::Dense { + // A file with no extractable structure (empty `__init__.py`, a data + // file, `x = 1`) yields an empty structural doc. Embedders reject + // empty input (the cloud backend 400s the whole batch), so fall + // back to the lexical tokens — still content-derived, cacheable by + // blob SHA. (Skipped entirely in Lexical mode — no embedding.) + let doc = structural_doc(&text); + let doc = if doc.trim().is_empty() { + if tokens.is_empty() { + "(no extractable content)".to_string() + } else { + tokens.join(" ") + } + } else { + doc + }; + pend_docs.push(doc); + } + pend_tokens.push(tokens); + pend_sha.push(sha.clone()); + } + + // Phase 2 — produce a vector per pending blob. Lexical: empty vectors (no + // embedder call). Dense: embed the structural docs in batches (few + // round-trips, not one per file), L2-normalising each. + let mut embs: Vec> = Vec::with_capacity(pend_sha.len()); + match mode { + IndexMode::Lexical => embs.resize(pend_sha.len(), Vec::new()), + IndexMode::Dense => { + for chunk in pend_docs.chunks(EMBED_BATCH) { + let refs: Vec<&str> = chunk.iter().map(String::as_str).collect(); + let out = embedder + .embed(&refs) + .await + .context("codegraph: embed structural docs")?; + if out.len() != chunk.len() { + anyhow::bail!( + "codegraph: embedder returned {} vectors for {} inputs", + out.len(), + chunk.len() + ); + } + for mut v in out { + l2_normalize(&mut v); + embs.push(v); + } + } + } + } + + // Phase 3 — persist the whole batch in one transaction, then rewrite the + // ref's manifest. + let computed = pend_sha.len(); + let entries: Vec<(String, Vec, Vec)> = pend_sha + .into_iter() + .zip(pend_tokens) + .zip(embs) + .map(|((sha, tokens), emb)| (sha, tokens, emb)) + .collect(); + store.put_blobs(&model, &entries)?; + store.set_manifest(repo_id, &git_ref, &blobs)?; + + Ok(IndexReport { + repo_id: repo_id.to_string(), + git_ref, + files: blobs.len(), + computed, + cached, + skipped, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use tempfile::TempDir; + + #[test] + fn code_tokens_splits_identifiers() { + let t = code_tokens("def __floordiv__(self): TimedeltaIndex"); + assert!(t.contains(&"floordiv".to_string())); + assert!(t.contains(&"timedelta".to_string()) || t.contains(&"timedeltaindex".to_string())); + } + + #[test] + fn structural_doc_pulls_signatures_imports_calls() { + let src = "import os\nfn reconcile(charge):\n return backoff(charge)\n"; + let d = structural_doc(src); + assert!(d.contains("imports:") && d.contains("import os")); + assert!(d.contains("symbols:") && d.contains("reconcile")); + assert!(d.contains("calls:") && d.contains("backoff")); + } + + struct FakeEmbedder; + #[async_trait] + impl EmbeddingProvider for FakeEmbedder { + fn name(&self) -> &str { + "fake" + } + fn model_id(&self) -> &str { + "fake-1" + } + fn dimensions(&self) -> usize { + 3 + } + async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { + // deterministic non-zero vector per input (length-based, just needs to be stable) + Ok(texts + .iter() + .map(|t| vec![t.len() as f32 + 1.0, 1.0, 0.5]) + .collect()) + } + } + + fn git(dir: &std::path::Path, args: &[&str]) { + let ok = std::process::Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .unwrap() + .status + .success(); + assert!(ok, "git {args:?}"); + } + + #[tokio::test] + async fn index_ref_is_content_addressed_and_incremental() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir_all(&repo).unwrap(); + git(&repo, &["init", "-q"]); + git(&repo, &["config", "user.email", "t@t"]); + git(&repo, &["config", "user.name", "t"]); + std::fs::write(repo.join("a.rs"), "fn reconcile() { backoff(); }\n").unwrap(); + std::fs::write(repo.join("readme.md"), "not code\n").unwrap(); // non-code ext → ignored + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-q", "-m", "init"]); + + let mut store = CodegraphStore::open(&tmp.path().join("cg.db")).unwrap(); + let emb = FakeEmbedder; + + let r1 = index_ref(&mut store, "r", &repo, Some("main"), &emb, IndexMode::Dense) + .await + .unwrap(); + assert_eq!(r1.files, 1, "only the .rs file is indexed"); + assert_eq!(r1.computed, 1); + assert_eq!(r1.cached, 0); + + // Re-index unchanged tree → all cache hits, nothing re-embedded. + let r2 = index_ref(&mut store, "r", &repo, Some("main"), &emb, IndexMode::Dense) + .await + .unwrap(); + assert_eq!(r2.computed, 0); + assert_eq!(r2.cached, 1); + + // The blob hydrates with tokens + a normalized embedding. + let hits = store.hydrate("r", "main", &emb.signature()).unwrap(); + assert_eq!(hits.len(), 1); + assert!(hits[0].tokens.contains(&"reconcile".to_string())); + let norm: f32 = hits[0].emb.iter().map(|x| x * x).sum::().sqrt(); + assert!((norm - 1.0).abs() < 1e-3, "embedding is L2-normalized"); + } + + /// An embedder that errors on empty input, like the real cloud backend + /// (which 400s "input must be a non-empty string"). Guards the fallback. + struct StrictEmbedder; + #[async_trait] + impl EmbeddingProvider for StrictEmbedder { + fn name(&self) -> &str { + "strict" + } + fn model_id(&self) -> &str { + "strict-1" + } + fn dimensions(&self) -> usize { + 2 + } + async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { + if texts.iter().any(|t| t.trim().is_empty()) { + anyhow::bail!("input must be a non-empty string"); + } + Ok(texts + .iter() + .map(|t| vec![t.len() as f32 + 1.0, 1.0]) + .collect()) + } + } + + #[tokio::test] + async fn index_ref_never_embeds_empty_doc() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir_all(&repo).unwrap(); + git(&repo, &["init", "-q"]); + git(&repo, &["config", "user.email", "t@t"]); + git(&repo, &["config", "user.name", "t"]); + // Structure-less files: empty, and a bare assignment (no def/import/call/doc). + std::fs::write(repo.join("__init__.py"), "").unwrap(); + std::fs::write(repo.join("data.py"), "x = 1\n").unwrap(); + std::fs::write(repo.join("ok.py"), "def go():\n run()\n").unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-q", "-m", "init"]); + + let mut store = CodegraphStore::open(&tmp.path().join("cg.db")).unwrap(); + // Must NOT bail with the empty-input error: the fallback keeps every + // embed input non-empty even for files with no extractable structure. + let rep = index_ref( + &mut store, + "r", + &repo, + Some("main"), + &StrictEmbedder, + IndexMode::Dense, + ) + .await + .expect("index_ref tolerates structure-less files"); + assert_eq!(rep.computed, 3, "all three files embedded + stored"); + } + + /// Embedder that fails if called at all — proves the lexical path embeds nothing. + struct NoEmbed; + #[async_trait] + impl EmbeddingProvider for NoEmbed { + fn name(&self) -> &str { + "noembed" + } + fn model_id(&self) -> &str { + "noembed-1" + } + fn dimensions(&self) -> usize { + 2 + } + async fn embed(&self, _t: &[&str]) -> anyhow::Result>> { + anyhow::bail!("embedder must not be called in lexical mode") + } + } + + #[tokio::test] + async fn lexical_mode_indexes_and_searches_without_embedding() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir_all(&repo).unwrap(); + git(&repo, &["init", "-q"]); + git(&repo, &["config", "user.email", "t@t"]); + git(&repo, &["config", "user.name", "t"]); + std::fs::write(repo.join("auth.rs"), "fn login() { session(); token(); }\n").unwrap(); + std::fs::write(repo.join("retry.rs"), "fn reconcile() { backoff(); }\n").unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-q", "-m", "init"]); + + let mut store = CodegraphStore::open(&tmp.path().join("cg.db")).unwrap(); + // Lexical index makes no embedder call (NoEmbed would bail) … + let rep = index_ref( + &mut store, + "r", + &repo, + Some("main"), + &NoEmbed, + IndexMode::Lexical, + ) + .await + .expect("lexical index never embeds"); + assert_eq!(rep.computed, 2); + + // … and lexical search is BM25-only — still no embedder call — yet ranks. + let out = crate::openhuman::codegraph::search_ref( + &mut store, + "r", + "main", + "reconcile backoff", + &NoEmbed, + 5, + ) + .await + .expect("lexical search never embeds"); + assert!(matches!( + out.coverage, + crate::openhuman::codegraph::Coverage::Full + )); + assert_eq!( + out.hits.first().map(String::as_str), + Some("retry.rs"), + "BM25 ranks retry.rs first for 'reconcile backoff'" + ); + } + + // ---- manual indexing benchmark ------------------------------------- + // A zero-latency embedder returning realistically-sized (default 1024-d) + // vectors, with cumulative embed-time accounting so the harness can + // subtract it and report *pure engine* throughput (extract + tokenize + + // SQLite + manifest). Real cloud embedding latency adds on top of that. + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + + struct BenchEmbedder { + dim: usize, + embed_nanos: Arc, + invocations: Arc, + docs: Arc, + } + #[async_trait] + impl EmbeddingProvider for BenchEmbedder { + fn name(&self) -> &str { + "bench" + } + fn model_id(&self) -> &str { + "bench-vec" + } + fn dimensions(&self) -> usize { + self.dim + } + async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { + let t = std::time::Instant::now(); + let out: Vec> = texts + .iter() + .map(|s| { + // cheap, deterministic, non-degenerate vector of the real size + let mut v = vec![0.0f32; self.dim]; + v[0] = s.len() as f32 + 1.0; + if self.dim > 1 { + v[1] = 1.0; + } + v + }) + .collect(); + self.embed_nanos + .fetch_add(t.elapsed().as_nanos() as u64, Ordering::Relaxed); + self.invocations.fetch_add(1, Ordering::Relaxed); + self.docs.fetch_add(texts.len() as u64, Ordering::Relaxed); + Ok(out) + } + } + + #[tokio::test] + #[ignore = "manual benchmark: CODEGRAPH_BENCH_REPO=/path cargo test ... -- --ignored --nocapture"] + async fn bench_index_speed() { + let repo = match std::env::var("CODEGRAPH_BENCH_REPO") { + Ok(p) => std::path::PathBuf::from(p), + Err(_) => { + eprintln!("bench_index_speed: set CODEGRAPH_BENCH_REPO=/path/to/git/repo"); + return; + } + }; + let dim: usize = std::env::var("CODEGRAPH_BENCH_DIM") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(1024); + + let tmp = TempDir::new().unwrap(); + let mut store = CodegraphStore::open(&tmp.path().join("cg.db")).unwrap(); + let embed_nanos = Arc::new(AtomicU64::new(0)); + let invocations = Arc::new(AtomicU64::new(0)); + let docs = Arc::new(AtomicU64::new(0)); + let emb = BenchEmbedder { + dim, + embed_nanos: embed_nanos.clone(), + invocations: invocations.clone(), + docs: docs.clone(), + }; + + // COLD — nothing cached, every blob is read + extracted + embedded + stored. + let t0 = std::time::Instant::now(); + let cold = index_ref(&mut store, "bench", &repo, None, &emb, IndexMode::Dense) + .await + .unwrap(); + let cold_ms = t0.elapsed().as_secs_f64() * 1e3; + let embed_ms = embed_nanos.load(Ordering::Relaxed) as f64 / 1e6; + let engine_ms = (cold_ms - embed_ms).max(0.0); + let n = cold.computed.max(1) as f64; + + // WARM — re-index the same tree: content-addressed → all cache hits. + let t1 = std::time::Instant::now(); + let warm = index_ref(&mut store, "bench", &repo, None, &emb, IndexMode::Dense) + .await + .unwrap(); + let warm_ms = t1.elapsed().as_secs_f64() * 1e3; + + eprintln!("\n==== codegraph index bench ====================================="); + eprintln!("repo : {}", repo.display()); + eprintln!("embed dim : {dim} (zero-latency fake embedder)"); + eprintln!( + "files (tracked) : {} computed={} cached={} skipped={}", + cold.files, cold.computed, cold.cached, cold.skipped + ); + eprintln!("-- COLD (full index) -------------------------------------------"); + eprintln!(" wall total : {:>8.1} ms", cold_ms); + eprintln!( + " fake embed : {:>8.1} ms ({:.1}% — replaced by real cloud latency in prod)", + embed_ms, + 100.0 * embed_ms / cold_ms.max(1e-9) + ); + eprintln!( + " ENGINE only : {:>8.1} ms → {:>7.0} files/s ({:.3} ms/file)", + engine_ms, + n / (engine_ms / 1e3).max(1e-9), + engine_ms / n + ); + eprintln!( + " embed : {} call(s) for {} docs (batched ≤{}/call)", + invocations.load(Ordering::Relaxed), + docs.load(Ordering::Relaxed), + EMBED_BATCH + ); + eprintln!("-- WARM (content-addressed re-index, all cache hits) -----------"); + eprintln!( + " wall total : {:>8.1} ms → {:>7.0} files/s ({:.4} ms/file) cached={}", + warm_ms, + warm.files as f64 / (warm_ms / 1e3).max(1e-9), + warm_ms / warm.files.max(1) as f64, + warm.cached + ); + eprintln!("================================================================\n"); + } + + /// Live probe — build the *real* provider from the workspace config and + /// embed one string. Confirms the cloud session JWT + backend are reachable + /// before attempting a full real-embedding index. A `401`/expired session + /// prints `EMBED FAILED` rather than panicking. + /// + /// OPENHUMAN_WORKSPACE=/path OPENHUMAN_KEYRING_BACKEND=file \ + /// cargo test --lib codegraph::index::tests::cloud_embed_probe -- --ignored --nocapture + #[tokio::test] + #[ignore = "live: needs OPENHUMAN_WORKSPACE + a valid backend session"] + async fn cloud_embed_probe() { + let config = crate::openhuman::config::Config::load_or_init() + .await + .expect("load config"); + let provider = crate::openhuman::embeddings::provider_from_config(&config) + .expect("build embedding provider"); + eprintln!( + "\n==== cloud embed probe ====\nprovider={} model={} dims={} sig={}", + provider.name(), + provider.model_id(), + provider.dimensions(), + provider.signature(), + ); + let t = std::time::Instant::now(); + match provider.embed(&["hello world from codegraph"]).await { + Ok(vs) => { + let v = vs.first().map(Vec::as_slice).unwrap_or(&[]); + eprintln!( + "OK: {} vector(s), dim={}, first5={:?} ({:.0} ms)", + vs.len(), + v.len(), + &v[..v.len().min(5)], + t.elapsed().as_secs_f64() * 1e3 + ); + } + Err(e) => eprintln!("EMBED FAILED: {e:#}"), + } + eprintln!("===========================\n"); + } + + /// Full real-embedding e2e: load the workspace config → build the cloud + /// provider → `index_ref` a real repo → `search_ref`, asserting full + /// coverage + non-empty hits and printing real wall-time (embedding + /// included). Defaults to the small flask checkout (one embed batch); + /// override with `CODEGRAPH_E2E_REPO` / `CODEGRAPH_E2E_QUERY`. + /// + /// OPENHUMAN_WORKSPACE=/path OPENHUMAN_KEYRING_BACKEND=file \ + /// cargo test --lib codegraph::index::tests::index_e2e_cloud -- --ignored --nocapture + #[tokio::test] + #[ignore = "live: real cloud embeddings; needs OPENHUMAN_WORKSPACE + a valid session"] + async fn index_e2e_cloud() { + let repo = std::path::PathBuf::from(std::env::var("CODEGRAPH_E2E_REPO").unwrap_or_else( + |_| { + "/home/sanil/vezures/openhuman-cbmem-ab/bench/codebase-memory-ab/repos/pallets__flask" + .to_string() + }, + )); + if !repo.exists() { + eprintln!("index_e2e_cloud: repo not found: {}", repo.display()); + return; + } + let query = std::env::var("CODEGRAPH_E2E_QUERY") + .unwrap_or_else(|_| "register blueprint route url rule".to_string()); + + let config = crate::openhuman::config::Config::load_or_init() + .await + .expect("load config"); + let provider = crate::openhuman::embeddings::provider_from_config(&config) + .expect("build embedding provider"); + + let tmp = TempDir::new().unwrap(); + let mut store = CodegraphStore::open(&tmp.path().join("cg.db")).unwrap(); + + let t0 = std::time::Instant::now(); + let rep = index_ref( + &mut store, + "e2e", + &repo, + None, + provider.as_ref(), + IndexMode::Dense, + ) + .await + .expect("index_ref"); + let index_ms = t0.elapsed().as_secs_f64() * 1e3; + + let t1 = std::time::Instant::now(); + let out = crate::openhuman::codegraph::search_ref( + &mut store, + "e2e", + &rep.git_ref, + &query, + provider.as_ref(), + 10, + ) + .await + .expect("search_ref"); + let search_ms = t1.elapsed().as_secs_f64() * 1e3; + + eprintln!("\n==== codegraph e2e (REAL cloud embeddings) ====================="); + eprintln!("repo : {} ref={}", repo.display(), rep.git_ref); + eprintln!( + "index : files={} computed={} cached={} skipped={} in {:.0} ms (embedding incl.)", + rep.files, rep.computed, rep.cached, rep.skipped, index_ms + ); + eprintln!("query : {query:?}"); + eprintln!( + "search: coverage={:?} indexed={} total={} in {:.0} ms", + out.coverage, out.indexed, out.total, search_ms + ); + eprintln!("top hits:"); + for (i, h) in out.hits.iter().take(10).enumerate() { + eprintln!(" {}. {}", i + 1, h); + } + eprintln!("================================================================\n"); + + assert!(rep.computed > 0, "indexed at least one blob"); + // Not None — we got real coverage. A clean small repo is Full; a large + // repo with oversized/binary files skipped is legitimately Partial. + assert!( + !matches!(out.coverage, crate::openhuman::codegraph::Coverage::None), + "search has at least partial coverage" + ); + assert!(!out.hits.is_empty(), "search returned hits"); + } +} diff --git a/src/openhuman/codegraph/mod.rs b/src/openhuman/codegraph/mod.rs new file mode 100644 index 0000000000..f0a66d1548 --- /dev/null +++ b/src/openhuman/codegraph/mod.rs @@ -0,0 +1,30 @@ +//! codegraph — content-addressed code retrieval for coding subagents. +//! +//! The seed engine behind the issue-crusher / pr-reviewer skills. Retrieval is +//! `in-memory BM25 ∪ structural-aug dense (embeddings domain)`, RRF-fused. +//! Indexing is content-addressed: every file's `{tokens, struct-doc embedding}` +//! is cached by its git **blob SHA** (+ embedding-model signature); a branch's +//! index is just its per-`(repo, ref)` **manifest** rows joined to the shared +//! blob cache at query time. Branch switch / new commit / pull only (re)embed +//! the blobs that actually changed. +//! +//! Pure Rust: heuristic structural extraction for tokens/docs, `rusqlite` for +//! the content-addressed blob cache + manifests (WAL), in-memory BM25 for +//! lexical ranking, and the `embeddings` domain (cloud by default) for vectors. +//! No Python, no extra services. +//! +//! Layers: +//! - [`store`] — persistent SQLite blob cache + per-`(repo, ref)` manifests. +//! - [`index`] — heuristic structural-doc extraction + BM25 tokens + dense embeddings, incremental. +//! - [`search`] — in-memory BM25 ∪ dense RRF + coverage flag. + +pub mod index; +pub mod search; +pub mod store; + +pub use index::{ + code_tokens, count_code_files, current_ref, index_ref, structural_doc, IndexMode, IndexReport, + LEXICAL_MODEL, +}; +pub use search::{search_ref, Coverage, SearchOutcome}; +pub use store::{BlobEntry, CodegraphStore}; diff --git a/src/openhuman/codegraph/search.rs b/src/openhuman/codegraph/search.rs new file mode 100644 index 0000000000..534e1fe9ae --- /dev/null +++ b/src/openhuman/codegraph/search.rs @@ -0,0 +1,289 @@ +//! Retrieval: the seed. Hydrate a `(repo, ref)` working set from the store, +//! score it with **BM25 (lexical) ∪ dense (cosine)**, **RRF-fuse**, and report +//! a **coverage** flag (`full`/`partial`/`none`) so callers know whether the +//! index is complete or the agent should lean on grep. +//! +//! BM25 is in-memory over the hydrated tokens (the working set is one repo's +//! files — small; this matches the validated prototype and keeps the +//! hydrate-per-query model simple). Dense is cosine over the L2-normalised +//! structural-aug vectors. The query is embedded once with the same provider +//! the index was built with (its `signature()` is the cache `model` key). + +use std::collections::{HashMap, HashSet}; + +use anyhow::{Context, Result}; + +use crate::openhuman::embeddings::EmbeddingProvider; + +use super::index::code_tokens; +use super::store::{BlobEntry, CodegraphStore}; + +const RRF_K: f32 = 60.0; +const PER_ARM: usize = 20; // top-N from each arm fed into RRF +const BM25_K1: f32 = 1.5; +const BM25_B: f32 = 0.75; + +/// How complete the index is for the queried `(repo, ref)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Coverage { + /// Every manifest file is embedded — trust the candidates. + Full, + /// Some files still pending (background index in flight) — treat as hints. + Partial, + /// Nothing indexed yet — fall back to grep. + None, +} + +/// The seed result: ranked candidate paths + how complete the index was. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SearchOutcome { + pub hits: Vec, + pub coverage: Coverage, + /// Files embedded (hydrated) vs total in the manifest. + pub indexed: usize, + pub total: usize, +} + +fn l2_normalize(v: &mut [f32]) { + let norm = v.iter().map(|x| x * x).sum::().sqrt(); + if norm > 0.0 { + for x in v.iter_mut() { + *x /= norm; + } + } +} + +/// BM25-Okapi over the hydrated docs; returns doc indices ranked best-first. +fn bm25_rank(docs: &[BlobEntry], query: &[String]) -> Vec { + let n = docs.len() as f32; + let lens: Vec = docs.iter().map(|d| d.tokens.len() as f32).collect(); + let avgdl = (lens.iter().sum::() / n).max(1.0); + // per-doc term frequency tables + let tfs: Vec> = docs + .iter() + .map(|d| { + let mut m: HashMap<&str, f32> = HashMap::new(); + for w in &d.tokens { + *m.entry(w.as_str()).or_insert(0.0) += 1.0; + } + m + }) + .collect(); + let q_terms: HashSet<&str> = query.iter().map(|s| s.as_str()).collect(); + + let mut scores = vec![0.0f32; docs.len()]; + for &t in &q_terms { + let df = tfs.iter().filter(|m| m.contains_key(t)).count() as f32; + if df == 0.0 { + continue; + } + let idf = (((n - df + 0.5) / (df + 0.5)) + 1.0).ln(); + for (i, m) in tfs.iter().enumerate() { + if let Some(&f) = m.get(t) { + let denom = f + BM25_K1 * (1.0 - BM25_B + BM25_B * lens[i] / avgdl); + scores[i] += idf * (f * (BM25_K1 + 1.0)) / denom; + } + } + } + rank_by_score(&scores) +} + +/// Cosine (dot over normalised vectors) of `qv` against each doc; best-first. +fn dense_rank(docs: &[BlobEntry], qv: &[f32]) -> Vec { + let scores: Vec = docs + .iter() + .map(|d| d.emb.iter().zip(qv).map(|(a, b)| a * b).sum::()) + .collect(); + rank_by_score(&scores) +} + +fn rank_by_score(scores: &[f32]) -> Vec { + let mut idx: Vec = (0..scores.len()).collect(); + idx.sort_by(|&a, &b| { + scores[b] + .partial_cmp(&scores[a]) + .unwrap_or(std::cmp::Ordering::Equal) + }); + idx +} + +/// Reciprocal-rank fusion of several rankings (top-`PER_ARM` of each), top-`k`. +fn rrf(rankings: &[Vec], k: usize) -> Vec { + let mut score: HashMap = HashMap::new(); + for ranking in rankings { + for (rank, &doc) in ranking.iter().take(PER_ARM).enumerate() { + *score.entry(doc).or_insert(0.0) += 1.0 / (RRF_K + rank as f32 + 1.0); + } + } + let mut items: Vec<(usize, f32)> = score.into_iter().collect(); + items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + items.into_iter().take(k).map(|(i, _)| i).collect() +} + +/// Seed `query` against a `(repo, ref)` index: BM25 ∪ dense, RRF-fused, top-`k`, +/// with a coverage flag. Embeds the query once with `embedder`. +pub async fn search_ref( + store: &mut CodegraphStore, + repo_id: &str, + git_ref: &str, + query: &str, + embedder: &dyn EmbeddingProvider, + k: usize, +) -> Result { + let total = store.manifest_size(repo_id, git_ref)?; + // Auto-detect the index mode: prefer the dense arm (rows under the + // embedder's signature); if none, fall back to the lexical-only key (a + // small repo indexed BM25-only). Lexical search makes no embedder call. + let dense_model = embedder.signature(); + let mut docs = store.hydrate(repo_id, git_ref, &dense_model)?; + let dense_active = !docs.is_empty(); + if !dense_active { + docs = store.hydrate(repo_id, git_ref, super::index::LEXICAL_MODEL)?; + } + + let coverage = if total == 0 { + Coverage::None + } else if docs.len() >= total { + Coverage::Full + } else { + Coverage::Partial + }; + if docs.is_empty() { + return Ok(SearchOutcome { + hits: vec![], + coverage, + indexed: 0, + total, + }); + } + + let q_tokens = code_tokens(query); + let bm = bm25_rank(&docs, &q_tokens); + + // Dense arm only when the index has vectors — otherwise BM25 alone, and no + // query-embed round-trip. RRF over a single ranking preserves its order. + let arms = if dense_active { + let mut qv = embedder + .embed(&[query]) + .await + .context("codegraph: embed query")? + .into_iter() + .next() + .unwrap_or_default(); + l2_normalize(&mut qv); + vec![bm, dense_rank(&docs, &qv)] + } else { + vec![bm] + }; + + let fused = rrf(&arms, k); + let hits = fused.into_iter().map(|i| docs[i].path.clone()).collect(); + Ok(SearchOutcome { + hits, + coverage, + indexed: docs.len(), + total, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use tempfile::TempDir; + + fn doc(path: &str, toks: &[&str]) -> BlobEntry { + BlobEntry { + path: path.into(), + tokens: toks.iter().map(|s| s.to_string()).collect(), + emb: vec![0.0, 0.0, 0.0], + } + } + + #[test] + fn bm25_ranks_the_matching_doc_first() { + let docs = vec![ + doc("auth.rs", &["login", "session", "token"]), + doc("retry.rs", &["reconcile", "backoff", "charge"]), + doc("util.rs", &["helper", "misc"]), + ]; + let ranked = bm25_rank(&docs, &code_tokens("reconcile after backoff")); + assert_eq!(ranked[0], 1, "retry.rs ranks first for 'reconcile/backoff'"); + } + + #[test] + fn rrf_blends_two_rankings() { + // bm25 likes doc 2, dense likes doc 0; both should surface above doc 1. + let fused = rrf(&[vec![2, 1, 0], vec![0, 1, 2]], 3); + assert!(fused.contains(&0) && fused.contains(&2)); + assert_eq!(fused.len(), 3); + } + + struct FakeEmbedder; + #[async_trait] + impl EmbeddingProvider for FakeEmbedder { + fn name(&self) -> &str { + "fake" + } + fn model_id(&self) -> &str { + "fake-1" + } + fn dimensions(&self) -> usize { + 3 + } + async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { + Ok(texts.iter().map(|_| vec![1.0, 0.0, 0.0]).collect()) + } + } + + #[tokio::test] + async fn search_ref_returns_ranked_hits_and_partial_coverage() { + let tmp = TempDir::new().unwrap(); + let mut store = CodegraphStore::open(&tmp.path().join("cg.db")).unwrap(); + let sig = FakeEmbedder.signature(); + store + .put_blob( + "a", + &sig, + &["reconcile".into(), "backoff".into()], + &[1.0, 0.0, 0.0], + ) + .unwrap(); + store + .put_blob( + "b", + &sig, + &["login".into(), "token".into()], + &[0.0, 1.0, 0.0], + ) + .unwrap(); + // manifest has a 3rd file with no cached blob → partial coverage. + store + .set_manifest( + "r", + "main", + &[ + ("retry.rs".into(), "a".into()), + ("auth.rs".into(), "b".into()), + ("pending.rs".into(), "uncached".into()), + ], + ) + .unwrap(); + + let out = search_ref( + &mut store, + "r", + "main", + "reconcile backoff", + &FakeEmbedder, + 10, + ) + .await + .unwrap(); + assert_eq!(out.coverage, Coverage::Partial); + assert_eq!(out.indexed, 2); + assert_eq!(out.total, 3); + assert_eq!(out.hits[0], "retry.rs", "lexical match surfaces first"); + } +} diff --git a/src/openhuman/codegraph/store.rs b/src/openhuman/codegraph/store.rs new file mode 100644 index 0000000000..b4ef3c7060 --- /dev/null +++ b/src/openhuman/codegraph/store.rs @@ -0,0 +1,334 @@ +//! Persistent, content-addressed store for codegraph. +//! +//! Two tables (SQLite, WAL): +//! +//! - `blob(sha, model, tokens, emb, dim)` PK `(sha, model)` — the shared +//! content cache: one row per unique file content per embedding model. +//! `tokens` is the space-joined BM25 token stream; `emb` is the L2-normalised +//! structural-aug vector stored as little-endian `f32` bytes. Shared across +//! every repo and branch, so renames / unchanged files are free. +//! +//! - `manifest(repo_id, git_ref, path, sha)` PK `(repo_id, git_ref, path)` — +//! one row per file per branch/commit. A branch's index is its rows here, +//! joined to `blob` at query time. A file deleted on a branch drops from +//! *that ref's* rows; its blob lingers until no manifest references it +//! ([`CodegraphStore::gc`]). +//! +//! This is the storage layer only — tree-sitter extraction, FTS5 ranking, and +//! the embeddings call live in `index`/`search`. + +use anyhow::{Context, Result}; +use rusqlite::{params, Connection}; +use std::path::Path; + +const SCHEMA: &str = "\ +CREATE TABLE IF NOT EXISTS blob ( + sha TEXT NOT NULL, + model TEXT NOT NULL, + tokens TEXT NOT NULL, + emb BLOB NOT NULL, + dim INTEGER NOT NULL, + PRIMARY KEY (sha, model) +); +CREATE TABLE IF NOT EXISTS manifest ( + repo_id TEXT NOT NULL, + git_ref TEXT NOT NULL, + path TEXT NOT NULL, + sha TEXT NOT NULL, + PRIMARY KEY (repo_id, git_ref, path) +); +CREATE INDEX IF NOT EXISTS manifest_repo_ref ON manifest(repo_id, git_ref); +"; + +/// One hydrated file in a `(repo, ref)` working set: its path plus the cached +/// BM25 tokens and dense vector. Returned by [`CodegraphStore::hydrate`]. +#[derive(Debug, Clone)] +pub struct BlobEntry { + pub path: String, + pub tokens: Vec, + pub emb: Vec, +} + +/// Content-addressed blob cache + per-`(repo, ref)` manifests, backed by SQLite. +pub struct CodegraphStore { + conn: Connection, +} + +impl CodegraphStore { + /// Open (creating if needed) the codegraph DB at `db_path`. + pub fn open(db_path: &Path) -> Result { + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).ok(); + } + let conn = Connection::open(db_path) + .with_context(|| format!("open codegraph db at {}", db_path.display()))?; + // Back off briefly under concurrent writers rather than surfacing SQLITE_BUSY. + conn.busy_timeout(std::time::Duration::from_secs(5))?; + conn.pragma_update(None, "journal_mode", "WAL")?; + // NORMAL is durable across an app crash under WAL (only a power/OS crash + // can lose the last commit) and drops the per-commit fsync that + // otherwise dominates a cold index — and this is a rebuildable cache. + conn.pragma_update(None, "synchronous", "NORMAL")?; + conn.execute_batch(SCHEMA) + .context("init codegraph schema")?; + Ok(Self { conn }) + } + + /// True if this content (`sha`) is already cached for `model` — the + /// incremental check: a cache hit means no re-embed on (re)index. + pub fn has_blob(&self, sha: &str, model: &str) -> Result { + let n: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM blob WHERE sha=?1 AND model=?2", + params![sha, model], + |r| r.get(0), + )?; + Ok(n > 0) + } + + /// Insert a computed blob (idempotent on `(sha, model)`). + pub fn put_blob(&self, sha: &str, model: &str, tokens: &[String], emb: &[f32]) -> Result<()> { + let token_str = tokens.join(" "); + let mut bytes = Vec::with_capacity(emb.len() * 4); + for f in emb { + bytes.extend_from_slice(&f.to_le_bytes()); + } + self.conn.execute( + "INSERT OR IGNORE INTO blob(sha, model, tokens, emb, dim) VALUES (?1,?2,?3,?4,?5)", + params![sha, model, token_str, bytes, emb.len() as i64], + )?; + Ok(()) + } + + /// Insert many computed blobs in a single transaction (one fsync for the + /// batch, not one per blob). Idempotent on `(sha, model)` via `OR IGNORE`, + /// so duplicate content within the batch keeps its first row. The hot path + /// for a cold index — prefer this over a `put_blob` loop. + pub fn put_blobs( + &mut self, + model: &str, + blobs: &[(String, Vec, Vec)], + ) -> Result<()> { + if blobs.is_empty() { + return Ok(()); + } + let tx = self.conn.transaction()?; + { + let mut stmt = tx.prepare( + "INSERT OR IGNORE INTO blob(sha, model, tokens, emb, dim) VALUES (?1,?2,?3,?4,?5)", + )?; + for (sha, tokens, emb) in blobs { + let token_str = tokens.join(" "); + let mut bytes = Vec::with_capacity(emb.len() * 4); + for f in emb { + bytes.extend_from_slice(&f.to_le_bytes()); + } + stmt.execute(params![sha, model, token_str, bytes, emb.len() as i64])?; + } + } + tx.commit()?; + Ok(()) + } + + /// Replace a `(repo, ref)` manifest with `files` (`(path, sha)`), handling + /// deletes/renames: the ref's rows are rewritten to exactly `files`. + pub fn set_manifest( + &mut self, + repo_id: &str, + git_ref: &str, + files: &[(String, String)], + ) -> Result<()> { + let tx = self.conn.transaction()?; + tx.execute( + "DELETE FROM manifest WHERE repo_id=?1 AND git_ref=?2", + params![repo_id, git_ref], + )?; + { + let mut stmt = tx.prepare( + "INSERT INTO manifest(repo_id, git_ref, path, sha) VALUES (?1,?2,?3,?4)", + )?; + for (path, sha) in files { + stmt.execute(params![repo_id, git_ref, path, sha])?; + } + } + tx.commit()?; + Ok(()) + } + + /// Hydrate one `(repo, ref)` working set: manifest joined to the blob cache + /// for `model`. Files whose blob isn't cached (e.g. skipped/oversized) are + /// omitted — the caller derives coverage from `returned / manifest_size`. + pub fn hydrate(&self, repo_id: &str, git_ref: &str, model: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT m.path, b.tokens, b.emb FROM manifest m \ + JOIN blob b ON b.sha = m.sha AND b.model = ?1 \ + WHERE m.repo_id = ?2 AND m.git_ref = ?3", + )?; + let rows = stmt.query_map(params![model, repo_id, git_ref], |r| { + let path: String = r.get(0)?; + let tokens: String = r.get(1)?; + let bytes: Vec = r.get(2)?; + Ok((path, tokens, bytes)) + })?; + let mut out = Vec::new(); + for row in rows { + let (path, tokens, bytes) = row?; + let emb = bytes + .chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect(); + out.push(BlobEntry { + path, + tokens: tokens.split_whitespace().map(str::to_string).collect(), + emb, + }); + } + Ok(out) + } + + /// Number of files in a `(repo, ref)` manifest (the coverage denominator). + pub fn manifest_size(&self, repo_id: &str, git_ref: &str) -> Result { + let n: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM manifest WHERE repo_id=?1 AND git_ref=?2", + params![repo_id, git_ref], + |r| r.get(0), + )?; + Ok(n as usize) + } + + /// Distinct refs indexed for a repo. + pub fn refs(&self, repo_id: &str) -> Result> { + let mut stmt = self + .conn + .prepare("SELECT DISTINCT git_ref FROM manifest WHERE repo_id=?1")?; + let rows = stmt.query_map(params![repo_id], |r| r.get::<_, String>(0))?; + Ok(rows.collect::>>()?) + } + + /// Drop cached blobs no live manifest references. Returns rows removed. + pub fn gc(&self) -> Result { + let removed = self.conn.execute( + "DELETE FROM blob WHERE sha NOT IN (SELECT DISTINCT sha FROM manifest)", + [], + )?; + Ok(removed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn store(dir: &TempDir) -> CodegraphStore { + CodegraphStore::open(&dir.path().join("codegraph").join("index.db")).unwrap() + } + + #[test] + fn blob_roundtrip_and_dedup() { + let tmp = TempDir::new().unwrap(); + let s = store(&tmp); + assert!(!s.has_blob("sha1", "m").unwrap()); + s.put_blob("sha1", "m", &["foo".into(), "bar".into()], &[0.5, -0.5]) + .unwrap(); + assert!(s.has_blob("sha1", "m").unwrap()); + // Different model = distinct cache entry. + assert!(!s.has_blob("sha1", "other").unwrap()); + // Idempotent. + s.put_blob("sha1", "m", &["foo".into()], &[1.0]).unwrap(); + } + + #[test] + fn put_blobs_batches_and_dedups() { + let tmp = TempDir::new().unwrap(); + let mut s = store(&tmp); + s.put_blobs( + "m", + &[ + ("s1".into(), vec!["a".into(), "b".into()], vec![1.0, 0.0]), + ("s2".into(), vec!["c".into()], vec![0.0, 1.0]), + ("s1".into(), vec!["dup".into()], vec![9.0]), // OR IGNORE keeps the first + ], + ) + .unwrap(); + assert!(s.has_blob("s1", "m").unwrap()); + assert!(s.has_blob("s2", "m").unwrap()); + // Empty batch is a no-op (warm re-index path). + s.put_blobs("m", &[]).unwrap(); + s.set_manifest( + "r", + "main", + &[("a.rs".into(), "s1".into()), ("b.rs".into(), "s2".into())], + ) + .unwrap(); + let hits = s.hydrate("r", "main", "m").unwrap(); + assert_eq!(hits.len(), 2); + let a = hits.iter().find(|h| h.path == "a.rs").unwrap(); + assert_eq!( + a.tokens, + vec!["a".to_string(), "b".to_string()], + "first insert kept, not the dup" + ); + } + + #[test] + fn manifest_hydrate_and_coverage() { + let tmp = TempDir::new().unwrap(); + let mut s = store(&tmp); + s.put_blob("shaA", "m", &["alpha".into()], &[1.0, 0.0]) + .unwrap(); + // shaB intentionally not cached (simulates skipped/oversized) → omitted from hydrate. + s.set_manifest( + "repo", + "main", + &[ + ("a.rs".into(), "shaA".into()), + ("b.rs".into(), "shaB".into()), + ], + ) + .unwrap(); + let hits = s.hydrate("repo", "main", "m").unwrap(); + assert_eq!(hits.len(), 1, "only the cached blob hydrates"); + assert_eq!(hits[0].path, "a.rs"); + assert_eq!(hits[0].tokens, vec!["alpha".to_string()]); + assert_eq!(hits[0].emb, vec![1.0, 0.0]); + assert_eq!(s.manifest_size("repo", "main").unwrap(), 2); + } + + #[test] + fn manifest_is_per_ref_and_rewrites_on_set() { + let tmp = TempDir::new().unwrap(); + let mut s = store(&tmp); + s.put_blob("x", "m", &["x".into()], &[0.0]).unwrap(); + s.set_manifest("r", "brA", &[("util.rs".into(), "x".into())]) + .unwrap(); + s.set_manifest("r", "brB", &[("util/mod.rs".into(), "x".into())]) + .unwrap(); + let mut refs = s.refs("r").unwrap(); + refs.sort(); + assert_eq!(refs, vec!["brA".to_string(), "brB".to_string()]); + // Re-setting a ref rewrites it (delete on brA: file gone from that ref). + s.set_manifest("r", "brA", &[]).unwrap(); + assert_eq!(s.manifest_size("r", "brA").unwrap(), 0); + assert_eq!(s.manifest_size("r", "brB").unwrap(), 1); + } + + #[test] + fn gc_drops_unreferenced_blobs_and_persists() { + let path = TempDir::new().unwrap(); + let db = path.path().join("cg.db"); + { + let mut s = CodegraphStore::open(&db).unwrap(); + s.put_blob("live", "m", &["a".into()], &[1.0]).unwrap(); + s.put_blob("orphan", "m", &["b".into()], &[1.0]).unwrap(); + s.set_manifest("r", "main", &[("a.rs".into(), "live".into())]) + .unwrap(); + assert_eq!(s.gc().unwrap(), 1, "orphan blob removed"); + assert!(s.has_blob("live", "m").unwrap()); + assert!(!s.has_blob("orphan", "m").unwrap()); + } + // Reopen: state persisted across "restart". + let s = CodegraphStore::open(&db).unwrap(); + assert!(s.has_blob("live", "m").unwrap()); + assert_eq!(s.hydrate("r", "main", "m").unwrap().len(), 1); + } +} diff --git a/src/openhuman/cron/schemas.rs b/src/openhuman/cron/schemas.rs index fe87402c05..6cebf379a3 100644 --- a/src/openhuman/cron/schemas.rs +++ b/src/openhuman/cron/schemas.rs @@ -18,6 +18,7 @@ fn job_id_input(comment: &'static str) -> FieldSchema { pub fn all_controller_schemas() -> Vec { vec![ + schemas("add"), schemas("list"), schemas("update"), schemas("remove"), @@ -28,6 +29,10 @@ pub fn all_controller_schemas() -> Vec { pub fn all_registered_controllers() -> Vec { vec![ + RegisteredController { + schema: schemas("add"), + handler: handle_add, + }, RegisteredController { schema: schemas("list"), handler: handle_list, @@ -53,6 +58,83 @@ pub fn all_registered_controllers() -> Vec { pub fn schemas(function: &str) -> ControllerSchema { match function { + "add" => ControllerSchema { + namespace: "cron", + function: "add", + description: "Create a new cron job (shell or agent).", + inputs: vec![ + FieldSchema { + name: "name", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Human-readable job name.", + required: false, + }, + FieldSchema { + name: "schedule", + ty: TypeSchema::Ref("CronSchedule"), + comment: "When to run — { kind: 'cron', expr } | { kind: 'at', at } | { kind: 'every', every_ms }.", + required: true, + }, + FieldSchema { + name: "job_type", + ty: TypeSchema::Option(Box::new(TypeSchema::Enum { + variants: vec!["shell", "agent"], + })), + comment: "Defaults to 'agent' when prompt is set, 'shell' when command is set.", + required: false, + }, + FieldSchema { + name: "command", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Shell command (required for shell jobs).", + required: false, + }, + FieldSchema { + name: "prompt", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Agent task prompt (required for agent jobs).", + required: false, + }, + FieldSchema { + name: "session_target", + ty: TypeSchema::Option(Box::new(TypeSchema::Enum { + variants: vec!["isolated", "main"], + })), + comment: "Defaults to 'isolated'.", + required: false, + }, + FieldSchema { + name: "model", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Model override for agent jobs.", + required: false, + }, + FieldSchema { + name: "agent_id", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Built-in agent or skill definition ID.", + required: false, + }, + FieldSchema { + name: "delivery", + ty: TypeSchema::Option(Box::new(TypeSchema::Ref("DeliveryConfig"))), + comment: "Delivery mode (proactive, announce, etc.).", + required: false, + }, + FieldSchema { + name: "delete_after_run", + ty: TypeSchema::Option(Box::new(TypeSchema::Bool)), + comment: "If true, remove the job after its first execution.", + required: false, + }, + ], + outputs: vec![FieldSchema { + name: "job", + ty: TypeSchema::Ref("CronJob"), + comment: "Newly created cron job.", + required: true, + }], + }, "list" => ControllerSchema { namespace: "cron", function: "list", @@ -195,6 +277,90 @@ pub fn schemas(function: &str) -> ControllerSchema { } } +fn handle_add(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + + let schedule: crate::openhuman::cron::Schedule = read_required(¶ms, "schedule")?; + let name = params + .get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let command = params + .get("command") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let prompt = params + .get("prompt") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let session_target_str = params + .get("session_target") + .and_then(|v| v.as_str()) + .unwrap_or("isolated"); + let session_target = match session_target_str { + "main" => crate::openhuman::cron::SessionTarget::Main, + _ => crate::openhuman::cron::SessionTarget::Isolated, + }; + let model = params + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let agent_id = params + .get("agent_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let delivery: Option = params + .get("delivery") + .map(|v| { + serde_json::from_value(v.clone()) + .map_err(|e| format!("invalid 'delivery' payload: {e}")) + }) + .transpose()?; + let delete_after_run = params + .get("delete_after_run") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // Determine job type — explicit field takes precedence; infer from + // which payload key is present; reject anything other than "shell"/"agent". + let job_type = params + .get("job_type") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| if prompt.is_some() { "agent" } else { "shell" }); + if job_type != "shell" && job_type != "agent" { + return Err(format!( + "invalid 'job_type': expected 'shell' or 'agent', got '{job_type}'" + )); + } + + let job = match job_type { + "shell" => { + let cmd = command.ok_or("'command' is required for shell jobs")?; + crate::openhuman::cron::store::add_shell_job(&config, name, schedule, &cmd) + .map_err(|e| e.to_string())? + } + _ => { + let p = prompt.ok_or("'prompt' is required for agent jobs")?; + crate::openhuman::cron::store::add_agent_job_with_definition( + &config, + name, + schedule, + &p, + session_target, + model, + delivery, + delete_after_run, + agent_id, + ) + .map_err(|e| e.to_string())? + } + }; + + to_json(RpcOutcome::single_log(job, "cron job created")) + }) +} + fn handle_list(_params: Map) -> ControllerFuture { Box::pin(async { let config = config_rpc::load_config_with_timeout().await?; @@ -343,21 +509,42 @@ mod tests { // ── registry helpers ──────────────────────────────────────────── + #[test] + fn schemas_add_requires_schedule_and_returns_job() { + let s = schemas("add"); + assert_eq!(s.namespace, "cron"); + assert_eq!(s.function, "add"); + let required: Vec<_> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert_eq!(required, vec!["schedule"]); + assert_eq!(s.outputs[0].name, "job"); + } + #[test] fn all_controller_schemas_covers_every_supported_function() { let names: Vec<_> = all_controller_schemas() .into_iter() .map(|s| s.function) .collect(); - assert_eq!(names, vec!["list", "update", "remove", "run", "runs"]); + assert_eq!( + names, + vec!["add", "list", "update", "remove", "run", "runs"] + ); } #[test] fn all_registered_controllers_has_handler_per_schema() { let controllers = all_registered_controllers(); - assert_eq!(controllers.len(), 5); + assert_eq!(controllers.len(), 6); let names: Vec<_> = controllers.iter().map(|c| c.schema.function).collect(); - assert_eq!(names, vec!["list", "update", "remove", "run", "runs"]); + assert_eq!( + names, + vec!["add", "list", "update", "remove", "run", "runs"] + ); } // ── read_required ─────────────────────────────────────────────── diff --git a/src/openhuman/embeddings/mod.rs b/src/openhuman/embeddings/mod.rs index 5e69e8d40d..1f3e5cd896 100644 --- a/src/openhuman/embeddings/mod.rs +++ b/src/openhuman/embeddings/mod.rs @@ -41,6 +41,7 @@ pub use noop::NoopEmbedding; pub use ollama::{OllamaEmbedding, DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL}; pub use openai::OpenAiEmbedding; pub use provider_trait::{format_embedding_signature, EmbeddingProvider}; +pub use rpc::provider_from_config; pub use schemas::{ all_controller_schemas as all_embeddings_controller_schemas, all_registered_controllers as all_embeddings_registered_controllers, diff --git a/src/openhuman/embeddings/rpc.rs b/src/openhuman/embeddings/rpc.rs index 74deafedc6..b7df3bb808 100644 --- a/src/openhuman/embeddings/rpc.rs +++ b/src/openhuman/embeddings/rpc.rs @@ -378,6 +378,29 @@ pub async fn test_connection( } } +/// Build an embedding provider from the live config — the same construction +/// [`embed`] uses, exposed so other domains (e.g. `codegraph`) can obtain a +/// provider for `signature()` + direct embedding without a JSON-RPC round-trip. +pub fn provider_from_config(config: &Config) -> anyhow::Result> { + let provider_name = &config.memory.embedding_provider; + let model = &config.memory.embedding_model; + let dims = config.memory.embedding_dimensions; + let api_key = resolve_api_key(config, provider_name); + let custom_endpoint = provider_name.strip_prefix("custom:").map(|s| s.to_string()); + let provider_slug = if provider_name.starts_with("custom:") { + "custom" + } else { + provider_name.as_str() + }; + create_embedding_provider_with_credentials( + provider_slug, + model, + dims, + &api_key, + custom_endpoint.as_deref(), + ) +} + fn resolve_api_key(config: &Config, provider_name: &str) -> String { let slug = if provider_name.starts_with("custom:") { "custom" diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index b9afe25535..41371cd9fd 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -26,6 +26,7 @@ pub mod audio_toolkit; pub mod autocomplete; pub mod billing; pub mod channels; +pub mod codegraph; pub mod composio; pub mod config; pub mod connectivity; diff --git a/src/openhuman/skills/defaults/dev-workflow/SKILL.md b/src/openhuman/skills/defaults/dev-workflow/SKILL.md new file mode 100644 index 0000000000..31aafa1d41 --- /dev/null +++ b/src/openhuman/skills/defaults/dev-workflow/SKILL.md @@ -0,0 +1,38 @@ +# Dev Workflow — Autonomous Issue Crusher + +You are an autonomous developer agent. Your job is to find a GitHub issue on `{upstream}`, implement a fix, and deliver a PR. + +## The two repos +- **Upstream** = `{upstream}` — where issues live and where PRs target (base = `{target_branch}`). +- **Fork** = `{fork_owner}/` — where the fix branch is pushed. (`` is derived from `{upstream}`.) +- You act as the **connected GitHub identity**. **Commit through the GitHub API** — assume you have *no* local `git push` credentials. Never block on `git push`. + +## Issue selection (smart fallback) + +1. **First**: Look for open issues assigned to `{fork_owner}` on `{upstream}` with no linked PR. Pick the oldest. +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 `{fork_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". + +## Per-run workflow + +1. **Pick issue** using the selection strategy above. +2. **Read the issue.** Fetch the full issue body, comments, and labels. Note the connected login. +3. **Ensure the fork.** If `{fork_owner}/` exists, use it. Otherwise create a fork of `{upstream}` under `{fork_owner}`. +4. **Clone & branch.** Clone `{upstream}` locally. Create branch `dev-workflow/-` off `{target_branch}`. +5. **Index the codebase.** Run `codegraph_index` on the cloned repo to build a retrieval index. +6. **Locate the cause.** Use `codegraph_search` with the issue's key symbols and error strings. Respect the `coverage` flag — if not `full`, also use `grep`/`glob`. Open top candidates to confirm the exact edit site. +7. **Implement.** Make the **minimal** correct fix/feature. Follow existing code style. Re-read files and `git diff` instead of trusting memory. +8. **Test.** Detect and run available test commands (npm test, cargo test, pytest, etc.). Iterate until green. +9. **Push via API.** Create the fix branch on the **fork** through the GitHub API (blob → tree → commit → update-ref). **Do not `git push`.** +10. **Open cross-repo PR.** Open a PR against `{upstream}:{target_branch}` with head `{fork_owner}:`. Body must include `Closes #`, a root-cause + fix summary, and verification steps. + +## Rules +- **One PR per run.** After opening the PR, stop. +- **Scope.** Only changes that fix the picked issue. +- **API commits only.** No `git push` — use the GitHub API. +- **codegraph is an accelerant, not a gate.** If cold or unavailable, fall back to `grep`/`glob` — never block on indexing. +- **If too large/risky** (would touch >20 files or needs multi-system changes), comment on the issue explaining why and skip. +- Never force-push. Never push to upstream directly. +- You are the **orchestrator**: delegate narrow subtasks to subagents when helpful, but own the end goal. +- **Stop** when the PR is open, or surface a blocker and stop — don't thrash. diff --git a/src/openhuman/skills/defaults/dev-workflow/skill.toml b/src/openhuman/skills/defaults/dev-workflow/skill.toml new file mode 100644 index 0000000000..e07a14f57b --- /dev/null +++ b/src/openhuman/skills/defaults/dev-workflow/skill.toml @@ -0,0 +1,32 @@ +# dev-workflow — a DEFAULT skill shipped with OpenHuman. +# Bundled into the binary and seeded into /skills/ on first load +# (idempotent — never clobbers user edits). Parsed as a SkillDefinition: +# AgentDefinition fields are flattened in, plus the declared [[inputs]]. At +# skills_run time it runs as the `orchestrator` agent, focused by SKILL.md, +# with these inputs rendered into the task prompt. +# +# Autonomous developer: picks GitHub issues assigned to the user on an upstream +# repo, implements fixes using codegraph-accelerated code navigation, and opens +# cross-repo PRs from a fork. +id = "dev-workflow" +when_to_use = "Autonomous developer — picks GitHub issues assigned to the user and raises pull requests. Runs on a schedule via cron." + +[[inputs]] +name = "repo" +description = "The UPSTREAM repo to pick issues from and target PRs against, as owner/name (e.g. acme/web)." +required = true + +[[inputs]] +name = "upstream" +description = "Alias for the upstream repo full name. Same as repo if this IS the upstream." +required = true + +[[inputs]] +name = "target_branch" +description = "Branch on the upstream to base PRs against (e.g. main)." +required = true + +[[inputs]] +name = "fork_owner" +description = "GitHub username of the fork owner — the fix branch is pushed to fork_owner/repo." +required = true diff --git a/src/openhuman/skills/defaults/github-issue-crusher/SKILL.md b/src/openhuman/skills/defaults/github-issue-crusher/SKILL.md new file mode 100644 index 0000000000..628c3dfcb3 --- /dev/null +++ b/src/openhuman/skills/defaults/github-issue-crusher/SKILL.md @@ -0,0 +1,29 @@ +# GitHub Issue Crusher + +Fix the **single** GitHub issue named in the inputs, end to end, then open a pull request — handling the **fork workflow**: the issue lives on the upstream repo `{repo}`, you push your fix to a **fork**, and you open a **cross-repo PR** back to `{repo}`. Stay strictly scoped to this one issue — do not pick up unrelated work. + +## The two repos +- **Upstream** = `{repo}` — where issue `#{issue}` lives and where the PR is opened (base = `{pr_base}`, or the upstream's default branch). +- **Fork** = `{fork}` if provided, otherwise a fork under the **connected GitHub account** — where your fix branch is pushed. +- You act as the **connected GitHub identity**. **Commit through the GitHub API** — assume you have *no* local `git push` credentials for the fork. Never block on `git push`. + +## Steps + +1. **Read the issue.** Fetch issue `#{issue}` in `{repo}` (title, body, comments) via the GitHub tool. Note the connected login — it namespaces the PR head. +2. **Ensure the fork.** If `{fork}` is set, use it. Otherwise fork `{repo}` under the connected account (create the fork if it doesn't exist) and use that. Call its owner ``. +3. **Get the code locally.** Clone the **upstream** `{repo}` to a worktree at `{pr_base}` (or its default branch). Start `codegraph_index` on the worktree (background — don't wait). +4. **Locate the cause.** Call `codegraph_search` with the issue's key symbols / error strings. **Respect the `coverage` flag** — if it's not `full`, treat hits as hints and also use `grep`/`lsp`; re-search as coverage grows. Open the top candidates and confirm the exact edit site. +5. **Fix.** Make the **minimal** change locally. Re-`read` / `git diff` instead of trusting memory. +6. **Verify.** Run the relevant tests + linter locally; iterate until green. +7. **Push to the fork via the API.** Create a fix branch `fix/{issue}-` on the **fork** (a ref off the base commit). Apply your changed files (from `git diff`) onto that branch **through the GitHub API** — for a multi-file change prefer a single commit (blob → tree → commit → update-ref); for one or two files, create-or-update file contents is fine. **Do not `git push`.** +8. **Open the cross-repo PR.** Open a PR **against `{repo}`** with **head = `:fix/{issue}-`** and **base = `{pr_base}`** (or the upstream default). The body must include `Closes #{issue}`, a short root-cause + fix summary, and how you verified. + +## Rules + +- **Scope:** only changes that fix `#{issue}`. +- **Two repos:** the issue + PR target are the upstream `{repo}`; the branch + commits live on the **fork**; the PR is **cross-repo** (head = fork, base = upstream). +- **API commits only:** the host has no fork push credentials — push the diff via the GitHub API as the connected identity; never block on `git push`. +- **Source of truth** is the filesystem + `git` + `codegraph` — re-read / re-search rather than relying on recall; recover progress with `git diff`. +- **codegraph is an accelerant, not a gate:** if it's cold or unavailable, fall back to `grep`/`lsp` — never block on indexing. +- You are the **orchestrator**: delegate narrow, well-scoped subtasks to subagents when it helps, but keep ownership of the single end goal. +- **Stop** when the PR is open, or surface a blocker plainly and stop — don't thrash. diff --git a/src/openhuman/skills/defaults/github-issue-crusher/skill.toml b/src/openhuman/skills/defaults/github-issue-crusher/skill.toml new file mode 100644 index 0000000000..64a0445ce3 --- /dev/null +++ b/src/openhuman/skills/defaults/github-issue-crusher/skill.toml @@ -0,0 +1,32 @@ +# github-issue-crusher — a DEFAULT skill shipped with OpenHuman. +# Bundled into the binary and seeded into /skills/ on first load +# (idempotent — never clobbers user edits). Parsed as a SkillDefinition: +# AgentDefinition fields are flattened in, plus the declared [[inputs]]. At +# skills_run time it runs as the `orchestrator` agent, focused by SKILL.md, +# with these inputs rendered into the task prompt. +# +# Fork-aware: the issue lives on the UPSTREAM repo, the fix is pushed to a FORK +# (via the GitHub API — no local push creds needed), and the PR is cross-repo. +id = "github-issue-crusher" +when_to_use = "Fix one GitHub issue end to end and open a pull request — including the fork workflow (issue on an upstream repo, fix pushed to a fork, cross-repo PR back to upstream)." + +[[inputs]] +name = "repo" +description = "The UPSTREAM repo the issue lives on AND the PR targets, as owner/name (e.g. acme/web)." +required = true + +[[inputs]] +name = "issue" +description = "Issue number on the upstream repo to pick and fix." +required = true +type = "integer" + +[[inputs]] +name = "fork" +description = "Fork to push the fix branch to, as owner/name. Omit to use (or create) a fork under the connected GitHub account." +required = false + +[[inputs]] +name = "pr_base" +description = "Base branch on the upstream the PR targets (default: the upstream's default branch)." +required = false diff --git a/src/openhuman/skills/mod.rs b/src/openhuman/skills/mod.rs index c1a4da1d90..6c0edd9e24 100644 --- a/src/openhuman/skills/mod.rs +++ b/src/openhuman/skills/mod.rs @@ -8,6 +8,8 @@ pub mod ops_discover; pub mod ops_install; pub mod ops_parse; pub mod ops_types; +pub mod registry; +pub mod run_log; pub mod schemas; pub mod types; diff --git a/src/openhuman/skills/registry.rs b/src/openhuman/skills/registry.rs new file mode 100644 index 0000000000..0184536c2d --- /dev/null +++ b/src/openhuman/skills/registry.rs @@ -0,0 +1,328 @@ +//! Skill registry types: a **skill** is an [`AgentDefinition`] plus declared +//! `[[inputs]]`. The agent fields (`id`, `system_prompt`, `tools`, +//! `max_iterations`, `sandbox_mode`, …) are flattened in from the same +//! `skill.toml`, so a skill is just a runnable agent that also advertises the +//! inputs it needs. Schema lives here; values are supplied at `skill_run` time +//! and rendered into the prompt (see [`render_inputs_block`]). +//! +//! This keeps [`AgentDefinition`] untouched (no widespread struct-literal +//! churn) — inputs ride at the skill layer via `#[serde(flatten)]`. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::openhuman::agent::harness::definition::{AgentDefinition, PromptSource}; + +/// One declared input — a parameter the skill needs, with a human description. +/// `required` inputs must be supplied at run time; `kind` is an optional type +/// hint (`"string"`, `"integer"`, …) for the UI / validation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SkillInput { + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub required: bool, + #[serde(default, rename = "type")] + pub kind: Option, +} + +/// A skill = an agent definition + its declared inputs (parsed from `skill.toml`). +#[derive(Debug, Clone, Deserialize)] +pub struct SkillDefinition { + #[serde(flatten)] + pub definition: AgentDefinition, + #[serde(default)] + pub inputs: Vec, +} + +/// Names of `required` inputs that are absent or null in `provided`. Empty ⇒ OK. +pub fn missing_required_inputs(defs: &[SkillInput], provided: &serde_json::Value) -> Vec { + defs.iter() + .filter(|d| d.required) + .filter(|d| provided.get(&d.name).map(|v| v.is_null()).unwrap_or(true)) + .map(|d| d.name.clone()) + .collect() +} + +/// Render the resolved inputs as an `## Inputs` prompt block injected alongside +/// the skill's `SKILL.md`. Empty string when the skill declares no inputs. +pub fn render_inputs_block(defs: &[SkillInput], provided: &serde_json::Value) -> String { + if defs.is_empty() { + return String::new(); + } + let mut lines = vec!["## Inputs".to_string()]; + for d in defs { + let shown = match provided.get(&d.name) { + None | Some(serde_json::Value::Null) => "(not provided)".to_string(), + Some(serde_json::Value::String(s)) => s.clone(), + Some(other) => other.to_string(), + }; + lines.push(format!("- **{}**: {}", d.name, shown)); + } + lines.join("\n") +} + +/// Default skills shipped *with* OpenHuman — bundled into the binary and +/// materialised into `/skills//` on first load. Each entry is +/// `(id, skill.toml, SKILL.md)`. +const DEFAULT_SKILLS: &[(&str, &str, &str)] = &[ + ( + "github-issue-crusher", + include_str!("defaults/github-issue-crusher/skill.toml"), + include_str!("defaults/github-issue-crusher/SKILL.md"), + ), + ( + "dev-workflow", + include_str!("defaults/dev-workflow/skill.toml"), + include_str!("defaults/dev-workflow/SKILL.md"), + ), +]; + +/// Seed the bundled [`DEFAULT_SKILLS`] into `/skills//` when +/// absent. Idempotent and non-destructive: an existing `skill.toml` (already +/// seeded, or user-edited) is left untouched, so a default can be customised or +/// removed. This is what makes a default skill "come with the system" — every +/// workspace gets it without a manual drop. +pub fn seed_default_skills(workspace_dir: &Path) { + let base = workspace_dir.join("skills"); + for (id, skill_toml, skill_md) in DEFAULT_SKILLS { + let dir = base.join(id); + if dir.join("skill.toml").exists() { + continue; // already present — never clobber + } + if let Err(e) = std::fs::create_dir_all(&dir) { + log::warn!("[skills] seed {id}: mkdir failed: {e}"); + continue; + } + if let Err(e) = std::fs::write(dir.join("skill.toml"), skill_toml) { + log::warn!("[skills] seed {id}: write skill.toml failed: {e}"); + continue; + } + if let Err(e) = std::fs::write(dir.join("SKILL.md"), skill_md) { + log::warn!("[skills] seed {id}: write SKILL.md failed: {e}"); + continue; + } + log::info!( + "[skills] seeded default skill '{id}' into {}", + dir.display() + ); + } +} + +/// Load the skill registry: bundled defaults (seeded into the workspace) + +/// compile-time builtins (no declared inputs) + runtime skills under +/// `/skills//{skill.toml, SKILL.md}`. A skill's `SKILL.md`, when +/// present, becomes its inline system prompt. A bad `skill.toml` is skipped +/// with a warning, not fatal. +pub fn load_skills(workspace_dir: &Path) -> Vec { + // Materialise the bundled defaults (idempotent) so they're always present + // and user-editable in the workspace, then picked up by the scan below. + seed_default_skills(workspace_dir); + + let mut skills: Vec = Vec::new(); + + if let Ok(builtins) = crate::openhuman::agent::agents::load_builtins() { + for definition in builtins { + skills.push(SkillDefinition { + definition, + inputs: Vec::new(), + }); + } + } + + let dir = workspace_dir.join("skills"); + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let sd = entry.path(); + if !sd.is_dir() { + continue; + } + let toml_path = sd.join("skill.toml"); + let Ok(toml_str) = std::fs::read_to_string(&toml_path) else { + continue; + }; + let mut skill: SkillDefinition = match toml::from_str(&toml_str) { + Ok(s) => s, + Err(e) => { + log::warn!("[skills] skipping {}: {e}", toml_path.display()); + continue; + } + }; + if let Ok(md) = std::fs::read_to_string(sd.join("SKILL.md")) { + skill.definition.system_prompt = PromptSource::Inline(md); + } + skills.push(skill); + } + } + skills +} + +/// Look up one skill by id across the registry. +pub fn get_skill(workspace_dir: &Path, id: &str) -> Option { + load_skills(workspace_dir) + .into_iter() + .find(|s| s.definition.id == id) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn defs() -> Vec { + vec![ + SkillInput { + name: "repo".into(), + description: "owner/name".into(), + required: true, + kind: None, + }, + SkillInput { + name: "issue".into(), + description: "issue #".into(), + required: true, + kind: Some("integer".into()), + }, + SkillInput { + name: "pr_base".into(), + description: "base branch".into(), + required: false, + kind: None, + }, + ] + } + + #[test] + fn missing_required_is_detected() { + assert_eq!( + missing_required_inputs(&defs(), &json!({"repo": "acme/web"})), + vec!["issue".to_string()] + ); + assert!( + missing_required_inputs(&defs(), &json!({"repo": "acme/web", "issue": 42})).is_empty() + ); + // null counts as missing + assert_eq!( + missing_required_inputs(&defs(), &json!({"repo": "acme/web", "issue": null})), + vec!["issue".to_string()] + ); + } + + #[test] + fn renders_inputs_block_with_values_and_gaps() { + let b = render_inputs_block(&defs(), &json!({"repo": "acme/web", "issue": 42})); + assert!(b.starts_with("## Inputs")); + assert!(b.contains("**repo**: acme/web")); + assert!(b.contains("**issue**: 42")); + assert!(b.contains("**pr_base**: (not provided)")); + assert!(render_inputs_block(&[], &json!({})).is_empty()); + } + + #[test] + fn skill_input_parses_type_alias() { + let i: SkillInput = serde_json::from_value(json!({ + "name": "issue", "description": "issue #", "required": true, "type": "integer" + })) + .unwrap(); + assert_eq!(i.kind.as_deref(), Some("integer")); + assert!(i.required); + } + + #[test] + fn load_skills_reads_runtime_skill_prompt_and_inputs() { + let tmp = tempfile::TempDir::new().unwrap(); + let sd = tmp.path().join("skills").join("github-issue-crusher"); + std::fs::create_dir_all(&sd).unwrap(); + std::fs::write( + sd.join("skill.toml"), + "id = \"github-issue-crusher\"\nwhen_to_use = \"fix a github issue\"\n\ + [[inputs]]\nname = \"repo\"\ndescription = \"owner/name\"\nrequired = true\n\ + [[inputs]]\nname = \"issue\"\ndescription = \"issue #\"\nrequired = true\ntype = \"integer\"\n", + ) + .unwrap(); + std::fs::write(sd.join("SKILL.md"), "# Issue Crusher\nFix it.").unwrap(); + + let skills = load_skills(tmp.path()); + let s = skills + .iter() + .find(|s| s.definition.id == "github-issue-crusher") + .expect("runtime skill loaded"); + assert_eq!(s.inputs.len(), 2); + assert_eq!(s.inputs[1].kind.as_deref(), Some("integer")); + match &s.definition.system_prompt { + PromptSource::Inline(p) => assert!(p.contains("Fix it.")), + other => panic!("expected inline prompt, got {other:?}"), + } + } + + #[test] + fn default_skills_seed_into_empty_workspace() { + let tmp = tempfile::TempDir::new().unwrap(); + // Fresh workspace, nothing pre-written: the bundled default must appear. + let skills = load_skills(tmp.path()); + let s = skills + .iter() + .find(|s| s.definition.id == "github-issue-crusher") + .expect("bundled default seeded + loaded"); + assert_eq!(s.inputs.len(), 4, "repo + issue + fork + pr_base"); + assert_eq!(s.inputs[0].name, "repo"); + assert!(s.inputs[0].required); + assert_eq!( + s.inputs[1].kind.as_deref(), + Some("integer"), + "issue is integer" + ); + assert_eq!(s.inputs[2].name, "fork"); + assert!(!s.inputs[2].required, "fork is optional"); + assert!(!s.inputs[3].required, "pr_base is optional"); + match &s.definition.system_prompt { + PromptSource::Inline(p) => assert!(p.contains("GitHub Issue Crusher")), + other => panic!("expected inline prompt, got {other:?}"), + } + // Materialised on disk (user-editable), and re-seeding is non-destructive. + let toml = tmp.path().join("skills/github-issue-crusher/skill.toml"); + assert!(toml.exists()); + std::fs::write( + &toml, + "id = \"github-issue-crusher\"\nwhen_to_use = \"edited\"\n", + ) + .unwrap(); + seed_default_skills(tmp.path()); + assert!( + std::fs::read_to_string(&toml).unwrap().contains("edited"), + "existing skill.toml must not be clobbered" + ); + } + + #[test] + fn dev_workflow_default_skill_seeds_and_loads() { + let tmp = tempfile::TempDir::new().unwrap(); + let skills = load_skills(tmp.path()); + let s = skills + .iter() + .find(|s| s.definition.id == "dev-workflow") + .expect("dev-workflow bundled default seeded + loaded"); + assert_eq!( + s.inputs.len(), + 4, + "repo + upstream + target_branch + fork_owner" + ); + assert_eq!(s.inputs[0].name, "repo"); + assert_eq!(s.inputs[1].name, "upstream"); + assert_eq!(s.inputs[2].name, "target_branch"); + assert_eq!(s.inputs[3].name, "fork_owner"); + // Prompt from SKILL.md + match &s.definition.system_prompt { + PromptSource::Inline(text) => { + assert!(text.contains("Dev Workflow"), "SKILL.md content present"); + assert!( + text.contains("{fork_owner}"), + "template placeholders preserved" + ); + } + other => panic!("expected inline prompt, got {other:?}"), + } + } +} diff --git a/src/openhuman/skills/run_log.rs b/src/openhuman/skills/run_log.rs new file mode 100644 index 0000000000..bd669439ca --- /dev/null +++ b/src/openhuman/skills/run_log.rs @@ -0,0 +1,236 @@ +//! Per-run streaming logs for `skills_run`. +//! +//! Each run writes a human-readable trace to +//! `/skills/.runs/__.log`: a header (skill, +//! inputs, task prompt), one line per agent step (tool calls + results, +//! sub-agent lifecycle, iteration boundaries) streamed live off the agent's +//! [`AgentProgress`] channel, then a footer (status, duration, final output). +//! +//! `.runs` is a sibling of the runtime skill *definitions* (`/ +//! skills//`) so run logs never collide with a skill-id directory. + +use std::path::{Path, PathBuf}; + +use serde_json::Value; +use tokio::io::AsyncWriteExt; +use tokio::sync::mpsc::Receiver; + +use crate::openhuman::agent::progress::AgentProgress; + +/// `/skills/.runs`. +pub fn runs_dir(workspace: &Path) -> PathBuf { + workspace.join("skills").join(".runs") +} + +fn sanitize(s: &str) -> String { + s.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect() +} + +fn short(s: &str) -> &str { + s.get(..8).unwrap_or(s) +} + +/// `/__.log`. +pub fn run_log_path(workspace: &Path, skill_id: &str, run_id: &str) -> PathBuf { + let ts = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); + runs_dir(workspace).join(format!( + "{}_{}_{}.log", + sanitize(skill_id), + ts, + sanitize(short(run_id)) + )) +} + +async fn append(path: &Path, line: &str) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + tokio::fs::create_dir_all(dir).await.ok(); + } + let mut f = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .await?; + f.write_all(line.as_bytes()).await?; + if !line.ends_with('\n') { + f.write_all(b"\n").await?; + } + f.flush().await +} + +fn truncate(s: &str, n: usize) -> String { + let s = s.replace('\n', " "); + if s.chars().count() > n { + format!("{}…", s.chars().take(n).collect::()) + } else { + s + } +} + +/// Write the run header (skill, inputs, the resolved task prompt). +pub async fn write_header( + path: &Path, + skill_id: &str, + run_id: &str, + inputs: &Value, + task_prompt: &str, +) -> std::io::Result<()> { + let header = format!( + "==== skill_run: {skill} ====\n\ + run_id : {run}\n\ + started: {start} UTC\n\ + inputs : {inputs}\n\n\ + --- task prompt ---\n{prompt}\n\n\ + --- steps ---", + skill = skill_id, + run = run_id, + start = chrono::Utc::now().to_rfc3339(), + inputs = serde_json::to_string(inputs).unwrap_or_default(), + prompt = task_prompt, + ); + append(path, &header).await +} + +/// One log line for a step, or `None` for events too noisy to log per-event +/// (token / argument deltas, cost ticks — the final text lands in the footer). +pub fn format_event(ev: &AgentProgress) -> Option { + let line = match ev { + AgentProgress::TurnStarted => "turn started".to_string(), + AgentProgress::IterationStarted { + iteration, + max_iterations, + } => format!("· iteration {iteration}/{max_iterations}"), + AgentProgress::ToolCallStarted { + tool_name, + arguments, + iteration, + .. + } => format!( + "[it {iteration}] tool {tool_name}({})", + truncate(&arguments.to_string(), 200) + ), + AgentProgress::ToolCallCompleted { + tool_name, + success, + output_chars, + elapsed_ms, + .. + } => format!( + " ↳ {tool_name} {} ({output_chars} chars, {elapsed_ms} ms)", + if *success { "ok" } else { "FAILED" } + ), + AgentProgress::SubagentSpawned { + agent_id, + task_id, + prompt_chars, + .. + } => format!( + " ⮑ spawned subagent {agent_id} [{}] ({prompt_chars}-char prompt)", + short(task_id) + ), + AgentProgress::SubagentToolCallStarted { + agent_id, + tool_name, + .. + } => format!(" [{agent_id}] tool {tool_name}"), + AgentProgress::SubagentToolCallCompleted { + agent_id, + tool_name, + success, + elapsed_ms, + .. + } => format!( + " [{agent_id}] ↳ {tool_name} {} ({elapsed_ms} ms)", + if *success { "ok" } else { "FAILED" } + ), + AgentProgress::SubagentCompleted { + agent_id, + elapsed_ms, + iterations, + .. + } => format!(" ⮑ subagent {agent_id} done ({iterations} turns, {elapsed_ms} ms)"), + AgentProgress::SubagentFailed { + agent_id, error, .. + } => format!(" ⮑ subagent {agent_id} FAILED: {}", truncate(error, 200)), + AgentProgress::TurnCompleted { iterations } => { + format!("turn completed ({iterations} iterations)") + } + // Noisy / non-step events — skipped (the final text is in the footer). + AgentProgress::TextDelta { .. } + | AgentProgress::ThinkingDelta { .. } + | AgentProgress::ToolCallArgsDelta { .. } + | AgentProgress::TurnCostUpdated { .. } + | AgentProgress::TaskBoardUpdated { .. } + | AgentProgress::SubagentIterationStarted { .. } => return None, + }; + Some(format!( + "{} {}", + chrono::Utc::now().format("%H:%M:%S%.3f"), + line + )) +} + +/// Drain the progress channel to the log until the agent drops its sender. +pub async fn drain_to_log(mut rx: Receiver, path: PathBuf) { + while let Some(ev) = rx.recv().await { + if let Some(line) = format_event(&ev) { + let _ = append(&path, &line).await; + } + } +} + +/// Final footer: status, duration, and the agent's final output text. +pub async fn write_footer( + path: &Path, + status: &str, + elapsed_ms: u64, + output: &str, +) -> std::io::Result<()> { + let footer = format!( + "\n--- result ---\n\ + status : {status}\n\ + duration: {elapsed_ms} ms\n\ + finished: {fin} UTC\n\n{output}\n", + fin = chrono::Utc::now().to_rfc3339(), + ); + append(path, &footer).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn log_path_is_under_runs_and_sanitised() { + let p = run_log_path(Path::new("/ws"), "github/issue crusher", "abcdef12-3456"); + let s = p.to_string_lossy(); + assert!(s.contains("/ws/skills/.runs/")); + assert!(s.contains("github-issue-crusher_")); + assert!(s.ends_with("_abcdef12.log"), "got {s}"); + } + + #[test] + fn noisy_events_are_skipped_steps_are_kept() { + assert!(format_event(&AgentProgress::TextDelta { + delta: "hi".into(), + iteration: 1 + }) + .is_none()); + let line = format_event(&AgentProgress::ToolCallStarted { + call_id: "c1".into(), + tool_name: "codegraph_search".into(), + arguments: serde_json::json!({"query": "x"}), + iteration: 2, + }) + .expect("tool call logged"); + assert!(line.contains("codegraph_search")); + assert!(line.contains("it 2")); + } +} diff --git a/src/openhuman/skills/schemas.rs b/src/openhuman/skills/schemas.rs index d8688c4110..6f7c1b3196 100644 --- a/src/openhuman/skills/schemas.rs +++ b/src/openhuman/skills/schemas.rs @@ -32,6 +32,15 @@ use crate::openhuman::skills::ops::{ }; use crate::rpc::RpcOutcome; +use crate::openhuman::agent::harness::session::Agent; +use crate::openhuman::agent::harness::subagent_runner::with_autonomous_iter_cap; +use crate::openhuman::skills::{registry, run_log}; + +/// Iteration cap for an autonomous skill run (orchestrator + sub-agents). High +/// enough to "run until done", while the repeated-failure circuit breaker still +/// stops dead-end grinding — deliberately bounded (not infinite) to cap spend. +const SKILL_RUN_MAX_ITERATIONS: usize = 200; + #[derive(Debug, Deserialize, Default)] struct SkillsListParams { // No params today. Kept as an empty struct so future filters (scope, @@ -184,6 +193,7 @@ pub fn all_skills_controller_schemas() -> Vec { skills_schemas("skills_create"), skills_schemas("skills_install_from_url"), skills_schemas("skills_uninstall"), + skills_schemas("skills_run"), ] } @@ -209,6 +219,10 @@ pub fn all_skills_registered_controllers() -> Vec { schema: skills_schemas("skills_uninstall"), handler: handle_skills_uninstall, }, + RegisteredController { + schema: skills_schemas("skills_run"), + handler: handle_skills_run, + }, ] } @@ -226,6 +240,51 @@ pub fn skills_schemas(function: &str) -> ControllerSchema { required: true, }], }, + "skills_run" => ControllerSchema { + namespace: "skills", + function: "run", + description: "Start a skill in the background: run the orchestrator agent focused by the skill's SKILL.md + the given inputs, streaming every step to a per-run log file. Validates required inputs and returns immediately with a run id and the log path.", + inputs: vec![ + FieldSchema { + name: "skill_id", + ty: TypeSchema::String, + comment: "Id of the skill to run (matches SkillDefinition.id).", + required: true, + }, + FieldSchema { + name: "inputs", + ty: TypeSchema::Json, + comment: "Object of input values keyed by the skill's declared input names.", + required: false, + }, + ], + outputs: vec![ + FieldSchema { + name: "run_id", + ty: TypeSchema::String, + comment: "Id for this background run.", + required: true, + }, + FieldSchema { + name: "status", + ty: TypeSchema::String, + comment: "Always \"started\" — the orchestrator runs in the background.", + required: true, + }, + FieldSchema { + name: "skill_id", + ty: TypeSchema::String, + comment: "Echo of the requested skill id.", + required: true, + }, + FieldSchema { + name: "log", + ty: TypeSchema::String, + comment: "Path to the per-run streaming log (/skills/.runs/_.log).", + required: true, + }, + ], + }, "skills_read_resource" => ControllerSchema { namespace: "skills", function: "read_resource", @@ -439,6 +498,149 @@ fn handle_skills_list(params: Map) -> ControllerFuture { }) } +#[derive(serde::Deserialize)] +struct SkillsRunParams { + skill_id: String, + #[serde(default)] + inputs: Option, +} + +fn handle_skills_run(params: Map) -> ControllerFuture { + Box::pin(async move { + let payload = deserialize_params::(params)?; + let workspace = resolve_workspace_dir().await; + let skill = registry::get_skill(&workspace, &payload.skill_id) + .ok_or_else(|| format!("skill_run: unknown skill '{}'", payload.skill_id))?; + let inputs = payload.inputs.unwrap_or(Value::Null); + let missing = registry::missing_required_inputs(&skill.inputs, &inputs); + if !missing.is_empty() { + return Err(format!( + "skill_run: missing required inputs: {}", + missing.join(", ") + )); + } + // Focus the orchestrator on this single skill: its SKILL.md rides in + // the task prompt as guidelines + the resolved inputs; the + // orchestrator's own system prompt and full tool access are kept. + let skill_id = skill.definition.id.clone(); + let guidelines = match &skill.definition.system_prompt { + crate::openhuman::agent::harness::definition::PromptSource::Inline(s) => s.clone(), + other => { + return Err(format!( + "skill_run: skill '{skill_id}' has a non-inline system prompt ({other:?}); \ + only inline prompts (from SKILL.md) are supported for skills_run" + )); + } + }; + let inputs_block = registry::render_inputs_block(&skill.inputs, &inputs); + let task_prompt = format!( + "You are running a single skill: **{skill_id}**. Follow these guidelines exactly and \ + focus solely on completing this one task — do not pick up unrelated work.\n\n\ + # Skill guidelines\n{guidelines}\n\n{inputs_block}", + ); + let run_id = uuid::Uuid::new_v4().to_string(); + let log_path = run_log::run_log_path(&workspace, &skill_id, &run_id); + tracing::info!( + skill_id = %skill_id, + run_id = %run_id, + log = %log_path.display(), + "[skills][rpc] skill_run: starting orchestrator run" + ); + + // Detached: build the orchestrator Agent, stream every step to the run + // log, and return the run id immediately. Running a full turn (not a + // bare subagent) establishes its own parent context, so there is no + // NoParentContext failure, and the AgentProgress sink gives a complete + // tool-by-tool trace. + { + let run_id = run_id.clone(); + let skill_id = skill_id.clone(); + let inputs = inputs.clone(); + let log_path = log_path.clone(); + tokio::spawn(async move { + if let Err(e) = + run_log::write_header(&log_path, &skill_id, &run_id, &inputs, &task_prompt) + .await + { + tracing::warn!(run_id = %run_id, error = %e, "[skills][rpc] skill_run: header write failed"); + } + let mut config = match Config::load_or_init().await { + Ok(c) => c, + Err(e) => { + let _ = run_log::write_footer( + &log_path, + "FAILED", + 0, + &format!("load config: {e:#}"), + ) + .await; + return; + } + }; + // Autonomous skill run: lift the orchestrator's iteration cap. + // Sub-agents get the lifted cap via with_autonomous_iter_cap + // around run_single below; approval prompts don't apply (a + // background run carries no chat context, so the gate never parks). + // http_request.allowed_domains is intentionally NOT overridden — + // the configured policy (default: ["*"] with SSRF guard) applies. + config.agent.max_tool_iterations = SKILL_RUN_MAX_ITERATIONS; + let mut agent = match Agent::from_config_for_agent(&config, "orchestrator") { + Ok(a) => a, + Err(e) => { + let _ = run_log::write_footer( + &log_path, + "FAILED", + 0, + &format!("build agent: {e:#}"), + ) + .await; + return; + } + }; + agent.set_event_context(run_id.clone(), "skill"); + let (tx, rx) = tokio::sync::mpsc::channel(256); + agent.set_on_progress(Some(tx)); + let bridge = tokio::spawn(run_log::drain_to_log(rx, log_path.clone())); + + let started = std::time::Instant::now(); + // Scope the lifted iteration cap over the whole run — the + // orchestrator turn and every inline sub-agent loop. + let result = with_autonomous_iter_cap( + SKILL_RUN_MAX_ITERATIONS, + agent.run_single(&task_prompt), + ) + .await; + agent.set_on_progress(None); // drop the sender → bridge drains and exits + drop(agent); + let _ = bridge.await; + + let ms = started.elapsed().as_millis() as u64; + match result { + Ok(out) => { + let _ = run_log::write_footer(&log_path, "DONE", ms, &out).await; + tracing::info!(run_id = %run_id, "[skills][rpc] skill_run: completed"); + } + Err(e) => { + let _ = + run_log::write_footer(&log_path, "FAILED", ms, &format!("{e:#}")).await; + tracing::warn!(run_id = %run_id, error = ?e, "[skills][rpc] skill_run: failed"); + } + } + }); + } + + to_json(RpcOutcome::new( + serde_json::json!({ + "run_id": run_id, + "status": "started", + "skill_id": skill_id, + "log": log_path.display().to_string(), + }), + Vec::new(), + )) + }) +} + fn handle_skills_read_resource(params: Map) -> ControllerFuture { Box::pin(async move { let payload = deserialize_params::(params)?; diff --git a/src/openhuman/tools/impl/codegraph/mod.rs b/src/openhuman/tools/impl/codegraph/mod.rs new file mode 100644 index 0000000000..b5be79d3ff --- /dev/null +++ b/src/openhuman/tools/impl/codegraph/mod.rs @@ -0,0 +1,220 @@ +//! Agent-facing codegraph tools: `codegraph_index` (start/refresh a repo's +//! index) and `codegraph_search` (the fused BM25 ∪ dense seed). Coding +//! subagents call these on a checked-out worktree; the embedder is the +//! configured (cloud-default) provider, and its `signature()` keys the cache. + +use std::path::Path; +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::Value; + +use crate::openhuman::codegraph::{ + count_code_files, current_ref, index_ref, search_ref, CodegraphStore, IndexMode, +}; +use crate::openhuman::config::Config; +use crate::openhuman::embeddings; +use crate::openhuman::tools::traits::{Tool, ToolResult}; + +fn codegraph_db(workspace_dir: &Path) -> std::path::PathBuf { + workspace_dir.join("codegraph").join("index.db") +} + +/// File count at/above which auto-indexing builds the dense (embedding) index; +/// below it, BM25-only. Small repos saturate recall, so dense buys little there +/// while costing real embedding latency. Override with the env var. +fn dense_min_files() -> usize { + std::env::var("OPENHUMAN_CODEGRAPH_DENSE_MIN_FILES") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(400) +} + +/// Size-gated mode for `auto`: dense above the threshold, else lexical. The +/// count is cheap (`git ls-files`, no reads/embeds). +fn auto_mode(repo_dir: &Path) -> IndexMode { + match count_code_files(repo_dir) { + Ok(n) if n > dense_min_files() => IndexMode::Dense, + _ => IndexMode::Lexical, + } +} + +/// Resolve an explicit `mode` arg (`auto`/`lexical`/`dense`) against the repo. +fn resolve_mode(arg: Option<&str>, repo_dir: &Path) -> IndexMode { + match arg { + Some("dense") => IndexMode::Dense, + Some("lexical") => IndexMode::Lexical, + _ => auto_mode(repo_dir), + } +} + +/// Stable per-repo key: the canonical worktree path (manifests are per +/// `(repo_id, ref)`; the blob cache is content-addressed so it's shared anyway). +fn repo_id(repo_dir: &Path) -> String { + std::fs::canonicalize(repo_dir) + .unwrap_or_else(|_| repo_dir.to_path_buf()) + .to_string_lossy() + .into_owned() +} + +fn arg_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> { + args.get(key).and_then(|v| v.as_str()) +} + +/// `codegraph_index { path, ref? }` — (re)index the worktree at `path` under its +/// current branch (or `ref`). Incremental: only changed blobs are embedded. +pub struct CodegraphIndexTool { + config: Arc, + workspace_dir: std::path::PathBuf, +} + +impl CodegraphIndexTool { + pub fn new(config: Arc, workspace_dir: std::path::PathBuf) -> Self { + Self { + config, + workspace_dir, + } + } +} + +#[async_trait] +impl Tool for CodegraphIndexTool { + fn name(&self) -> &str { + "codegraph_index" + } + + fn description(&self) -> &str { + "Index a checked-out repo for fast retrieval. Args: `path` (repo working dir, required), \ + `ref` (branch/commit; defaults to the current checkout), `mode` (`auto` (default) | `lexical` | `dense`). \ + `auto` builds BM25-only for small repos and adds dense embeddings above a file-count threshold. \ + Incremental and content-addressed — only changed files are (re)processed. \ + Returns {mode, files, computed, cached, skipped}." + } + + fn parameters_schema(&self) -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Repo working directory to index."}, + "ref": {"type": "string", "description": "Branch/commit to index (defaults to current checkout)."}, + "mode": {"type": "string", "enum": ["auto", "lexical", "dense"], "description": "auto (size-gated, default), lexical (BM25 only), or dense (embeddings)."} + }, + "required": ["path"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let path = match arg_str(&args, "path") { + Some(p) => p, + None => { + return Ok(ToolResult::error( + "codegraph_index: `path` (repo working dir) is required", + )) + } + }; + let repo_dir = Path::new(path); + let git_ref = match arg_str(&args, "ref") { + Some(r) => r.to_string(), + None => current_ref(repo_dir)?, + }; + let mode = resolve_mode(arg_str(&args, "mode"), repo_dir); + let provider = embeddings::provider_from_config(&self.config)?; + let mut store = CodegraphStore::open(&codegraph_db(&self.workspace_dir))?; + let report = index_ref( + &mut store, + &repo_id(repo_dir), + repo_dir, + Some(&git_ref), + &*provider, + mode, + ) + .await?; + let out = serde_json::json!({ + "mode": if mode == IndexMode::Dense { "dense" } else { "lexical" }, + "files": report.files, + "computed": report.computed, + "cached": report.cached, + "skipped": report.skipped, + }); + Ok(ToolResult::success(serde_json::to_string_pretty(&out)?)) + } +} + +/// `codegraph_search { query, path, ref?, k? }` — the seed: BM25 ∪ dense, +/// RRF-fused, with a `coverage` flag (`full`/`partial`/`none`). On `none`/`partial` +/// the agent should treat hits as hints and lean on grep. +pub struct CodegraphSearchTool { + config: Arc, + workspace_dir: std::path::PathBuf, +} + +impl CodegraphSearchTool { + pub fn new(config: Arc, workspace_dir: std::path::PathBuf) -> Self { + Self { + config, + workspace_dir, + } + } +} + +#[async_trait] +impl Tool for CodegraphSearchTool { + fn name(&self) -> &str { + "codegraph_search" + } + + fn description(&self) -> &str { + "Find the files most relevant to a query in a repo (lexical + semantic, fused). \ + Indexes the repo first if it hasn't been indexed yet (synchronous; BM25-only for small \ + repos, dense embeddings for larger ones). \ + Args: `query` (required), `path` (repo working dir, required), `ref` (defaults to current), \ + `k` (max hits, default 10). Returns {hits:[paths], coverage:full|partial|none, indexed, total}. \ + If coverage is not `full`, treat hits as hints and also use grep." + } + + fn parameters_schema(&self) -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "query": {"type": "string", "description": "What to find (issue text / symbols)."}, + "path": {"type": "string", "description": "Repo working directory."}, + "ref": {"type": "string", "description": "Branch/commit (defaults to current checkout)."}, + "k": {"type": "integer", "description": "Max hits to return (default 10)."} + }, + "required": ["query", "path"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let query = match arg_str(&args, "query") { + Some(q) => q, + None => return Ok(ToolResult::error("codegraph_search: `query` is required")), + }; + let path = match arg_str(&args, "path") { + Some(p) => p, + None => { + return Ok(ToolResult::error( + "codegraph_search: `path` (repo working dir) is required", + )) + } + }; + let repo_dir = Path::new(path); + let git_ref = match arg_str(&args, "ref") { + Some(r) => r.to_string(), + None => current_ref(repo_dir)?, + }; + let k = args.get("k").and_then(|v| v.as_u64()).unwrap_or(10) as usize; + let provider = embeddings::provider_from_config(&self.config)?; + let mut store = CodegraphStore::open(&codegraph_db(&self.workspace_dir))?; + let rid = repo_id(repo_dir); + // Index-first: if this (repo, ref) has never been indexed, build it now + // (synchronously) so the search has something to hit. Mode is size-gated + // — BM25-only for small repos, dense above the threshold. + if store.manifest_size(&rid, &git_ref)? == 0 { + let mode = auto_mode(repo_dir); + index_ref(&mut store, &rid, repo_dir, Some(&git_ref), &*provider, mode).await?; + } + let outcome = search_ref(&mut store, &rid, &git_ref, query, &*provider, k).await?; + Ok(ToolResult::success(serde_json::to_string_pretty(&outcome)?)) + } +} diff --git a/src/openhuman/tools/impl/mod.rs b/src/openhuman/tools/impl/mod.rs index d5e52f3bb5..d54280060b 100644 --- a/src/openhuman/tools/impl/mod.rs +++ b/src/openhuman/tools/impl/mod.rs @@ -1,6 +1,7 @@ pub mod agent; pub mod audio; pub mod browser; +pub mod codegraph; pub mod computer; pub mod cron; pub mod filesystem; @@ -13,6 +14,7 @@ pub mod whatsapp_data; pub use agent::*; pub use audio::*; pub use browser::*; +pub use codegraph::*; pub use computer::*; pub use cron::*; pub use filesystem::*; diff --git a/src/openhuman/tools/impl/network/url_guard.rs b/src/openhuman/tools/impl/network/url_guard.rs index c52d3d4bd5..47d0c88371 100644 --- a/src/openhuman/tools/impl/network/url_guard.rs +++ b/src/openhuman/tools/impl/network/url_guard.rs @@ -820,4 +820,23 @@ mod tests { .to_string(); assert!(err.contains("local/private")); } + + #[test] + fn wildcard_allows_any_host() { + let any = vec!["*".to_string()]; + assert!(host_matches_allowlist("docs.rs", &any)); + assert!(host_matches_allowlist("api.github.com", &any)); + assert!(host_matches_allowlist("whatever.example.org", &any)); + } + + #[tokio::test] + async fn wildcard_still_blocks_private_hosts() { + // `*` opens public hosts only — SSRF block on private/local hosts stays. + let any = vec!["*".to_string()]; + let err = validate_url_with_dns_check("https://127.0.0.1", &any) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("local/private"), "got: {err}"); + } } diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index c6dd6eeb39..206c0d6e4a 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -158,6 +158,14 @@ pub fn all_tools_with_runtime( Box::new(TodoTool::new()), Box::new(PlanExitTool::new()), Box::new(CurrentTimeTool::new()), + Box::new(CodegraphIndexTool::new( + config.clone(), + workspace_dir.to_path_buf(), + )), + Box::new(CodegraphSearchTool::new( + config.clone(), + workspace_dir.to_path_buf(), + )), Box::new(DetectToolsTool::new()), Box::new(InstallToolTool::new(security.clone())), Box::new(CronAddTool::new(config.clone(), security.clone())),