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())),