feat(skills): scheduled dashboard + run/new pages + [github] preflight gate + composio-only GitHub I/O (#2882)

Co-authored-by: sanil-23 <sanil@alphahuman.xyz>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: sanil-23 <sanil@vezures.xyz>
Co-authored-by: cyrus@tinyhumans.ai <cyrus@tinyhumans.ai>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Mega Mind
2026-05-29 10:17:58 +05:30
committed by GitHub
co-authored by sanil-23 Claude Opus 4.7 sanil-23 cyrus@tinyhumans.ai <cyrus@tinyhumans.ai> Steven Enamakel
parent a41d9132ba
commit db3fdc2e6c
97 changed files with 14866 additions and 669 deletions
+5 -1
View File
@@ -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 <PID>`. 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/<id>/` with `skill.toml` + `SKILL.md`, registered in `registry.rs` `DEFAULT_SKILLS` const. Seeded into `<workspace>/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)
+17 -2
View File
@@ -2023,10 +2023,25 @@ fn append_platform_cef_gpu_workarounds(args: &mut Vec<CefCommandLineArg>, os: &s
#[cfg(target_os = "linux")]
{
let uid = nix::unistd::getuid().as_raw();
if os == "linux" && linux_is_root_uid(uid) {
// Dev-only: also honor OPENHUMAN_CEF_NO_SANDBOX=1 so a non-root headless
// box (no sudo to chown chrome-sandbox root:4755) can launch over RDP.
//
// SECURITY: gated to debug builds only. Disabling Chromium's process
// sandbox in a release binary would let anyone with env-write access
// on the host silently turn off a meaningful defense-in-depth layer
// (graycyrus review on PR #2875). In release builds `forced` stays
// false so only the `linux_is_root_uid` path can opt in (uid=0
// already implies root-equivalent trust).
#[cfg(debug_assertions)]
let forced = std::env::var("OPENHUMAN_CEF_NO_SANDBOX")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
#[cfg(not(debug_assertions))]
let forced = false;
if os == "linux" && (linux_is_root_uid(uid) || forced) {
args.push(("--no-sandbox", None));
log::info!(
"[cef-startup] running as root (uid=0) on Linux: adding --no-sandbox \
"[cef-startup] Linux: adding --no-sandbox (root uid or OPENHUMAN_CEF_NO_SANDBOX) \
(OPENHUMAN-TAURI-K1)"
);
}
+29
View File
@@ -16,7 +16,9 @@ import Onboarding from './pages/onboarding/Onboarding';
import Rewards from './pages/Rewards';
import Routines from './pages/Routines';
import Settings from './pages/Settings';
import SkillNew from './pages/SkillNew';
import Skills from './pages/Skills';
import SkillsRun from './pages/SkillsRun';
import WebCallbackPage from './pages/WebCallbackPage';
import Welcome from './pages/Welcome';
@@ -80,6 +82,33 @@ const AppRoutes = () => {
}
/>
{/* Skills lives at /skills with its 4 sub-tabs (Composio / Channels /
MCP Servers / Runners). The scheduled-skills dashboard concept
composes INSIDE the Runners sub-tab, not as a separate top-level
page — the bottom-bar "Connections" entry has always pointed at
/skills to surface Composio integrations + MCP, and that muscle
memory is restored here.
`/skills/new` is the create-a-skill authoring page.
Order matters: keep `/skills/new` before `/skills` so it wins the
prefix match. */}
<Route
path="/skills/new"
element={
<ProtectedRoute requireAuth={true}>
<SkillNew />
</ProtectedRoute>
}
/>
<Route
path="/skills/run"
element={
<ProtectedRoute requireAuth={true}>
<SkillsRun />
</ProtectedRoute>
}
/>
<Route
path="/skills"
element={
@@ -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<string | null>(null);
// Lazy-initialised state from persisted config
const initialConfig = loadSavedConfig();
const [savedConfig, setSavedConfig] = useState<DevWorkflowConfig | null>(initialConfig);
const [selectedRepo, setSelectedRepo] = useState(initialConfig?.repoFullName ?? '');
const [forkInfo, setForkInfo] = useState<ForkInfo | null>(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<ForkInfo | null>(null);
const [targetBranch, setTargetBranch] = useState('');
const [schedule, setSchedule] = useState(SCHEDULE_PRESETS[0].value);
// Fork detection loading
const [forkLoading, setForkLoading] = useState(false);
@@ -99,6 +77,78 @@ const DevWorkflowPanel = () => {
// Save state
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
// Cron job state
const [existingJob, setExistingJob] = useState<CoreCronJob | null>(null);
const [cronLoading, setCronLoading] = useState(false);
const [runHistory, setRunHistory] = useState<CoreCronRun[]>([]);
const [historyExpanded, setHistoryExpanded] = useState(false);
const [expandedRunId, setExpandedRunId] = useState<number | null>(null);
const [running, setRunning] = useState(false);
// ── Load existing cron job on mount ─────────────────────────────────
const loadExistingJob = useCallback(async () => {
setCronLoading(true);
try {
const res = await openhumanCronList();
// RPC returns { result: CronJob[], logs: [...] }
const jobs = (res as { result?: CoreCronJob[] }).result ?? [];
const jobList = Array.isArray(jobs) ? jobs : [];
const found = jobList.find((j: CoreCronJob) => j.name?.startsWith('dev-workflow') ?? false);
if (found) {
setExistingJob(found);
// Restore form state from the stored job so returning to the panel
// with an active job doesn't show blank dropdowns.
const restored: { repo?: string; schedule?: string; branch?: string } = {};
// Schedule: prefer the structured cron expr, fall back to `expression`.
const scheduleExpr =
(found.schedule?.kind === 'cron' ? found.schedule.expr : undefined) ??
found.expression ??
undefined;
if (scheduleExpr) {
setSchedule(scheduleExpr);
restored.schedule = scheduleExpr;
}
// Repo: encoded in the job name as `dev-workflow-<owner>-<repo>`
// where the original `/` separator became the first `-` after the prefix.
const repoSlug = found.name?.replace(/^dev-workflow-/, '') ?? '';
if (repoSlug) {
// Re-derive `owner/repo` by replacing only the first `-` with `/`.
const fullName = repoSlug.replace('-', '/');
setSelectedRepo(fullName);
restored.repo = fullName;
}
// Target branch: recoverable from the prompt, which embeds
// `PRs target \`<branch>\``.
const branchMatch = found.prompt?.match(/PRs target `([^`]+)`/);
if (branchMatch?.[1]) {
setTargetBranch(branchMatch[1]);
restored.branch = branchMatch[1];
}
log(
'found existing dev-workflow cron job: %s, restored form state: %o',
found.id,
restored
);
} else {
setExistingJob(null);
log('no existing dev-workflow cron job found');
}
} catch (err) {
log('failed to load existing cron job: %s', err);
} finally {
setCronLoading(false);
}
}, []);
useEffect(() => {
void loadExistingJob();
}, [loadExistingJob]);
// ── Fetch repos via composio_execute ────────────────────────────────
const loadRepos = useCallback(async () => {
setReposLoading(true);
@@ -289,40 +339,162 @@ const DevWorkflowPanel = () => {
[repos]
);
// ── Load run history ───────────────────────────────────────────────
const loadRunHistory = useCallback(async () => {
if (!existingJob) return;
try {
const res = await openhumanCronRuns(existingJob.id, 5);
// RPC returns { result: { runs: CronRun[] }, logs: [...] }
const raw = (res as { result?: { runs?: CoreCronRun[] } }).result;
const runs = raw?.runs ?? [];
setRunHistory(Array.isArray(runs) ? runs : []);
log(
'loaded %d run history entries for job %s',
Array.isArray(runs) ? runs.length : 0,
existingJob.id
);
} catch (err) {
log('failed to load run history: %s', err);
}
}, [existingJob]);
useEffect(() => {
if (existingJob) {
void loadRunHistory();
}
}, [existingJob, loadRunHistory]);
// ── Save config ────────────────────────────────────────────────────
const handleSave = () => {
const handleSave = useCallback(async () => {
if (!selectedRepo || !targetBranch) return;
const [owner, repo] = selectedRepo.split('/');
const config: DevWorkflowConfig = {
repoFullName: selectedRepo,
repoOwner: owner,
repoName: repo,
forkInfo,
targetBranch,
schedule,
const [owner] = selectedRepo.split('/');
const upstreamName = forkInfo ? forkInfo.upstreamFullName : selectedRepo;
const repoName = upstreamName.split('/')[1] ?? selectedRepo.split('/')[1] ?? '';
const skillPrompt = [
`You are running the dev-workflow skill. Follow these guidelines exactly.`,
``,
`# Dev Workflow — Autonomous Issue Crusher`,
``,
`Find a GitHub issue on \`${upstreamName}\`, implement a fix, and deliver a PR.`,
``,
`## Repos`,
`- **Upstream** = \`${upstreamName}\` — issues live here, PRs target \`${targetBranch}\`.`,
`- **Fork** = \`${owner}/${repoName}\` — push the fix branch here.`,
`- Commit through the GitHub API — no local git push.`,
``,
`## Issue Selection (smart fallback)`,
`1. **First**: Look for open issues assigned to \`${owner}\` on \`${upstreamName}\` with no linked PR.`,
`2. **If none assigned**: Find unassigned open issues. Prefer issues labeled \`good first issue\`, \`bug\`, \`help wanted\`, or \`easy\`. Prefer issues with detailed descriptions (>500 chars). Skip issues that already have an open PR linked.`,
`3. **Self-assign**: Once you pick an unassigned issue, assign it to \`${owner}\` using GITHUB_ADD_ASSIGNEES so no one else picks it up concurrently.`,
`4. **If no suitable issues at all**: Exit cleanly — report "no suitable issues found".`,
``,
`## Implementation Steps`,
`1. Read the full issue body, comments, and labels.`,
`2. Ensure fork \`${owner}/${repoName}\` exists (create if needed).`,
`3. Clone \`${upstreamName}\` locally, branch \`dev-workflow/<issue>-<slug>\` 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}:<branch>\`. Body: Closes #N + summary + how you verified.`,
``,
`## Rules`,
`- One PR per run, then stop.`,
`- Only fix the picked issue — no unrelated changes.`,
`- codegraph is an accelerant, not a gate — fall back to grep if cold.`,
`- If too large/risky (would touch >20 files or needs multi-system changes), comment on the issue explaining why and skip.`,
`- Never force-push or push to upstream directly.`,
].join('\n');
const cronParams: CronAddParams = {
name: `dev-workflow-${selectedRepo.replace('/', '-')}`,
schedule: { kind: 'cron', expr: schedule },
job_type: 'agent',
prompt: skillPrompt,
session_target: 'isolated',
delivery: { mode: 'proactive', best_effort: true },
};
saveConfig(config);
setSavedConfig(config);
setSaveStatus('saved');
log('saved dev workflow config: %o', config);
log(
'saving dev-workflow cron job: existingJob=%s, repo=%s',
existingJob?.id ?? 'none',
selectedRepo
);
setTimeout(() => setSaveStatus('idle'), 3000);
};
try {
if (existingJob) {
// Update existing job
await openhumanCronUpdate(existingJob.id, {
name: cronParams.name,
schedule: cronParams.schedule,
prompt: cronParams.prompt,
});
log('updated cron job %s', existingJob.id);
} else {
// Create new job
await openhumanCronAdd(cronParams);
log('created new dev-workflow cron job for repo=%s', selectedRepo);
}
setSaveStatus('saved');
void loadExistingJob(); // Refresh
setTimeout(() => setSaveStatus('idle'), 3000);
} catch (err) {
log('save error: %s', err);
setSaveStatus('error');
}
}, [selectedRepo, targetBranch, forkInfo, schedule, existingJob, loadExistingJob]);
// ── Remove config ──────────────────────────────────────────────────
const handleRemove = () => {
clearConfig();
setSavedConfig(null);
setSelectedRepo('');
setForkInfo(null);
setBranches([]);
setTargetBranch('');
setSchedule(SCHEDULE_PRESETS[0].value);
setSaveStatus('idle');
log('removed dev workflow config');
};
const handleRemove = useCallback(async () => {
if (!existingJob) return;
log('removing dev-workflow cron job %s', existingJob.id);
try {
await openhumanCronRemove(existingJob.id);
setExistingJob(null);
setSelectedRepo('');
setForkInfo(null);
setBranches([]);
setTargetBranch('');
setSchedule(SCHEDULE_PRESETS[0].value);
setSaveStatus('idle');
setRunHistory([]);
log('removed dev workflow cron job');
} catch (err) {
log('remove error: %s', err);
}
}, [existingJob]);
// ── Toggle enable/disable ──────────────────────────────────────────
const handleToggle = useCallback(async () => {
if (!existingJob) return;
const newEnabled = !existingJob.enabled;
log('toggling cron job %s enabled=%s', existingJob.id, newEnabled);
try {
await openhumanCronUpdate(existingJob.id, { enabled: newEnabled });
void loadExistingJob();
} catch (err) {
log('toggle error: %s', err);
}
}, [existingJob, loadExistingJob]);
// ── Run Now ────────────────────────────────────────────────────────
const handleRunNow = useCallback(async () => {
if (!existingJob) return;
setRunning(true);
log('running cron job %s now', existingJob.id);
try {
await openhumanCronRun(existingJob.id);
void loadExistingJob();
void loadRunHistory();
} catch (err) {
log('run now error: %s', err);
} finally {
setRunning(false);
}
}, [existingJob, loadExistingJob, loadRunHistory]);
// ── Render ─────────────────────────────────────────────────────────
const canSave = selectedRepo && targetBranch && schedule;
@@ -342,188 +514,313 @@ const DevWorkflowPanel = () => {
{t('settings.developerMenu.devWorkflow.panelDesc')}
</p>
{/* Repo selector */}
<div>
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1.5">
{t('settings.devWorkflow.githubRepository')}
</label>
{reposError && (
<div className="mb-2 px-3 py-2 rounded-md bg-coral-50 dark:bg-coral-500/10 border border-coral-200 dark:border-coral-500/30 text-xs text-coral-700 dark:text-coral-300">
{reposError}
</div>
)}
<select
value={selectedRepo}
onChange={e => void onRepoSelect(e.target.value)}
disabled={reposLoading}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50">
<option value="">
{reposLoading
? t('settings.devWorkflow.loadingRepositories')
: t('settings.devWorkflow.selectRepository')}
</option>
{repos.map(r => (
<option key={r.fullName} value={r.fullName}>
{r.fullName} {r.private ? t('settings.devWorkflow.privateTag') : ''}
</option>
))}
</select>
</div>
{/* Fork info */}
{forkLoading && (
{/* Active config summary — shown at top regardless of repo loading */}
{cronLoading && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.devWorkflow.detectingForkInfo')}
{t('settings.devWorkflow.loadingRepositories')}
</div>
)}
{forkInfo && (
<div className="px-3 py-2 rounded-md bg-primary-50 dark:bg-primary-500/10 border border-primary-200 dark:border-primary-500/30">
<div className="text-xs font-medium text-primary-800 dark:text-primary-300">
{t('settings.devWorkflow.forkDetected')}
</div>
<div className="text-xs text-primary-700 dark:text-primary-200 mt-0.5">
{t('settings.devWorkflow.upstream')}{' '}
<span className="font-mono">{forkInfo.upstreamFullName}</span>
</div>
<div className="text-xs text-primary-600 dark:text-primary-300 mt-0.5">
{t('settings.devWorkflow.forkPrNote')}
</div>
</div>
)}
{selectedRepo && !forkLoading && !forkInfo && (
<div className="px-3 py-2 rounded-md bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700">
<div className="text-xs text-neutral-600 dark:text-neutral-400">
{t('settings.devWorkflow.notForkNote')}
</div>
</div>
)}
{/* Branch selector */}
{branches.length > 0 && (
<div>
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1.5">
{t('settings.devWorkflow.targetBranch')}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-1.5">
{t('settings.devWorkflow.targetBranchNote')}
{forkInfo ? ` on ${forkInfo.upstreamFullName}` : ''}.
</p>
<select
value={targetBranch}
onChange={e => {
setTargetBranch(e.target.value);
setSaveStatus('idle');
}}
disabled={branchesLoading}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50">
{branches.map(b => (
<option key={b.name} value={b.name}>
{b.name}
</option>
))}
</select>
</div>
)}
{branchesLoading && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.devWorkflow.loadingBranches')}
</div>
)}
{/* Schedule */}
{selectedRepo && (
<div>
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1.5">
{t('settings.devWorkflow.runFrequency')}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-1.5">
{t('settings.devWorkflow.runFrequencyNote')}
</p>
<select
value={schedule}
onChange={e => {
setSchedule(e.target.value);
setSaveStatus('idle');
}}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
{SCHEDULE_PRESETS.map(p => (
<option key={p.value} value={p.value}>
{t(p.labelKey)}
</option>
))}
</select>
</div>
)}
{/* Actions */}
{selectedRepo && (
<div className="flex items-center gap-3 pt-2">
<button
onClick={handleSave}
disabled={!canSave}
className="px-4 py-2 rounded-md bg-primary-600 hover:bg-primary-500 text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{savedConfig
? t('settings.devWorkflow.updateConfiguration')
: t('settings.devWorkflow.saveConfiguration')}
</button>
{savedConfig && (
<button
onClick={handleRemove}
className="px-4 py-2 rounded-md bg-coral-600 hover:bg-coral-500 text-white text-sm font-medium transition-colors">
{t('settings.devWorkflow.remove')}
</button>
{existingJob && (
<div className="px-4 py-3 rounded-lg border border-sage-200 dark:border-sage-500/30 bg-sage-50 dark:bg-sage-500/10">
{/* Running indicator */}
{running && (
<div className="mb-3 px-3 py-2 rounded-md bg-primary-50 dark:bg-primary-500/10 border border-primary-200 dark:border-primary-500/30 flex items-center gap-2">
<span className="inline-block h-2 w-2 rounded-full bg-primary-500 animate-pulse" />
<span className="text-xs font-medium text-primary-700 dark:text-primary-300">
{t('settings.devWorkflow.runningStatus')}
</span>
</div>
)}
{saveStatus === 'saved' && (
<span className="text-xs text-sage-600 dark:text-sage-400 font-medium">
{t('settings.devWorkflow.saved')}
</span>
)}
</div>
)}
{/* Active config summary */}
{savedConfig && (
<div className="mt-2 px-4 py-3 rounded-lg border border-sage-200 dark:border-sage-500/30 bg-sage-50 dark:bg-sage-500/10">
<div className="text-sm font-semibold text-sage-900 dark:text-sage-200">
{t('settings.devWorkflow.activeConfiguration')}
<div className="flex items-center justify-between">
<div className="text-sm font-semibold text-sage-900 dark:text-sage-200">
{t('settings.devWorkflow.activeConfiguration')}
</div>
<div className="flex items-center gap-2">
<button
type="button"
role="switch"
aria-checked={existingJob.enabled}
onClick={() => void handleToggle()}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors ${
existingJob.enabled ? 'bg-sage-500' : 'bg-neutral-300 dark:bg-neutral-600'
}`}>
<span
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform mt-0.5 ${
existingJob.enabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
<span className="text-xs text-sage-600 dark:text-sage-400">
{existingJob.enabled
? t('settings.devWorkflow.enabled')
: t('settings.devWorkflow.paused')}
</span>
</div>
</div>
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt className="text-sage-600 dark:text-sage-400">
{t('settings.devWorkflow.activeConfigRepository')}
</dt>
<dd className="font-mono text-sage-900 dark:text-sage-200">
{savedConfig.repoFullName}
</dd>
{savedConfig.forkInfo && (
<>
<dt className="text-sage-600 dark:text-sage-400">
{t('settings.devWorkflow.activeConfigUpstream')}
</dt>
<dd className="font-mono text-sage-900 dark:text-sage-200">
{savedConfig.forkInfo.upstreamFullName}
</dd>
</>
)}
<dt className="text-sage-600 dark:text-sage-400">
{t('settings.devWorkflow.activeConfigTargetBranch')}
</dt>
<dd className="font-mono text-sage-900 dark:text-sage-200">
{savedConfig.targetBranch}
{existingJob.name?.replace(/^dev-workflow-/, '') ?? '—'}
</dd>
<dt className="text-sage-600 dark:text-sage-400">
{t('settings.devWorkflow.activeConfigSchedule')}
</dt>
<dd className="text-sage-900 dark:text-sage-200">
{SCHEDULE_PRESETS.find(p => p.value === savedConfig.schedule) != null
? t(SCHEDULE_PRESETS.find(p => p.value === savedConfig.schedule)!.labelKey)
: savedConfig.schedule}
{SCHEDULE_PRESETS.find(p => p.value === existingJob.expression)
? t(SCHEDULE_PRESETS.find(p => p.value === existingJob.expression)!.labelKey)
: existingJob.expression}
</dd>
<dt className="text-sage-600 dark:text-sage-400">
{t('settings.devWorkflow.nextRun')}
</dt>
<dd className="text-sage-900 dark:text-sage-200">
{existingJob.next_run ? new Date(existingJob.next_run).toLocaleString() : '—'}
</dd>
{existingJob.last_run && (
<>
<dt className="text-sage-600 dark:text-sage-400">
{t('settings.devWorkflow.lastRun')}
</dt>
<dd className="text-sage-900 dark:text-sage-200">
{new Date(existingJob.last_run).toLocaleString()}
{existingJob.last_status && (
<span
className={`ml-2 px-1.5 py-0.5 rounded text-[10px] font-medium ${
existingJob.last_status === 'ok'
? 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300'
: 'bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300'
}`}>
{existingJob.last_status}
</span>
)}
</dd>
</>
)}
</dl>
<p className="mt-2 text-xs text-sage-500 dark:text-sage-400">
{t('settings.devWorkflow.phase2Note')}
</p>
<div className="mt-3 flex items-center gap-2">
<button
onClick={() => void handleRunNow()}
disabled={running}
className="px-3 py-1.5 rounded-md bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300 text-xs font-medium hover:bg-primary-200 dark:hover:bg-primary-500/30 transition-colors disabled:opacity-50">
{running ? t('settings.devWorkflow.running') : t('settings.devWorkflow.runNow')}
</button>
<button
onClick={() => void handleRemove()}
className="px-3 py-1.5 rounded-md bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300 text-xs font-medium hover:bg-coral-200 dark:hover:bg-coral-500/30 transition-colors">
{t('settings.devWorkflow.remove')}
</button>
</div>
{existingJob.last_output && (
<div className="mt-3">
<div className="text-xs font-medium text-sage-600 dark:text-sage-400 mb-1">
{t('settings.devWorkflow.lastOutput')}
</div>
<pre className="px-3 py-2 rounded-md bg-neutral-100 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-[11px] text-neutral-700 dark:text-neutral-300 font-mono whitespace-pre-wrap break-words max-h-48 overflow-y-auto">
{existingJob.last_output}
</pre>
</div>
)}
{runHistory.length > 0 && (
<div className="mt-3">
<button
onClick={() => setHistoryExpanded(!historyExpanded)}
className="text-xs text-sage-600 dark:text-sage-400 hover:text-sage-800 dark:hover:text-sage-200 transition-colors">
{historyExpanded ? '▾' : '▸'} {t('settings.devWorkflow.recentRuns')} (
{runHistory.length})
</button>
{historyExpanded && (
<div className="mt-1.5 space-y-1">
{runHistory.map(run => (
<div key={run.id} className="rounded bg-white dark:bg-neutral-800">
<button
onClick={() => setExpandedRunId(expandedRunId === run.id ? null : run.id)}
className="w-full flex items-center justify-between px-2 py-1.5 text-xs hover:bg-neutral-50 dark:hover:bg-neutral-750 rounded transition-colors">
<div className="flex items-center gap-2">
<span className="text-neutral-400">
{expandedRunId === run.id ? '▾' : '▸'}
</span>
<span className="text-neutral-600 dark:text-neutral-400">
{new Date(run.started_at).toLocaleString()}
</span>
</div>
<div className="flex items-center gap-2">
{run.duration_ms != null && (
<span className="text-neutral-500 dark:text-neutral-500">
{(run.duration_ms / 1000).toFixed(1)}s
</span>
)}
<span
className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
run.status === 'ok'
? 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300'
: 'bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300'
}`}>
{run.status}
</span>
</div>
</button>
{expandedRunId === run.id && run.output && (
<pre className="mx-2 mb-2 px-3 py-2 rounded-md bg-neutral-100 dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 text-[11px] text-neutral-700 dark:text-neutral-300 font-mono whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
{run.output}
</pre>
)}
{expandedRunId === run.id && !run.output && (
<div className="mx-2 mb-2 px-3 py-2 text-[11px] text-neutral-400 dark:text-neutral-500 italic">
{t('settings.devWorkflow.noOutput')}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
)}
{/* Setup form — only shown when no active config exists */}
{!existingJob && (
<>
<div>
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1.5">
{t('settings.devWorkflow.githubRepository')}
</label>
{reposError && (
<div className="mb-2 px-3 py-2 rounded-md bg-coral-50 dark:bg-coral-500/10 border border-coral-200 dark:border-coral-500/30 text-xs text-coral-700 dark:text-coral-300">
{reposError}
</div>
)}
<select
value={selectedRepo}
onChange={e => void onRepoSelect(e.target.value)}
disabled={reposLoading}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50">
<option value="">
{reposLoading
? t('settings.devWorkflow.loadingRepositories')
: t('settings.devWorkflow.selectRepository')}
</option>
{repos.map(r => (
<option key={r.fullName} value={r.fullName}>
{r.fullName} {r.private ? t('settings.devWorkflow.privateTag') : ''}
</option>
))}
</select>
</div>
{/* Fork info */}
{forkLoading && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.devWorkflow.detectingForkInfo')}
</div>
)}
{forkInfo && (
<div className="px-3 py-2 rounded-md bg-primary-50 dark:bg-primary-500/10 border border-primary-200 dark:border-primary-500/30">
<div className="text-xs font-medium text-primary-800 dark:text-primary-300">
{t('settings.devWorkflow.forkDetected')}
</div>
<div className="text-xs text-primary-700 dark:text-primary-200 mt-0.5">
{t('settings.devWorkflow.upstream')}{' '}
<span className="font-mono">{forkInfo.upstreamFullName}</span>
</div>
<div className="text-xs text-primary-600 dark:text-primary-300 mt-0.5">
{t('settings.devWorkflow.forkPrNote')}
</div>
</div>
)}
{selectedRepo && !forkLoading && !forkInfo && (
<div className="px-3 py-2 rounded-md bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700">
<div className="text-xs text-neutral-600 dark:text-neutral-400">
{t('settings.devWorkflow.notForkNote')}
</div>
</div>
)}
{/* Branch selector */}
{branches.length > 0 && (
<div>
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1.5">
{t('settings.devWorkflow.targetBranch')}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-1.5">
{t('settings.devWorkflow.targetBranchNote')}
{forkInfo ? ` on ${forkInfo.upstreamFullName}` : ''}.
</p>
<select
value={targetBranch}
onChange={e => {
setTargetBranch(e.target.value);
setSaveStatus('idle');
}}
disabled={branchesLoading}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50">
{branches.map(b => (
<option key={b.name} value={b.name}>
{b.name}
</option>
))}
</select>
</div>
)}
{branchesLoading && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.devWorkflow.loadingBranches')}
</div>
)}
{/* Schedule */}
{selectedRepo && (
<div>
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1.5">
{t('settings.devWorkflow.runFrequency')}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-1.5">
{t('settings.devWorkflow.runFrequencyNote')}
</p>
<select
value={schedule}
onChange={e => {
setSchedule(e.target.value);
setSaveStatus('idle');
}}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
{SCHEDULE_PRESETS.map(p => (
<option key={p.value} value={p.value}>
{t(p.labelKey)}
</option>
))}
</select>
</div>
)}
{/* Actions */}
{selectedRepo && (
<div className="flex items-center gap-3 pt-2">
<button
onClick={() => void handleSave()}
disabled={!canSave}
className="px-4 py-2 rounded-md bg-primary-600 hover:bg-primary-500 text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{t('settings.devWorkflow.saveConfiguration')}
</button>
{saveStatus === 'saved' && (
<span className="text-xs text-sage-600 dark:text-sage-400 font-medium">
{t('settings.devWorkflow.saved')}
</span>
)}
{saveStatus === 'error' && (
<span className="text-xs text-coral-600 dark:text-coral-400 font-medium">
{t('settings.devWorkflow.cronSaveError')}
</span>
)}
</div>
)}
</>
)}
</div>
</div>
);
@@ -74,6 +74,28 @@ const developerItems = [
</svg>
),
},
// Settings → Developer → Skills Runner is commented out: the same UX
// (and more) now lives at /skills (Connections → Runners sub-tab) as
// the scheduled-skills dashboard, with /skills/run for ad-hoc runs.
// The route + panel component remain wired (Settings.tsx:458 keeps the
// /settings/skills-runner route), so deep links and bookmarks still
// resolve — only the menu entry is hidden.
// {
// id: 'skills-runner',
// titleKey: 'settings.developerMenu.skillsRunner.title',
// descriptionKey: 'settings.developerMenu.skillsRunner.desc',
// route: 'skills-runner',
// icon: (
// <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
// <path
// strokeLinecap="round"
// strokeLinejoin="round"
// strokeWidth={2}
// d="M13 10V3L4 14h7v7l9-11h-7z"
// />
// </svg>
// ),
// },
{
id: 'dev-workflow',
titleKey: 'settings.developerMenu.devWorkflow.title',
@@ -0,0 +1,28 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import SkillsRunnerPanel from './SkillsRunnerPanel';
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
vi.mock('../../skills/SkillsRunnerBody', () => ({
default: () => <div data-testid="skills-runner-body" />,
}));
vi.mock('../components/SettingsHeader', () => ({
default: ({ title }: { title: string }) => <div data-testid="settings-header">{title}</div>,
}));
vi.mock('../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
}));
describe('SkillsRunnerPanel', () => {
it('renders the settings header and runner body', () => {
render(
<MemoryRouter>
<SkillsRunnerPanel />
</MemoryRouter>
);
expect(screen.getByTestId('settings-header')).toBeInTheDocument();
expect(screen.getByTestId('skills-runner-body')).toBeInTheDocument();
});
});
@@ -0,0 +1,31 @@
// Settings → Developer Options → Skills Runner — thin wrapper around the
// reusable `<SkillsRunnerBody />` so the settings shell (header + back
// button + breadcrumbs) stays consistent with other panels. The actual
// picker / Run / Schedule / Recent Runs UX lives in
// `app/src/components/skills/SkillsRunnerBody.tsx`, shared with the
// top-level /skills page's "Runners" tab.
import { useT } from '../../../lib/i18n/I18nContext';
import SkillsRunnerBody from '../../skills/SkillsRunnerBody';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const SkillsRunnerPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
return (
<div className="flex flex-col h-full">
<SettingsHeader
title={t('settings.developerMenu.skillsRunner.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="flex-1 overflow-y-auto p-6">
<SkillsRunnerBody />
</div>
</div>
);
};
export default SkillsRunnerPanel;
@@ -4,15 +4,33 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
// [dev-workflow] Unit tests for DevWorkflowPanel.tsx — covers repo loading,
// not-connected error, fork detection, branch population, and save/clear wiring.
// not-connected error, fork detection, branch population, and cron job wiring.
const hoisted = vi.hoisted(() => ({ composioExecute: vi.fn(), listConnections: vi.fn() }));
const hoisted = vi.hoisted(() => ({
composioExecute: vi.fn(),
listConnections: vi.fn(),
cronAdd: vi.fn(),
cronList: vi.fn(),
cronRemove: vi.fn(),
cronUpdate: vi.fn(),
cronRun: vi.fn(),
cronRuns: vi.fn(),
}));
vi.mock('../../../../lib/composio/composioApi', () => ({
execute: hoisted.composioExecute,
listConnections: hoisted.listConnections,
}));
vi.mock('../../../../utils/tauriCommands/cron', () => ({
openhumanCronAdd: hoisted.cronAdd,
openhumanCronList: hoisted.cronList,
openhumanCronRemove: hoisted.cronRemove,
openhumanCronUpdate: hoisted.cronUpdate,
openhumanCronRun: hoisted.cronRun,
openhumanCronRuns: hoisted.cronRuns,
}));
// Stable t function — creating a new function object on every render
// would cause useCallback([t]) to re-create on every render, triggering
// the loadRepos useEffect in an infinite loop.
@@ -32,7 +50,7 @@ vi.mock('../../components/SettingsHeader', () => ({
}));
// Import once — DevWorkflowPanel state is managed via API mocks and
// localStorage, not module-level vars, so a single import is sufficient.
// cron RPC, not module-level vars, so a single import is sufficient.
async function importPanel() {
const mod = await import('../DevWorkflowPanel');
return mod.default;
@@ -82,9 +100,15 @@ const branchesResponse = {
describe('DevWorkflowPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
hoisted.listConnections.mockResolvedValue(githubConnection);
hoisted.composioExecute.mockResolvedValue(reposResponse);
hoisted.cronList.mockResolvedValue({ result: [], logs: [] });
hoisted.cronAdd.mockResolvedValue({
result: { id: 'cron-1', name: 'dev-workflow-user-repo1' },
logs: [],
});
hoisted.cronRemove.mockResolvedValue({ result: { job_id: 'cron-1', removed: true }, logs: [] });
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] }, logs: [] });
});
test('renders header immediately and populates repo dropdown on successful fetch', async () => {
@@ -183,7 +207,7 @@ describe('DevWorkflowPanel', () => {
});
});
test('save button stores config in localStorage', async () => {
test('save button creates a cron job via openhumanCronAdd', async () => {
// Call sequence: LIST_REPOS → GET_A_REPO (non-fork) → LIST_BRANCHES
hoisted.composioExecute
.mockResolvedValueOnce(reposResponse)
@@ -209,50 +233,57 @@ describe('DevWorkflowPanel', () => {
// Click save
const saveBtn = screen.getByRole('button', {
name: /settings\.devWorkflow\.(save|update)Configuration/,
name: /settings\.devWorkflow\.saveConfiguration/,
});
fireEvent.click(saveBtn);
// Verify localStorage was written
const raw = localStorage.getItem('openhuman:dev-workflow-config');
expect(raw).not.toBeNull();
const stored = JSON.parse(raw!);
expect(stored.repoFullName).toBe('user/repo1');
expect(stored.repoOwner).toBe('user');
expect(stored.repoName).toBe('repo1');
expect(stored.targetBranch).toBe('main');
expect(typeof stored.schedule).toBe('string');
// Saved status indicator
expect(screen.getByText('settings.devWorkflow.saved')).toBeInTheDocument();
// Verify cron_add was called
await waitFor(() => {
expect(hoisted.cronAdd).toHaveBeenCalledTimes(1);
});
const addCall = hoisted.cronAdd.mock.calls[0][0];
expect(addCall.name).toBe('dev-workflow-user-repo1');
expect(addCall.schedule).toEqual({ kind: 'cron', expr: '*/30 * * * *' });
expect(addCall.job_type).toBe('agent');
expect(addCall.prompt).toContain('dev-workflow');
expect(addCall.prompt).toContain('user/repo1');
});
test('remove button clears localStorage config', async () => {
// Pre-populate localStorage so savedConfig is non-null on mount
const existingConfig = {
repoFullName: 'user/repo1',
repoOwner: 'user',
repoName: 'repo1',
forkInfo: null,
targetBranch: 'main',
schedule: '*/30 * * * *',
test('remove button deletes cron job via openhumanCronRemove', async () => {
// Pre-populate cron list so existingJob is set on mount
const existingCronJob = {
id: 'cron-1',
name: 'dev-workflow-user-repo1',
expression: '*/30 * * * *',
schedule: { kind: 'cron', expr: '*/30 * * * *' },
command: '',
prompt: 'Run the dev-workflow skill.',
job_type: 'agent',
session_target: 'isolated',
enabled: true,
delivery: { mode: 'proactive', best_effort: true },
delete_after_run: false,
created_at: '2026-01-01T00:00:00Z',
next_run: '2026-01-01T01:00:00Z',
};
localStorage.setItem('openhuman:dev-workflow-config', JSON.stringify(existingConfig));
hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] });
const Panel = await importPanel();
renderWithProviders(<Panel />);
// Active config summary is shown immediately (initialised from localStorage)
expect(screen.getByText('settings.devWorkflow.activeConfiguration')).toBeInTheDocument();
// Active config card shows at top regardless of repo loading
await waitFor(() => {
expect(screen.getByText('settings.devWorkflow.activeConfiguration')).toBeInTheDocument();
});
// Remove button is visible because savedConfig is set
// Remove button is in the active config card
const removeBtn = screen.getByRole('button', { name: 'settings.devWorkflow.remove' });
fireEvent.click(removeBtn);
// localStorage should be cleared
expect(localStorage.getItem('openhuman:dev-workflow-config')).toBeNull();
// Active config summary gone
expect(screen.queryByText('settings.devWorkflow.activeConfiguration')).toBeNull();
// Verify cron_remove was called
await waitFor(() => {
expect(hoisted.cronRemove).toHaveBeenCalledWith('cron-1');
});
});
test('shows branches fetched from upstream when fork is detected', async () => {
@@ -297,4 +328,657 @@ describe('DevWorkflowPanel', () => {
expect(screen.getByText('network error')).toBeInTheDocument();
});
});
test('toggle button calls openhumanCronUpdate with enabled flag', async () => {
const existingCronJob = {
id: 'cron-1',
name: 'dev-workflow-user-repo1',
expression: '*/30 * * * *',
schedule: { kind: 'cron', expr: '*/30 * * * *' },
command: '',
prompt: 'Run the dev-workflow skill.',
job_type: 'agent',
session_target: 'isolated',
enabled: true,
delivery: { mode: 'proactive', best_effort: true },
delete_after_run: false,
created_at: '2026-01-01T00:00:00Z',
next_run: '2026-01-01T01:00:00Z',
};
hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] });
hoisted.cronUpdate.mockResolvedValue({ data: { ...existingCronJob, enabled: false } });
const Panel = await importPanel();
renderWithProviders(<Panel />);
// 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(<Panel />);
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(<Panel />);
// 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(<Panel />);
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(<Panel />);
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 />);
// 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(<Panel />);
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(<Panel />);
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(<Panel />);
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(<Panel />);
// 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(<Panel />);
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(<Panel />);
// 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(<Panel />);
// 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(<Panel />);
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(<Panel />);
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(<Panel />);
await waitFor(() => {
expect(screen.getByRole('option', { name: /user\/repo1/ })).toBeInTheDocument();
});
const repoSelect = screen.getAllByRole('combobox')[0];
fireEvent.change(repoSelect, { target: { value: 'user/repo1' } });
await waitFor(() => {
expect(screen.getByRole('option', { name: 'main' })).toBeInTheDocument();
});
const saveBtn = screen.getByRole('button', {
name: /settings\.devWorkflow\.saveConfiguration/,
});
fireEvent.click(saveBtn);
await waitFor(() => {
expect(hoisted.cronAdd).toHaveBeenCalledTimes(1);
});
const addCall = hoisted.cronAdd.mock.calls[0][0];
// Fork detected — prompt should reference upstream repo
expect(addCall.prompt).toContain('upstream/repo');
expect(addCall.prompt).toContain('Self-assign');
expect(addCall.prompt).toContain('unassigned');
});
test('remove on existing job calls cronRemove (not cronUpdate/cronAdd)', async () => {
const existingCronJob = {
id: 'cron-1',
name: 'dev-workflow-user-repo1',
expression: '*/30 * * * *',
schedule: { kind: 'cron', expr: '*/30 * * * *' },
command: '',
prompt: 'Run the dev-workflow skill.',
job_type: 'agent',
session_target: 'isolated',
enabled: true,
delivery: { mode: 'proactive', best_effort: true },
delete_after_run: false,
created_at: '2026-01-01T00:00:00Z',
next_run: '2026-01-01T01:00:00Z',
};
// First call returns existing job, second call (after remove+re-render) returns empty
hoisted.cronList
.mockResolvedValueOnce({ result: [existingCronJob], logs: [] })
.mockResolvedValue({ result: [], logs: [] });
const Panel = await importPanel();
renderWithProviders(<Panel />);
// Wait for active config to show
await waitFor(() => {
expect(screen.getByText('settings.devWorkflow.activeConfiguration')).toBeInTheDocument();
});
// Remove the existing job so setup form appears
const removeBtn = screen.getByRole('button', { name: 'settings.devWorkflow.remove' });
fireEvent.click(removeBtn);
await waitFor(() => {
expect(hoisted.cronRemove).toHaveBeenCalledWith('cron-1');
});
// Remove must not persist via the add/update RPCs.
expect(hoisted.cronAdd).not.toHaveBeenCalled();
expect(hoisted.cronUpdate).not.toHaveBeenCalled();
});
test('toggling an existing job persists via cronUpdate and never cronAdd', async () => {
const existingCronJob = {
id: 'cron-42',
name: 'dev-workflow-user-repo1',
expression: '*/30 * * * *',
schedule: { kind: 'cron', expr: '*/30 * * * *' },
command: '',
prompt: 'Run the dev-workflow skill.',
job_type: 'agent',
session_target: 'isolated',
enabled: true,
delivery: { mode: 'proactive', best_effort: true },
delete_after_run: false,
created_at: '2026-01-01T00:00:00Z',
next_run: '2026-01-01T01:00:00Z',
};
hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] });
hoisted.cronUpdate.mockResolvedValue({ data: { ...existingCronJob, enabled: false } });
const Panel = await importPanel();
renderWithProviders(<Panel />);
// Wait for the active config card (existing-job UI) to render.
await waitFor(() => {
expect(screen.getByText('settings.devWorkflow.activeConfiguration')).toBeInTheDocument();
});
// Trigger the persist-on-existing-job action (the enable/disable switch).
const toggleBtn = screen.getByText('settings.devWorkflow.enabled').previousElementSibling;
if (toggleBtn) fireEvent.click(toggleBtn);
// cronUpdate is called with the existing job id and the toggled payload.
await waitFor(() => {
expect(hoisted.cronUpdate).toHaveBeenCalledWith('cron-42', { enabled: false });
});
// The update path must never fall through to cronAdd.
expect(hoisted.cronAdd).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,422 @@
/**
* CreateSkillForm
* ----------------
*
* Body of the "create a new SKILL.md" flow, shared between
* `CreateSkillModal` (modal chrome) and the `/skills/new` page wrapper.
*
* Owns:
* - All form fields (name, description, scope, license, author,
* tags, allowed-tools).
* - Slug preview + validation (name and description required).
* - Submit handler that calls `skillsApi.createSkill` and surfaces
* the result via `onCreated(skill)` / error string via inline
* `<div role="alert">`.
*
* Does NOT own:
* - The submit/cancel buttons (the wrapper provides them so the
* modal can use a footer bar and the page can render a top-right
* primary action).
* - Modal-specific concerns (focus capture, Escape-to-close,
* backdrop click). Those stay in `CreateSkillModal`.
*
* The wrapper drives submission by either calling the imperative
* handle exposed via a ref (`<CreateSkillForm ref={ref} ... />` →
* `ref.current.submit()`) OR by reading `formValid` + `submitting`
* from the props the form raises and wiring its own submit button to
* the underlying `<form>` via the standard `form="..."` attribute.
* Both modal and page use the latter, so the form mounts a real
* `<form id={formId}>` and they bind `<button form={formId}>`.
*/
import debug from 'debug';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import {
type CreateSkillInput,
type CreateSkillInputDef,
type SkillScope,
type SkillSummary,
skillsApi,
} from '../../services/api/skillsApi';
/** Mirrors `SkillCreateInputDef` shape used as wire payload, with one
* extra `localId` for stable React keys across re-renders (the wire
* payload strips this field at submit time). */
interface InputRow {
localId: string;
name: string;
description: string;
required: boolean;
type: 'string' | 'integer' | 'boolean';
}
const NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
let nextLocalId = 0;
function newRow(): InputRow {
nextLocalId += 1;
return {
localId: `row-${nextLocalId}`,
name: '',
description: '',
required: true,
type: 'string',
};
}
const log = debug('skills:create-form');
export interface CreateSkillFormHandle {
/** True iff name+description are present and no submit is in flight. */
isValid: () => boolean;
/** True while skillsApi.createSkill is in flight. */
isSubmitting: () => boolean;
/** Imperatively trigger submit. Resolves once the round-trip finishes. */
submit: () => Promise<void>;
}
export interface CreateSkillFormProps {
/**
* The id assigned to the underlying `<form>` element. Wrappers that
* render their submit button outside the form (modal footer / page
* header) set `<button form={formId}>` to fire submit via this id.
*/
formId: string;
/** Called with the freshly-created skill on success. */
onCreated: (skill: SkillSummary) => void;
/**
* Called whenever validity / submission state changes so the
* wrapper can sync its submit button's disabled state without
* needing to introspect via a ref every render.
*/
onStateChange?: (state: { valid: boolean; submitting: boolean }) => void;
/** If true, autofocus the first field on mount (modal default). */
autoFocus?: boolean;
}
/**
* Client-side slug preview — mirrors the Rust `slugify_skill_name`
* heuristic (lowercase, ASCII alphanumerics + `-`, collapse repeats,
* trim hyphens at the edges). The preview is advisory only; the Rust
* side is authoritative when the skill is persisted.
*/
export function previewSlug(name: string): string {
const lower = name.normalize('NFKD').toLowerCase();
let out = '';
let prevHyphen = false;
for (const ch of lower) {
if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) {
out += ch;
prevHyphen = false;
continue;
}
if ((ch === '-' || ch === '_' || /\s/.test(ch)) && !prevHyphen) {
out += '-';
prevHyphen = true;
}
}
return out.replace(/^-+|-+$/g, '');
}
const CreateSkillForm = forwardRef<CreateSkillFormHandle, CreateSkillFormProps>(
({ formId, onCreated, onStateChange, autoFocus = false }, ref) => {
const { t } = useT();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
// Scope is fixed to 'user' — the form previously exposed a radio
// toggle for user/project plus license/author/tags/allowed-tools
// fields. None of those were useful in practice and they cluttered
// the create flow; user-scoped is the only sensible default for
// dashboard-created skills. Project-scoped skills are still
// creatable by editing the workspace skill files directly. The
// backend payload still requires `scope` so we hold it as a const.
const scope: SkillScope = 'user';
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [inputs, setInputs] = useState<InputRow[]>([]);
const firstFieldRef = useRef<HTMLInputElement | null>(null);
const slug = useMemo(() => previewSlug(name), [name]);
const nameValid = slug.length > 0;
const descriptionValid = description.trim().length > 0;
// Each row must have a non-empty, regex-valid name. Empty rows block
// submission so the user explicitly removes them rather than getting
// a malformed [[inputs]] entry silently dropped on the Rust side.
const inputsValid = inputs.every((r) => NAME_RE.test(r.name.trim()));
const formValid = nameValid && descriptionValid && inputsValid && !submitting;
const addRow = useCallback(() => {
setInputs((cur) => [...cur, newRow()]);
}, []);
const removeRow = useCallback((localId: string) => {
setInputs((cur) => cur.filter((r) => r.localId !== localId));
}, []);
const updateRow = useCallback((localId: string, patch: Partial<InputRow>) => {
setInputs((cur) => cur.map((r) => (r.localId === localId ? { ...r, ...patch } : r)));
}, []);
// Surface state to the wrapper for its submit button's disabled prop.
useEffect(() => {
onStateChange?.({ valid: formValid, submitting });
}, [formValid, submitting, onStateChange]);
useEffect(() => {
if (!autoFocus) return;
const raf = window.requestAnimationFrame(() => {
firstFieldRef.current?.focus();
});
return () => {
window.cancelAnimationFrame(raf);
};
}, [autoFocus]);
const submit = useCallback(async () => {
if (!formValid) return;
const payload: CreateSkillInput = {
name: name.trim(),
description: description.trim(),
scope,
};
if (inputs.length > 0) {
payload.inputs = inputs.map<CreateSkillInputDef>((r) => {
const def: CreateSkillInputDef = {
name: r.name.trim(),
required: r.required,
};
const desc = r.description.trim();
if (desc) def.description = desc;
// Default 'string' on the Rust side, omit to keep payload tidy.
if (r.type !== 'string') def.type = r.type;
return def;
});
}
log('submit name=%s scope=%s inputs=%d', payload.name, payload.scope, inputs.length);
setSubmitting(true);
setError(null);
try {
const created = await skillsApi.createSkill(payload);
log('submit-ok id=%s', created.id);
onCreated(created);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log('submit-err %s', message);
setError(message);
} finally {
setSubmitting(false);
}
}, [description, formValid, inputs, name, onCreated]);
useImperativeHandle(
ref,
() => ({
isValid: () => formValid,
isSubmitting: () => submitting,
submit,
}),
[formValid, submitting, submit]
);
const handleFormSubmit = (e: React.FormEvent) => {
e.preventDefault();
void submit();
};
return (
<form id={formId} onSubmit={handleFormSubmit} className="space-y-4">
{/* Name */}
<div>
<label
htmlFor="create-skill-name"
className="block text-xs font-medium text-stone-600 dark:text-neutral-300"
>
{t('skills.create.name')}
<span className="text-coral-500"> *</span>
</label>
<input
id="create-skill-name"
ref={firstFieldRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
maxLength={128}
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
placeholder={t('skills.create.namePlaceholder')}
/>
<p className="mt-1 text-[11px] text-stone-500 dark:text-neutral-400">
{t('skills.create.slugLabel')}{' '}
<code className="rounded bg-stone-100 dark:bg-neutral-800 px-1 py-[1px] font-mono text-stone-700 dark:text-neutral-200">
{slug || '—'}
</code>
</p>
</div>
{/* Description */}
<div>
<label
htmlFor="create-skill-description"
className="block text-xs font-medium text-stone-600 dark:text-neutral-300"
>
{t('skills.create.description')}
<span className="text-coral-500"> *</span>
</label>
<textarea
id="create-skill-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
required
rows={3}
maxLength={500}
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
placeholder={t('skills.create.descriptionPlaceholder')}
/>
</div>
{/* Inputs (optional) — declare [[inputs]] for the generated
skill.toml. The Skills Runner reads this to render dynamic
form controls per input (text / number / checkbox). The
section stays optional — formValid doesn't depend on
non-empty rows — but every row that exists must have a
valid, non-empty name (regex enforced) so the Rust side
never receives a malformed [[inputs]] entry. */}
<div>
<div className="flex items-baseline justify-between">
<label className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('skills.create.inputs.heading')}
<span className="ml-1 font-normal text-stone-400 dark:text-neutral-500">
{t('skills.create.optional')}
</span>
</label>
<button
type="button"
data-testid="create-skill-add-input"
onClick={addRow}
className="text-xs font-medium text-primary-600 hover:text-primary-700"
>
+ {t('skills.create.inputs.add')}
</button>
</div>
<p className="mt-0.5 text-[11px] text-stone-500 dark:text-neutral-400">
{t('skills.create.inputs.help')}
</p>
{inputs.length > 0 && (
<div className="mt-2 space-y-2">
{inputs.map((row) => {
const trimmed = row.name.trim();
const showNameErr = row.name.length > 0 && !NAME_RE.test(trimmed);
return (
<div
key={row.localId}
data-testid={`create-skill-input-row-${row.localId}`}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-950/40 p-3"
>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_1fr_auto]">
<div>
<input
type="text"
value={row.name}
onChange={(e) => updateRow(row.localId, { name: e.target.value })}
maxLength={64}
placeholder={t('skills.create.inputs.row.namePlaceholder')}
aria-label={t('skills.create.inputs.row.name')}
className={`w-full rounded-md border bg-white dark:bg-neutral-900 px-2 py-1.5 text-xs text-stone-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500/30 ${showNameErr ? 'border-coral-400' : 'border-stone-200 dark:border-neutral-800 focus:border-primary-500'}`}
/>
{showNameErr && (
<p className="mt-0.5 text-[10px] text-coral-600">
{t('skills.create.inputs.row.nameError')}
</p>
)}
</div>
<input
type="text"
value={row.description}
onChange={(e) =>
updateRow(row.localId, { description: e.target.value })
}
maxLength={256}
placeholder={t('skills.create.inputs.row.descriptionPlaceholder')}
aria-label={t('skills.create.inputs.row.description')}
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1.5 text-xs text-stone-900 dark:text-neutral-100 shadow-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
/>
<button
type="button"
data-testid={`create-skill-remove-input-${row.localId}`}
onClick={() => removeRow(row.localId)}
aria-label={t('skills.create.inputs.row.remove')}
className="self-center rounded-md px-2 py-1.5 text-xs text-stone-500 hover:bg-coral-100 hover:text-coral-700"
>
🗑
</button>
</div>
<div className="mt-2 flex items-center gap-3 text-[11px]">
<label className="flex items-center gap-1">
<span className="text-stone-500 dark:text-neutral-400">
{t('skills.create.inputs.row.type')}:
</span>
<select
value={row.type}
onChange={(e) =>
updateRow(row.localId, {
type: e.target.value as InputRow['type'],
})
}
aria-label={t('skills.create.inputs.row.type')}
className="rounded border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-1 py-0.5 text-[11px] text-stone-900 dark:text-neutral-100"
>
<option value="string">{t('skills.create.inputs.type.string')}</option>
<option value="integer">
{t('skills.create.inputs.type.integer')}
</option>
<option value="boolean">
{t('skills.create.inputs.type.boolean')}
</option>
</select>
</label>
<label className="flex items-center gap-1">
<input
type="checkbox"
checked={row.required}
onChange={(e) =>
updateRow(row.localId, { required: e.target.checked })
}
className="h-3 w-3 accent-primary-500"
/>
<span className="text-stone-500 dark:text-neutral-400">
{t('skills.create.inputs.row.required')}
</span>
</label>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Error */}
{error ? (
<div
role="alert"
className="rounded-xl border border-coral-200 bg-coral-50 p-3 text-xs text-coral-900"
>
<p className="font-semibold">{t('skills.create.createError')}</p>
<p className="mt-1 whitespace-pre-wrap font-mono">{error}</p>
</div>
) : null}
</form>
);
}
);
export default CreateSkillForm;
+84 -336
View File
@@ -8,110 +8,40 @@
* Escape/click-out to close, focus capture) — see
* `.claude/rules/15-settings-modal-system.md`.
*
* Form fields mirror `SkillsCreateParams` on the Rust side:
* - name (required) — display name; also slugified into the
* on-disk skill directory. A live preview surfaces the
* slug so users can see what will hit the filesystem.
* - description (required) — short prose; persisted as the
* `description:` field in the generated YAML frontmatter.
* - scope (user | project) — where SKILL.md is written. The UI
* hides the `legacy` scope since that layout is read-only
* and being phased out.
* - license (optional) — free-form SPDX string (e.g. `MIT`,
* `Apache-2.0`). Forwarded verbatim.
* - tags (optional, CSV) — normalized client-side into an array;
* empty entries are dropped.
* - allowedTools (optional, CSV) — rekeyed to `allowed-tools` on the
* wire by `skillsApi.createSkill`.
*
* On success `onCreated(skill)` fires with the freshly-discovered
* `SkillSummary` so the parent grid can insert the new row without a
* full refetch. On failure the Rust error string is surfaced verbatim
* at the bottom of the form and the submit button re-enables.
* The form fields + submit pipeline live in `CreateSkillForm` so the
* `/skills/new` page can share the exact same body. This file is the
* modal chrome: header, close-button, backdrop, Escape handler,
* focus-return, submit/cancel footer. The footer's submit button is
* wired to the form via the standard HTML `form=` attribute so we
* don't need an imperative handle here.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import debug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useT } from '../../lib/i18n/I18nContext';
import {
skillsApi,
type CreateSkillInput,
type SkillScope,
type SkillSummary,
} from '../../services/api/skillsApi';
import { type SkillSummary } from '../../services/api/skillsApi';
import CreateSkillForm from './CreateSkillForm';
const log = debug('skills:create-modal');
const CREATE_FORM_ID = 'create-skill-modal-form';
interface Props {
onClose: () => void;
onCreated: (skill: SkillSummary) => void;
}
const INITIAL_SCOPE: SkillScope = 'user';
/**
* Client-side slug preview — mirrors the Rust `slugify_skill_name`
* heuristic (lowercase, ASCII alphanumerics + `-`, collapse repeats,
* trim hyphens at the edges). The preview is advisory only; the Rust
* side is authoritative when the skill is persisted.
*/
function previewSlug(name: string): string {
const lower = name.normalize('NFKD').toLowerCase();
let out = '';
let prevHyphen = false;
for (const ch of lower) {
// ASCII alnum pass-through
if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) {
out += ch;
prevHyphen = false;
continue;
}
if ((ch === '-' || ch === '_' || /\s/.test(ch)) && !prevHyphen) {
out += '-';
prevHyphen = true;
}
}
// Trim leading/trailing hyphens
return out.replace(/^-+|-+$/g, '');
}
function splitCsv(raw: string): string[] {
return raw
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0);
}
export default function CreateSkillModal({ onClose, onCreated }: Props) {
const { t } = useT();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [scope, setScope] = useState<SkillScope>(INITIAL_SCOPE);
const [license, setLicense] = useState('');
const [author, setAuthor] = useState('');
const [tagsCsv, setTagsCsv] = useState('');
const [allowedToolsCsv, setAllowedToolsCsv] = useState('');
const [formValid, setFormValid] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const firstFieldRef = useRef<HTMLInputElement | null>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const slug = useMemo(() => previewSlug(name), [name]);
const nameValid = slug.length > 0;
const descriptionValid = description.trim().length > 0;
const formValid = nameValid && descriptionValid && !submitting;
useEffect(() => {
previousFocusRef.current = document.activeElement as HTMLElement | null;
const raf = window.requestAnimationFrame(() => {
firstFieldRef.current?.focus();
});
log('mount');
return () => {
window.cancelAnimationFrame(raf);
previousFocusRef.current?.focus?.();
log('unmount');
};
@@ -128,50 +58,24 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
return () => document.removeEventListener('keydown', handler);
}, [onClose, submitting]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!formValid) {
return;
}
const payload: CreateSkillInput = {
name: name.trim(),
description: description.trim(),
scope,
};
if (license.trim()) payload.license = license.trim();
if (author.trim()) payload.author = author.trim();
const tags = splitCsv(tagsCsv);
if (tags.length > 0) payload.tags = tags;
const allowedTools = splitCsv(allowedToolsCsv);
if (allowedTools.length > 0) payload.allowedTools = allowedTools;
log('submit name=%s scope=%s', payload.name, payload.scope);
setSubmitting(true);
setError(null);
try {
const created = await skillsApi.createSkill(payload);
log('submit-ok id=%s', created.id);
onCreated(created);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log('submit-err %s', message);
setError(message);
setSubmitting(false);
}
const handleStateChange = useCallback(
(state: { valid: boolean; submitting: boolean }) => {
setFormValid(state.valid);
setSubmitting(state.submitting);
},
[allowedToolsCsv, author, description, formValid, license, name, onCreated, scope, tagsCsv]
[]
);
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={e => {
onClick={(e) => {
if (e.target === e.currentTarget && !submitting) {
log('backdrop-click close');
onClose();
}
}}>
}}
>
<div
aria-hidden="true"
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-fade-in"
@@ -187,229 +91,73 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
role="dialog"
aria-modal="true"
aria-labelledby="create-skill-title"
className="relative w-full max-w-[520px] rounded-2xl bg-white dark:bg-neutral-900 shadow-2xl animate-fade-in">
<form onSubmit={handleSubmit}>
{/* Header */}
<div className="flex items-start justify-between gap-3 border-b border-stone-100 dark:border-neutral-800 px-5 py-4">
<div className="min-w-0 flex-1">
<h2
id="create-skill-title"
className="text-base font-semibold text-stone-900 dark:text-neutral-100 font-sans">
{t('skills.create.title')}
</h2>
<p className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
{t('skills.create.subtitle')}
</p>
</div>
<button
type="button"
onClick={() => {
if (!submitting) {
log('close-button');
onClose();
}
}}
disabled={submitting}
aria-label={t('common.close')}
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-stone-400 dark:text-neutral-500 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 hover:text-stone-600 dark:hover:text-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:opacity-40">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
className="relative w-full max-w-[520px] rounded-2xl bg-white dark:bg-neutral-900 shadow-2xl animate-fade-in"
>
{/* Header */}
<div className="flex items-start justify-between gap-3 border-b border-stone-100 dark:border-neutral-800 px-5 py-4">
<div className="min-w-0 flex-1">
<h2
id="create-skill-title"
className="text-base font-semibold text-stone-900 dark:text-neutral-100 font-sans"
>
{t('skills.create.title')}
</h2>
<p className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
{t('skills.create.subtitle')}
</p>
</div>
{/* Body */}
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-5 py-4">
{/* Name */}
<div>
<label
htmlFor="create-skill-name"
className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('skills.create.name')}<span className="text-coral-500"> *</span>
</label>
<input
id="create-skill-name"
ref={firstFieldRef}
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
maxLength={128}
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
placeholder={t('skills.create.namePlaceholder')}
<button
type="button"
onClick={() => {
if (!submitting) {
log('close-button');
onClose();
}
}}
disabled={submitting}
aria-label={t('common.close')}
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-stone-400 dark:text-neutral-500 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 hover:text-stone-600 dark:hover:text-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:opacity-40"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
<p className="mt-1 text-[11px] text-stone-500 dark:text-neutral-400">
{t('skills.create.slugLabel')}{' '}
<code className="rounded bg-stone-100 dark:bg-neutral-800 px-1 py-[1px] font-mono text-stone-700 dark:text-neutral-200">
{slug || '—'}
</code>
</p>
</div>
</svg>
</button>
</div>
{/* Description */}
<div>
<label
htmlFor="create-skill-description"
className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('skills.create.description')}<span className="text-coral-500"> *</span>
</label>
<textarea
id="create-skill-description"
value={description}
onChange={e => setDescription(e.target.value)}
required
rows={3}
maxLength={500}
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
placeholder={t('skills.create.descriptionPlaceholder')}
/>
</div>
{/* Body — shared form component */}
<div className="max-h-[70vh] overflow-y-auto px-5 py-4">
<CreateSkillForm
formId={CREATE_FORM_ID}
onCreated={onCreated}
onStateChange={handleStateChange}
autoFocus
/>
</div>
{/* Scope */}
<fieldset>
<legend className="block text-xs font-medium text-stone-600 dark:text-neutral-300">{t('skills.create.scope')}</legend>
<div className="mt-1 flex gap-2">
{(['user', 'project'] as const).map(s => {
const selected = scope === s;
return (
<label
key={s}
className={`flex flex-1 cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
selected
? 'border-primary-500 bg-primary-50 text-primary-900'
: 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-700 dark:text-neutral-200 hover:border-stone-300 dark:border-neutral-700'
}`}>
<input
type="radio"
name="create-skill-scope"
value={s}
checked={selected}
onChange={() => setScope(s)}
className="h-3 w-3 accent-primary-500"
/>
<span>{t(`scope.${s}`)}</span>
</label>
);
})}
</div>
<p className="mt-1 text-[11px] text-stone-500 dark:text-neutral-400">
{scope === 'user'
? t('skills.create.scopeUserHint')
: t('skills.create.scopeProjectHint')}
</p>
</fieldset>
{/* License / Author */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label
htmlFor="create-skill-license"
className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('skills.create.license')}
</label>
<input
id="create-skill-license"
type="text"
value={license}
onChange={e => setLicense(e.target.value)}
maxLength={64}
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
placeholder={t('skills.create.licensePlaceholder')}
/>
</div>
<div>
<label
htmlFor="create-skill-author"
className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('skills.create.author')}
</label>
<input
id="create-skill-author"
type="text"
value={author}
onChange={e => setAuthor(e.target.value)}
maxLength={128}
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
placeholder={t('skills.create.authorPlaceholder')}
/>
</div>
</div>
{/* Tags */}
<div>
<label
htmlFor="create-skill-tags"
className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('skills.create.tags')}
<span className="ml-1 font-normal text-stone-400 dark:text-neutral-500">{t('skills.create.commaSeparated')}</span>
</label>
<input
id="create-skill-tags"
type="text"
value={tagsCsv}
onChange={e => setTagsCsv(e.target.value)}
maxLength={256}
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
placeholder={t('skills.create.tagsPlaceholder')}
/>
</div>
{/* Allowed tools */}
<div>
<label
htmlFor="create-skill-tools"
className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('skills.create.allowedTools')}
<span className="ml-1 font-normal text-stone-400 dark:text-neutral-500">{t('skills.create.commaSeparated')}</span>
</label>
<input
id="create-skill-tools"
type="text"
value={allowedToolsCsv}
onChange={e => setAllowedToolsCsv(e.target.value)}
maxLength={512}
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm font-mono text-stone-900 dark:text-neutral-100 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
placeholder={t('skills.create.allowedToolsPlaceholder')}
/>
<p className="mt-1 text-[11px] text-stone-500 dark:text-neutral-400">
{t('skills.create.allowedToolsHelp')}{' '}
<code className="font-mono">allowed-tools:</code>.
</p>
</div>
{/* Error */}
{error ? (
<div
role="alert"
className="rounded-xl border border-coral-200 bg-coral-50 p-3 text-xs text-coral-900">
<p className="font-semibold">{t('skills.create.createError')}</p>
<p className="mt-1 whitespace-pre-wrap font-mono">{error}</p>
</div>
) : null}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-stone-100 dark:border-neutral-800 px-5 py-3">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="rounded-lg px-4 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:opacity-40">
{t('common.cancel')}
</button>
<button
type="submit"
disabled={!formValid}
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50">
{submitting ? t('skills.create.creating') : t('skills.create.createBtn')}
</button>
</div>
</form>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-stone-100 dark:border-neutral-800 px-5 py-3">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="rounded-lg px-4 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:opacity-40"
>
{t('common.cancel')}
</button>
<button
type="submit"
form={CREATE_FORM_ID}
disabled={!formValid || submitting}
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? t('skills.create.creating') : t('skills.create.createBtn')}
</button>
</div>
</div>
</div>,
document.body
@@ -0,0 +1,258 @@
/**
* ScheduledCronCard — Phase 1 coverage.
*
* The card is presentation-only: callers drive `onToggle` and `onClick`,
* the card just renders a polished surface for one CoreCronJob. Tests
* lock down the contract:
*
* - toggle round-trip: click → `onToggle(!enabled)`.
* - whole-card click → `onClick()` (only when `onClick` is provided).
* - schedule rendered via cronToHuman for the common preset.
* - last_run + next_run + last_status badge surface in the meta row.
* - badgeCount renders `×N` only when > 1.
* - activeBadge renders the `★ Active` pill when set.
* - busy disables the toggle.
* - actions slot lives next to the toggle and doesn't bubble to onClick.
* - testIdRoot drives the emitted testids so consumers can target
* parts of the card.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
const stableT = (key: string) => key;
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: stableT }) }));
import type { CoreCronJob } from '../../utils/tauriCommands/cron';
import ScheduledCronCard from './ScheduledCronCard';
function makeJob(overrides: Partial<CoreCronJob> = {}): CoreCronJob {
return {
id: 'job-1',
expression: '*/30 * * * *',
schedule: { kind: 'cron', expr: '*/30 * * * *' },
command: '',
prompt: '',
name: 'skill-run-dev-workflow-repo=owner-repo',
job_type: 'agent',
session_target: 'isolated',
enabled: true,
delivery: { mode: 'proactive', best_effort: true },
delete_after_run: false,
created_at: '2026-05-20T10:00:00Z',
next_run: '2026-05-29T03:00:00Z',
last_run: '2026-05-29T02:30:00Z',
last_status: 'ok',
last_output: null,
...overrides,
} as CoreCronJob;
}
describe('ScheduledCronCard', () => {
it('renders the title, cronToHuman schedule, and meta row', () => {
render(
<ScheduledCronCard
job={makeJob()}
title="dev-workflow"
onToggle={() => undefined}
/>
);
expect(screen.getByTestId('scheduled-cron-job-1-title')).toHaveTextContent('dev-workflow');
// `*/30 * * * *` → "Every 30 minutes" per cronToHuman.
expect(screen.getByTestId('scheduled-cron-job-1-schedule')).toHaveTextContent(
'Every 30 minutes'
);
// last_run + next_run labels surface.
expect(screen.getByText(/skills.dashboard.lastRun/)).toBeInTheDocument();
expect(screen.getByText(/skills.dashboard.nextRun/)).toBeInTheDocument();
// last_status chip.
expect(screen.getByTestId('scheduled-cron-job-1-last-status')).toHaveTextContent('ok');
});
it('defaults the heading to job.name when no title prop given', () => {
render(<ScheduledCronCard job={makeJob()} onToggle={() => undefined} />);
expect(screen.getByTestId('scheduled-cron-job-1-title')).toHaveTextContent(
'skill-run-dev-workflow-repo=owner-repo'
);
});
it('calls onToggle with the inverted enabled state when the switch is clicked', () => {
const onToggle = vi.fn();
render(<ScheduledCronCard job={makeJob({ enabled: true })} onToggle={onToggle} />);
const toggle = screen.getByTestId('scheduled-cron-job-1-toggle');
expect(toggle).toHaveAttribute('aria-checked', 'true');
fireEvent.click(toggle);
expect(onToggle).toHaveBeenCalledTimes(1);
expect(onToggle).toHaveBeenCalledWith(false);
});
it('round-trips off→on when initial enabled is false', () => {
const onToggle = vi.fn();
render(<ScheduledCronCard job={makeJob({ enabled: false })} onToggle={onToggle} />);
const toggle = screen.getByTestId('scheduled-cron-job-1-toggle');
expect(toggle).toHaveAttribute('aria-checked', 'false');
fireEvent.click(toggle);
expect(onToggle).toHaveBeenCalledWith(true);
});
it('reflects busy=true by disabling the toggle', () => {
const onToggle = vi.fn();
render(<ScheduledCronCard job={makeJob()} onToggle={onToggle} busy />);
const toggle = screen.getByTestId('scheduled-cron-job-1-toggle') as HTMLButtonElement;
expect(toggle).toBeDisabled();
});
it('renders a clickable surface and fires onClick when provided', () => {
const onClick = vi.fn();
const onToggle = vi.fn();
render(<ScheduledCronCard job={makeJob()} onClick={onClick} onToggle={onToggle} />);
const opener = screen.getByTestId('scheduled-cron-job-1-open');
fireEvent.click(opener);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('does not render the clickable opener when onClick is omitted', () => {
render(<ScheduledCronCard job={makeJob()} onToggle={() => undefined} />);
expect(screen.queryByTestId('scheduled-cron-job-1-open')).not.toBeInTheDocument();
// Falls back to a static row.
expect(screen.getByTestId('scheduled-cron-job-1-row')).toBeInTheDocument();
});
it('clicking the toggle on a clickable card does NOT also fire onClick', () => {
const onClick = vi.fn();
const onToggle = vi.fn();
render(<ScheduledCronCard job={makeJob()} onClick={onClick} onToggle={onToggle} />);
fireEvent.click(screen.getByTestId('scheduled-cron-job-1-toggle'));
expect(onToggle).toHaveBeenCalledTimes(1);
expect(onClick).not.toHaveBeenCalled();
});
it('renders the ×N count badge when badgeCount > 1', () => {
render(
<ScheduledCronCard
job={makeJob()}
badgeCount={3}
onToggle={() => undefined}
/>
);
expect(screen.getByTestId('scheduled-cron-job-1-count-badge')).toHaveTextContent('×3');
});
it('omits the count badge when badgeCount is 1 or absent', () => {
const { rerender } = render(
<ScheduledCronCard job={makeJob()} onToggle={() => undefined} />
);
expect(screen.queryByTestId('scheduled-cron-job-1-count-badge')).not.toBeInTheDocument();
rerender(
<ScheduledCronCard job={makeJob()} badgeCount={1} onToggle={() => undefined} />
);
expect(screen.queryByTestId('scheduled-cron-job-1-count-badge')).not.toBeInTheDocument();
});
it('renders the ★ Active badge when activeBadge is true', () => {
render(
<ScheduledCronCard job={makeJob()} activeBadge onToggle={() => undefined} />
);
expect(screen.getByTestId('scheduled-cron-job-1-active-badge')).toBeInTheDocument();
});
it('renders actions slot, and clicking an action does not bubble to onClick', () => {
const onClick = vi.fn();
const onAction = vi.fn();
render(
<ScheduledCronCard
job={makeJob()}
onClick={onClick}
onToggle={() => undefined}
actions={
<button type="button" data-testid="action-run-now" onClick={onAction}>
run now
</button>
}
/>
);
fireEvent.click(screen.getByTestId('action-run-now'));
expect(onAction).toHaveBeenCalledTimes(1);
expect(onClick).not.toHaveBeenCalled();
});
it('renders children below the row (used for history disclosure)', () => {
render(
<ScheduledCronCard job={makeJob()} onToggle={() => undefined}>
<div data-testid="history-slot">history goes here</div>
</ScheduledCronCard>
);
expect(screen.getByTestId('history-slot')).toBeInTheDocument();
});
it('honours custom testIdRoot so consumers can target parts of the card', () => {
render(
<ScheduledCronCard
job={makeJob()}
onToggle={() => undefined}
testIdRoot="skill-card-dev-workflow"
/>
);
expect(screen.getByTestId('skill-card-dev-workflow')).toBeInTheDocument();
expect(screen.getByTestId('skill-card-dev-workflow-toggle')).toBeInTheDocument();
});
it('falls back to the raw expression when schedule shape is unknown', () => {
const job = makeJob({
// Cast to any to feed an unrecognised schedule shape — same defensive
// path the card relies on for legacy jobs.
schedule: { kind: 'mystery' } as unknown as CoreCronJob['schedule'],
expression: '*/30 * * * *',
});
render(<ScheduledCronCard job={job} onToggle={() => undefined} />);
expect(screen.getByTestId('scheduled-cron-job-1-schedule')).toHaveTextContent(
'Every 30 minutes'
);
});
it('renders an `at` schedule as a localised timestamp', () => {
const job = makeJob({
schedule: { kind: 'at', at: '2026-05-29T12:00:00Z' } as CoreCronJob['schedule'],
});
render(<ScheduledCronCard job={job} onToggle={() => undefined} />);
const schedule = screen.getByTestId('scheduled-cron-job-1-schedule');
// The exact wallclock string is locale-dependent; just assert it
// contains a year so we know we hit the `at` branch.
expect(schedule.textContent).toMatch(/2026/);
});
it('renders an `every` schedule as a minute count', () => {
const job = makeJob({
schedule: { kind: 'every', every_ms: 5 * 60_000 } as CoreCronJob['schedule'],
});
render(<ScheduledCronCard job={job} onToggle={() => undefined} />);
expect(screen.getByTestId('scheduled-cron-job-1-schedule')).toHaveTextContent(
'Every 5 minutes'
);
});
it('renders the raw expression as a blank fallback when no schedule is set', () => {
const job = makeJob({
schedule: undefined as unknown as CoreCronJob['schedule'],
expression: '0 9 * * *',
});
render(<ScheduledCronCard job={job} onToggle={() => undefined} />);
// Cronstrue-ish helper returns "Every day at 09:00" for the daily preset.
expect(screen.getByTestId('scheduled-cron-job-1-schedule').textContent).not.toEqual('');
});
it('renders the data-active attribute matching enabled state', () => {
const { rerender } = render(
<ScheduledCronCard job={makeJob({ enabled: true })} onToggle={() => undefined} />
);
expect(screen.getByTestId('scheduled-cron-job-1')).toHaveAttribute('data-active', 'true');
rerender(
<ScheduledCronCard job={makeJob({ enabled: false })} onToggle={() => undefined} />
);
expect(screen.getByTestId('scheduled-cron-job-1')).toHaveAttribute('data-active', 'false');
});
});
@@ -0,0 +1,279 @@
/**
* ScheduledCronCard — the polished "scheduled skill" card.
*
* Originally lived inline inside SkillsDashboard.tsx (the global
* dashboard at `/skills` — see commit c474bc36) and inside
* SkillsRunnerBody.tsx (the per-skill saved-schedules list at
* `/skills/run`). The two surfaces had diverged: the dashboard rendered
* a roomy, DevWorkflowPanel-style "active config" card with a friendly
* cronToHuman schedule and a clickable surface that navigates into the
* runner; the runner rendered a slim row with the raw cron expression
* and inline action buttons. The user asked for the runner to adopt
* the dashboard card pattern so the visual language is consistent.
*
* This component is the single shared implementation used by both
* surfaces. Composition is intentional:
*
* onClick — if provided, the whole card becomes a clickable button
* (used by the dashboard to navigate into the runner).
* Absent on the runner page where you're already on the
* right skill, so clicking would be a no-op.
*
* onToggle — enable/disable toggle. Wired identically on both
* surfaces; visual lifted from DevWorkflowPanel:502-516.
*
* title — visible heading. Dashboard passes the extracted skill_id;
* runner passes the cron job's `name`.
*
* badgeCount — small "×N" pill for the dashboard's grouped-multi-job
* case.
*
* activeBadge — "★ Active" pill for the runner's top-most enabled
* schedule (the one cron will tick first).
*
* actions — render-slot for extra action buttons next to the
* toggle (runner places Run-Now + Remove here).
*
* children — render-slot below the row for things like the runner's
* per-job history disclosure.
*
* The card stays presentation-only: it does NOT manage any toggle / RPC
* state itself. Callers pass `onToggle(nextEnabled)` and own the
* round-trip (call cron_update, re-fetch list, re-render with the new
* job).
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { CoreCronJob } from '../../utils/tauriCommands/cron';
import { formatSchedule } from './scheduledCronFormat';
export interface ScheduledCronCardProps {
/** The cron job this card represents (drives schedule + enable state). */
job: CoreCronJob;
/** Visible heading. Defaults to `job.name ?? job.id`. */
title?: string;
/**
* Optional `×N` count pill — used by the dashboard when multiple cron
* jobs collapse into one card (one per skill_id).
*/
badgeCount?: number;
/**
* Optional `★ Active` pill — used by the runner to mark the top-most
* enabled schedule of a skill (the one the cron tick will fire first).
*/
activeBadge?: boolean;
/**
* Toggle handler — invoked with the desired new enabled state. Returns
* void; the caller's responsible for cron_update + re-fetch.
*/
onToggle: (nextEnabled: boolean) => void;
/**
* Optional whole-card click handler. When present, the card surface
* becomes clickable (visually too — focus ring, hover affordance) and
* navigates the user somewhere — currently `/skills/run?skill=<id>` on
* the dashboard. Absent on the runner page (you're already there).
*/
onClick?: () => void;
/** Stable testid root — defaults to `scheduled-cron-${job.id}`. */
testIdRoot?: string;
/**
* Disable the toggle while a parent-level update is in flight. The card
* itself doesn't track this — it just reflects what the caller knows.
*/
busy?: boolean;
/** Render-slot for extra action buttons next to the toggle. */
actions?: React.ReactNode;
/** Render-slot for content below the toggle row (e.g. history disclosure). */
children?: React.ReactNode;
}
/**
* Polished card for a scheduled cron job. Used on both the global
* `/skills` dashboard and the per-skill `/skills/run` runner.
*/
export default function ScheduledCronCard({
job,
title,
badgeCount,
activeBadge,
onToggle,
onClick,
testIdRoot,
busy,
actions,
children,
}: ScheduledCronCardProps) {
const { t } = useT();
const isActive = job.enabled;
const heading = title ?? job.name ?? job.id;
const rootId = testIdRoot ?? `scheduled-cron-${job.id}`;
// Visual states match DevWorkflowPanel's active-config card and the
// dashboard's grouped-skill card.
const containerClass = `rounded-2xl border shadow-soft transition-colors ${
isActive
? 'border-sage-200 dark:border-sage-500/30 bg-sage-50 dark:bg-sage-500/10'
: 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900'
}`;
const headingRow = (
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
{activeBadge && (
<span
data-testid={`${rootId}-active-badge`}
className="px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-sage-200 dark:bg-sage-500/30 text-sage-800 dark:text-sage-200 shrink-0"
>
{`${t('settings.skillsRunner.schedule.active')}`}
</span>
)}
<span
data-testid={`${rootId}-title`}
className={`font-mono text-sm font-semibold truncate ${
isActive
? 'text-sage-900 dark:text-sage-100'
: 'text-stone-700 dark:text-neutral-200'
}`}
>
{heading}
</span>
{badgeCount && badgeCount > 1 ? (
<span
data-testid={`${rootId}-count-badge`}
className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-stone-200 dark:bg-neutral-700 text-stone-700 dark:text-neutral-300 shrink-0"
>
×{badgeCount}
</span>
) : null}
</div>
<div
data-testid={`${rootId}-schedule`}
className="mt-0.5 text-xs text-stone-600 dark:text-neutral-400"
>
{formatSchedule(job)}
</div>
<div className="mt-1 text-[11px] text-stone-500 dark:text-neutral-500">
{job.last_run && (
<span>
{t('skills.dashboard.lastRun')}: {new Date(job.last_run).toLocaleString()}
{job.last_status && (
<span
data-testid={`${rootId}-last-status`}
className={`ml-1.5 px-1 py-0.5 rounded text-[10px] font-medium ${
job.last_status === 'ok'
? 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300'
: 'bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300'
}`}
>
{job.last_status}
</span>
)}
</span>
)}
{job.last_run && job.next_run && <span className="mx-1">·</span>}
{job.next_run && (
<span>
{t('skills.dashboard.nextRun')}: {new Date(job.next_run).toLocaleString()}
</span>
)}
</div>
</div>
);
// Toggle markup is shared between clickable + non-clickable variants.
// We wrap it in a stopPropagation span so toggling never bubbles up to
// a parent card-click handler.
const toggleBlock = (
<span
className="flex items-center gap-1.5 shrink-0"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
role="switch"
aria-checked={job.enabled}
aria-label={
job.enabled ? t('skills.dashboard.disable') : t('skills.dashboard.enable')
}
data-testid={`${rootId}-toggle`}
disabled={busy}
onClick={() => onToggle(!job.enabled)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors disabled:opacity-50 ${
job.enabled ? 'bg-sage-500' : 'bg-neutral-300 dark:bg-neutral-600'
}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform mt-0.5 ${
job.enabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
<span className="text-[10px] text-stone-500 dark:text-neutral-400 min-w-[44px]">
{job.enabled ? t('common.enabled') : t('common.disabled')}
</span>
</span>
);
// The right-edge cluster: extra `actions` (Run Now / Remove on the
// runner) followed by the toggle. We wrap in a stopPropagation span so
// clicking an action button on a clickable card doesn't ALSO navigate.
const rightCluster = (
<div className="flex items-center gap-1.5 shrink-0">
{actions && (
<span
className="flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
>
{actions}
</span>
)}
{toggleBlock}
</div>
);
// If the caller passed onClick, render the upper row as a clickable
// container. It must NOT be a real <button>: rightCluster contains the
// toggle/action <button>s, and nested <button> elements are invalid HTML
// (breaks a11y + interaction). Use a div with role="button" + keyboard
// handling instead. Otherwise render a plain div — keeps the runner from
// carrying a giant clickable surface it doesn't need.
const upperRow = onClick ? (
<div
role="button"
tabIndex={0}
data-testid={`${rootId}-open`}
aria-label={t('skills.dashboard.cardOpenRunner')}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === ' ') e.preventDefault();
onClick();
}
}}
className="w-full text-left px-4 py-3 flex items-center justify-between gap-3 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 rounded-2xl"
>
{headingRow}
{rightCluster}
</div>
) : (
<div
data-testid={`${rootId}-row`}
className="w-full px-4 py-3 flex items-center justify-between gap-3"
>
{headingRow}
{rightCluster}
</div>
);
return (
<div
key={job.id}
data-testid={rootId}
data-active={isActive ? 'true' : 'false'}
className={containerClass}
>
{upperRow}
{children}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import SmartIssuePicker from './SmartIssuePicker';
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
const mockListConnections = vi.fn();
const mockExecute = vi.fn();
vi.mock('../../lib/composio/composioApi', () => ({
listConnections: () => mockListConnections(),
execute: (...args: unknown[]) => mockExecute(...args),
}));
describe('SmartIssuePicker', () => {
const baseProps = { values: {}, onPatchInputs: vi.fn() };
beforeEach(() => {
mockListConnections.mockResolvedValue({ connections: [] });
mockExecute.mockResolvedValue({ repositories: [] });
});
it('renders the repo dropdown', async () => {
render(<SmartIssuePicker {...baseProps} />);
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});
it('shows loading and then empty state when no GitHub connection found', async () => {
mockListConnections.mockResolvedValue({ connections: [] });
render(<SmartIssuePicker {...baseProps} />);
await waitFor(() => {
// After loading resolves, dropdown should be present
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});
it('renders repos when GitHub connection is active', async () => {
mockListConnections.mockResolvedValue({
connections: [{ toolkit: 'github', status: 'ACTIVE', username: 'testuser' }],
});
// First call: list repos; subsequent calls: fork detect, branches
mockExecute
.mockResolvedValueOnce({
successful: true,
data: [
{
full_name: 'testuser/myrepo',
name: 'myrepo',
owner: { login: 'testuser' },
private: false,
default_branch: 'main',
},
],
})
.mockResolvedValue({ successful: true, data: { fork: false } });
render(<SmartIssuePicker {...baseProps} />);
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
// After repos load, there should be an option available
expect(mockExecute).toHaveBeenCalled();
});
it('pre-selects repo from values prop', async () => {
mockListConnections.mockResolvedValue({ connections: [] });
render(<SmartIssuePicker {...baseProps} values={{ repo: 'owner/repo' }} />);
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});
it('handles listConnections error gracefully', async () => {
mockListConnections.mockRejectedValue(new Error('network error'));
render(<SmartIssuePicker {...baseProps} />);
await waitFor(() => {
// Component should render without throwing
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,328 @@
// SmartIssuePicker — dev-workflow's repo / fork / upstream / branch
// auto-detection lifted out of DevWorkflowPanel into a reusable
// subcomponent. SkillsRunnerBody conditionally mounts it when the
// selected skill is `dev-workflow` to give that one skill the same
// frictionless setup it had in its bespoke Settings panel:
//
// - One dropdown shows the user's GitHub-connected repos (via
// Composio GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER).
// - Picking a repo runs GITHUB_GET_A_REPOSITORY to detect whether
// it's a fork and, if so, resolves the upstream's owner/name.
// - Branches are then listed (from the upstream side when forked) so
// the target_branch field can be a real dropdown rather than a
// freeform text field.
//
// All four dev-workflow inputs (`repo`, `upstream`, `target_branch`,
// `fork_owner`) are populated through the single `onPatchInputs`
// callback so the parent's form state is the source of truth and Run /
// Save behaviour is untouched.
//
// TODO(picker-schema): today this subcomponent is wired in based on
// the skill id being literally `dev-workflow`. The cleaner long-term
// path is to extend `skill.toml`'s `[[inputs]]` with an optional
// `picker = "github-issue"` discriminator and route here from that;
// see docs/skills-runner-unification.md open question 1.
import createDebug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import { execute as composioExecute, listConnections } from '../../lib/composio/composioApi';
import { useT } from '../../lib/i18n/I18nContext';
const log = createDebug('app:skills:SmartIssuePicker');
interface ComposioGhRepo {
owner: string;
repo: string;
fullName: string;
private?: boolean;
defaultBranch?: string;
}
interface ForkInfo {
isFork: boolean;
upstreamOwner: string;
upstreamRepo: string;
upstreamFullName: string;
}
interface GhBranch {
name: string;
}
export interface SmartIssuePickerProps {
/** Current resolved input values (the four dev-workflow fields). */
values: { repo?: string; upstream?: string; target_branch?: string; fork_owner?: string };
/** Patch the parent's form-values map with the picker's resolutions. */
onPatchInputs: (
patch: Partial<{
repo: string;
upstream: string;
target_branch: string;
fork_owner: string;
}>
) => void;
}
const SmartIssuePicker = ({ values, onPatchInputs }: SmartIssuePickerProps) => {
const { t } = useT();
const [repos, setRepos] = useState<ComposioGhRepo[]>([]);
const [reposLoading, setReposLoading] = useState(false);
const [reposError, setReposError] = useState<string | null>(null);
const [forkInfo, setForkInfo] = useState<ForkInfo | null>(null);
const [forkLoading, setForkLoading] = useState(false);
const [branches, setBranches] = useState<GhBranch[]>([]);
const [branchesLoading, setBranchesLoading] = useState(false);
// Monotonic counter guarding against race conditions when the user
// changes repos faster than the async fork/branch lookups resolve:
// each onRepoSelect captures its own id and bails out of any state
// update once a newer selection has superseded it.
const selectionSeqRef = useRef(0);
// ── Load repos via Composio ─────────────────────────────────────────
const loadRepos = useCallback(async () => {
setReposLoading(true);
setReposError(null);
try {
const connections = await listConnections();
const ghConn = connections.connections?.find(
(c) =>
c.toolkit.toLowerCase().includes('github') &&
(c.status === 'ACTIVE' || c.status === 'CONNECTED')
);
if (!ghConn) throw new Error('NOT_CONNECTED');
const res = await composioExecute('GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER', {});
if (!res.successful) throw new Error(res.error ?? 'Failed to fetch repositories');
const raw = res.data;
let repoList: ComposioGhRepo[] = [];
const items = Array.isArray(raw)
? raw
: ((raw as Record<string, unknown>)?.repositories ?? []);
if (Array.isArray(items)) {
repoList = (items as Record<string, unknown>[]).map((r) => ({
owner: String((r.owner as Record<string, unknown>)?.login ?? r.owner ?? ''),
repo: String(r.name ?? ''),
fullName: String(
r.full_name ?? `${(r.owner as Record<string, unknown>)?.login ?? r.owner}/${r.name}`
),
private: r.private as boolean | undefined,
defaultBranch: r.default_branch as string | undefined,
}));
}
setRepos(repoList);
if (repoList.length === 0) {
setReposError(t('settings.devWorkflow.errorNoRepositories'));
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log('loadRepos error: %s', msg);
if (msg === 'NOT_CONNECTED') {
setReposError(t('settings.devWorkflow.errorNotConnected'));
} else {
setReposError(msg);
}
} finally {
setReposLoading(false);
}
}, [t]);
useEffect(() => {
void loadRepos();
}, [loadRepos]);
// ── Fork detect + branch list on repo select ────────────────────────
const onRepoSelect = useCallback(
async (repoFullName: string) => {
// Claim this selection's id; any later selection bumps the ref and
// makes the work below abandon its now-stale state updates.
const localId = ++selectionSeqRef.current;
// Reset downstream resolutions and bubble the new repo up.
onPatchInputs({
repo: repoFullName,
upstream: repoFullName,
target_branch: '',
fork_owner: repoFullName.split('/')[0] ?? '',
});
setForkInfo(null);
setBranches([]);
if (!repoFullName) return;
const [owner, repo] = repoFullName.split('/');
if (!owner || !repo) return;
setForkLoading(true);
try {
const res = await composioExecute('GITHUB_GET_A_REPOSITORY', { owner, repo });
if (localId !== selectionSeqRef.current) return;
let branchOwner = owner;
let branchRepo = repo;
let detectedFork: ForkInfo | null = null;
let defaultBranch = 'main';
if (res.successful) {
const data = res.data as {
fork?: boolean;
parent?: { full_name: string; owner: { login: string }; name: string };
default_branch?: string;
};
if (data.fork && data.parent) {
detectedFork = {
isFork: true,
upstreamOwner: data.parent.owner.login,
upstreamRepo: data.parent.name,
upstreamFullName: data.parent.full_name,
};
branchOwner = data.parent.owner.login;
branchRepo = data.parent.name;
}
defaultBranch = data.default_branch ?? 'main';
} else {
const fromList = repos.find((r) => r.fullName === repoFullName);
defaultBranch = fromList?.defaultBranch ?? 'main';
}
setForkInfo(detectedFork);
onPatchInputs({
upstream: detectedFork ? detectedFork.upstreamFullName : repoFullName,
fork_owner: owner,
});
setBranchesLoading(true);
const branchRes = await composioExecute('GITHUB_LIST_BRANCHES', {
owner: branchOwner,
repo: branchRepo,
per_page: 100,
});
if (localId !== selectionSeqRef.current) return;
if (branchRes.successful) {
const raw = branchRes.data;
let list: GhBranch[] = [];
if (Array.isArray(raw)) {
list = raw as GhBranch[];
} else if (raw && typeof raw === 'object') {
const obj = raw as Record<string, unknown>;
const dataObj = obj.data as Record<string, unknown> | undefined;
const arr =
(obj.details as unknown) ?? dataObj?.details ?? obj.branches ?? obj.items ?? dataObj;
if (Array.isArray(arr)) list = arr as GhBranch[];
}
if (list.length > 0) {
setBranches(list);
const hasDefault = list.some((b) => b.name === defaultBranch);
onPatchInputs({ target_branch: hasDefault ? defaultBranch : list[0].name });
} else {
const fallback = [...new Set([defaultBranch, 'main', 'master'])];
setBranches(fallback.map((name) => ({ name })));
onPatchInputs({ target_branch: defaultBranch });
}
} else {
const fallback = [...new Set([defaultBranch, 'main', 'master'])];
setBranches(fallback.map((name) => ({ name })));
onPatchInputs({ target_branch: defaultBranch });
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log('onRepoSelect error: %s', msg);
if (localId !== selectionSeqRef.current) return;
setReposError(msg);
} finally {
if (localId === selectionSeqRef.current) {
setForkLoading(false);
setBranchesLoading(false);
}
}
},
[repos, onPatchInputs]
);
// ── Render ──────────────────────────────────────────────────────────
return (
<div data-testid="smart-issue-picker" className="space-y-3">
<div>
<label
htmlFor="smart-issue-picker-repo"
className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-1"
>
{t('settings.devWorkflow.githubRepository')}
</label>
{reposError && (
<div className="mb-2 px-3 py-2 rounded-md bg-coral-50 dark:bg-coral-500/10 border border-coral-200 dark:border-coral-500/30 text-xs text-coral-700 dark:text-coral-300">
{reposError}
</div>
)}
<select
id="smart-issue-picker-repo"
value={values.repo ?? ''}
onChange={(e) => void onRepoSelect(e.target.value)}
disabled={reposLoading}
className="w-full rounded border border-stone-300 dark:border-stone-600 bg-white dark:bg-stone-800 px-3 py-2 text-sm text-stone-900 dark:text-stone-100"
>
<option value="">
{reposLoading
? t('settings.devWorkflow.loadingRepositories')
: t('settings.devWorkflow.selectRepository')}
</option>
{repos.map((r) => (
<option key={r.fullName} value={r.fullName}>
{r.fullName} {r.private ? t('settings.devWorkflow.privateTag') : ''}
</option>
))}
</select>
</div>
{forkLoading && (
<div className="text-xs text-stone-500 dark:text-stone-400">
{t('settings.devWorkflow.detectingForkInfo')}
</div>
)}
{forkInfo && (
<div
data-testid="smart-issue-picker-fork-banner"
className="px-3 py-2 rounded-md bg-primary-50 dark:bg-primary-500/10 border border-primary-200 dark:border-primary-500/30"
>
<div className="text-xs font-medium text-primary-800 dark:text-primary-300">
{t('settings.devWorkflow.forkDetected')}
</div>
<div className="text-xs text-primary-700 dark:text-primary-200 mt-0.5">
{t('settings.devWorkflow.upstream')}{' '}
<span className="font-mono">{forkInfo.upstreamFullName}</span>
</div>
</div>
)}
{branches.length > 0 && (
<div>
<label
htmlFor="smart-issue-picker-branch"
className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-1"
>
{t('settings.devWorkflow.targetBranch')}
</label>
<select
id="smart-issue-picker-branch"
value={values.target_branch ?? ''}
onChange={(e) => onPatchInputs({ target_branch: e.target.value })}
disabled={branchesLoading}
className="w-full rounded border border-stone-300 dark:border-stone-600 bg-white dark:bg-stone-800 px-3 py-2 text-sm text-stone-900 dark:text-stone-100"
>
{branches.map((b) => (
<option key={b.name} value={b.name}>
{b.name}
</option>
))}
</select>
</div>
)}
</div>
);
};
export default SmartIssuePicker;
@@ -0,0 +1,277 @@
/**
* CreateSkillForm — standalone form coverage.
*
* Phase 5 of the /skills IA restructure: validates the form behaves
* the same way as it did inside CreateSkillModal, so both the modal
* and the /skills/new page can rely on it.
*
* Covers:
* - submit calls skillsApi.createSkill with the trimmed/normalised
* payload (CSVs split, optional fields omitted when empty).
* - onStateChange is called with validity + submitting flags so
* wrappers can sync their submit button's disabled state.
* - error path surfaces the Rust message in role="alert".
* - slug preview reflects the typed name.
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const stableT = (key: string) => key;
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: stableT }) }));
const hoisted = vi.hoisted(() => ({
createSkill: vi.fn(),
}));
vi.mock('../../../services/api/skillsApi', () => ({
skillsApi: { createSkill: hoisted.createSkill },
}));
import CreateSkillForm, { previewSlug } from '../CreateSkillForm';
const FORM_ID = 'create-skill-test-form';
describe('previewSlug', () => {
it('lowercases ASCII alnum, collapses spaces/underscores to single hyphens', () => {
expect(previewSlug('My New Skill')).toBe('my-new-skill');
expect(previewSlug('foo___bar')).toBe('foo-bar');
expect(previewSlug('Hello, World!')).toBe('hello-world');
});
it('trims leading/trailing hyphens', () => {
expect(previewSlug(' - leading and trailing - ')).toBe('leading-and-trailing');
});
it('strips diacritics via NFKD and drops symbols', () => {
// NFKD decomposes é → e + combining acute; the combining mark is
// outside ASCII alnum so it's dropped, leaving `cafe-beans`.
expect(previewSlug('café & beans')).toBe('cafe-beans');
});
});
describe('CreateSkillForm', () => {
beforeEach(() => {
hoisted.createSkill.mockReset();
});
it('renders required fields and the slug preview updates as the name changes', () => {
render(
<CreateSkillForm formId={FORM_ID} onCreated={vi.fn()} />
);
const nameInput = screen.getByLabelText(/skills.create.name/i) as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: 'My Cool Skill' } });
expect(screen.getByText('my-cool-skill')).toBeInTheDocument();
});
it('reports validity to the wrapper via onStateChange when name and description are filled', () => {
const onStateChange = vi.fn();
render(
<CreateSkillForm formId={FORM_ID} onCreated={vi.fn()} onStateChange={onStateChange} />
);
// Initially invalid (empty form).
expect(onStateChange).toHaveBeenLastCalledWith({ valid: false, submitting: false });
fireEvent.change(screen.getByLabelText(/skills.create.name/i), {
target: { value: 'My Skill' },
});
// Name alone is not enough.
expect(onStateChange).toHaveBeenLastCalledWith({ valid: false, submitting: false });
fireEvent.change(screen.getByLabelText(/skills.create.description/i), {
target: { value: 'Does the thing.' },
});
expect(onStateChange).toHaveBeenLastCalledWith({ valid: true, submitting: false });
});
it('submits the trimmed minimal payload (name + description + scope=user)', async () => {
// The form was simplified to name + description only — scope is
// hard-coded to 'user' (the only sensible default for skills created
// through the UI), and the previous license/author/tags/allowed-tools
// fields were dropped. Anyone needing project-scoped or
// tagged skills edits the workspace SKILL.md directly.
const created = { id: 'my-skill', name: 'My Skill', scope: 'user', legacy: false };
hoisted.createSkill.mockResolvedValue(created);
const onCreated = vi.fn();
render(<CreateSkillForm formId={FORM_ID} onCreated={onCreated} />);
fireEvent.change(screen.getByLabelText(/skills.create.name/i), {
target: { value: ' My Skill ' },
});
fireEvent.change(screen.getByLabelText(/skills.create.description/i), {
target: { value: ' Does the thing. ' },
});
// The form has no internal submit button — fire a submit event on
// the <form id> directly (this is what `<button form=...>` does
// from a wrapper).
fireEvent.submit(document.getElementById(FORM_ID)!);
await waitFor(() => {
expect(hoisted.createSkill).toHaveBeenCalledWith({
name: 'My Skill',
description: 'Does the thing.',
scope: 'user',
});
});
expect(onCreated).toHaveBeenCalledWith(created);
});
it('surfaces the Rust error message in an alert when createSkill rejects', async () => {
hoisted.createSkill.mockRejectedValue(new Error('slug already exists'));
render(<CreateSkillForm formId={FORM_ID} onCreated={vi.fn()} />);
fireEvent.change(screen.getByLabelText(/skills.create.name/i), {
target: { value: 'Dupe' },
});
fireEvent.change(screen.getByLabelText(/skills.create.description/i), {
target: { value: 'whatever' },
});
fireEvent.submit(document.getElementById(FORM_ID)!);
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('slug already exists');
});
it('does not call createSkill if the form is invalid (no name)', async () => {
render(<CreateSkillForm formId={FORM_ID} onCreated={vi.fn()} />);
fireEvent.submit(document.getElementById(FORM_ID)!);
// Give the microtask queue a tick — should still be 0.
await Promise.resolve();
expect(hoisted.createSkill).not.toHaveBeenCalled();
});
// ── Inputs editor ───────────────────────────────────────────────────
// The form gained an optional [[inputs]] editor in 5d77839f. These
// tests pin its contract end-to-end: the rows the user adds become the
// `inputs` field on the createSkill payload, name validation blocks
// submission, and removing a row drops it from the payload.
/** Fill name + description so the rest of the form is submittable. */
function fillRequiredFields() {
fireEvent.change(screen.getByLabelText(/skills.create.name/i), {
target: { value: 'My Skill' },
});
fireEvent.change(screen.getByLabelText(/skills.create.description/i), {
target: { value: 'Does the thing.' },
});
}
/** Find the most-recently-added input row's inner controls. */
function lastRow() {
const rows = document.querySelectorAll<HTMLDivElement>(
'[data-testid^="create-skill-input-row-"]'
);
return rows[rows.length - 1];
}
it('zero inputs submits a payload without an `inputs` field', async () => {
hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
render(<CreateSkillForm formId={FORM_ID} onCreated={vi.fn()} />);
fillRequiredFields();
fireEvent.submit(document.getElementById(FORM_ID)!);
await waitFor(() => {
expect(hoisted.createSkill).toHaveBeenCalledWith({
name: 'My Skill',
description: 'Does the thing.',
scope: 'user',
});
});
const payload = hoisted.createSkill.mock.calls[0]![0] as Record<string, unknown>;
expect(payload).not.toHaveProperty('inputs');
});
it('one filled input row ships in the payload with name + required: true', async () => {
hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
render(<CreateSkillForm formId={FORM_ID} onCreated={vi.fn()} />);
fillRequiredFields();
fireEvent.click(screen.getByTestId('create-skill-add-input'));
const row = lastRow();
const [nameInput, descInput] = row.querySelectorAll<HTMLInputElement>('input[type="text"]');
fireEvent.change(nameInput, { target: { value: 'repo' } });
fireEvent.change(descInput, { target: { value: 'owner/name' } });
fireEvent.submit(document.getElementById(FORM_ID)!);
await waitFor(() => {
expect(hoisted.createSkill).toHaveBeenCalledWith({
name: 'My Skill',
description: 'Does the thing.',
scope: 'user',
inputs: [{ name: 'repo', required: true, description: 'owner/name' }],
});
});
});
it('blocks submission while any row has an invalid name', async () => {
hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
render(<CreateSkillForm formId={FORM_ID} onCreated={vi.fn()} />);
fillRequiredFields();
fireEvent.click(screen.getByTestId('create-skill-add-input'));
// Add a row but leave the name empty — submission must be blocked.
fireEvent.submit(document.getElementById(FORM_ID)!);
await Promise.resolve();
expect(hoisted.createSkill).not.toHaveBeenCalled();
// Fill the name with an invalid character (leading digit).
const row = lastRow();
const [nameInput] = row.querySelectorAll<HTMLInputElement>('input[type="text"]');
fireEvent.change(nameInput, { target: { value: '2repo' } });
fireEvent.submit(document.getElementById(FORM_ID)!);
await Promise.resolve();
expect(hoisted.createSkill).not.toHaveBeenCalled();
// Inline error visible.
expect(screen.getByText(/nameError/i)).toBeInTheDocument();
});
it('remove row drops it from the payload — submission then succeeds', async () => {
hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
render(<CreateSkillForm formId={FORM_ID} onCreated={vi.fn()} />);
fillRequiredFields();
fireEvent.click(screen.getByTestId('create-skill-add-input'));
const row = lastRow();
const removeBtn = row.querySelector<HTMLButtonElement>(
'[data-testid^="create-skill-remove-input-"]'
)!;
fireEvent.click(removeBtn);
// After removal, zero rows → submission goes through with no
// `inputs` field at all (back to the no-inputs payload shape).
fireEvent.submit(document.getElementById(FORM_ID)!);
await waitFor(() => {
expect(hoisted.createSkill).toHaveBeenCalledTimes(1);
});
const payload = hoisted.createSkill.mock.calls[0]![0] as Record<string, unknown>;
expect(payload).not.toHaveProperty('inputs');
});
it('integer + required=false carry through the type + required flags', async () => {
hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
render(<CreateSkillForm formId={FORM_ID} onCreated={vi.fn()} />);
fillRequiredFields();
fireEvent.click(screen.getByTestId('create-skill-add-input'));
const row = lastRow();
const [nameInput] = row.querySelectorAll<HTMLInputElement>('input[type="text"]');
fireEvent.change(nameInput, { target: { value: 'issue' } });
// Flip type → integer, uncheck required.
const typeSelect = row.querySelector<HTMLSelectElement>('select')!;
fireEvent.change(typeSelect, { target: { value: 'integer' } });
const requiredCheckbox = row.querySelector<HTMLInputElement>('input[type="checkbox"]')!;
fireEvent.click(requiredCheckbox);
fireEvent.submit(document.getElementById(FORM_ID)!);
await waitFor(() => {
expect(hoisted.createSkill).toHaveBeenCalledWith(
expect.objectContaining({
inputs: [{ name: 'issue', required: false, type: 'integer' }],
})
);
});
});
});
@@ -82,7 +82,17 @@ describe('CreateSkillModal', () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it('rekeys allowedTools to allowed-tools on submit and calls onCreated', async () => {
it('submits name + description, calls onCreated with the new skill', async () => {
// The previous incarnation of this test also drove Tags + Allowed-tools
// inputs and asserted the `allowedTools` → `allowed-tools` rekey at the
// call site. CreateSkillForm dropped those inputs in the refactor (the
// form is now Name + Description + the `[[inputs]]` editor only — see
// ScheduledCronCard / CreateSkillForm.tsx), so the inputs are no longer
// collectable from the modal UI. The rekey itself still happens in
// `skillsApi.createSkill` (services/api/skillsApi.ts → params build) and
// is covered by the skillsApi unit tests; this test now just guards the
// modal's submit-pipeline shape: name + description → createSkill →
// onCreated.
const { skillsApi } = await import('../../../services/api/skillsApi');
const created = builtSkill();
vi.mocked(skillsApi.createSkill).mockResolvedValueOnce(created);
@@ -93,23 +103,19 @@ describe('CreateSkillModal', () => {
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'My Skill' } });
fireEvent.change(screen.getByLabelText(/Description/), { target: { value: 'does stuff' } });
fireEvent.change(screen.getByLabelText(/Tags/), { target: { value: 'alpha, beta' } });
fireEvent.change(screen.getByLabelText(/Allowed tools/), {
target: { value: 'mcp/fs, fetch' },
});
const submit = screen.getByRole('button', { name: /Create skill/ });
await act(async () => {
fireEvent.click(submit);
});
expect(vi.mocked(skillsApi.createSkill)).toHaveBeenCalledWith({
name: 'My Skill',
description: 'does stuff',
scope: 'user',
tags: ['alpha', 'beta'],
allowedTools: ['mcp/fs', 'fetch'],
});
expect(vi.mocked(skillsApi.createSkill)).toHaveBeenCalledWith(
expect.objectContaining({
name: 'My Skill',
description: 'does stuff',
scope: 'user',
})
);
expect(onCreated).toHaveBeenCalledWith(created);
});
@@ -131,4 +137,11 @@ describe('CreateSkillModal', () => {
});
expect(submit.disabled).toBe(false);
});
it('close button calls onClose when not submitting', () => {
const onClose = vi.fn();
render(<CreateSkillModal onClose={onClose} onCreated={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: /close/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,618 @@
/**
* SkillsRunnerBody — vitest coverage for the saved-schedules block.
*
* Phase 2 of the SkillsRunnerBody / DevWorkflowPanel unification (see
* docs/skills-runner-unification.md): this file is seeded with the
* smoke-test for the enable/disable toggle so future Phase 3 chunks
* (run-history, active-config card, smart-issue picker gating) drop
* additional cases alongside.
*
* Covered here:
* - Mount with one saved schedule for the picked skill (mocking
* skills_list, skills_describe, cron_list, recent_runs).
* - Toggle flips enabled → false via openhumanCronUpdate(id, { enabled }).
* - The list re-loads after toggle (openhumanCronList called again).
* - aria-checked reflects the new state once the list refreshes.
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock the i18n hook with a stable identity-returning t() so our
// assertions can query by key (matches existing patterns in the repo,
// e.g. DevWorkflowPanel.test.tsx).
const stableT = (key: string) => key;
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: stableT }) }));
// Hoisted mocks so vi.mock factories can reach them.
const hoisted = vi.hoisted(() => ({
cronList: vi.fn(),
cronAdd: vi.fn(),
cronRemove: vi.fn(),
cronRun: vi.fn(),
cronUpdate: vi.fn(),
cronRuns: vi.fn(),
listSkills: vi.fn(),
describeSkill: vi.fn(),
runSkill: vi.fn(),
recentRuns: vi.fn(),
readRunLog: vi.fn(),
}));
vi.mock('../../../utils/tauriCommands/cron', () => ({
openhumanCronAdd: hoisted.cronAdd,
openhumanCronList: hoisted.cronList,
openhumanCronRemove: hoisted.cronRemove,
openhumanCronRun: hoisted.cronRun,
openhumanCronUpdate: hoisted.cronUpdate,
openhumanCronRuns: hoisted.cronRuns,
}));
vi.mock('../../../services/api/skillsApi', () => ({
skillsApi: {
listSkills: hoisted.listSkills,
describeSkill: hoisted.describeSkill,
runSkill: hoisted.runSkill,
recentRuns: hoisted.recentRuns,
readRunLog: hoisted.readRunLog,
},
}));
// Composio-backed pickers fetch on mount — stub them so they don't
// throw on the test environment.
vi.mock('../inputs/RepoPicker', () => ({
default: (props: { id: string; value: string; onChange: (s: string) => void }) => (
<input
data-testid="repo-picker-stub"
id={props.id}
value={props.value}
onChange={(e) => props.onChange(e.target.value)}
/>
),
}));
vi.mock('../inputs/BranchPicker', () => ({
default: (props: { id: string; value: string; onChange: (s: string) => void }) => (
<input
data-testid="branch-picker-stub"
id={props.id}
value={props.value}
onChange={(e) => props.onChange(e.target.value)}
/>
),
}));
// SmartIssuePicker mounts Composio + needs the i18n context's `t` to
// resolve a bunch of keys; we just stub the marker so the gating
// assertion below is unambiguous (its internal behaviour has its own
// unit coverage on the subcomponent itself).
vi.mock('../SmartIssuePicker', () => ({
default: () => <div data-testid="smart-issue-picker-stub" />,
}));
// Mock data ──────────────────────────────────────────────────────────
const SKILL_ID = 'github-issue-crusher';
const skillsList = [{ id: SKILL_ID, name: 'GitHub Issue Crusher' }];
const skillDescription = {
id: SKILL_ID,
name: 'GitHub Issue Crusher',
when_to_use: 'Pick + fix an issue.',
inputs: [],
};
function makeJob(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'job-1',
expression: '*/30 * * * *',
schedule: { kind: 'cron', expr: '*/30 * * * *' },
command: '',
prompt: '',
name: `skill-run-${SKILL_ID}`,
job_type: 'agent',
session_target: 'isolated',
enabled: true,
delivery: { mode: 'proactive', best_effort: true },
delete_after_run: false,
created_at: '2026-05-29T10:00:00Z',
next_run: '2026-05-29T11:00:00Z',
...overrides,
};
}
async function importBody() {
const mod = await import('../SkillsRunnerBody');
return mod.SkillsRunnerBody;
}
/**
* Wrap the body in a MemoryRouter so the URL-binding effect (added in
* Phase 4 of the /skills IA restructure) has a router context to read
* `?skill=` from / write back to. Default entry is `/skills/run`
* matching where the runner now lives.
*/
function renderBody(Body: React.ComponentType, initialPath = '/skills/run') {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<Body />
</MemoryRouter>
);
}
// Tests ──────────────────────────────────────────────────────────────
describe('SkillsRunnerBody — saved-schedule toggle', () => {
beforeEach(() => {
Object.values(hoisted).forEach((fn) => fn.mockReset());
hoisted.listSkills.mockResolvedValue(skillsList);
hoisted.describeSkill.mockResolvedValue(skillDescription);
hoisted.recentRuns.mockResolvedValue([]);
hoisted.cronList.mockResolvedValue({ result: [makeJob({ enabled: true })] });
hoisted.cronUpdate.mockResolvedValue({ result: makeJob({ enabled: false }) });
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
});
it('renders the toggle in the enabled state for an enabled job', async () => {
const Body = await importBody();
renderBody(Body);
// Wait for skills_list to resolve and populate the dropdown.
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
// Pick the skill so the schedule list mounts.
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
fireEvent.change(select, { target: { value: SKILL_ID } });
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
// The runner now renders saved schedules through ScheduledCronCard,
// which emits a single `<root>-toggle` testid per card. Querying
// by testid keeps us independent of the card's internal aria-label.
const toggle = await screen.findByTestId('scheduled-job-job-1-toggle');
expect(toggle).toHaveAttribute('aria-checked', 'true');
// Card uses the shared `common.enabled` / `common.disabled` label.
expect(screen.getByText('common.enabled')).toBeInTheDocument();
});
it('calls openhumanCronUpdate with { enabled: false } when toggled on→off', async () => {
const Body = await importBody();
renderBody(Body);
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
fireEvent.change(select, { target: { value: SKILL_ID } });
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
// After the first list, the next call (post-toggle) should return
// the disabled job so the UI refresh reflects the new state.
hoisted.cronList.mockResolvedValueOnce({ result: [makeJob({ enabled: false })] });
const toggle = await screen.findByTestId('scheduled-job-job-1-toggle');
fireEvent.click(toggle);
await waitFor(() =>
expect(hoisted.cronUpdate).toHaveBeenCalledWith('job-1', { enabled: false })
);
// Refresh-list invoked after toggle (so the label updates).
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalledTimes(2));
await waitFor(() =>
expect(screen.getByTestId('scheduled-job-job-1-toggle')).toHaveAttribute(
'aria-checked',
'false'
)
);
expect(screen.getByText('common.disabled')).toBeInTheDocument();
});
it('round-trips off→on as well', async () => {
hoisted.cronList.mockResolvedValueOnce({ result: [makeJob({ enabled: false })] });
const Body = await importBody();
renderBody(Body);
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
fireEvent.change(select, { target: { value: SKILL_ID } });
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
const toggle = await screen.findByTestId('scheduled-job-job-1-toggle');
expect(toggle).toHaveAttribute('aria-checked', 'false');
fireEvent.click(toggle);
await waitFor(() =>
expect(hoisted.cronUpdate).toHaveBeenCalledWith('job-1', { enabled: true })
);
});
});
// ── Per-job history expand ──────────────────────────────────────────
function makeRun(
id: number,
overrides: Partial<{ status: string; output: string | null; duration_ms: number }> = {}
) {
return {
id,
job_id: 'job-1',
started_at: '2026-05-29T10:00:00Z',
finished_at: '2026-05-29T10:00:51Z',
status: 'ok',
output: 'hello world\nrun output line 2',
duration_ms: 51000,
...overrides,
};
}
describe('SkillsRunnerBody — per-job history viewer', () => {
beforeEach(() => {
Object.values(hoisted).forEach((fn) => fn.mockReset());
hoisted.listSkills.mockResolvedValue(skillsList);
hoisted.describeSkill.mockResolvedValue(skillDescription);
hoisted.recentRuns.mockResolvedValue([]);
hoisted.cronList.mockResolvedValue({ result: [makeJob({ enabled: true })] });
hoisted.cronRuns.mockResolvedValue({ result: { runs: [makeRun(1), makeRun(2)] } });
});
it('loads cron_runs and renders history rows on first toggle', async () => {
const Body = await importBody();
renderBody(Body);
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
fireEvent.change(select, { target: { value: SKILL_ID } });
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
const historyToggle = await screen.findByTestId('history-toggle-job-1');
fireEvent.click(historyToggle);
await waitFor(() => expect(hoisted.cronRuns).toHaveBeenCalledWith('job-1', 5));
expect(await screen.findByTestId('history-run-job-1-1')).toBeInTheDocument();
expect(screen.getByTestId('history-run-job-1-2')).toBeInTheDocument();
});
it("expands a run row to show its captured output, hides on collapse", async () => {
const Body = await importBody();
renderBody(Body);
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
fireEvent.change(select, { target: { value: SKILL_ID } });
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
fireEvent.click(await screen.findByTestId('history-toggle-job-1'));
const runRow = await screen.findByTestId('history-run-job-1-1');
expect(screen.queryByText(/hello world/)).not.toBeInTheDocument();
fireEvent.click(runRow);
expect(await screen.findByText(/hello world/)).toBeInTheDocument();
expect(runRow).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(runRow);
await waitFor(() => expect(screen.queryByText(/hello world/)).not.toBeInTheDocument());
});
it('marks the most-recent enabled schedule as Active and sorts it first', async () => {
const jobs = [
makeJob({
id: 'job-old-enabled',
name: `skill-run-${SKILL_ID}-old`,
enabled: true,
last_run: '2026-05-29T08:00:00Z',
}),
makeJob({
id: 'job-recent-enabled',
name: `skill-run-${SKILL_ID}-recent`,
enabled: true,
last_run: '2026-05-29T10:00:00Z',
}),
makeJob({
id: 'job-paused',
name: `skill-run-${SKILL_ID}-paused`,
enabled: false,
}),
];
hoisted.cronList.mockResolvedValue({ result: jobs });
const Body = await importBody();
renderBody(Body);
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
fireEvent.change(select, { target: { value: SKILL_ID } });
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
// The recent enabled job should be marked active (only one active
// badge present, and it's on the recent job). ScheduledCronCard
// emits the badge as `<root>-active-badge` where `<root>` is the
// runner's `scheduled-job-<jobId>` testIdRoot.
const badges = await screen.findAllByTestId(/-active-badge$/);
expect(badges).toHaveLength(1);
expect(badges[0]).toHaveAttribute(
'data-testid',
'scheduled-job-job-recent-enabled-active-badge'
);
// Sort order: recent enabled, old enabled, paused. We pull the
// rendered card roots and assert their relative DOM order. The
// card emits a number of helper testids (`*-toggle`, `*-open`,
// etc.) prefixed with the same root — narrow the regex to just
// the card root by anchoring on a job-id pattern.
const rows = ['job-recent-enabled', 'job-old-enabled', 'job-paused'].map((id) =>
screen.getByTestId(`scheduled-job-${id}`)
);
expect(rows[0]).toHaveAttribute('data-active', 'true');
expect(rows[1]).toHaveAttribute('data-active', 'true');
expect(rows[2]).toHaveAttribute('data-active', 'false');
// Confirm DOM order by walking the parent's children.
const parent = rows[0].parentElement!;
const cardChildren = Array.from(parent.children).filter((el) =>
el.getAttribute('data-testid')?.startsWith('scheduled-job-job-')
);
expect(cardChildren.map((el) => el.getAttribute('data-testid'))).toEqual([
'scheduled-job-job-recent-enabled',
'scheduled-job-job-old-enabled',
'scheduled-job-job-paused',
]);
});
it('does not show an Active badge when no schedules are enabled', async () => {
hoisted.cronList.mockResolvedValue({
result: [makeJob({ enabled: false })],
});
const Body = await importBody();
renderBody(Body);
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
fireEvent.change(select, { target: { value: SKILL_ID } });
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
await screen.findByTestId('scheduled-job-job-1');
expect(screen.queryByTestId(/-active-badge$/)).not.toBeInTheDocument();
});
it('shows the empty-history placeholder when cron_runs returns no rows', async () => {
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
const Body = await importBody();
renderBody(Body);
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
fireEvent.change(select, { target: { value: SKILL_ID } });
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
fireEvent.click(await screen.findByTestId('history-toggle-job-1'));
await waitFor(() => expect(hoisted.cronRuns).toHaveBeenCalled());
expect(
await screen.findByText('settings.skillsRunner.schedule.historyEmpty')
).toBeInTheDocument();
});
});
describe('SkillsRunnerBody — SmartIssuePicker conditional mount', () => {
beforeEach(() => {
Object.values(hoisted).forEach((fn) => fn.mockReset());
hoisted.recentRuns.mockResolvedValue([]);
hoisted.cronList.mockResolvedValue({ result: [] });
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
});
it('renders SmartIssuePicker when the picked skill is dev-workflow', async () => {
hoisted.listSkills.mockResolvedValue([{ id: 'dev-workflow', name: 'Dev Workflow' }]);
hoisted.describeSkill.mockResolvedValue({
id: 'dev-workflow',
name: 'Dev Workflow',
when_to_use: 'Autonomous developer.',
inputs: [
{ name: 'repo', type: 'string', required: true, description: 'upstream repo' },
{ name: 'upstream', type: 'string', required: true, description: 'upstream alias' },
{ name: 'target_branch', type: 'string', required: true, description: 'PR base' },
{ name: 'fork_owner', type: 'string', required: true, description: 'fork owner' },
],
});
const Body = await importBody();
renderBody(Body);
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
fireEvent.change(select, { target: { value: 'dev-workflow' } });
expect(await screen.findByTestId('smart-issue-picker-stub')).toBeInTheDocument();
// The four managed inputs should NOT appear as plain text fields
// — they're driven by the picker. We probe one of them.
expect(screen.queryByLabelText(/target_branch/)).not.toBeInTheDocument();
});
it('does NOT render SmartIssuePicker for generic skills', async () => {
hoisted.listSkills.mockResolvedValue([
{ id: 'github-issue-crusher', name: 'GitHub Issue Crusher' },
]);
hoisted.describeSkill.mockResolvedValue({
id: 'github-issue-crusher',
name: 'GitHub Issue Crusher',
when_to_use: 'Crush issues.',
inputs: [
{ name: 'repo', type: 'string', required: true, description: 'repo' },
{ name: 'issue_number', type: 'integer', required: true, description: 'issue' },
],
});
const Body = await importBody();
renderBody(Body);
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
fireEvent.change(select, { target: { value: 'github-issue-crusher' } });
await waitFor(() => expect(hoisted.describeSkill).toHaveBeenCalled());
expect(screen.queryByTestId('smart-issue-picker-stub')).not.toBeInTheDocument();
// The generic schema-driven repo field IS rendered via the
// existing RepoPicker stub.
expect(await screen.findByTestId('repo-picker-stub')).toBeInTheDocument();
});
});
// ── Phase 4: URL ?skill= preselect binding ───────────────────────────
describe('SkillsRunnerBody — URL ?skill= preselect', () => {
beforeEach(() => {
Object.values(hoisted).forEach((fn) => fn.mockReset());
hoisted.listSkills.mockResolvedValue([
{ id: 'dev-workflow', name: 'Dev Workflow' },
{ id: 'github-issue-crusher', name: 'GitHub Issue Crusher' },
]);
hoisted.describeSkill.mockResolvedValue({
id: 'dev-workflow',
name: 'Dev Workflow',
when_to_use: 'Autonomous developer.',
inputs: [],
});
hoisted.recentRuns.mockResolvedValue([]);
hoisted.cronList.mockResolvedValue({ result: [] });
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
});
it('pre-selects the skill from the ?skill= query on mount', async () => {
const Body = await importBody();
renderBody(Body, '/skills/run?skill=dev-workflow');
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
// The picker should already be pointing at dev-workflow without any
// user interaction. We assert this two ways: (a) the <select>'s
// value matches, and (b) describeSkill was fetched for it.
const select = (await screen.findByLabelText(
'settings.skillsRunner.skill'
)) as HTMLSelectElement;
expect(select.value).toBe('dev-workflow');
await waitFor(() =>
expect(hoisted.describeSkill).toHaveBeenCalledWith('dev-workflow')
);
});
it('does not preselect when no ?skill= is present', async () => {
const Body = await importBody();
renderBody(Body, '/skills/run');
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
const select = (await screen.findByLabelText(
'settings.skillsRunner.skill'
)) as HTMLSelectElement;
expect(select.value).toBe('');
expect(hoisted.describeSkill).not.toHaveBeenCalled();
});
it('ignores ?skill= when the value is not in the skills_list (picker stays empty, describeSkill called once with empty=never)', async () => {
// ?skill=unknown-skill is treated as best-effort: we set the state
// but the picker shows "Select a skill" since the option isn't in
// the list. The describe call IS attempted (we don't pre-filter
// against the catalog) — but the cancellation effect tears it
// down if the value never resolves to a real skill.
hoisted.describeSkill.mockRejectedValue(new Error('unknown skill'));
const Body = await importBody();
renderBody(Body, '/skills/run?skill=does-not-exist');
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
await waitFor(() =>
expect(hoisted.describeSkill).toHaveBeenCalledWith('does-not-exist')
);
// The dropdown value won't render as an option (not in the list),
// so its current value normalises to '' visually — but the state
// we care about is that the error surfaces, not crashes.
expect(
await screen.findByText(/settings.skillsRunner.error.describe/)
).toBeInTheDocument();
});
});
// ── Phase 5: Run Now flow ────────────────────────────────────────────
//
// Exercises handleRun → buildInputsPayload (lines 167-201), missing-
// required validation (lines 415-429), and the run-result render paths
// (lines 441-452).
describe('SkillsRunnerBody — Run Now flow', () => {
beforeEach(() => {
Object.values(hoisted).forEach((fn) => fn.mockReset());
hoisted.listSkills.mockResolvedValue([{ id: 'pr-review-shepherd', name: 'PR Review Shepherd' }]);
hoisted.describeSkill.mockResolvedValue({
id: 'pr-review-shepherd',
name: 'PR Review Shepherd',
when_to_use: 'Shepherd PRs.',
inputs: [
{ name: 'repo', type: 'string', required: true, description: 'repo owner/name' },
{ name: 'pr_number', type: 'integer', required: false, description: 'PR number' },
{ name: 'dry_run', type: 'boolean', required: false, description: 'Dry run?' },
],
});
hoisted.recentRuns.mockResolvedValue([]);
hoisted.cronList.mockResolvedValue({ result: [] });
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
hoisted.runSkill.mockResolvedValue({
run_id: 'run-abc',
skill_id: 'pr-review-shepherd',
log: '/tmp/run-abc.log',
});
});
it('Run Now button is disabled while required fields are empty', async () => {
const Body = await importBody();
renderBody(Body);
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
fireEvent.change(select, { target: { value: 'pr-review-shepherd' } });
await waitFor(() => expect(hoisted.describeSkill).toHaveBeenCalledWith('pr-review-shepherd'));
// Run Now button should be disabled when required field is empty
const runBtn = await screen.findByText('settings.skillsRunner.runNow');
expect(runBtn.closest('button')).toBeDisabled();
expect(hoisted.runSkill).not.toHaveBeenCalled();
});
it('calls runSkill with built payload when required fields are filled', async () => {
const Body = await importBody();
renderBody(Body);
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
fireEvent.change(select, { target: { value: 'pr-review-shepherd' } });
await waitFor(() => expect(hoisted.describeSkill).toHaveBeenCalled());
// Fill the repo input (rendered as a RepoPicker stub <input>)
const repoInput = await screen.findByTestId('repo-picker-stub');
fireEvent.change(repoInput, { target: { value: 'owner/myrepo' } });
// Wait for button to become enabled (state update after required field filled)
const runBtn = await screen.findByText('settings.skillsRunner.runNow');
await waitFor(() => expect(runBtn.closest('button')).not.toBeDisabled());
fireEvent.click(runBtn.closest('button')!);
await waitFor(() =>
expect(hoisted.runSkill).toHaveBeenCalledWith(
'pr-review-shepherd',
expect.objectContaining({ repo: 'owner/myrepo' })
)
);
});
it('surfaces error when runSkill rejects', async () => {
hoisted.runSkill.mockRejectedValue(new Error('backend error'));
const Body = await importBody();
renderBody(Body);
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
fireEvent.change(select, { target: { value: 'pr-review-shepherd' } });
await waitFor(() => expect(hoisted.describeSkill).toHaveBeenCalled());
const repoInput = await screen.findByTestId('repo-picker-stub');
fireEvent.change(repoInput, { target: { value: 'owner/myrepo' } });
const runBtn = await screen.findByText('settings.skillsRunner.runNow');
await waitFor(() => expect(runBtn.closest('button')).not.toBeDisabled());
fireEvent.click(runBtn.closest('button')!);
await waitFor(() =>
expect(screen.getByTestId('skill-run-error')).toBeInTheDocument()
);
});
});
@@ -0,0 +1,64 @@
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import BranchPicker from './BranchPicker';
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
const mockExecute = vi.fn();
vi.mock('../../../lib/composio/composioApi', () => ({
execute: (...a: unknown[]) => mockExecute(...a),
}));
describe('BranchPicker', () => {
const baseProps = { value: '', onChange: vi.fn(), repo: '' };
beforeEach(() => {
mockExecute.mockReset();
mockExecute.mockResolvedValue({ successful: true, data: [{ name: 'main' }, { name: 'dev' }] });
});
it('renders disabled with hint when no repo is selected', async () => {
render(<BranchPicker {...baseProps} />);
const select = await screen.findByRole('combobox');
expect(select).toBeDisabled();
});
it('loads and displays branches when repo is set', async () => {
render(<BranchPicker {...baseProps} repo="owner/repo" />);
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
expect(mockExecute).toHaveBeenCalled();
});
it('reflects a pre-selected value', async () => {
render(<BranchPicker {...baseProps} repo="owner/repo" value="main" />);
const select = await screen.findByRole('combobox');
await waitFor(() => expect(select).toHaveValue('main'));
});
it('falls back to main/master when API returns empty list', async () => {
mockExecute.mockResolvedValue({ successful: true, data: [] });
render(<BranchPicker {...baseProps} repo="owner/repo" />);
await waitFor(() => expect(screen.getByRole('combobox')).toBeInTheDocument());
});
it('handles API error gracefully', async () => {
mockExecute.mockResolvedValue({ successful: false, error: 'API error' });
render(<BranchPicker {...baseProps} repo="owner/repo" />);
await waitFor(() => expect(screen.getByRole('combobox')).toBeInTheDocument());
});
it('handles incomplete repo string (missing slash)', async () => {
render(<BranchPicker {...baseProps} repo="noslash" />);
const select = await screen.findByRole('combobox');
expect(select).toBeInTheDocument();
});
it('is disabled when disabled prop is true', async () => {
render(<BranchPicker {...baseProps} repo="owner/repo" disabled />);
const select = await screen.findByRole('combobox');
expect(select).toBeDisabled();
});
});
@@ -0,0 +1,148 @@
// Reusable GitHub branch picker — dropdown sourced from
// `composio_execute(GITHUB_LIST_BRANCHES)` for the linked repo input.
//
// Used by SkillsRunnerBody for any skill input whose name matches the
// branch-shaped conventions (`branch`, `target_branch`, `base_branch`,
// `pr_base`, `head_branch`). Depends on a sibling `repo`-shaped input
// for which repo to list branches for; if that sibling is empty, the
// picker renders a disabled dropdown with a "select a repo first" hint.
//
// Refetches whenever `repo` changes. Like RepoPicker, this is a
// parallel component to the inline impl in DevWorkflowPanel — the
// original panel stays untouched.
import createDebug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import { execute as composioExecute } from '../../../lib/composio/composioApi';
import { useT } from '../../../lib/i18n/I18nContext';
const log = createDebug('app:skills:BranchPicker');
interface GhBranch {
name: string;
}
export interface BranchPickerProps {
/** Selected branch name (or empty). */
value: string;
/** Fires with the picked branch name. */
onChange: (next: string) => void;
/**
* `owner/repo` of the repo to list branches for. When empty, the
* picker renders disabled with a "select a repo first" hint.
*/
repo: string;
id?: string;
placeholder?: string;
disabled?: boolean;
}
const BranchPicker = ({ value, onChange, repo, id, placeholder, disabled }: BranchPickerProps) => {
const { t } = useT();
const [branches, setBranches] = useState<GhBranch[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Monotonic request token so stale GITHUB_LIST_BRANCHES responses
// (a slower fetch for a previous repo) can't overwrite state for the
// current repo. Each call captures the seq it started with and bails
// before every post-await state write if a newer call has begun.
const requestSeqRef = useRef(0);
const loadBranches = useCallback(async () => {
if (!repo || !repo.includes('/')) {
setBranches([]);
setError(null);
return;
}
const [owner, repoName] = repo.split('/');
if (!owner || !repoName) {
setBranches([]);
return;
}
const seq = ++requestSeqRef.current;
setLoading(true);
setError(null);
try {
const res = await composioExecute('GITHUB_LIST_BRANCHES', {
owner,
repo: repoName,
per_page: 100,
});
if (seq !== requestSeqRef.current) return;
if (!res.successful) throw new Error(res.error ?? 'Failed to list branches');
// Composio wraps GitHub branch data in a few different shapes
// (details, data.details, branches, items, direct array under data) —
// probe the same way DevWorkflowPanel does.
const raw = res.data;
let list: GhBranch[] = [];
if (Array.isArray(raw)) {
list = raw as GhBranch[];
} else if (raw && typeof raw === 'object') {
const obj = raw as Record<string, unknown>;
const dataObj = obj.data as Record<string, unknown> | undefined;
const arr =
(obj.details as unknown[] | undefined) ??
(dataObj?.details as unknown[] | undefined) ??
(obj.branches as unknown[] | undefined) ??
(obj.items as unknown[] | undefined) ??
(dataObj as unknown[] | undefined);
if (Array.isArray(arr)) {
list = arr as GhBranch[];
}
}
log('loaded %d branches for %s', list.length, repo);
setBranches(list);
if (list.length === 0) {
// Fall back so the user can still pick something sensible.
setBranches([{ name: 'main' }, { name: 'master' }]);
}
} catch (err: unknown) {
if (seq !== requestSeqRef.current) return;
const msg = err instanceof Error ? err.message : String(err);
log('loadBranches error: %s', msg);
setError(msg);
// Even on error, give the user the standard defaults.
setBranches([{ name: 'main' }, { name: 'master' }]);
} finally {
if (seq === requestSeqRef.current) setLoading(false);
}
}, [repo]);
useEffect(() => {
void loadBranches();
}, [loadBranches]);
const selectClass =
'w-full rounded border border-stone-300 dark:border-stone-600 bg-white dark:bg-stone-800 px-3 py-2 text-sm text-stone-900 dark:text-stone-100';
return (
<div>
<select
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled || loading || !repo}
className={selectClass}
>
<option value="">
{!repo
? t('settings.skillsRunner.branchPicker.needRepo')
: loading
? t('settings.skillsRunner.branchPicker.loading')
: (placeholder ?? t('settings.skillsRunner.branchPicker.select'))}
</option>
{branches.map((b) => (
<option key={b.name} value={b.name}>
{b.name}
</option>
))}
</select>
{error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">{error}</p>
)}
</div>
);
};
export default BranchPicker;
@@ -0,0 +1,68 @@
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import RepoPicker from './RepoPicker';
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
const mockListConnections = vi.fn();
const mockExecute = vi.fn();
vi.mock('../../../lib/composio/composioApi', () => ({
execute: (...a: unknown[]) => mockExecute(...a),
listConnections: () => mockListConnections(),
}));
describe('RepoPicker', () => {
const baseProps = { value: '', onChange: vi.fn() };
beforeEach(() => {
mockListConnections.mockReset();
mockExecute.mockReset();
mockListConnections.mockResolvedValue({
connections: [{ toolkit: 'github', status: 'ACTIVE', username: 'u' }],
});
mockExecute.mockResolvedValue({
successful: true,
data: [{ name: 'repo1', owner: { login: 'user' }, full_name: 'user/repo1' }],
});
});
it('renders a combobox and loads repos', async () => {
render(<RepoPicker {...baseProps} />);
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
expect(mockListConnections).toHaveBeenCalled();
});
it('shows not-connected error when no GitHub connection', async () => {
mockListConnections.mockResolvedValue({ connections: [] });
render(<RepoPicker {...baseProps} />);
await waitFor(() => expect(screen.getByRole('combobox')).toBeInTheDocument());
});
it('handles failed repo fetch gracefully', async () => {
mockExecute.mockResolvedValue({ successful: false, error: 'rate limited' });
render(<RepoPicker {...baseProps} />);
await waitFor(() => expect(screen.getByRole('combobox')).toBeInTheDocument());
});
it('handles empty repo list', async () => {
mockExecute.mockResolvedValue({ successful: true, data: [] });
render(<RepoPicker {...baseProps} />);
await waitFor(() => expect(screen.getByRole('combobox')).toBeInTheDocument());
});
it('respects disabled prop', async () => {
render(<RepoPicker {...baseProps} disabled />);
const select = await screen.findByRole('combobox');
expect(select).toBeDisabled();
});
it('forwards id to the select element', async () => {
render(<RepoPicker {...baseProps} id="repo-input" />);
const select = await screen.findByRole('combobox');
expect(select).toHaveAttribute('id', 'repo-input');
});
});
@@ -0,0 +1,148 @@
// Reusable GitHub repo picker — autocomplete dropdown sourced from the
// user's Composio-connected GitHub account via
// `composio_execute(GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER)`.
//
// Used by SkillsRunnerBody for any skill input whose name matches the
// repo-shaped conventions (`repo`, `repository`, `upstream`, `fork`,
// `fork_owner`). Replaces the plain text input with this picker so users
// don't have to type `owner/name` manually for skills like
// github-issue-crusher and dev-workflow.
//
// Logic mirrors DevWorkflowPanel's existing repo-loading flow (same
// Composio RPCs, same wire-shape parsing) so the picker behaves
// identically to the Settings → Dev Workflow panel. The original panel
// stays in place with its own inline implementation; this is a parallel
// component for the generic Skills Runner surface.
import createDebug from 'debug';
import { useCallback, useEffect, useState } from 'react';
import { execute as composioExecute, listConnections } from '../../../lib/composio/composioApi';
import { useT } from '../../../lib/i18n/I18nContext';
const log = createDebug('app:skills:RepoPicker');
/** Shape returned by `openhuman.composio_list_github_repos`. */
export interface ComposioGhRepo {
owner: string;
repo: string;
fullName: string;
private?: boolean;
defaultBranch?: string;
htmlUrl?: string;
}
export interface RepoPickerProps {
/** Currently-selected `owner/name` (or empty). */
value: string;
/** Fires with the picked `owner/name`. */
onChange: (next: string) => void;
/** Optional `id` for `<label htmlFor>` to bind correctly. */
id?: string;
/** Optional `placeholder` text override. */
placeholder?: string;
/** Disable the picker entirely. */
disabled?: boolean;
}
const RepoPicker = ({ value, onChange, id, placeholder, disabled }: RepoPickerProps) => {
const { t } = useT();
const [repos, setRepos] = useState<ComposioGhRepo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// ── Fetch repos via Composio (mirrors DevWorkflowPanel) ────────────
const loadRepos = useCallback(async () => {
setLoading(true);
setError(null);
try {
// Step 1: Is GitHub connected via Composio?
const conns = await listConnections();
const ghConn = conns.connections?.find(
(c) =>
c.toolkit.toLowerCase().includes('github') &&
(c.status === 'ACTIVE' || c.status === 'CONNECTED')
);
if (!ghConn) throw new Error('NOT_CONNECTED');
// Step 2: Fetch repos.
const res = await composioExecute(
'GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER',
{}
);
if (!res.successful) throw new Error(res.error ?? 'Failed to fetch repositories');
// Step 3: Parse — GitHub API returns an array of repo objects;
// Composio sometimes wraps it under `.repositories`.
const raw = res.data;
const items = Array.isArray(raw)
? raw
: ((raw as Record<string, unknown>)?.repositories ?? []);
const list: ComposioGhRepo[] = Array.isArray(items)
? (items as Record<string, unknown>[]).map((r) => ({
owner: String((r.owner as Record<string, unknown>)?.login ?? r.owner ?? ''),
repo: String(r.name ?? ''),
fullName: String(
r.full_name ?? `${(r.owner as Record<string, unknown>)?.login ?? r.owner}/${r.name}`
),
private: r.private as boolean | undefined,
defaultBranch: r.default_branch as string | undefined,
htmlUrl: r.html_url as string | undefined,
}))
: [];
log('loaded %d repos', list.length);
setRepos(list);
if (list.length === 0) {
setError(t('settings.skillsRunner.repoPicker.empty'));
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
log('loadRepos error: %s', msg);
if (msg === 'NOT_CONNECTED') {
setError(t('settings.skillsRunner.repoPicker.notConnected'));
} else {
setError(msg);
}
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void loadRepos();
}, [loadRepos]);
// Common <select> classes — match the plain inputs in SkillsRunnerBody
// so the picker visually blends with the surrounding form.
const selectClass =
'w-full rounded border border-stone-300 dark:border-stone-600 bg-white dark:bg-stone-800 px-3 py-2 text-sm text-stone-900 dark:text-stone-100';
return (
<div>
<select
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled || loading || error !== null}
className={selectClass}
>
<option value="">
{loading
? t('settings.skillsRunner.repoPicker.loading')
: (placeholder ?? t('settings.skillsRunner.repoPicker.select'))}
</option>
{repos.map((r) => (
<option key={r.fullName} value={r.fullName}>
{r.fullName}
{r.private ? ` ${t('settings.skillsRunner.repoPicker.privateTag')}` : ''}
</option>
))}
</select>
{error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">{error}</p>
)}
</div>
);
};
export default RepoPicker;
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import { isGithubGateFailure, parseSkillRunError } from './preflightGate';
describe('parseSkillRunError', () => {
it('returns the raw body unchanged when no preflight prefix is present', () => {
const out = parseSkillRunError('Run failed because foo');
expect(out.gate).toBeNull();
expect(out.tag).toBeNull();
expect(out.body).toBe('Run failed because foo');
});
it('handles null / undefined / empty without throwing', () => {
expect(parseSkillRunError(null).body).toBe('');
expect(parseSkillRunError(undefined).body).toBe('');
expect(parseSkillRunError('').body).toBe('');
});
it('parses a github identity_mismatch failure into gate + tag + body', () => {
const raw =
'[preflight:github:identity_mismatch] GitHub preflight failed: identity mismatch — Composio is `octo-alice` but git is `Alice`.';
const out = parseSkillRunError(raw);
expect(out.gate).toBe('github');
expect(out.tag).toBe('identity_mismatch');
expect(out.body).toContain('GitHub preflight failed');
expect(out.body).not.toContain('[preflight');
});
it('parses every documented github gate tag', () => {
const tags = [
'composio_github_missing',
'git_binary_missing',
'git_user_name_missing',
'git_user_email_missing',
'identity_mismatch',
'composio_identity_unresolved',
];
for (const tag of tags) {
const raw = `[preflight:github:${tag}] body for ${tag}`;
const out = parseSkillRunError(raw);
expect(out.gate).toBe('github');
expect(out.tag).toBe(tag);
expect(out.body).toBe(`body for ${tag}`);
}
});
it('is idempotent — re-parsing a stripped body is a no-op', () => {
const raw = '[preflight:github:git_user_name_missing] please set user.name';
const once = parseSkillRunError(raw);
const twice = parseSkillRunError(once.body);
expect(twice.gate).toBeNull();
expect(twice.tag).toBeNull();
expect(twice.body).toBe(once.body);
});
it('tolerates lowercase / mixed case in the prefix gate name', () => {
const out = parseSkillRunError('[preflight:GITHUB:Identity_Mismatch] body');
expect(out.gate).toBe('github');
expect(out.tag).toBe('identity_mismatch');
expect(out.body).toBe('body');
});
it('does NOT parse a prefix-shaped string with a stray prefix-like text', () => {
// Anchored at start of string only — a dump that contains the
// prefix mid-text shouldn't be misinterpreted.
const raw = 'orchestrator log: [preflight:github:tag] not the head of the string';
const out = parseSkillRunError(raw);
expect(out.gate).toBeNull();
expect(out.body).toBe(raw);
});
});
describe('isGithubGateFailure', () => {
it('returns true for a github-gate parsed error', () => {
const err = parseSkillRunError('[preflight:github:identity_mismatch] x');
expect(isGithubGateFailure(err)).toBe(true);
});
it('returns false for a free-form error', () => {
const err = parseSkillRunError('Something else failed');
expect(isGithubGateFailure(err)).toBe(false);
});
it('returns false for a future non-github gate (forward-compat)', () => {
const err = parseSkillRunError('[preflight:slack:scope_missing] body');
expect(isGithubGateFailure(err)).toBe(false);
expect(err.gate).toBe('slack');
});
});
@@ -0,0 +1,68 @@
// Parse and present the structured error message that the
// `openhuman.skills_run` RPC returns when a skill's `[github]`
// preflight gate refuses the run.
//
// Backend contract (see src/openhuman/skills/schemas.rs's
// `spawn_skill_run_background` → preflight branch and
// src/openhuman/skills/preflight.rs's `GithubGateError::tag` /
// `to_user_message`): the error string is shaped as
//
// `[preflight:<gate>:<tag>] <user-readable body>`
//
// where `<gate>` is `github` today and `<tag>` is one of:
//
// composio_github_missing | git_binary_missing | git_user_name_missing |
// git_user_email_missing | identity_mismatch | composio_identity_unresolved
//
// Free-form RPC errors (anything else) parse as `gate: null`
// `body: <raw>` so the caller can fall back to a generic error
// pill. The split is what lets the runner UI surface the gate
// failure as a distinct status (pill + remediation) rather than
// blending it into the generic "Run failed to start" surface.
const PREFLIGHT_PREFIX_RE = /^\[preflight:([a-z0-9_-]+):([a-z0-9_-]+)\]\s+/i;
/** Parsed shape of a backend RPC error returned by `openhuman.skills_run`. */
export interface SkillRunError {
/** `'github'` when this is a github-gate failure; `null` for any other error. */
gate: string | null;
/**
* Short machine tag (e.g. `'identity_mismatch'`). Stable across versions —
* see the rustdoc on `GithubGateError::tag()`. `null` for non-gate errors.
*/
tag: string | null;
/** User-readable body with the gate prefix stripped. */
body: string;
}
/**
* Parse the message string returned by the `openhuman.skills_run` RPC
* error path (or thrown by `skillsApi.runSkill`). Anything that matches
* the `[preflight:<gate>:<tag>]` prefix becomes a structured gate
* failure; anything else falls through with `gate: null` so the caller
* can render the raw text.
*
* Idempotent: calling this twice on the same message is fine (the
* second call sees no prefix and returns the same body unchanged).
*/
export function parseSkillRunError(message: string | undefined | null): SkillRunError {
const raw = (message ?? '').toString();
const m = PREFLIGHT_PREFIX_RE.exec(raw);
if (!m) {
return { gate: null, tag: null, body: raw };
}
return {
gate: m[1].toLowerCase(),
tag: m[2].toLowerCase(),
body: raw.slice(m[0].length),
};
}
/**
* True when this error is a github-gate failure. Convenience for
* the rendering layer that wants a single boolean rather than
* matching on the `gate` string.
*/
export function isGithubGateFailure(err: SkillRunError): boolean {
return err.gate === 'github';
}
@@ -0,0 +1,31 @@
/**
* Shared schedule-rendering helpers for ScheduledCronCard. Lives
* alongside the card rather than under `lib/cron/` because the card is
* the only consumer today and we want to keep churn localised — if
* another surface picks it up we'll promote the helper.
*/
import { cronToHuman } from '../../lib/cron/cronToHuman';
import type { CoreCronJob } from '../../utils/tauriCommands/cron';
/**
* Pull the cron expression out of the schedule discriminated-union and
* render it as a human-friendly string. Today only `kind: 'cron'`
* carries an `expr`; the other variants (`at`, `every`) render their
* own shape.
*
* Falls back to the raw `expression` field if the schedule shape is
* unrecognisable — keeps the card non-blank on legacy jobs.
*/
export function formatSchedule(job: CoreCronJob): string {
const s = job.schedule as
| { kind?: string; expr?: string; at?: string; every_ms?: number }
| undefined;
if (!s) return job.expression ?? '';
if (s.kind === 'cron' && s.expr) return cronToHuman(s.expr);
if (s.kind === 'at' && s.at) return new Date(s.at).toLocaleString();
if (s.kind === 'every' && s.every_ms) {
const minutes = Math.round(s.every_ms / 60_000);
return `Every ${minutes} minutes`;
}
return cronToHuman(job.expression ?? '');
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import { cronToHuman } from './cronToHuman';
describe('cronToHuman', () => {
describe('SkillsRunnerBody / DevWorkflowPanel preset expressions', () => {
it('every 30 minutes', () => {
expect(cronToHuman('*/30 * * * *')).toBe('Every 30 minutes');
});
it('every hour (minute=0)', () => {
expect(cronToHuman('0 * * * *')).toBe('Every hour');
});
it('every 2 hours', () => {
expect(cronToHuman('0 */2 * * *')).toBe('Every 2 hours');
});
it('every 6 hours', () => {
expect(cronToHuman('0 */6 * * *')).toBe('Every 6 hours');
});
it('once daily at 09:00', () => {
expect(cronToHuman('0 9 * * *')).toBe('Daily at 09:00');
});
});
describe('generic patterns', () => {
it('every minute', () => {
expect(cronToHuman('*/1 * * * *')).toBe('Every minute');
});
it('hourly at a non-zero minute', () => {
expect(cronToHuman('15 * * * *')).toBe('Hourly at :15');
});
it('every N hours with a non-zero minute offset', () => {
expect(cronToHuman('30 */3 * * *')).toBe('Every 3 hours at :30');
});
it('every hour (step=1) with non-zero minute', () => {
expect(cronToHuman('45 */1 * * *')).toBe('Every hour at :45');
});
it('daily at a non-rounded hour:minute', () => {
expect(cronToHuman('30 14 * * *')).toBe('Daily at 14:30');
});
});
describe('edge cases', () => {
it('empty string', () => {
expect(cronToHuman('')).toBe('');
});
it('whitespace only', () => {
expect(cronToHuman(' ')).toBe('');
});
it('not a string', () => {
// @ts-expect-error testing runtime fallthrough on bad input
expect(cronToHuman(null)).toBe('');
// @ts-expect-error testing runtime fallthrough on bad input
expect(cronToHuman(undefined)).toBe('');
});
it('wrong number of fields falls back to raw expression', () => {
expect(cronToHuman('* * *')).toBe('* * *');
expect(cronToHuman('0 0 1 1 0 2026')).toBe('0 0 1 1 0 2026');
});
it('day-of-month constraint falls back to raw expression', () => {
expect(cronToHuman('0 9 1 * *')).toBe('0 9 1 * *');
});
it('day-of-week constraint falls back to raw expression', () => {
expect(cronToHuman('0 9 * * 1')).toBe('0 9 * * 1');
});
it('month constraint falls back to raw expression', () => {
expect(cronToHuman('0 9 * 6 *')).toBe('0 9 * 6 *');
});
it('collapses extra whitespace before parsing', () => {
expect(cronToHuman(' 0 9 * * * ')).toBe('Daily at 09:00');
});
});
});
+90
View File
@@ -0,0 +1,90 @@
/**
* Human-readable rendering of a cron expression.
*
* Used by Skills dashboard cards (and DevWorkflowPanel's active-config
* card via the preset-key mapping it already does) to display a
* friendly string like "Every 30 minutes" or "Daily at 09:00" instead
* of the raw `*\/30 * * * *` next to a schedule.
*
* Scope: this is intentionally small — it recognises the five
* preset expressions both DevWorkflowPanel and SkillsRunnerBody offer
* (`every30min` / `everyHour` / `every2hours` / `every6hours` /
* `onceDaily`) plus a few generic patterns that fall out naturally
* from those (hourly at minute N, every N minutes/hours, daily at
* HH:MM). Anything we can't parse round-trips as the raw expression so
* the user still sees *something* deterministic.
*
* We DO NOT pull in a full cron-parser dependency — every byte added
* to the renderer-side bundle ships in CEF and the schedule presets
* we surface today are deliberately a small fixed set. If schedules
* grow into truly arbitrary cron expressions, swap this helper for
* `cronstrue` and keep the function signature.
*/
/** Trim whitespace + collapse internal runs to single spaces. */
function normalise(expr: string): string {
return expr.trim().replace(/\s+/g, ' ');
}
/** Pad an integer to two digits (`9` → `"09"`). */
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
/**
* Render a 5-field cron expression in human-readable English.
*
* Returns the raw expression unchanged if we can't recognise the
* shape — callers should render it inline (the schedule list already
* does this fallback when a preset-key lookup misses).
*/
export function cronToHuman(expr: string): string {
if (typeof expr !== 'string') return '';
const e = normalise(expr);
if (e === '') return '';
// 5-field standard cron: minute hour dom month dow
const parts = e.split(' ');
if (parts.length !== 5) return e;
const [min, hour, dom, mon, dow] = parts;
const allDays = dom === '*' && mon === '*' && dow === '*';
// "*/N * * * *" → "Every N minutes"
const stepMin = /^\*\/(\d+)$/.exec(min);
if (stepMin && hour === '*' && allDays) {
const n = Number(stepMin[1]);
if (n === 1) return 'Every minute';
return `Every ${n} minutes`;
}
// "M * * * *" → "Hourly at :MM" (minute literal, every hour)
const minLiteral = /^(\d+)$/.exec(min);
if (minLiteral && hour === '*' && allDays) {
const m = Number(minLiteral[1]);
if (m === 0) return 'Every hour';
return `Hourly at :${pad2(m)}`;
}
// "M */N * * *" → "Every N hours at :MM" (or just "Every N hours" if MM=0)
const stepHour = /^\*\/(\d+)$/.exec(hour);
if (stepHour && minLiteral && allDays) {
const n = Number(stepHour[1]);
const m = Number(minLiteral[1]);
const suffix = m === 0 ? '' : ` at :${pad2(m)}`;
if (n === 1) return `Every hour${suffix}`;
return `Every ${n} hours${suffix}`;
}
// "M H * * *" → "Daily at HH:MM"
const hourLiteral = /^(\d+)$/.exec(hour);
if (minLiteral && hourLiteral && allDays) {
const h = Number(hourLiteral[1]);
const m = Number(minLiteral[1]);
return `Daily at ${pad2(h)}:${pad2(m)}`;
}
// Fall back to the raw expression — better a deterministic string
// than guessing at "every weekday at midnight unless month".
return e;
}
+114 -2
View File
@@ -204,8 +204,27 @@ 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.skillsRunner.scheduleEnabled': 'Enabled',
'settings.skillsRunner.scheduleDisabled': 'Paused',
'settings.skillsRunner.scheduleToggleAria': 'Toggle schedule enabled',
'settings.skillsRunner.schedule.history': 'History',
'settings.skillsRunner.schedule.historyLoading': 'Loading history\u2026',
'settings.skillsRunner.schedule.historyEmpty': 'No runs yet for this schedule.',
'settings.skillsRunner.schedule.historyNoOutput': 'No output captured.',
'settings.skillsRunner.schedule.active': 'Active',
'settings.skillsRunner.schedule.lastRunLabel': 'last:',
'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':
@@ -341,6 +360,23 @@ const ar5: TranslationMap = {
'skills.create.creating': 'جارٍ الإنشاء…',
'skills.create.description': 'الوصف',
'skills.create.descriptionPlaceholder': 'ما الذي تفعله هذه المهارة؟',
'skills.create.optional': '(optional)',
'skills.create.inputs.heading': 'Inputs',
'skills.create.inputs.help':
'Declare parameters the skill needs. The Skills Runner will render a form for these at run time.',
'skills.create.inputs.add': 'Add input',
'skills.create.inputs.row.name': 'Input name',
'skills.create.inputs.row.namePlaceholder': 'e.g. repo',
'skills.create.inputs.row.nameError':
'Letters, digits, underscores, and dashes only; must start with a letter.',
'skills.create.inputs.row.description': 'Input description',
'skills.create.inputs.row.descriptionPlaceholder': 'What goes in this field?',
'skills.create.inputs.row.type': 'Type',
'skills.create.inputs.row.required': 'Required',
'skills.create.inputs.row.remove': 'Remove input',
'skills.create.inputs.type.string': 'Text',
'skills.create.inputs.type.integer': 'Number',
'skills.create.inputs.type.boolean': 'Yes / No',
'skills.create.license': 'الترخيص',
'skills.create.name': 'الاسم',
'skills.create.namePlaceholder': 'مثال: يومية التداول',
@@ -810,6 +846,64 @@ const ar5: TranslationMap = {
'settings.agentAccess.add': 'Add',
'settings.agentAccess.saving': 'Saving…',
'settings.agentAccess.changesApply': 'Changes apply on your next message.',
'skills.tabs.runners': 'Runners',
'settings.developerMenu.skillsRunner.title': 'Skills Runner',
'settings.developerMenu.skillsRunner.desc':
'Run any bundled skill ad-hoc — fill its inputs and fire a background autonomous run',
'settings.developerMenu.skillsRunner.panelDesc':
'Pick a bundled skill, fill in its declared inputs, and fire a fire-and-forget background run. Use Dev Workflow instead if you want a cron-scheduled recurring job.',
'settings.skillsRunner.skill': 'Skill',
'settings.skillsRunner.selectSkill': 'Select a skill…',
'settings.skillsRunner.loadingSkills': 'Loading skills…',
'settings.skillsRunner.loadingDescription': 'Loading skill inputs…',
'settings.skillsRunner.noInputs': 'This skill declares no inputs.',
'settings.skillsRunner.placeholder.required': 'required',
'settings.skillsRunner.runNow': 'Run now',
'settings.skillsRunner.starting': 'Starting…',
'settings.skillsRunner.started': 'Started — run id:',
'settings.skillsRunner.logPath': 'Log:',
'settings.skillsRunner.error.listSkills': 'Failed to load skills:',
'settings.skillsRunner.error.describe': 'Failed to load inputs:',
'settings.skillsRunner.error.missingRequired': 'Missing required input(s):',
'settings.skillsRunner.error.run': 'Run failed to start:',
'settings.skillsRunner.error.preflightGate': 'Preflight gate failed',
'settings.skillsRunner.schedule.heading': 'Schedule (recurring)',
'settings.skillsRunner.schedule.help':
'Save this skill + inputs as a recurring cron job. The agent will call run_skill at each tick.',
'settings.skillsRunner.schedule.frequency': 'Frequency',
'settings.skillsRunner.schedule.every30min': 'Every 30 minutes',
'settings.skillsRunner.schedule.everyHour': 'Every hour',
'settings.skillsRunner.schedule.every2hours': 'Every 2 hours',
'settings.skillsRunner.schedule.every6hours': 'Every 6 hours',
'settings.skillsRunner.schedule.onceDaily': 'Once daily (9:00)',
'settings.skillsRunner.schedule.save': 'Save schedule',
'settings.skillsRunner.schedule.saving': 'Saving…',
'settings.skillsRunner.schedule.saved': 'Schedule saved.',
'settings.skillsRunner.schedule.error': 'Schedule save failed:',
'settings.skillsRunner.schedule.loadingJobs': 'Loading existing schedules…',
'settings.skillsRunner.schedule.noJobs': 'No schedules saved for this skill yet.',
'settings.skillsRunner.schedule.existing': 'Scheduled jobs for this skill:',
'settings.skillsRunner.schedule.runNow': 'Run',
'settings.skillsRunner.schedule.remove': 'Remove',
'settings.skillsRunner.recentRuns.headingForSkill': 'Recent runs for this skill',
'settings.skillsRunner.recentRuns.headingAll': 'Recent skill runs (all)',
'settings.skillsRunner.recentRuns.refresh': 'Refresh',
'settings.skillsRunner.recentRuns.loading': 'Loading recent runs…',
'settings.skillsRunner.recentRuns.empty': 'No recent runs.',
'settings.skillsRunner.viewer.loading': 'Loading log…',
'settings.skillsRunner.viewer.tailing': 'Live tailing',
'settings.skillsRunner.viewer.fetching': 'fetching',
'settings.skillsRunner.viewer.error': 'Log read failed:',
'settings.skillsRunner.repoPicker.loading': 'Loading repositories…',
'settings.skillsRunner.repoPicker.select': 'Select a repository…',
'settings.skillsRunner.repoPicker.empty':
'No repositories returned. Connect GitHub via Composio to populate this list.',
'settings.skillsRunner.repoPicker.notConnected':
'GitHub isnt connected via Composio. Connect it under Skills → Composio first.',
'settings.skillsRunner.repoPicker.privateTag': '(private)',
'settings.skillsRunner.branchPicker.needRepo': 'Pick a repo first…',
'settings.skillsRunner.branchPicker.loading': 'Loading branches…',
'settings.skillsRunner.branchPicker.select': 'Select a branch…',
'settings.modelHealth.title': 'Model Health',
'settings.modelHealth.desc':
'Per-model quality, hallucination rate, and cost comparison across active models',
@@ -834,6 +928,24 @@ const ar5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
'skills.dashboard.scheduledHeading': 'Scheduled skills',
'skills.dashboard.emptyTitle': 'No scheduled skills',
'skills.dashboard.emptyBody':
'Run a bundled skill once or save a recurring schedule to see it here.',
'skills.dashboard.create': 'Create a Skill',
'skills.dashboard.run': 'Run a Skill',
'skills.dashboard.enable': 'Enable scheduled skill',
'skills.dashboard.disable': 'Disable scheduled skill',
'skills.dashboard.lastRun': 'Last run',
'skills.dashboard.nextRun': 'Next run',
'skills.dashboard.cardOpenRunner': 'Open in runner',
'skills.dashboard.loadError': 'Failed to load scheduled skills',
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
};
export default ar5;
+114 -2
View File
@@ -209,8 +209,27 @@ 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.skillsRunner.scheduleEnabled': 'Enabled',
'settings.skillsRunner.scheduleDisabled': 'Paused',
'settings.skillsRunner.scheduleToggleAria': 'Toggle schedule enabled',
'settings.skillsRunner.schedule.history': 'History',
'settings.skillsRunner.schedule.historyLoading': 'Loading history\u2026',
'settings.skillsRunner.schedule.historyEmpty': 'No runs yet for this schedule.',
'settings.skillsRunner.schedule.historyNoOutput': 'No output captured.',
'settings.skillsRunner.schedule.active': 'Active',
'settings.skillsRunner.schedule.lastRunLabel': 'last:',
'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':
@@ -348,6 +367,23 @@ const bn5: TranslationMap = {
'skills.create.creating': 'তৈরি হচ্ছে…',
'skills.create.description': 'বিবরণ',
'skills.create.descriptionPlaceholder': 'এই স্কিল কী করে?',
'skills.create.optional': '(optional)',
'skills.create.inputs.heading': 'Inputs',
'skills.create.inputs.help':
'Declare parameters the skill needs. The Skills Runner will render a form for these at run time.',
'skills.create.inputs.add': 'Add input',
'skills.create.inputs.row.name': 'Input name',
'skills.create.inputs.row.namePlaceholder': 'e.g. repo',
'skills.create.inputs.row.nameError':
'Letters, digits, underscores, and dashes only; must start with a letter.',
'skills.create.inputs.row.description': 'Input description',
'skills.create.inputs.row.descriptionPlaceholder': 'What goes in this field?',
'skills.create.inputs.row.type': 'Type',
'skills.create.inputs.row.required': 'Required',
'skills.create.inputs.row.remove': 'Remove input',
'skills.create.inputs.type.string': 'Text',
'skills.create.inputs.type.integer': 'Number',
'skills.create.inputs.type.boolean': 'Yes / No',
'skills.create.license': 'লাইসেন্স',
'skills.create.name': 'নাম',
'skills.create.namePlaceholder': 'যেমন Trade Journal',
@@ -823,6 +859,64 @@ const bn5: TranslationMap = {
'settings.agentAccess.add': 'Add',
'settings.agentAccess.saving': 'Saving…',
'settings.agentAccess.changesApply': 'Changes apply on your next message.',
'skills.tabs.runners': 'Runners',
'settings.developerMenu.skillsRunner.title': 'Skills Runner',
'settings.developerMenu.skillsRunner.desc':
'Run any bundled skill ad-hoc — fill its inputs and fire a background autonomous run',
'settings.developerMenu.skillsRunner.panelDesc':
'Pick a bundled skill, fill in its declared inputs, and fire a fire-and-forget background run. Use Dev Workflow instead if you want a cron-scheduled recurring job.',
'settings.skillsRunner.skill': 'Skill',
'settings.skillsRunner.selectSkill': 'Select a skill…',
'settings.skillsRunner.loadingSkills': 'Loading skills…',
'settings.skillsRunner.loadingDescription': 'Loading skill inputs…',
'settings.skillsRunner.noInputs': 'This skill declares no inputs.',
'settings.skillsRunner.placeholder.required': 'required',
'settings.skillsRunner.runNow': 'Run now',
'settings.skillsRunner.starting': 'Starting…',
'settings.skillsRunner.started': 'Started — run id:',
'settings.skillsRunner.logPath': 'Log:',
'settings.skillsRunner.error.listSkills': 'Failed to load skills:',
'settings.skillsRunner.error.describe': 'Failed to load inputs:',
'settings.skillsRunner.error.missingRequired': 'Missing required input(s):',
'settings.skillsRunner.error.run': 'Run failed to start:',
'settings.skillsRunner.error.preflightGate': 'Preflight gate failed',
'settings.skillsRunner.schedule.heading': 'Schedule (recurring)',
'settings.skillsRunner.schedule.help':
'Save this skill + inputs as a recurring cron job. The agent will call run_skill at each tick.',
'settings.skillsRunner.schedule.frequency': 'Frequency',
'settings.skillsRunner.schedule.every30min': 'Every 30 minutes',
'settings.skillsRunner.schedule.everyHour': 'Every hour',
'settings.skillsRunner.schedule.every2hours': 'Every 2 hours',
'settings.skillsRunner.schedule.every6hours': 'Every 6 hours',
'settings.skillsRunner.schedule.onceDaily': 'Once daily (9:00)',
'settings.skillsRunner.schedule.save': 'Save schedule',
'settings.skillsRunner.schedule.saving': 'Saving…',
'settings.skillsRunner.schedule.saved': 'Schedule saved.',
'settings.skillsRunner.schedule.error': 'Schedule save failed:',
'settings.skillsRunner.schedule.loadingJobs': 'Loading existing schedules…',
'settings.skillsRunner.schedule.noJobs': 'No schedules saved for this skill yet.',
'settings.skillsRunner.schedule.existing': 'Scheduled jobs for this skill:',
'settings.skillsRunner.schedule.runNow': 'Run',
'settings.skillsRunner.schedule.remove': 'Remove',
'settings.skillsRunner.recentRuns.headingForSkill': 'Recent runs for this skill',
'settings.skillsRunner.recentRuns.headingAll': 'Recent skill runs (all)',
'settings.skillsRunner.recentRuns.refresh': 'Refresh',
'settings.skillsRunner.recentRuns.loading': 'Loading recent runs…',
'settings.skillsRunner.recentRuns.empty': 'No recent runs.',
'settings.skillsRunner.viewer.loading': 'Loading log…',
'settings.skillsRunner.viewer.tailing': 'Live tailing',
'settings.skillsRunner.viewer.fetching': 'fetching',
'settings.skillsRunner.viewer.error': 'Log read failed:',
'settings.skillsRunner.repoPicker.loading': 'Loading repositories…',
'settings.skillsRunner.repoPicker.select': 'Select a repository…',
'settings.skillsRunner.repoPicker.empty':
'No repositories returned. Connect GitHub via Composio to populate this list.',
'settings.skillsRunner.repoPicker.notConnected':
'GitHub isnt connected via Composio. Connect it under Skills → Composio first.',
'settings.skillsRunner.repoPicker.privateTag': '(private)',
'settings.skillsRunner.branchPicker.needRepo': 'Pick a repo first…',
'settings.skillsRunner.branchPicker.loading': 'Loading branches…',
'settings.skillsRunner.branchPicker.select': 'Select a branch…',
'settings.modelHealth.title': 'Model Health',
'settings.modelHealth.desc':
'Per-model quality, hallucination rate, and cost comparison across active models',
@@ -847,6 +941,24 @@ const bn5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
'skills.dashboard.scheduledHeading': 'Scheduled skills',
'skills.dashboard.emptyTitle': 'No scheduled skills',
'skills.dashboard.emptyBody':
'Run a bundled skill once or save a recurring schedule to see it here.',
'skills.dashboard.create': 'Create a Skill',
'skills.dashboard.run': 'Run a Skill',
'skills.dashboard.enable': 'Enable scheduled skill',
'skills.dashboard.disable': 'Disable scheduled skill',
'skills.dashboard.lastRun': 'Last run',
'skills.dashboard.nextRun': 'Next run',
'skills.dashboard.cardOpenRunner': 'Open in runner',
'skills.dashboard.loadError': 'Failed to load scheduled skills',
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
};
export default bn5;
+114 -2
View File
@@ -217,8 +217,27 @@ 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.skillsRunner.scheduleEnabled': 'Enabled',
'settings.skillsRunner.scheduleDisabled': 'Paused',
'settings.skillsRunner.scheduleToggleAria': 'Toggle schedule enabled',
'settings.skillsRunner.schedule.history': 'History',
'settings.skillsRunner.schedule.historyLoading': 'Loading history\u2026',
'settings.skillsRunner.schedule.historyEmpty': 'No runs yet for this schedule.',
'settings.skillsRunner.schedule.historyNoOutput': 'No output captured.',
'settings.skillsRunner.schedule.active': 'Active',
'settings.skillsRunner.schedule.lastRunLabel': 'last:',
'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':
@@ -364,6 +383,23 @@ const de5: TranslationMap = {
'skills.create.creating': 'Erstellen…',
'skills.create.description': 'Beschreibung',
'skills.create.descriptionPlaceholder': 'Was bewirkt diese Fähigkeit?',
'skills.create.optional': '(optional)',
'skills.create.inputs.heading': 'Inputs',
'skills.create.inputs.help':
'Declare parameters the skill needs. The Skills Runner will render a form for these at run time.',
'skills.create.inputs.add': 'Add input',
'skills.create.inputs.row.name': 'Input name',
'skills.create.inputs.row.namePlaceholder': 'e.g. repo',
'skills.create.inputs.row.nameError':
'Letters, digits, underscores, and dashes only; must start with a letter.',
'skills.create.inputs.row.description': 'Input description',
'skills.create.inputs.row.descriptionPlaceholder': 'What goes in this field?',
'skills.create.inputs.row.type': 'Type',
'skills.create.inputs.row.required': 'Required',
'skills.create.inputs.row.remove': 'Remove input',
'skills.create.inputs.type.string': 'Text',
'skills.create.inputs.type.integer': 'Number',
'skills.create.inputs.type.boolean': 'Yes / No',
'skills.create.license': 'Lizenz',
'skills.create.name': 'Name',
'skills.create.namePlaceholder': 'z.B. Fachzeitschrift',
@@ -851,6 +887,64 @@ const de5: TranslationMap = {
'settings.agentAccess.add': 'Add',
'settings.agentAccess.saving': 'Saving…',
'settings.agentAccess.changesApply': 'Changes apply on your next message.',
'skills.tabs.runners': 'Runners',
'settings.developerMenu.skillsRunner.title': 'Skills Runner',
'settings.developerMenu.skillsRunner.desc':
'Run any bundled skill ad-hoc — fill its inputs and fire a background autonomous run',
'settings.developerMenu.skillsRunner.panelDesc':
'Pick a bundled skill, fill in its declared inputs, and fire a fire-and-forget background run. Use Dev Workflow instead if you want a cron-scheduled recurring job.',
'settings.skillsRunner.skill': 'Skill',
'settings.skillsRunner.selectSkill': 'Select a skill…',
'settings.skillsRunner.loadingSkills': 'Loading skills…',
'settings.skillsRunner.loadingDescription': 'Loading skill inputs…',
'settings.skillsRunner.noInputs': 'This skill declares no inputs.',
'settings.skillsRunner.placeholder.required': 'required',
'settings.skillsRunner.runNow': 'Run now',
'settings.skillsRunner.starting': 'Starting…',
'settings.skillsRunner.started': 'Started — run id:',
'settings.skillsRunner.logPath': 'Log:',
'settings.skillsRunner.error.listSkills': 'Failed to load skills:',
'settings.skillsRunner.error.describe': 'Failed to load inputs:',
'settings.skillsRunner.error.missingRequired': 'Missing required input(s):',
'settings.skillsRunner.error.run': 'Run failed to start:',
'settings.skillsRunner.error.preflightGate': 'Preflight gate failed',
'settings.skillsRunner.schedule.heading': 'Schedule (recurring)',
'settings.skillsRunner.schedule.help':
'Save this skill + inputs as a recurring cron job. The agent will call run_skill at each tick.',
'settings.skillsRunner.schedule.frequency': 'Frequency',
'settings.skillsRunner.schedule.every30min': 'Every 30 minutes',
'settings.skillsRunner.schedule.everyHour': 'Every hour',
'settings.skillsRunner.schedule.every2hours': 'Every 2 hours',
'settings.skillsRunner.schedule.every6hours': 'Every 6 hours',
'settings.skillsRunner.schedule.onceDaily': 'Once daily (9:00)',
'settings.skillsRunner.schedule.save': 'Save schedule',
'settings.skillsRunner.schedule.saving': 'Saving…',
'settings.skillsRunner.schedule.saved': 'Schedule saved.',
'settings.skillsRunner.schedule.error': 'Schedule save failed:',
'settings.skillsRunner.schedule.loadingJobs': 'Loading existing schedules…',
'settings.skillsRunner.schedule.noJobs': 'No schedules saved for this skill yet.',
'settings.skillsRunner.schedule.existing': 'Scheduled jobs for this skill:',
'settings.skillsRunner.schedule.runNow': 'Run',
'settings.skillsRunner.schedule.remove': 'Remove',
'settings.skillsRunner.recentRuns.headingForSkill': 'Recent runs for this skill',
'settings.skillsRunner.recentRuns.headingAll': 'Recent skill runs (all)',
'settings.skillsRunner.recentRuns.refresh': 'Refresh',
'settings.skillsRunner.recentRuns.loading': 'Loading recent runs…',
'settings.skillsRunner.recentRuns.empty': 'No recent runs.',
'settings.skillsRunner.viewer.loading': 'Loading log…',
'settings.skillsRunner.viewer.tailing': 'Live tailing',
'settings.skillsRunner.viewer.fetching': 'fetching',
'settings.skillsRunner.viewer.error': 'Log read failed:',
'settings.skillsRunner.repoPicker.loading': 'Loading repositories…',
'settings.skillsRunner.repoPicker.select': 'Select a repository…',
'settings.skillsRunner.repoPicker.empty':
'No repositories returned. Connect GitHub via Composio to populate this list.',
'settings.skillsRunner.repoPicker.notConnected':
'GitHub isnt connected via Composio. Connect it under Skills → Composio first.',
'settings.skillsRunner.repoPicker.privateTag': '(private)',
'settings.skillsRunner.branchPicker.needRepo': 'Pick a repo first…',
'settings.skillsRunner.branchPicker.loading': 'Loading branches…',
'settings.skillsRunner.branchPicker.select': 'Select a branch…',
'settings.modelHealth.title': 'Model Health',
'settings.modelHealth.desc':
'Per-model quality, hallucination rate, and cost comparison across active models',
@@ -875,6 +969,24 @@ const de5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
'skills.dashboard.scheduledHeading': 'Scheduled skills',
'skills.dashboard.emptyTitle': 'No scheduled skills',
'skills.dashboard.emptyBody':
'Run a bundled skill once or save a recurring schedule to see it here.',
'skills.dashboard.create': 'Create a Skill',
'skills.dashboard.run': 'Run a Skill',
'skills.dashboard.enable': 'Enable scheduled skill',
'skills.dashboard.disable': 'Disable scheduled skill',
'skills.dashboard.lastRun': 'Last run',
'skills.dashboard.nextRun': 'Next run',
'skills.dashboard.cardOpenRunner': 'Open in runner',
'skills.dashboard.loadError': 'Failed to load scheduled skills',
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
};
export default de5;
+114 -2
View File
@@ -208,8 +208,27 @@ 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.skillsRunner.scheduleEnabled': 'Enabled',
'settings.skillsRunner.scheduleDisabled': 'Paused',
'settings.skillsRunner.scheduleToggleAria': 'Toggle schedule enabled',
'settings.skillsRunner.schedule.history': 'History',
'settings.skillsRunner.schedule.historyLoading': 'Loading history\u2026',
'settings.skillsRunner.schedule.historyEmpty': 'No runs yet for this schedule.',
'settings.skillsRunner.schedule.historyNoOutput': 'No output captured.',
'settings.skillsRunner.schedule.active': 'Active',
'settings.skillsRunner.schedule.lastRunLabel': 'last:',
'settings.devWorkflow.nextRun': 'Next run',
'settings.devWorkflow.lastRun': 'Last run',
'settings.devWorkflow.runNow': 'Run now',
'settings.devWorkflow.running': 'Running\u2026',
'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':
@@ -388,6 +407,23 @@ const en5: TranslationMap = {
'skills.create.creating': 'Creating…',
'skills.create.description': 'Description',
'skills.create.descriptionPlaceholder': 'What does this skill do?',
'skills.create.optional': '(optional)',
'skills.create.inputs.heading': 'Inputs',
'skills.create.inputs.help':
'Declare parameters the skill needs. The Skills Runner will render a form for these at run time.',
'skills.create.inputs.add': 'Add input',
'skills.create.inputs.row.name': 'Input name',
'skills.create.inputs.row.namePlaceholder': 'e.g. repo',
'skills.create.inputs.row.nameError':
'Letters, digits, underscores, and dashes only; must start with a letter.',
'skills.create.inputs.row.description': 'Input description',
'skills.create.inputs.row.descriptionPlaceholder': 'What goes in this field?',
'skills.create.inputs.row.type': 'Type',
'skills.create.inputs.row.required': 'Required',
'skills.create.inputs.row.remove': 'Remove input',
'skills.create.inputs.type.string': 'Text',
'skills.create.inputs.type.integer': 'Number',
'skills.create.inputs.type.boolean': 'Yes / No',
'skills.create.license': 'License',
'skills.create.name': 'Name',
'skills.create.namePlaceholder': 'e.g. Trade Journal',
@@ -818,6 +854,64 @@ const en5: TranslationMap = {
'skills.meetingBots.platforms.zoom': 'Zoom',
'skills.meetingBots.soonSuffix': 'soon',
'skills.setup.screenIntel.permissionPathLabel': 'macOS applies privacy to:',
'skills.tabs.runners': 'Runners',
'settings.developerMenu.skillsRunner.title': 'Skills Runner',
'settings.developerMenu.skillsRunner.desc':
'Run any bundled skill ad-hoc — fill its inputs and fire a background autonomous run',
'settings.developerMenu.skillsRunner.panelDesc':
'Pick a bundled skill, fill in its declared inputs, and fire a fire-and-forget background run. Use Dev Workflow instead if you want a cron-scheduled recurring job.',
'settings.skillsRunner.skill': 'Skill',
'settings.skillsRunner.selectSkill': 'Select a skill…',
'settings.skillsRunner.loadingSkills': 'Loading skills…',
'settings.skillsRunner.loadingDescription': 'Loading skill inputs…',
'settings.skillsRunner.noInputs': 'This skill declares no inputs.',
'settings.skillsRunner.placeholder.required': 'required',
'settings.skillsRunner.runNow': 'Run now',
'settings.skillsRunner.starting': 'Starting…',
'settings.skillsRunner.started': 'Started — run id:',
'settings.skillsRunner.logPath': 'Log:',
'settings.skillsRunner.error.listSkills': 'Failed to load skills:',
'settings.skillsRunner.error.describe': 'Failed to load inputs:',
'settings.skillsRunner.error.missingRequired': 'Missing required input(s):',
'settings.skillsRunner.error.run': 'Run failed to start:',
'settings.skillsRunner.error.preflightGate': 'Preflight gate failed',
'settings.skillsRunner.schedule.heading': 'Schedule (recurring)',
'settings.skillsRunner.schedule.help':
'Save this skill + inputs as a recurring cron job. The agent will call run_skill at each tick.',
'settings.skillsRunner.schedule.frequency': 'Frequency',
'settings.skillsRunner.schedule.every30min': 'Every 30 minutes',
'settings.skillsRunner.schedule.everyHour': 'Every hour',
'settings.skillsRunner.schedule.every2hours': 'Every 2 hours',
'settings.skillsRunner.schedule.every6hours': 'Every 6 hours',
'settings.skillsRunner.schedule.onceDaily': 'Once daily (9:00)',
'settings.skillsRunner.schedule.save': 'Save schedule',
'settings.skillsRunner.schedule.saving': 'Saving…',
'settings.skillsRunner.schedule.saved': 'Schedule saved.',
'settings.skillsRunner.schedule.error': 'Schedule save failed:',
'settings.skillsRunner.schedule.loadingJobs': 'Loading existing schedules…',
'settings.skillsRunner.schedule.noJobs': 'No schedules saved for this skill yet.',
'settings.skillsRunner.schedule.existing': 'Scheduled jobs for this skill:',
'settings.skillsRunner.schedule.runNow': 'Run',
'settings.skillsRunner.schedule.remove': 'Remove',
'settings.skillsRunner.recentRuns.headingForSkill': 'Recent runs for this skill',
'settings.skillsRunner.recentRuns.headingAll': 'Recent skill runs (all)',
'settings.skillsRunner.recentRuns.refresh': 'Refresh',
'settings.skillsRunner.recentRuns.loading': 'Loading recent runs…',
'settings.skillsRunner.recentRuns.empty': 'No recent runs.',
'settings.skillsRunner.viewer.loading': 'Loading log…',
'settings.skillsRunner.viewer.tailing': 'Live tailing',
'settings.skillsRunner.viewer.fetching': 'fetching',
'settings.skillsRunner.viewer.error': 'Log read failed:',
'settings.skillsRunner.repoPicker.loading': 'Loading repositories…',
'settings.skillsRunner.repoPicker.select': 'Select a repository…',
'settings.skillsRunner.repoPicker.empty':
'No repositories returned. Connect GitHub via Composio to populate this list.',
'settings.skillsRunner.repoPicker.notConnected':
'GitHub isnt connected via Composio. Connect it under Skills → Composio first.',
'settings.skillsRunner.repoPicker.privateTag': '(private)',
'settings.skillsRunner.branchPicker.needRepo': 'Pick a repo first…',
'settings.skillsRunner.branchPicker.loading': 'Loading branches…',
'settings.skillsRunner.branchPicker.select': 'Select a branch…',
'settings.modelHealth.title': 'Model Health',
'settings.modelHealth.desc':
'Per-model quality, hallucination rate, and cost comparison across active models',
@@ -842,6 +936,24 @@ const en5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
'skills.dashboard.scheduledHeading': 'Scheduled skills',
'skills.dashboard.emptyTitle': 'No scheduled skills',
'skills.dashboard.emptyBody':
'Run a bundled skill once or save a recurring schedule to see it here.',
'skills.dashboard.create': 'Create a Skill',
'skills.dashboard.run': 'Run a Skill',
'skills.dashboard.enable': 'Enable scheduled skill',
'skills.dashboard.disable': 'Disable scheduled skill',
'skills.dashboard.lastRun': 'Last run',
'skills.dashboard.nextRun': 'Next run',
'skills.dashboard.cardOpenRunner': 'Open in runner',
'skills.dashboard.loadError': 'Failed to load scheduled skills',
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
};
export default en5;
+114 -2
View File
@@ -212,8 +212,27 @@ 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.skillsRunner.scheduleEnabled': 'Enabled',
'settings.skillsRunner.scheduleDisabled': 'Paused',
'settings.skillsRunner.scheduleToggleAria': 'Toggle schedule enabled',
'settings.skillsRunner.schedule.history': 'History',
'settings.skillsRunner.schedule.historyLoading': 'Loading history\u2026',
'settings.skillsRunner.schedule.historyEmpty': 'No runs yet for this schedule.',
'settings.skillsRunner.schedule.historyNoOutput': 'No output captured.',
'settings.skillsRunner.schedule.active': 'Active',
'settings.skillsRunner.schedule.lastRunLabel': 'last:',
'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':
@@ -352,6 +371,23 @@ const es5: TranslationMap = {
'skills.create.creating': 'Creando…',
'skills.create.description': 'Descripción',
'skills.create.descriptionPlaceholder': '¿Qué hace este skill?',
'skills.create.optional': '(optional)',
'skills.create.inputs.heading': 'Inputs',
'skills.create.inputs.help':
'Declare parameters the skill needs. The Skills Runner will render a form for these at run time.',
'skills.create.inputs.add': 'Add input',
'skills.create.inputs.row.name': 'Input name',
'skills.create.inputs.row.namePlaceholder': 'e.g. repo',
'skills.create.inputs.row.nameError':
'Letters, digits, underscores, and dashes only; must start with a letter.',
'skills.create.inputs.row.description': 'Input description',
'skills.create.inputs.row.descriptionPlaceholder': 'What goes in this field?',
'skills.create.inputs.row.type': 'Type',
'skills.create.inputs.row.required': 'Required',
'skills.create.inputs.row.remove': 'Remove input',
'skills.create.inputs.type.string': 'Text',
'skills.create.inputs.type.integer': 'Number',
'skills.create.inputs.type.boolean': 'Yes / No',
'skills.create.license': 'Licencia',
'skills.create.name': 'Nombre',
'skills.create.namePlaceholder': 'p. ej. Trade Journal',
@@ -837,6 +873,64 @@ const es5: TranslationMap = {
'settings.agentAccess.add': 'Add',
'settings.agentAccess.saving': 'Saving…',
'settings.agentAccess.changesApply': 'Changes apply on your next message.',
'skills.tabs.runners': 'Runners',
'settings.developerMenu.skillsRunner.title': 'Skills Runner',
'settings.developerMenu.skillsRunner.desc':
'Run any bundled skill ad-hoc — fill its inputs and fire a background autonomous run',
'settings.developerMenu.skillsRunner.panelDesc':
'Pick a bundled skill, fill in its declared inputs, and fire a fire-and-forget background run. Use Dev Workflow instead if you want a cron-scheduled recurring job.',
'settings.skillsRunner.skill': 'Skill',
'settings.skillsRunner.selectSkill': 'Select a skill…',
'settings.skillsRunner.loadingSkills': 'Loading skills…',
'settings.skillsRunner.loadingDescription': 'Loading skill inputs…',
'settings.skillsRunner.noInputs': 'This skill declares no inputs.',
'settings.skillsRunner.placeholder.required': 'required',
'settings.skillsRunner.runNow': 'Run now',
'settings.skillsRunner.starting': 'Starting…',
'settings.skillsRunner.started': 'Started — run id:',
'settings.skillsRunner.logPath': 'Log:',
'settings.skillsRunner.error.listSkills': 'Failed to load skills:',
'settings.skillsRunner.error.describe': 'Failed to load inputs:',
'settings.skillsRunner.error.missingRequired': 'Missing required input(s):',
'settings.skillsRunner.error.run': 'Run failed to start:',
'settings.skillsRunner.error.preflightGate': 'Preflight gate failed',
'settings.skillsRunner.schedule.heading': 'Schedule (recurring)',
'settings.skillsRunner.schedule.help':
'Save this skill + inputs as a recurring cron job. The agent will call run_skill at each tick.',
'settings.skillsRunner.schedule.frequency': 'Frequency',
'settings.skillsRunner.schedule.every30min': 'Every 30 minutes',
'settings.skillsRunner.schedule.everyHour': 'Every hour',
'settings.skillsRunner.schedule.every2hours': 'Every 2 hours',
'settings.skillsRunner.schedule.every6hours': 'Every 6 hours',
'settings.skillsRunner.schedule.onceDaily': 'Once daily (9:00)',
'settings.skillsRunner.schedule.save': 'Save schedule',
'settings.skillsRunner.schedule.saving': 'Saving…',
'settings.skillsRunner.schedule.saved': 'Schedule saved.',
'settings.skillsRunner.schedule.error': 'Schedule save failed:',
'settings.skillsRunner.schedule.loadingJobs': 'Loading existing schedules…',
'settings.skillsRunner.schedule.noJobs': 'No schedules saved for this skill yet.',
'settings.skillsRunner.schedule.existing': 'Scheduled jobs for this skill:',
'settings.skillsRunner.schedule.runNow': 'Run',
'settings.skillsRunner.schedule.remove': 'Remove',
'settings.skillsRunner.recentRuns.headingForSkill': 'Recent runs for this skill',
'settings.skillsRunner.recentRuns.headingAll': 'Recent skill runs (all)',
'settings.skillsRunner.recentRuns.refresh': 'Refresh',
'settings.skillsRunner.recentRuns.loading': 'Loading recent runs…',
'settings.skillsRunner.recentRuns.empty': 'No recent runs.',
'settings.skillsRunner.viewer.loading': 'Loading log…',
'settings.skillsRunner.viewer.tailing': 'Live tailing',
'settings.skillsRunner.viewer.fetching': 'fetching',
'settings.skillsRunner.viewer.error': 'Log read failed:',
'settings.skillsRunner.repoPicker.loading': 'Loading repositories…',
'settings.skillsRunner.repoPicker.select': 'Select a repository…',
'settings.skillsRunner.repoPicker.empty':
'No repositories returned. Connect GitHub via Composio to populate this list.',
'settings.skillsRunner.repoPicker.notConnected':
'GitHub isnt connected via Composio. Connect it under Skills → Composio first.',
'settings.skillsRunner.repoPicker.privateTag': '(private)',
'settings.skillsRunner.branchPicker.needRepo': 'Pick a repo first…',
'settings.skillsRunner.branchPicker.loading': 'Loading branches…',
'settings.skillsRunner.branchPicker.select': 'Select a branch…',
'settings.modelHealth.title': 'Model Health',
'settings.modelHealth.desc':
'Per-model quality, hallucination rate, and cost comparison across active models',
@@ -861,6 +955,24 @@ const es5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
'skills.dashboard.scheduledHeading': 'Scheduled skills',
'skills.dashboard.emptyTitle': 'No scheduled skills',
'skills.dashboard.emptyBody':
'Run a bundled skill once or save a recurring schedule to see it here.',
'skills.dashboard.create': 'Create a Skill',
'skills.dashboard.run': 'Run a Skill',
'skills.dashboard.enable': 'Enable scheduled skill',
'skills.dashboard.disable': 'Disable scheduled skill',
'skills.dashboard.lastRun': 'Last run',
'skills.dashboard.nextRun': 'Next run',
'skills.dashboard.cardOpenRunner': 'Open in runner',
'skills.dashboard.loadError': 'Failed to load scheduled skills',
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
};
export default es5;
+114 -2
View File
@@ -214,8 +214,27 @@ 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.skillsRunner.scheduleEnabled': 'Enabled',
'settings.skillsRunner.scheduleDisabled': 'Paused',
'settings.skillsRunner.scheduleToggleAria': 'Toggle schedule enabled',
'settings.skillsRunner.schedule.history': 'History',
'settings.skillsRunner.schedule.historyLoading': 'Loading history\u2026',
'settings.skillsRunner.schedule.historyEmpty': 'No runs yet for this schedule.',
'settings.skillsRunner.schedule.historyNoOutput': 'No output captured.',
'settings.skillsRunner.schedule.active': 'Active',
'settings.skillsRunner.schedule.lastRunLabel': 'last:',
'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':
@@ -354,6 +373,23 @@ const fr5: TranslationMap = {
'skills.create.creating': 'Création…',
'skills.create.description': 'Description',
'skills.create.descriptionPlaceholder': 'Que fait cette compétence ?',
'skills.create.optional': '(optional)',
'skills.create.inputs.heading': 'Inputs',
'skills.create.inputs.help':
'Declare parameters the skill needs. The Skills Runner will render a form for these at run time.',
'skills.create.inputs.add': 'Add input',
'skills.create.inputs.row.name': 'Input name',
'skills.create.inputs.row.namePlaceholder': 'e.g. repo',
'skills.create.inputs.row.nameError':
'Letters, digits, underscores, and dashes only; must start with a letter.',
'skills.create.inputs.row.description': 'Input description',
'skills.create.inputs.row.descriptionPlaceholder': 'What goes in this field?',
'skills.create.inputs.row.type': 'Type',
'skills.create.inputs.row.required': 'Required',
'skills.create.inputs.row.remove': 'Remove input',
'skills.create.inputs.type.string': 'Text',
'skills.create.inputs.type.integer': 'Number',
'skills.create.inputs.type.boolean': 'Yes / No',
'skills.create.license': 'Licence',
'skills.create.name': 'Nom',
'skills.create.namePlaceholder': 'ex. Journal de trading',
@@ -841,6 +877,64 @@ const fr5: TranslationMap = {
'settings.agentAccess.add': 'Add',
'settings.agentAccess.saving': 'Saving…',
'settings.agentAccess.changesApply': 'Changes apply on your next message.',
'skills.tabs.runners': 'Runners',
'settings.developerMenu.skillsRunner.title': 'Skills Runner',
'settings.developerMenu.skillsRunner.desc':
'Run any bundled skill ad-hoc — fill its inputs and fire a background autonomous run',
'settings.developerMenu.skillsRunner.panelDesc':
'Pick a bundled skill, fill in its declared inputs, and fire a fire-and-forget background run. Use Dev Workflow instead if you want a cron-scheduled recurring job.',
'settings.skillsRunner.skill': 'Skill',
'settings.skillsRunner.selectSkill': 'Select a skill…',
'settings.skillsRunner.loadingSkills': 'Loading skills…',
'settings.skillsRunner.loadingDescription': 'Loading skill inputs…',
'settings.skillsRunner.noInputs': 'This skill declares no inputs.',
'settings.skillsRunner.placeholder.required': 'required',
'settings.skillsRunner.runNow': 'Run now',
'settings.skillsRunner.starting': 'Starting…',
'settings.skillsRunner.started': 'Started — run id:',
'settings.skillsRunner.logPath': 'Log:',
'settings.skillsRunner.error.listSkills': 'Failed to load skills:',
'settings.skillsRunner.error.describe': 'Failed to load inputs:',
'settings.skillsRunner.error.missingRequired': 'Missing required input(s):',
'settings.skillsRunner.error.run': 'Run failed to start:',
'settings.skillsRunner.error.preflightGate': 'Preflight gate failed',
'settings.skillsRunner.schedule.heading': 'Schedule (recurring)',
'settings.skillsRunner.schedule.help':
'Save this skill + inputs as a recurring cron job. The agent will call run_skill at each tick.',
'settings.skillsRunner.schedule.frequency': 'Frequency',
'settings.skillsRunner.schedule.every30min': 'Every 30 minutes',
'settings.skillsRunner.schedule.everyHour': 'Every hour',
'settings.skillsRunner.schedule.every2hours': 'Every 2 hours',
'settings.skillsRunner.schedule.every6hours': 'Every 6 hours',
'settings.skillsRunner.schedule.onceDaily': 'Once daily (9:00)',
'settings.skillsRunner.schedule.save': 'Save schedule',
'settings.skillsRunner.schedule.saving': 'Saving…',
'settings.skillsRunner.schedule.saved': 'Schedule saved.',
'settings.skillsRunner.schedule.error': 'Schedule save failed:',
'settings.skillsRunner.schedule.loadingJobs': 'Loading existing schedules…',
'settings.skillsRunner.schedule.noJobs': 'No schedules saved for this skill yet.',
'settings.skillsRunner.schedule.existing': 'Scheduled jobs for this skill:',
'settings.skillsRunner.schedule.runNow': 'Run',
'settings.skillsRunner.schedule.remove': 'Remove',
'settings.skillsRunner.recentRuns.headingForSkill': 'Recent runs for this skill',
'settings.skillsRunner.recentRuns.headingAll': 'Recent skill runs (all)',
'settings.skillsRunner.recentRuns.refresh': 'Refresh',
'settings.skillsRunner.recentRuns.loading': 'Loading recent runs…',
'settings.skillsRunner.recentRuns.empty': 'No recent runs.',
'settings.skillsRunner.viewer.loading': 'Loading log…',
'settings.skillsRunner.viewer.tailing': 'Live tailing',
'settings.skillsRunner.viewer.fetching': 'fetching',
'settings.skillsRunner.viewer.error': 'Log read failed:',
'settings.skillsRunner.repoPicker.loading': 'Loading repositories…',
'settings.skillsRunner.repoPicker.select': 'Select a repository…',
'settings.skillsRunner.repoPicker.empty':
'No repositories returned. Connect GitHub via Composio to populate this list.',
'settings.skillsRunner.repoPicker.notConnected':
'GitHub isnt connected via Composio. Connect it under Skills → Composio first.',
'settings.skillsRunner.repoPicker.privateTag': '(private)',
'settings.skillsRunner.branchPicker.needRepo': 'Pick a repo first…',
'settings.skillsRunner.branchPicker.loading': 'Loading branches…',
'settings.skillsRunner.branchPicker.select': 'Select a branch…',
'settings.modelHealth.title': 'Model Health',
'settings.modelHealth.desc':
'Per-model quality, hallucination rate, and cost comparison across active models',
@@ -865,6 +959,24 @@ const fr5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
'skills.dashboard.scheduledHeading': 'Scheduled skills',
'skills.dashboard.emptyTitle': 'No scheduled skills',
'skills.dashboard.emptyBody':
'Run a bundled skill once or save a recurring schedule to see it here.',
'skills.dashboard.create': 'Create a Skill',
'skills.dashboard.run': 'Run a Skill',
'skills.dashboard.enable': 'Enable scheduled skill',
'skills.dashboard.disable': 'Disable scheduled skill',
'skills.dashboard.lastRun': 'Last run',
'skills.dashboard.nextRun': 'Next run',
'skills.dashboard.cardOpenRunner': 'Open in runner',
'skills.dashboard.loadError': 'Failed to load scheduled skills',
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
};
export default fr5;
+114 -2
View File
@@ -209,8 +209,27 @@ 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.skillsRunner.scheduleEnabled': 'Enabled',
'settings.skillsRunner.scheduleDisabled': 'Paused',
'settings.skillsRunner.scheduleToggleAria': 'Toggle schedule enabled',
'settings.skillsRunner.schedule.history': 'History',
'settings.skillsRunner.schedule.historyLoading': 'Loading history\u2026',
'settings.skillsRunner.schedule.historyEmpty': 'No runs yet for this schedule.',
'settings.skillsRunner.schedule.historyNoOutput': 'No output captured.',
'settings.skillsRunner.schedule.active': 'Active',
'settings.skillsRunner.schedule.lastRunLabel': 'last:',
'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':
@@ -348,6 +367,23 @@ const hi5: TranslationMap = {
'skills.create.creating': 'बन रहा है…',
'skills.create.description': 'विवरण',
'skills.create.descriptionPlaceholder': 'यह स्किल क्या करती है?',
'skills.create.optional': '(optional)',
'skills.create.inputs.heading': 'Inputs',
'skills.create.inputs.help':
'Declare parameters the skill needs. The Skills Runner will render a form for these at run time.',
'skills.create.inputs.add': 'Add input',
'skills.create.inputs.row.name': 'Input name',
'skills.create.inputs.row.namePlaceholder': 'e.g. repo',
'skills.create.inputs.row.nameError':
'Letters, digits, underscores, and dashes only; must start with a letter.',
'skills.create.inputs.row.description': 'Input description',
'skills.create.inputs.row.descriptionPlaceholder': 'What goes in this field?',
'skills.create.inputs.row.type': 'Type',
'skills.create.inputs.row.required': 'Required',
'skills.create.inputs.row.remove': 'Remove input',
'skills.create.inputs.type.string': 'Text',
'skills.create.inputs.type.integer': 'Number',
'skills.create.inputs.type.boolean': 'Yes / No',
'skills.create.license': 'लाइसेंस',
'skills.create.name': 'नाम',
'skills.create.namePlaceholder': 'जैसे Trade Journal',
@@ -824,6 +860,64 @@ const hi5: TranslationMap = {
'settings.agentAccess.add': 'Add',
'settings.agentAccess.saving': 'Saving…',
'settings.agentAccess.changesApply': 'Changes apply on your next message.',
'skills.tabs.runners': 'Runners',
'settings.developerMenu.skillsRunner.title': 'Skills Runner',
'settings.developerMenu.skillsRunner.desc':
'Run any bundled skill ad-hoc — fill its inputs and fire a background autonomous run',
'settings.developerMenu.skillsRunner.panelDesc':
'Pick a bundled skill, fill in its declared inputs, and fire a fire-and-forget background run. Use Dev Workflow instead if you want a cron-scheduled recurring job.',
'settings.skillsRunner.skill': 'Skill',
'settings.skillsRunner.selectSkill': 'Select a skill…',
'settings.skillsRunner.loadingSkills': 'Loading skills…',
'settings.skillsRunner.loadingDescription': 'Loading skill inputs…',
'settings.skillsRunner.noInputs': 'This skill declares no inputs.',
'settings.skillsRunner.placeholder.required': 'required',
'settings.skillsRunner.runNow': 'Run now',
'settings.skillsRunner.starting': 'Starting…',
'settings.skillsRunner.started': 'Started — run id:',
'settings.skillsRunner.logPath': 'Log:',
'settings.skillsRunner.error.listSkills': 'Failed to load skills:',
'settings.skillsRunner.error.describe': 'Failed to load inputs:',
'settings.skillsRunner.error.missingRequired': 'Missing required input(s):',
'settings.skillsRunner.error.run': 'Run failed to start:',
'settings.skillsRunner.error.preflightGate': 'Preflight gate failed',
'settings.skillsRunner.schedule.heading': 'Schedule (recurring)',
'settings.skillsRunner.schedule.help':
'Save this skill + inputs as a recurring cron job. The agent will call run_skill at each tick.',
'settings.skillsRunner.schedule.frequency': 'Frequency',
'settings.skillsRunner.schedule.every30min': 'Every 30 minutes',
'settings.skillsRunner.schedule.everyHour': 'Every hour',
'settings.skillsRunner.schedule.every2hours': 'Every 2 hours',
'settings.skillsRunner.schedule.every6hours': 'Every 6 hours',
'settings.skillsRunner.schedule.onceDaily': 'Once daily (9:00)',
'settings.skillsRunner.schedule.save': 'Save schedule',
'settings.skillsRunner.schedule.saving': 'Saving…',
'settings.skillsRunner.schedule.saved': 'Schedule saved.',
'settings.skillsRunner.schedule.error': 'Schedule save failed:',
'settings.skillsRunner.schedule.loadingJobs': 'Loading existing schedules…',
'settings.skillsRunner.schedule.noJobs': 'No schedules saved for this skill yet.',
'settings.skillsRunner.schedule.existing': 'Scheduled jobs for this skill:',
'settings.skillsRunner.schedule.runNow': 'Run',
'settings.skillsRunner.schedule.remove': 'Remove',
'settings.skillsRunner.recentRuns.headingForSkill': 'Recent runs for this skill',
'settings.skillsRunner.recentRuns.headingAll': 'Recent skill runs (all)',
'settings.skillsRunner.recentRuns.refresh': 'Refresh',
'settings.skillsRunner.recentRuns.loading': 'Loading recent runs…',
'settings.skillsRunner.recentRuns.empty': 'No recent runs.',
'settings.skillsRunner.viewer.loading': 'Loading log…',
'settings.skillsRunner.viewer.tailing': 'Live tailing',
'settings.skillsRunner.viewer.fetching': 'fetching',
'settings.skillsRunner.viewer.error': 'Log read failed:',
'settings.skillsRunner.repoPicker.loading': 'Loading repositories…',
'settings.skillsRunner.repoPicker.select': 'Select a repository…',
'settings.skillsRunner.repoPicker.empty':
'No repositories returned. Connect GitHub via Composio to populate this list.',
'settings.skillsRunner.repoPicker.notConnected':
'GitHub isnt connected via Composio. Connect it under Skills → Composio first.',
'settings.skillsRunner.repoPicker.privateTag': '(private)',
'settings.skillsRunner.branchPicker.needRepo': 'Pick a repo first…',
'settings.skillsRunner.branchPicker.loading': 'Loading branches…',
'settings.skillsRunner.branchPicker.select': 'Select a branch…',
'settings.modelHealth.title': 'Model Health',
'settings.modelHealth.desc':
'Per-model quality, hallucination rate, and cost comparison across active models',
@@ -848,6 +942,24 @@ const hi5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
'skills.dashboard.scheduledHeading': 'Scheduled skills',
'skills.dashboard.emptyTitle': 'No scheduled skills',
'skills.dashboard.emptyBody':
'Run a bundled skill once or save a recurring schedule to see it here.',
'skills.dashboard.create': 'Create a Skill',
'skills.dashboard.run': 'Run a Skill',
'skills.dashboard.enable': 'Enable scheduled skill',
'skills.dashboard.disable': 'Disable scheduled skill',
'skills.dashboard.lastRun': 'Last run',
'skills.dashboard.nextRun': 'Next run',
'skills.dashboard.cardOpenRunner': 'Open in runner',
'skills.dashboard.loadError': 'Failed to load scheduled skills',
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
};
export default hi5;
+114 -2
View File
@@ -210,8 +210,27 @@ 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.skillsRunner.scheduleEnabled': 'Enabled',
'settings.skillsRunner.scheduleDisabled': 'Paused',
'settings.skillsRunner.scheduleToggleAria': 'Toggle schedule enabled',
'settings.skillsRunner.schedule.history': 'History',
'settings.skillsRunner.schedule.historyLoading': 'Loading history\u2026',
'settings.skillsRunner.schedule.historyEmpty': 'No runs yet for this schedule.',
'settings.skillsRunner.schedule.historyNoOutput': 'No output captured.',
'settings.skillsRunner.schedule.active': 'Active',
'settings.skillsRunner.schedule.lastRunLabel': 'last:',
'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':
@@ -348,6 +367,23 @@ const id5: TranslationMap = {
'skills.create.creating': 'Membuat...',
'skills.create.description': 'Deskripsi',
'skills.create.descriptionPlaceholder': 'Apa fungsi skill ini?',
'skills.create.optional': '(optional)',
'skills.create.inputs.heading': 'Inputs',
'skills.create.inputs.help':
'Declare parameters the skill needs. The Skills Runner will render a form for these at run time.',
'skills.create.inputs.add': 'Add input',
'skills.create.inputs.row.name': 'Input name',
'skills.create.inputs.row.namePlaceholder': 'e.g. repo',
'skills.create.inputs.row.nameError':
'Letters, digits, underscores, and dashes only; must start with a letter.',
'skills.create.inputs.row.description': 'Input description',
'skills.create.inputs.row.descriptionPlaceholder': 'What goes in this field?',
'skills.create.inputs.row.type': 'Type',
'skills.create.inputs.row.required': 'Required',
'skills.create.inputs.row.remove': 'Remove input',
'skills.create.inputs.type.string': 'Text',
'skills.create.inputs.type.integer': 'Number',
'skills.create.inputs.type.boolean': 'Yes / No',
'skills.create.license': 'Lisensi',
'skills.create.name': 'Nama',
'skills.create.namePlaceholder': 'mis. Jurnal Trading',
@@ -824,6 +860,64 @@ const id5: TranslationMap = {
'settings.agentAccess.add': 'Add',
'settings.agentAccess.saving': 'Saving…',
'settings.agentAccess.changesApply': 'Changes apply on your next message.',
'skills.tabs.runners': 'Runners',
'settings.developerMenu.skillsRunner.title': 'Skills Runner',
'settings.developerMenu.skillsRunner.desc':
'Run any bundled skill ad-hoc — fill its inputs and fire a background autonomous run',
'settings.developerMenu.skillsRunner.panelDesc':
'Pick a bundled skill, fill in its declared inputs, and fire a fire-and-forget background run. Use Dev Workflow instead if you want a cron-scheduled recurring job.',
'settings.skillsRunner.skill': 'Skill',
'settings.skillsRunner.selectSkill': 'Select a skill…',
'settings.skillsRunner.loadingSkills': 'Loading skills…',
'settings.skillsRunner.loadingDescription': 'Loading skill inputs…',
'settings.skillsRunner.noInputs': 'This skill declares no inputs.',
'settings.skillsRunner.placeholder.required': 'required',
'settings.skillsRunner.runNow': 'Run now',
'settings.skillsRunner.starting': 'Starting…',
'settings.skillsRunner.started': 'Started — run id:',
'settings.skillsRunner.logPath': 'Log:',
'settings.skillsRunner.error.listSkills': 'Failed to load skills:',
'settings.skillsRunner.error.describe': 'Failed to load inputs:',
'settings.skillsRunner.error.missingRequired': 'Missing required input(s):',
'settings.skillsRunner.error.run': 'Run failed to start:',
'settings.skillsRunner.error.preflightGate': 'Preflight gate failed',
'settings.skillsRunner.schedule.heading': 'Schedule (recurring)',
'settings.skillsRunner.schedule.help':
'Save this skill + inputs as a recurring cron job. The agent will call run_skill at each tick.',
'settings.skillsRunner.schedule.frequency': 'Frequency',
'settings.skillsRunner.schedule.every30min': 'Every 30 minutes',
'settings.skillsRunner.schedule.everyHour': 'Every hour',
'settings.skillsRunner.schedule.every2hours': 'Every 2 hours',
'settings.skillsRunner.schedule.every6hours': 'Every 6 hours',
'settings.skillsRunner.schedule.onceDaily': 'Once daily (9:00)',
'settings.skillsRunner.schedule.save': 'Save schedule',
'settings.skillsRunner.schedule.saving': 'Saving…',
'settings.skillsRunner.schedule.saved': 'Schedule saved.',
'settings.skillsRunner.schedule.error': 'Schedule save failed:',
'settings.skillsRunner.schedule.loadingJobs': 'Loading existing schedules…',
'settings.skillsRunner.schedule.noJobs': 'No schedules saved for this skill yet.',
'settings.skillsRunner.schedule.existing': 'Scheduled jobs for this skill:',
'settings.skillsRunner.schedule.runNow': 'Run',
'settings.skillsRunner.schedule.remove': 'Remove',
'settings.skillsRunner.recentRuns.headingForSkill': 'Recent runs for this skill',
'settings.skillsRunner.recentRuns.headingAll': 'Recent skill runs (all)',
'settings.skillsRunner.recentRuns.refresh': 'Refresh',
'settings.skillsRunner.recentRuns.loading': 'Loading recent runs…',
'settings.skillsRunner.recentRuns.empty': 'No recent runs.',
'settings.skillsRunner.viewer.loading': 'Loading log…',
'settings.skillsRunner.viewer.tailing': 'Live tailing',
'settings.skillsRunner.viewer.fetching': 'fetching',
'settings.skillsRunner.viewer.error': 'Log read failed:',
'settings.skillsRunner.repoPicker.loading': 'Loading repositories…',
'settings.skillsRunner.repoPicker.select': 'Select a repository…',
'settings.skillsRunner.repoPicker.empty':
'No repositories returned. Connect GitHub via Composio to populate this list.',
'settings.skillsRunner.repoPicker.notConnected':
'GitHub isnt connected via Composio. Connect it under Skills → Composio first.',
'settings.skillsRunner.repoPicker.privateTag': '(private)',
'settings.skillsRunner.branchPicker.needRepo': 'Pick a repo first…',
'settings.skillsRunner.branchPicker.loading': 'Loading branches…',
'settings.skillsRunner.branchPicker.select': 'Select a branch…',
'settings.modelHealth.title': 'Model Health',
'settings.modelHealth.desc':
'Per-model quality, hallucination rate, and cost comparison across active models',
@@ -848,6 +942,24 @@ const id5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
'skills.dashboard.scheduledHeading': 'Scheduled skills',
'skills.dashboard.emptyTitle': 'No scheduled skills',
'skills.dashboard.emptyBody':
'Run a bundled skill once or save a recurring schedule to see it here.',
'skills.dashboard.create': 'Create a Skill',
'skills.dashboard.run': 'Run a Skill',
'skills.dashboard.enable': 'Enable scheduled skill',
'skills.dashboard.disable': 'Disable scheduled skill',
'skills.dashboard.lastRun': 'Last run',
'skills.dashboard.nextRun': 'Next run',
'skills.dashboard.cardOpenRunner': 'Open in runner',
'skills.dashboard.loadError': 'Failed to load scheduled skills',
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
};
export default id5;
+114 -2
View File
@@ -212,8 +212,27 @@ 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.skillsRunner.scheduleEnabled': 'Enabled',
'settings.skillsRunner.scheduleDisabled': 'Paused',
'settings.skillsRunner.scheduleToggleAria': 'Toggle schedule enabled',
'settings.skillsRunner.schedule.history': 'History',
'settings.skillsRunner.schedule.historyLoading': 'Loading history\u2026',
'settings.skillsRunner.schedule.historyEmpty': 'No runs yet for this schedule.',
'settings.skillsRunner.schedule.historyNoOutput': 'No output captured.',
'settings.skillsRunner.schedule.active': 'Active',
'settings.skillsRunner.schedule.lastRunLabel': 'last:',
'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':
@@ -352,6 +371,23 @@ const it5: TranslationMap = {
'skills.create.creating': 'Creazione…',
'skills.create.description': 'Descrizione',
'skills.create.descriptionPlaceholder': 'Cosa fa questa skill?',
'skills.create.optional': '(optional)',
'skills.create.inputs.heading': 'Inputs',
'skills.create.inputs.help':
'Declare parameters the skill needs. The Skills Runner will render a form for these at run time.',
'skills.create.inputs.add': 'Add input',
'skills.create.inputs.row.name': 'Input name',
'skills.create.inputs.row.namePlaceholder': 'e.g. repo',
'skills.create.inputs.row.nameError':
'Letters, digits, underscores, and dashes only; must start with a letter.',
'skills.create.inputs.row.description': 'Input description',
'skills.create.inputs.row.descriptionPlaceholder': 'What goes in this field?',
'skills.create.inputs.row.type': 'Type',
'skills.create.inputs.row.required': 'Required',
'skills.create.inputs.row.remove': 'Remove input',
'skills.create.inputs.type.string': 'Text',
'skills.create.inputs.type.integer': 'Number',
'skills.create.inputs.type.boolean': 'Yes / No',
'skills.create.license': 'Licenza',
'skills.create.name': 'Nome',
'skills.create.namePlaceholder': 'es. Trade Journal',
@@ -835,6 +871,64 @@ const it5: TranslationMap = {
'settings.agentAccess.add': 'Add',
'settings.agentAccess.saving': 'Saving…',
'settings.agentAccess.changesApply': 'Changes apply on your next message.',
'skills.tabs.runners': 'Runners',
'settings.developerMenu.skillsRunner.title': 'Skills Runner',
'settings.developerMenu.skillsRunner.desc':
'Run any bundled skill ad-hoc — fill its inputs and fire a background autonomous run',
'settings.developerMenu.skillsRunner.panelDesc':
'Pick a bundled skill, fill in its declared inputs, and fire a fire-and-forget background run. Use Dev Workflow instead if you want a cron-scheduled recurring job.',
'settings.skillsRunner.skill': 'Skill',
'settings.skillsRunner.selectSkill': 'Select a skill…',
'settings.skillsRunner.loadingSkills': 'Loading skills…',
'settings.skillsRunner.loadingDescription': 'Loading skill inputs…',
'settings.skillsRunner.noInputs': 'This skill declares no inputs.',
'settings.skillsRunner.placeholder.required': 'required',
'settings.skillsRunner.runNow': 'Run now',
'settings.skillsRunner.starting': 'Starting…',
'settings.skillsRunner.started': 'Started — run id:',
'settings.skillsRunner.logPath': 'Log:',
'settings.skillsRunner.error.listSkills': 'Failed to load skills:',
'settings.skillsRunner.error.describe': 'Failed to load inputs:',
'settings.skillsRunner.error.missingRequired': 'Missing required input(s):',
'settings.skillsRunner.error.run': 'Run failed to start:',
'settings.skillsRunner.error.preflightGate': 'Preflight gate failed',
'settings.skillsRunner.schedule.heading': 'Schedule (recurring)',
'settings.skillsRunner.schedule.help':
'Save this skill + inputs as a recurring cron job. The agent will call run_skill at each tick.',
'settings.skillsRunner.schedule.frequency': 'Frequency',
'settings.skillsRunner.schedule.every30min': 'Every 30 minutes',
'settings.skillsRunner.schedule.everyHour': 'Every hour',
'settings.skillsRunner.schedule.every2hours': 'Every 2 hours',
'settings.skillsRunner.schedule.every6hours': 'Every 6 hours',
'settings.skillsRunner.schedule.onceDaily': 'Once daily (9:00)',
'settings.skillsRunner.schedule.save': 'Save schedule',
'settings.skillsRunner.schedule.saving': 'Saving…',
'settings.skillsRunner.schedule.saved': 'Schedule saved.',
'settings.skillsRunner.schedule.error': 'Schedule save failed:',
'settings.skillsRunner.schedule.loadingJobs': 'Loading existing schedules…',
'settings.skillsRunner.schedule.noJobs': 'No schedules saved for this skill yet.',
'settings.skillsRunner.schedule.existing': 'Scheduled jobs for this skill:',
'settings.skillsRunner.schedule.runNow': 'Run',
'settings.skillsRunner.schedule.remove': 'Remove',
'settings.skillsRunner.recentRuns.headingForSkill': 'Recent runs for this skill',
'settings.skillsRunner.recentRuns.headingAll': 'Recent skill runs (all)',
'settings.skillsRunner.recentRuns.refresh': 'Refresh',
'settings.skillsRunner.recentRuns.loading': 'Loading recent runs…',
'settings.skillsRunner.recentRuns.empty': 'No recent runs.',
'settings.skillsRunner.viewer.loading': 'Loading log…',
'settings.skillsRunner.viewer.tailing': 'Live tailing',
'settings.skillsRunner.viewer.fetching': 'fetching',
'settings.skillsRunner.viewer.error': 'Log read failed:',
'settings.skillsRunner.repoPicker.loading': 'Loading repositories…',
'settings.skillsRunner.repoPicker.select': 'Select a repository…',
'settings.skillsRunner.repoPicker.empty':
'No repositories returned. Connect GitHub via Composio to populate this list.',
'settings.skillsRunner.repoPicker.notConnected':
'GitHub isnt connected via Composio. Connect it under Skills → Composio first.',
'settings.skillsRunner.repoPicker.privateTag': '(private)',
'settings.skillsRunner.branchPicker.needRepo': 'Pick a repo first…',
'settings.skillsRunner.branchPicker.loading': 'Loading branches…',
'settings.skillsRunner.branchPicker.select': 'Select a branch…',
'settings.modelHealth.title': 'Model Health',
'settings.modelHealth.desc':
'Per-model quality, hallucination rate, and cost comparison across active models',
@@ -859,6 +953,24 @@ const it5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
'skills.dashboard.scheduledHeading': 'Scheduled skills',
'skills.dashboard.emptyTitle': 'No scheduled skills',
'skills.dashboard.emptyBody':
'Run a bundled skill once or save a recurring schedule to see it here.',
'skills.dashboard.create': 'Create a Skill',
'skills.dashboard.run': 'Run a Skill',
'skills.dashboard.enable': 'Enable scheduled skill',
'skills.dashboard.disable': 'Disable scheduled skill',
'skills.dashboard.lastRun': 'Last run',
'skills.dashboard.nextRun': 'Next run',
'skills.dashboard.cardOpenRunner': 'Open in runner',
'skills.dashboard.loadError': 'Failed to load scheduled skills',
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
};
export default it5;
+114 -2
View File
@@ -240,6 +240,23 @@ const ko5: TranslationMap = {
'skills.create.creating': '생성 중…',
'skills.create.description': '설명',
'skills.create.descriptionPlaceholder': '이 스킬은 무엇을 하나요?',
'skills.create.optional': '(optional)',
'skills.create.inputs.heading': 'Inputs',
'skills.create.inputs.help':
'Declare parameters the skill needs. The Skills Runner will render a form for these at run time.',
'skills.create.inputs.add': 'Add input',
'skills.create.inputs.row.name': 'Input name',
'skills.create.inputs.row.namePlaceholder': 'e.g. repo',
'skills.create.inputs.row.nameError':
'Letters, digits, underscores, and dashes only; must start with a letter.',
'skills.create.inputs.row.description': 'Input description',
'skills.create.inputs.row.descriptionPlaceholder': 'What goes in this field?',
'skills.create.inputs.row.type': 'Type',
'skills.create.inputs.row.required': 'Required',
'skills.create.inputs.row.remove': 'Remove input',
'skills.create.inputs.type.string': 'Text',
'skills.create.inputs.type.integer': 'Number',
'skills.create.inputs.type.boolean': 'Yes / No',
'skills.create.license': '라이선스',
'skills.create.name': '이름',
'skills.create.namePlaceholder': '예: Trade Journal',
@@ -541,8 +558,27 @@ 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.skillsRunner.scheduleEnabled': 'Enabled',
'settings.skillsRunner.scheduleDisabled': 'Paused',
'settings.skillsRunner.scheduleToggleAria': 'Toggle schedule enabled',
'settings.skillsRunner.schedule.history': 'History',
'settings.skillsRunner.schedule.historyLoading': 'Loading history\u2026',
'settings.skillsRunner.schedule.historyEmpty': 'No runs yet for this schedule.',
'settings.skillsRunner.schedule.historyNoOutput': 'No output captured.',
'settings.skillsRunner.schedule.active': 'Active',
'settings.skillsRunner.schedule.lastRunLabel': 'last:',
'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':
@@ -813,6 +849,64 @@ const ko5: TranslationMap = {
'settings.agentAccess.add': 'Add',
'settings.agentAccess.saving': 'Saving…',
'settings.agentAccess.changesApply': 'Changes apply on your next message.',
'skills.tabs.runners': 'Runners',
'settings.developerMenu.skillsRunner.title': 'Skills Runner',
'settings.developerMenu.skillsRunner.desc':
'Run any bundled skill ad-hoc — fill its inputs and fire a background autonomous run',
'settings.developerMenu.skillsRunner.panelDesc':
'Pick a bundled skill, fill in its declared inputs, and fire a fire-and-forget background run. Use Dev Workflow instead if you want a cron-scheduled recurring job.',
'settings.skillsRunner.skill': 'Skill',
'settings.skillsRunner.selectSkill': 'Select a skill…',
'settings.skillsRunner.loadingSkills': 'Loading skills…',
'settings.skillsRunner.loadingDescription': 'Loading skill inputs…',
'settings.skillsRunner.noInputs': 'This skill declares no inputs.',
'settings.skillsRunner.placeholder.required': 'required',
'settings.skillsRunner.runNow': 'Run now',
'settings.skillsRunner.starting': 'Starting…',
'settings.skillsRunner.started': 'Started — run id:',
'settings.skillsRunner.logPath': 'Log:',
'settings.skillsRunner.error.listSkills': 'Failed to load skills:',
'settings.skillsRunner.error.describe': 'Failed to load inputs:',
'settings.skillsRunner.error.missingRequired': 'Missing required input(s):',
'settings.skillsRunner.error.run': 'Run failed to start:',
'settings.skillsRunner.error.preflightGate': 'Preflight gate failed',
'settings.skillsRunner.schedule.heading': 'Schedule (recurring)',
'settings.skillsRunner.schedule.help':
'Save this skill + inputs as a recurring cron job. The agent will call run_skill at each tick.',
'settings.skillsRunner.schedule.frequency': 'Frequency',
'settings.skillsRunner.schedule.every30min': 'Every 30 minutes',
'settings.skillsRunner.schedule.everyHour': 'Every hour',
'settings.skillsRunner.schedule.every2hours': 'Every 2 hours',
'settings.skillsRunner.schedule.every6hours': 'Every 6 hours',
'settings.skillsRunner.schedule.onceDaily': 'Once daily (9:00)',
'settings.skillsRunner.schedule.save': 'Save schedule',
'settings.skillsRunner.schedule.saving': 'Saving…',
'settings.skillsRunner.schedule.saved': 'Schedule saved.',
'settings.skillsRunner.schedule.error': 'Schedule save failed:',
'settings.skillsRunner.schedule.loadingJobs': 'Loading existing schedules…',
'settings.skillsRunner.schedule.noJobs': 'No schedules saved for this skill yet.',
'settings.skillsRunner.schedule.existing': 'Scheduled jobs for this skill:',
'settings.skillsRunner.schedule.runNow': 'Run',
'settings.skillsRunner.schedule.remove': 'Remove',
'settings.skillsRunner.recentRuns.headingForSkill': 'Recent runs for this skill',
'settings.skillsRunner.recentRuns.headingAll': 'Recent skill runs (all)',
'settings.skillsRunner.recentRuns.refresh': 'Refresh',
'settings.skillsRunner.recentRuns.loading': 'Loading recent runs…',
'settings.skillsRunner.recentRuns.empty': 'No recent runs.',
'settings.skillsRunner.viewer.loading': 'Loading log…',
'settings.skillsRunner.viewer.tailing': 'Live tailing',
'settings.skillsRunner.viewer.fetching': 'fetching',
'settings.skillsRunner.viewer.error': 'Log read failed:',
'settings.skillsRunner.repoPicker.loading': 'Loading repositories…',
'settings.skillsRunner.repoPicker.select': 'Select a repository…',
'settings.skillsRunner.repoPicker.empty':
'No repositories returned. Connect GitHub via Composio to populate this list.',
'settings.skillsRunner.repoPicker.notConnected':
'GitHub isnt connected via Composio. Connect it under Skills → Composio first.',
'settings.skillsRunner.repoPicker.privateTag': '(private)',
'settings.skillsRunner.branchPicker.needRepo': 'Pick a repo first…',
'settings.skillsRunner.branchPicker.loading': 'Loading branches…',
'settings.skillsRunner.branchPicker.select': 'Select a branch…',
'settings.modelHealth.title': 'Model Health',
'settings.modelHealth.desc':
'Per-model quality, hallucination rate, and cost comparison across active models',
@@ -837,6 +931,24 @@ const ko5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
'skills.dashboard.scheduledHeading': 'Scheduled skills',
'skills.dashboard.emptyTitle': 'No scheduled skills',
'skills.dashboard.emptyBody':
'Run a bundled skill once or save a recurring schedule to see it here.',
'skills.dashboard.create': 'Create a Skill',
'skills.dashboard.run': 'Run a Skill',
'skills.dashboard.enable': 'Enable scheduled skill',
'skills.dashboard.disable': 'Disable scheduled skill',
'skills.dashboard.lastRun': 'Last run',
'skills.dashboard.nextRun': 'Next run',
'skills.dashboard.cardOpenRunner': 'Open in runner',
'skills.dashboard.loadError': 'Failed to load scheduled skills',
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
};
export default ko5;
+114 -2
View File
@@ -222,8 +222,27 @@ 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.skillsRunner.scheduleEnabled': 'Enabled',
'settings.skillsRunner.scheduleDisabled': 'Paused',
'settings.skillsRunner.scheduleToggleAria': 'Toggle schedule enabled',
'settings.skillsRunner.schedule.history': 'History',
'settings.skillsRunner.schedule.historyLoading': 'Loading history\u2026',
'settings.skillsRunner.schedule.historyEmpty': 'No runs yet for this schedule.',
'settings.skillsRunner.schedule.historyNoOutput': 'No output captured.',
'settings.skillsRunner.schedule.active': 'Active',
'settings.skillsRunner.schedule.lastRunLabel': 'last:',
'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':
@@ -384,6 +403,23 @@ const pl5: TranslationMap = {
'skills.create.creating': 'Tworzenie…',
'skills.create.description': 'Opis',
'skills.create.descriptionPlaceholder': 'Co robi ta umiejętność?',
'skills.create.optional': '(optional)',
'skills.create.inputs.heading': 'Inputs',
'skills.create.inputs.help':
'Declare parameters the skill needs. The Skills Runner will render a form for these at run time.',
'skills.create.inputs.add': 'Add input',
'skills.create.inputs.row.name': 'Input name',
'skills.create.inputs.row.namePlaceholder': 'e.g. repo',
'skills.create.inputs.row.nameError':
'Letters, digits, underscores, and dashes only; must start with a letter.',
'skills.create.inputs.row.description': 'Input description',
'skills.create.inputs.row.descriptionPlaceholder': 'What goes in this field?',
'skills.create.inputs.row.type': 'Type',
'skills.create.inputs.row.required': 'Required',
'skills.create.inputs.row.remove': 'Remove input',
'skills.create.inputs.type.string': 'Text',
'skills.create.inputs.type.integer': 'Number',
'skills.create.inputs.type.boolean': 'Yes / No',
'skills.create.license': 'Licencja',
'skills.create.name': 'Nazwa',
'skills.create.namePlaceholder': 'np. Dziennik transakcji',
@@ -857,6 +893,82 @@ const pl5: TranslationMap = {
'skills.meetingBots.platforms.zoom': 'Zoom',
'skills.meetingBots.soonSuffix': 'wkrótce',
'skills.setup.screenIntel.permissionPathLabel': 'macOS stosuje politykę prywatności do:',
'skills.tabs.runners': 'Runners',
'settings.developerMenu.skillsRunner.title': 'Skills Runner',
'settings.developerMenu.skillsRunner.desc':
'Run any bundled skill ad-hoc — fill its inputs and fire a background autonomous run',
'settings.developerMenu.skillsRunner.panelDesc':
'Pick a bundled skill, fill in its declared inputs, and fire a fire-and-forget background run. Use Dev Workflow instead if you want a cron-scheduled recurring job.',
'settings.skillsRunner.skill': 'Skill',
'settings.skillsRunner.selectSkill': 'Select a skill…',
'settings.skillsRunner.loadingSkills': 'Loading skills…',
'settings.skillsRunner.loadingDescription': 'Loading skill inputs…',
'settings.skillsRunner.noInputs': 'This skill declares no inputs.',
'settings.skillsRunner.placeholder.required': 'required',
'settings.skillsRunner.runNow': 'Run now',
'settings.skillsRunner.starting': 'Starting…',
'settings.skillsRunner.started': 'Started — run id:',
'settings.skillsRunner.logPath': 'Log:',
'settings.skillsRunner.error.listSkills': 'Failed to load skills:',
'settings.skillsRunner.error.describe': 'Failed to load inputs:',
'settings.skillsRunner.error.missingRequired': 'Missing required input(s):',
'settings.skillsRunner.error.run': 'Run failed to start:',
'settings.skillsRunner.error.preflightGate': 'Preflight gate failed',
'settings.skillsRunner.schedule.heading': 'Schedule (recurring)',
'settings.skillsRunner.schedule.help':
'Save this skill + inputs as a recurring cron job. The agent will call run_skill at each tick.',
'settings.skillsRunner.schedule.frequency': 'Frequency',
'settings.skillsRunner.schedule.every30min': 'Every 30 minutes',
'settings.skillsRunner.schedule.everyHour': 'Every hour',
'settings.skillsRunner.schedule.every2hours': 'Every 2 hours',
'settings.skillsRunner.schedule.every6hours': 'Every 6 hours',
'settings.skillsRunner.schedule.onceDaily': 'Once daily (9:00)',
'settings.skillsRunner.schedule.save': 'Save schedule',
'settings.skillsRunner.schedule.saving': 'Saving…',
'settings.skillsRunner.schedule.saved': 'Schedule saved.',
'settings.skillsRunner.schedule.error': 'Schedule save failed:',
'settings.skillsRunner.schedule.loadingJobs': 'Loading existing schedules…',
'settings.skillsRunner.schedule.noJobs': 'No schedules saved for this skill yet.',
'settings.skillsRunner.schedule.existing': 'Scheduled jobs for this skill:',
'settings.skillsRunner.schedule.runNow': 'Run',
'settings.skillsRunner.schedule.remove': 'Remove',
'settings.skillsRunner.recentRuns.headingForSkill': 'Recent runs for this skill',
'settings.skillsRunner.recentRuns.headingAll': 'Recent skill runs (all)',
'settings.skillsRunner.recentRuns.refresh': 'Refresh',
'settings.skillsRunner.recentRuns.loading': 'Loading recent runs…',
'settings.skillsRunner.recentRuns.empty': 'No recent runs.',
'settings.skillsRunner.viewer.loading': 'Loading log…',
'settings.skillsRunner.viewer.tailing': 'Live tailing',
'settings.skillsRunner.viewer.fetching': 'fetching',
'settings.skillsRunner.viewer.error': 'Log read failed:',
'settings.skillsRunner.repoPicker.loading': 'Loading repositories…',
'settings.skillsRunner.repoPicker.select': 'Select a repository…',
'settings.skillsRunner.repoPicker.empty':
'No repositories returned. Connect GitHub via Composio to populate this list.',
'settings.skillsRunner.repoPicker.notConnected':
'GitHub isnt connected via Composio. Connect it under Skills → Composio first.',
'settings.skillsRunner.repoPicker.privateTag': '(private)',
'settings.skillsRunner.branchPicker.needRepo': 'Pick a repo first…',
'settings.skillsRunner.branchPicker.loading': 'Loading branches…',
'settings.skillsRunner.branchPicker.select': 'Select a branch…',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
'skills.dashboard.scheduledHeading': 'Scheduled skills',
'skills.dashboard.emptyTitle': 'No scheduled skills',
'skills.dashboard.emptyBody':
'Run a bundled skill once or save a recurring schedule to see it here.',
'skills.dashboard.create': 'Create a Skill',
'skills.dashboard.run': 'Run a Skill',
'skills.dashboard.enable': 'Enable scheduled skill',
'skills.dashboard.disable': 'Disable scheduled skill',
'skills.dashboard.lastRun': 'Last run',
'skills.dashboard.nextRun': 'Next run',
'skills.dashboard.cardOpenRunner': 'Open in runner',
'skills.dashboard.loadError': 'Failed to load scheduled skills',
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
};
export default pl5;
+114 -2
View File
@@ -213,8 +213,27 @@ 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.skillsRunner.scheduleEnabled': 'Enabled',
'settings.skillsRunner.scheduleDisabled': 'Paused',
'settings.skillsRunner.scheduleToggleAria': 'Toggle schedule enabled',
'settings.skillsRunner.schedule.history': 'History',
'settings.skillsRunner.schedule.historyLoading': 'Loading history\u2026',
'settings.skillsRunner.schedule.historyEmpty': 'No runs yet for this schedule.',
'settings.skillsRunner.schedule.historyNoOutput': 'No output captured.',
'settings.skillsRunner.schedule.active': 'Active',
'settings.skillsRunner.schedule.lastRunLabel': 'last:',
'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':
@@ -353,6 +372,23 @@ const pt5: TranslationMap = {
'skills.create.creating': 'Criando…',
'skills.create.description': 'Descrição',
'skills.create.descriptionPlaceholder': 'O que essa habilidade faz?',
'skills.create.optional': '(optional)',
'skills.create.inputs.heading': 'Inputs',
'skills.create.inputs.help':
'Declare parameters the skill needs. The Skills Runner will render a form for these at run time.',
'skills.create.inputs.add': 'Add input',
'skills.create.inputs.row.name': 'Input name',
'skills.create.inputs.row.namePlaceholder': 'e.g. repo',
'skills.create.inputs.row.nameError':
'Letters, digits, underscores, and dashes only; must start with a letter.',
'skills.create.inputs.row.description': 'Input description',
'skills.create.inputs.row.descriptionPlaceholder': 'What goes in this field?',
'skills.create.inputs.row.type': 'Type',
'skills.create.inputs.row.required': 'Required',
'skills.create.inputs.row.remove': 'Remove input',
'skills.create.inputs.type.string': 'Text',
'skills.create.inputs.type.integer': 'Number',
'skills.create.inputs.type.boolean': 'Yes / No',
'skills.create.license': 'Licença',
'skills.create.name': 'Nome',
'skills.create.namePlaceholder': 'ex.: Trade Journal',
@@ -834,6 +870,64 @@ const pt5: TranslationMap = {
'settings.agentAccess.add': 'Add',
'settings.agentAccess.saving': 'Saving…',
'settings.agentAccess.changesApply': 'Changes apply on your next message.',
'skills.tabs.runners': 'Runners',
'settings.developerMenu.skillsRunner.title': 'Skills Runner',
'settings.developerMenu.skillsRunner.desc':
'Run any bundled skill ad-hoc — fill its inputs and fire a background autonomous run',
'settings.developerMenu.skillsRunner.panelDesc':
'Pick a bundled skill, fill in its declared inputs, and fire a fire-and-forget background run. Use Dev Workflow instead if you want a cron-scheduled recurring job.',
'settings.skillsRunner.skill': 'Skill',
'settings.skillsRunner.selectSkill': 'Select a skill…',
'settings.skillsRunner.loadingSkills': 'Loading skills…',
'settings.skillsRunner.loadingDescription': 'Loading skill inputs…',
'settings.skillsRunner.noInputs': 'This skill declares no inputs.',
'settings.skillsRunner.placeholder.required': 'required',
'settings.skillsRunner.runNow': 'Run now',
'settings.skillsRunner.starting': 'Starting…',
'settings.skillsRunner.started': 'Started — run id:',
'settings.skillsRunner.logPath': 'Log:',
'settings.skillsRunner.error.listSkills': 'Failed to load skills:',
'settings.skillsRunner.error.describe': 'Failed to load inputs:',
'settings.skillsRunner.error.missingRequired': 'Missing required input(s):',
'settings.skillsRunner.error.run': 'Run failed to start:',
'settings.skillsRunner.error.preflightGate': 'Preflight gate failed',
'settings.skillsRunner.schedule.heading': 'Schedule (recurring)',
'settings.skillsRunner.schedule.help':
'Save this skill + inputs as a recurring cron job. The agent will call run_skill at each tick.',
'settings.skillsRunner.schedule.frequency': 'Frequency',
'settings.skillsRunner.schedule.every30min': 'Every 30 minutes',
'settings.skillsRunner.schedule.everyHour': 'Every hour',
'settings.skillsRunner.schedule.every2hours': 'Every 2 hours',
'settings.skillsRunner.schedule.every6hours': 'Every 6 hours',
'settings.skillsRunner.schedule.onceDaily': 'Once daily (9:00)',
'settings.skillsRunner.schedule.save': 'Save schedule',
'settings.skillsRunner.schedule.saving': 'Saving…',
'settings.skillsRunner.schedule.saved': 'Schedule saved.',
'settings.skillsRunner.schedule.error': 'Schedule save failed:',
'settings.skillsRunner.schedule.loadingJobs': 'Loading existing schedules…',
'settings.skillsRunner.schedule.noJobs': 'No schedules saved for this skill yet.',
'settings.skillsRunner.schedule.existing': 'Scheduled jobs for this skill:',
'settings.skillsRunner.schedule.runNow': 'Run',
'settings.skillsRunner.schedule.remove': 'Remove',
'settings.skillsRunner.recentRuns.headingForSkill': 'Recent runs for this skill',
'settings.skillsRunner.recentRuns.headingAll': 'Recent skill runs (all)',
'settings.skillsRunner.recentRuns.refresh': 'Refresh',
'settings.skillsRunner.recentRuns.loading': 'Loading recent runs…',
'settings.skillsRunner.recentRuns.empty': 'No recent runs.',
'settings.skillsRunner.viewer.loading': 'Loading log…',
'settings.skillsRunner.viewer.tailing': 'Live tailing',
'settings.skillsRunner.viewer.fetching': 'fetching',
'settings.skillsRunner.viewer.error': 'Log read failed:',
'settings.skillsRunner.repoPicker.loading': 'Loading repositories…',
'settings.skillsRunner.repoPicker.select': 'Select a repository…',
'settings.skillsRunner.repoPicker.empty':
'No repositories returned. Connect GitHub via Composio to populate this list.',
'settings.skillsRunner.repoPicker.notConnected':
'GitHub isnt connected via Composio. Connect it under Skills → Composio first.',
'settings.skillsRunner.repoPicker.privateTag': '(private)',
'settings.skillsRunner.branchPicker.needRepo': 'Pick a repo first…',
'settings.skillsRunner.branchPicker.loading': 'Loading branches…',
'settings.skillsRunner.branchPicker.select': 'Select a branch…',
'settings.modelHealth.title': 'Model Health',
'settings.modelHealth.desc':
'Per-model quality, hallucination rate, and cost comparison across active models',
@@ -858,6 +952,24 @@ const pt5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
'skills.dashboard.scheduledHeading': 'Scheduled skills',
'skills.dashboard.emptyTitle': 'No scheduled skills',
'skills.dashboard.emptyBody':
'Run a bundled skill once or save a recurring schedule to see it here.',
'skills.dashboard.create': 'Create a Skill',
'skills.dashboard.run': 'Run a Skill',
'skills.dashboard.enable': 'Enable scheduled skill',
'skills.dashboard.disable': 'Disable scheduled skill',
'skills.dashboard.lastRun': 'Last run',
'skills.dashboard.nextRun': 'Next run',
'skills.dashboard.cardOpenRunner': 'Open in runner',
'skills.dashboard.loadError': 'Failed to load scheduled skills',
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
};
export default pt5;
+114 -2
View File
@@ -210,8 +210,27 @@ 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.skillsRunner.scheduleEnabled': 'Enabled',
'settings.skillsRunner.scheduleDisabled': 'Paused',
'settings.skillsRunner.scheduleToggleAria': 'Toggle schedule enabled',
'settings.skillsRunner.schedule.history': 'History',
'settings.skillsRunner.schedule.historyLoading': 'Loading history\u2026',
'settings.skillsRunner.schedule.historyEmpty': 'No runs yet for this schedule.',
'settings.skillsRunner.schedule.historyNoOutput': 'No output captured.',
'settings.skillsRunner.schedule.active': 'Active',
'settings.skillsRunner.schedule.lastRunLabel': 'last:',
'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':
@@ -349,6 +368,23 @@ const ru5: TranslationMap = {
'skills.create.creating': 'Создание…',
'skills.create.description': 'Описание',
'skills.create.descriptionPlaceholder': 'Что делает этот навык?',
'skills.create.optional': '(optional)',
'skills.create.inputs.heading': 'Inputs',
'skills.create.inputs.help':
'Declare parameters the skill needs. The Skills Runner will render a form for these at run time.',
'skills.create.inputs.add': 'Add input',
'skills.create.inputs.row.name': 'Input name',
'skills.create.inputs.row.namePlaceholder': 'e.g. repo',
'skills.create.inputs.row.nameError':
'Letters, digits, underscores, and dashes only; must start with a letter.',
'skills.create.inputs.row.description': 'Input description',
'skills.create.inputs.row.descriptionPlaceholder': 'What goes in this field?',
'skills.create.inputs.row.type': 'Type',
'skills.create.inputs.row.required': 'Required',
'skills.create.inputs.row.remove': 'Remove input',
'skills.create.inputs.type.string': 'Text',
'skills.create.inputs.type.integer': 'Number',
'skills.create.inputs.type.boolean': 'Yes / No',
'skills.create.license': 'Лицензия',
'skills.create.name': 'Название',
'skills.create.namePlaceholder': 'напр. Trade Journal',
@@ -830,6 +866,64 @@ const ru5: TranslationMap = {
'settings.agentAccess.add': 'Add',
'settings.agentAccess.saving': 'Saving…',
'settings.agentAccess.changesApply': 'Changes apply on your next message.',
'skills.tabs.runners': 'Runners',
'settings.developerMenu.skillsRunner.title': 'Skills Runner',
'settings.developerMenu.skillsRunner.desc':
'Run any bundled skill ad-hoc — fill its inputs and fire a background autonomous run',
'settings.developerMenu.skillsRunner.panelDesc':
'Pick a bundled skill, fill in its declared inputs, and fire a fire-and-forget background run. Use Dev Workflow instead if you want a cron-scheduled recurring job.',
'settings.skillsRunner.skill': 'Skill',
'settings.skillsRunner.selectSkill': 'Select a skill…',
'settings.skillsRunner.loadingSkills': 'Loading skills…',
'settings.skillsRunner.loadingDescription': 'Loading skill inputs…',
'settings.skillsRunner.noInputs': 'This skill declares no inputs.',
'settings.skillsRunner.placeholder.required': 'required',
'settings.skillsRunner.runNow': 'Run now',
'settings.skillsRunner.starting': 'Starting…',
'settings.skillsRunner.started': 'Started — run id:',
'settings.skillsRunner.logPath': 'Log:',
'settings.skillsRunner.error.listSkills': 'Failed to load skills:',
'settings.skillsRunner.error.describe': 'Failed to load inputs:',
'settings.skillsRunner.error.missingRequired': 'Missing required input(s):',
'settings.skillsRunner.error.run': 'Run failed to start:',
'settings.skillsRunner.error.preflightGate': 'Preflight gate failed',
'settings.skillsRunner.schedule.heading': 'Schedule (recurring)',
'settings.skillsRunner.schedule.help':
'Save this skill + inputs as a recurring cron job. The agent will call run_skill at each tick.',
'settings.skillsRunner.schedule.frequency': 'Frequency',
'settings.skillsRunner.schedule.every30min': 'Every 30 minutes',
'settings.skillsRunner.schedule.everyHour': 'Every hour',
'settings.skillsRunner.schedule.every2hours': 'Every 2 hours',
'settings.skillsRunner.schedule.every6hours': 'Every 6 hours',
'settings.skillsRunner.schedule.onceDaily': 'Once daily (9:00)',
'settings.skillsRunner.schedule.save': 'Save schedule',
'settings.skillsRunner.schedule.saving': 'Saving…',
'settings.skillsRunner.schedule.saved': 'Schedule saved.',
'settings.skillsRunner.schedule.error': 'Schedule save failed:',
'settings.skillsRunner.schedule.loadingJobs': 'Loading existing schedules…',
'settings.skillsRunner.schedule.noJobs': 'No schedules saved for this skill yet.',
'settings.skillsRunner.schedule.existing': 'Scheduled jobs for this skill:',
'settings.skillsRunner.schedule.runNow': 'Run',
'settings.skillsRunner.schedule.remove': 'Remove',
'settings.skillsRunner.recentRuns.headingForSkill': 'Recent runs for this skill',
'settings.skillsRunner.recentRuns.headingAll': 'Recent skill runs (all)',
'settings.skillsRunner.recentRuns.refresh': 'Refresh',
'settings.skillsRunner.recentRuns.loading': 'Loading recent runs…',
'settings.skillsRunner.recentRuns.empty': 'No recent runs.',
'settings.skillsRunner.viewer.loading': 'Loading log…',
'settings.skillsRunner.viewer.tailing': 'Live tailing',
'settings.skillsRunner.viewer.fetching': 'fetching',
'settings.skillsRunner.viewer.error': 'Log read failed:',
'settings.skillsRunner.repoPicker.loading': 'Loading repositories…',
'settings.skillsRunner.repoPicker.select': 'Select a repository…',
'settings.skillsRunner.repoPicker.empty':
'No repositories returned. Connect GitHub via Composio to populate this list.',
'settings.skillsRunner.repoPicker.notConnected':
'GitHub isnt connected via Composio. Connect it under Skills → Composio first.',
'settings.skillsRunner.repoPicker.privateTag': '(private)',
'settings.skillsRunner.branchPicker.needRepo': 'Pick a repo first…',
'settings.skillsRunner.branchPicker.loading': 'Loading branches…',
'settings.skillsRunner.branchPicker.select': 'Select a branch…',
'settings.modelHealth.title': 'Model Health',
'settings.modelHealth.desc':
'Per-model quality, hallucination rate, and cost comparison across active models',
@@ -854,6 +948,24 @@ const ru5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
'skills.dashboard.scheduledHeading': 'Scheduled skills',
'skills.dashboard.emptyTitle': 'No scheduled skills',
'skills.dashboard.emptyBody':
'Run a bundled skill once or save a recurring schedule to see it here.',
'skills.dashboard.create': 'Create a Skill',
'skills.dashboard.run': 'Run a Skill',
'skills.dashboard.enable': 'Enable scheduled skill',
'skills.dashboard.disable': 'Disable scheduled skill',
'skills.dashboard.lastRun': 'Last run',
'skills.dashboard.nextRun': 'Next run',
'skills.dashboard.cardOpenRunner': 'Open in runner',
'skills.dashboard.loadError': 'Failed to load scheduled skills',
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
};
export default ru5;
+114 -2
View File
@@ -199,8 +199,27 @@ 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.skillsRunner.scheduleEnabled': 'Enabled',
'settings.skillsRunner.scheduleDisabled': 'Paused',
'settings.skillsRunner.scheduleToggleAria': 'Toggle schedule enabled',
'settings.skillsRunner.schedule.history': 'History',
'settings.skillsRunner.schedule.historyLoading': 'Loading history\u2026',
'settings.skillsRunner.schedule.historyEmpty': 'No runs yet for this schedule.',
'settings.skillsRunner.schedule.historyNoOutput': 'No output captured.',
'settings.skillsRunner.schedule.active': 'Active',
'settings.skillsRunner.schedule.lastRunLabel': 'last:',
'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':
@@ -327,6 +346,23 @@ const zhCN5: TranslationMap = {
'skills.create.creating': '创建中…',
'skills.create.description': '描述',
'skills.create.descriptionPlaceholder': '这个技能做什么?',
'skills.create.optional': '(optional)',
'skills.create.inputs.heading': 'Inputs',
'skills.create.inputs.help':
'Declare parameters the skill needs. The Skills Runner will render a form for these at run time.',
'skills.create.inputs.add': 'Add input',
'skills.create.inputs.row.name': 'Input name',
'skills.create.inputs.row.namePlaceholder': 'e.g. repo',
'skills.create.inputs.row.nameError':
'Letters, digits, underscores, and dashes only; must start with a letter.',
'skills.create.inputs.row.description': 'Input description',
'skills.create.inputs.row.descriptionPlaceholder': 'What goes in this field?',
'skills.create.inputs.row.type': 'Type',
'skills.create.inputs.row.required': 'Required',
'skills.create.inputs.row.remove': 'Remove input',
'skills.create.inputs.type.string': 'Text',
'skills.create.inputs.type.integer': 'Number',
'skills.create.inputs.type.boolean': 'Yes / No',
'skills.create.license': '许可证',
'skills.create.name': '名称',
'skills.create.namePlaceholder': '例如:交易日志',
@@ -784,6 +820,64 @@ const zhCN5: TranslationMap = {
'settings.agentAccess.add': 'Add',
'settings.agentAccess.saving': 'Saving…',
'settings.agentAccess.changesApply': 'Changes apply on your next message.',
'skills.tabs.runners': 'Runners',
'settings.developerMenu.skillsRunner.title': 'Skills Runner',
'settings.developerMenu.skillsRunner.desc':
'Run any bundled skill ad-hoc — fill its inputs and fire a background autonomous run',
'settings.developerMenu.skillsRunner.panelDesc':
'Pick a bundled skill, fill in its declared inputs, and fire a fire-and-forget background run. Use Dev Workflow instead if you want a cron-scheduled recurring job.',
'settings.skillsRunner.skill': 'Skill',
'settings.skillsRunner.selectSkill': 'Select a skill…',
'settings.skillsRunner.loadingSkills': 'Loading skills…',
'settings.skillsRunner.loadingDescription': 'Loading skill inputs…',
'settings.skillsRunner.noInputs': 'This skill declares no inputs.',
'settings.skillsRunner.placeholder.required': 'required',
'settings.skillsRunner.runNow': 'Run now',
'settings.skillsRunner.starting': 'Starting…',
'settings.skillsRunner.started': 'Started — run id:',
'settings.skillsRunner.logPath': 'Log:',
'settings.skillsRunner.error.listSkills': 'Failed to load skills:',
'settings.skillsRunner.error.describe': 'Failed to load inputs:',
'settings.skillsRunner.error.missingRequired': 'Missing required input(s):',
'settings.skillsRunner.error.run': 'Run failed to start:',
'settings.skillsRunner.error.preflightGate': 'Preflight gate failed',
'settings.skillsRunner.schedule.heading': 'Schedule (recurring)',
'settings.skillsRunner.schedule.help':
'Save this skill + inputs as a recurring cron job. The agent will call run_skill at each tick.',
'settings.skillsRunner.schedule.frequency': 'Frequency',
'settings.skillsRunner.schedule.every30min': 'Every 30 minutes',
'settings.skillsRunner.schedule.everyHour': 'Every hour',
'settings.skillsRunner.schedule.every2hours': 'Every 2 hours',
'settings.skillsRunner.schedule.every6hours': 'Every 6 hours',
'settings.skillsRunner.schedule.onceDaily': 'Once daily (9:00)',
'settings.skillsRunner.schedule.save': 'Save schedule',
'settings.skillsRunner.schedule.saving': 'Saving…',
'settings.skillsRunner.schedule.saved': 'Schedule saved.',
'settings.skillsRunner.schedule.error': 'Schedule save failed:',
'settings.skillsRunner.schedule.loadingJobs': 'Loading existing schedules…',
'settings.skillsRunner.schedule.noJobs': 'No schedules saved for this skill yet.',
'settings.skillsRunner.schedule.existing': 'Scheduled jobs for this skill:',
'settings.skillsRunner.schedule.runNow': 'Run',
'settings.skillsRunner.schedule.remove': 'Remove',
'settings.skillsRunner.recentRuns.headingForSkill': 'Recent runs for this skill',
'settings.skillsRunner.recentRuns.headingAll': 'Recent skill runs (all)',
'settings.skillsRunner.recentRuns.refresh': 'Refresh',
'settings.skillsRunner.recentRuns.loading': 'Loading recent runs…',
'settings.skillsRunner.recentRuns.empty': 'No recent runs.',
'settings.skillsRunner.viewer.loading': 'Loading log…',
'settings.skillsRunner.viewer.tailing': 'Live tailing',
'settings.skillsRunner.viewer.fetching': 'fetching',
'settings.skillsRunner.viewer.error': 'Log read failed:',
'settings.skillsRunner.repoPicker.loading': 'Loading repositories…',
'settings.skillsRunner.repoPicker.select': 'Select a repository…',
'settings.skillsRunner.repoPicker.empty':
'No repositories returned. Connect GitHub via Composio to populate this list.',
'settings.skillsRunner.repoPicker.notConnected':
'GitHub isnt connected via Composio. Connect it under Skills → Composio first.',
'settings.skillsRunner.repoPicker.privateTag': '(private)',
'settings.skillsRunner.branchPicker.needRepo': 'Pick a repo first…',
'settings.skillsRunner.branchPicker.loading': 'Loading branches…',
'settings.skillsRunner.branchPicker.select': 'Select a branch…',
'settings.modelHealth.title': 'Model Health',
'settings.modelHealth.desc':
'Per-model quality, hallucination rate, and cost comparison across active models',
@@ -808,6 +902,24 @@ const zhCN5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
'skills.dashboard.scheduledHeading': 'Scheduled skills',
'skills.dashboard.emptyTitle': 'No scheduled skills',
'skills.dashboard.emptyBody':
'Run a bundled skill once or save a recurring schedule to see it here.',
'skills.dashboard.create': 'Create a Skill',
'skills.dashboard.run': 'Run a Skill',
'skills.dashboard.enable': 'Enable scheduled skill',
'skills.dashboard.disable': 'Disable scheduled skill',
'skills.dashboard.lastRun': 'Last run',
'skills.dashboard.nextRun': 'Next run',
'skills.dashboard.cardOpenRunner': 'Open in runner',
'skills.dashboard.loadError': 'Failed to load scheduled skills',
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
};
export default zhCN5;
+117 -3
View File
@@ -285,7 +285,7 @@ const en: TranslationMap = {
'skills.tabs.composio': 'Composio',
'skills.tabs.channels': 'Channels',
'skills.tabs.mcp': 'MCP Servers',
'skills.tabs.runners': 'Runners',
// Intelligence / Memory
'memory.title': 'Memory',
'memory.search': 'Search memories...',
@@ -3264,6 +3264,72 @@ const en: TranslationMap = {
'Autonomous agent that picks your GitHub issues and raises PRs on a schedule',
'settings.developerMenu.devWorkflow.panelDesc':
'Configure an autonomous developer agent that picks GitHub issues assigned to you and raises pull requests automatically on a schedule.',
'settings.developerMenu.skillsRunner.title': 'Skills Runner',
'settings.developerMenu.skillsRunner.desc':
'Run any bundled skill ad-hoc — fill its inputs and fire a background autonomous run',
'settings.developerMenu.skillsRunner.panelDesc':
'Pick a bundled skill, fill in its declared inputs, and fire a fire-and-forget background run. Use Dev Workflow instead if you want a cron-scheduled recurring job.',
'settings.skillsRunner.skill': 'Skill',
'settings.skillsRunner.selectSkill': 'Select a skill…',
'settings.skillsRunner.loadingSkills': 'Loading skills…',
'settings.skillsRunner.loadingDescription': 'Loading skill inputs…',
'settings.skillsRunner.noInputs': 'This skill declares no inputs.',
'settings.skillsRunner.placeholder.required': 'required',
'settings.skillsRunner.runNow': 'Run now',
'settings.skillsRunner.starting': 'Starting…',
'settings.skillsRunner.started': 'Started — run id:',
'settings.skillsRunner.logPath': 'Log:',
'settings.skillsRunner.error.listSkills': 'Failed to load skills:',
'settings.skillsRunner.error.describe': 'Failed to load inputs:',
'settings.skillsRunner.error.missingRequired': 'Missing required input(s):',
'settings.skillsRunner.error.run': 'Run failed to start:',
'settings.skillsRunner.error.preflightGate': 'Preflight gate failed',
'settings.skillsRunner.schedule.heading': 'Schedule (recurring)',
'settings.skillsRunner.schedule.help':
'Save this skill + inputs as a recurring cron job. The agent will call run_skill at each tick.',
'settings.skillsRunner.schedule.frequency': 'Frequency',
'settings.skillsRunner.schedule.every30min': 'Every 30 minutes',
'settings.skillsRunner.schedule.everyHour': 'Every hour',
'settings.skillsRunner.schedule.every2hours': 'Every 2 hours',
'settings.skillsRunner.schedule.every6hours': 'Every 6 hours',
'settings.skillsRunner.schedule.onceDaily': 'Once daily (9:00)',
'settings.skillsRunner.schedule.save': 'Save schedule',
'settings.skillsRunner.schedule.saving': 'Saving…',
'settings.skillsRunner.schedule.saved': 'Schedule saved.',
'settings.skillsRunner.schedule.error': 'Schedule save failed:',
'settings.skillsRunner.schedule.loadingJobs': 'Loading existing schedules…',
'settings.skillsRunner.schedule.noJobs': 'No schedules saved for this skill yet.',
'settings.skillsRunner.schedule.existing': 'Scheduled jobs for this skill:',
'settings.skillsRunner.schedule.runNow': 'Run',
'settings.skillsRunner.schedule.remove': 'Remove',
'settings.skillsRunner.scheduleEnabled': 'Enabled',
'settings.skillsRunner.scheduleDisabled': 'Paused',
'settings.skillsRunner.scheduleToggleAria': 'Toggle schedule enabled',
'settings.skillsRunner.schedule.history': 'History',
'settings.skillsRunner.schedule.historyLoading': 'Loading history…',
'settings.skillsRunner.schedule.historyEmpty': 'No runs yet for this schedule.',
'settings.skillsRunner.schedule.historyNoOutput': 'No output captured.',
'settings.skillsRunner.schedule.active': 'Active',
'settings.skillsRunner.schedule.lastRunLabel': 'last:',
'settings.skillsRunner.recentRuns.headingForSkill': 'Recent runs for this skill',
'settings.skillsRunner.recentRuns.headingAll': 'Recent skill runs (all)',
'settings.skillsRunner.recentRuns.refresh': 'Refresh',
'settings.skillsRunner.recentRuns.loading': 'Loading recent runs…',
'settings.skillsRunner.recentRuns.empty': 'No recent runs.',
'settings.skillsRunner.viewer.loading': 'Loading log…',
'settings.skillsRunner.viewer.tailing': 'Live tailing',
'settings.skillsRunner.viewer.fetching': 'fetching',
'settings.skillsRunner.viewer.error': 'Log read failed:',
'settings.skillsRunner.repoPicker.loading': 'Loading repositories…',
'settings.skillsRunner.repoPicker.select': 'Select a repository…',
'settings.skillsRunner.repoPicker.empty':
'No repositories returned. Connect GitHub via Composio to populate this list.',
'settings.skillsRunner.repoPicker.notConnected':
'GitHub isnt connected via Composio. Connect it under Skills → Composio first.',
'settings.skillsRunner.repoPicker.privateTag': '(private)',
'settings.skillsRunner.branchPicker.needRepo': 'Pick a repo first…',
'settings.skillsRunner.branchPicker.loading': 'Loading branches…',
'settings.skillsRunner.branchPicker.select': 'Select a branch…',
'settings.devWorkflow.githubRepository': 'GitHub Repository',
'settings.devWorkflow.loadingRepositories': 'Loading repositories...',
'settings.devWorkflow.selectRepository': 'Select a repository',
@@ -3289,8 +3355,18 @@ 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.nextRun': 'Next run',
'settings.devWorkflow.lastRun': 'Last run',
'settings.devWorkflow.runNow': 'Run now',
'settings.devWorkflow.running': 'Running\u2026',
'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':
@@ -3581,6 +3657,23 @@ const en: TranslationMap = {
'skills.create.creating': 'Creating…',
'skills.create.description': 'Description',
'skills.create.descriptionPlaceholder': 'What does this skill do?',
'skills.create.optional': '(optional)',
'skills.create.inputs.heading': 'Inputs',
'skills.create.inputs.help':
'Declare parameters the skill needs. The Skills Runner will render a form for these at run time.',
'skills.create.inputs.add': 'Add input',
'skills.create.inputs.row.name': 'Input name',
'skills.create.inputs.row.namePlaceholder': 'e.g. repo',
'skills.create.inputs.row.nameError':
'Letters, digits, underscores, and dashes only; must start with a letter.',
'skills.create.inputs.row.description': 'Input description',
'skills.create.inputs.row.descriptionPlaceholder': 'What goes in this field?',
'skills.create.inputs.row.type': 'Type',
'skills.create.inputs.row.required': 'Required',
'skills.create.inputs.row.remove': 'Remove input',
'skills.create.inputs.type.string': 'Text',
'skills.create.inputs.type.integer': 'Number',
'skills.create.inputs.type.boolean': 'Yes / No',
'skills.create.license': 'License',
'skills.create.licensePlaceholder': 'MIT',
'skills.create.name': 'Name',
@@ -3932,6 +4025,27 @@ const en: TranslationMap = {
'settings.appearanceDesc': 'Pick light, dark, or match your system theme',
'settings.mascot': 'Mascot',
'settings.mascotDesc': 'Pick the mascot color used across the app',
// /skills IA restructure: landing-dashboard + /skills/new authoring page.
// The runner UX (existing Skills page → SkillsRunnerBody) moves to
// /skills/run; this dashboard surfaces currently-scheduled skills as
// DevWorkflowPanel-style cards.
'skills.dashboard.title': 'Skills',
'skills.dashboard.scheduledHeading': 'Scheduled skills',
'skills.dashboard.emptyTitle': 'No scheduled skills',
'skills.dashboard.emptyBody':
'Run a bundled skill once or save a recurring schedule to see it here.',
'skills.dashboard.create': 'Create a Skill',
'skills.dashboard.run': 'Run a Skill',
'skills.dashboard.enable': 'Enable scheduled skill',
'skills.dashboard.disable': 'Disable scheduled skill',
'skills.dashboard.lastRun': 'Last run',
'skills.dashboard.nextRun': 'Next run',
'skills.dashboard.cardOpenRunner': 'Open in runner',
'skills.dashboard.loadError': 'Failed to load scheduled skills',
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
};
export default en;
+2
View File
@@ -37,6 +37,7 @@ import RecoveryPhrasePanel from '../components/settings/panels/RecoveryPhrasePan
import ScreenAwarenessDebugPanel from '../components/settings/panels/ScreenAwarenessDebugPanel';
import ScreenIntelligencePanel from '../components/settings/panels/ScreenIntelligencePanel';
import SearchPanel from '../components/settings/panels/SearchPanel';
import SkillsRunnerPanel from '../components/settings/panels/SkillsRunnerPanel';
import TeamInvitesPanel from '../components/settings/panels/TeamInvitesPanel';
import TeamManagementPanel from '../components/settings/panels/TeamManagementPanel';
import TeamMembersPanel from '../components/settings/panels/TeamMembersPanel';
@@ -460,6 +461,7 @@ const Settings = () => {
<Route path="agent-chat" element={wrapSettingsPage(<AgentChatPanel />)} />
<Route path="cron-jobs" element={wrapSettingsPage(<CronJobsPanel />)} />
<Route path="dev-workflow" element={wrapSettingsPage(<DevWorkflowPanel />)} />
<Route path="skills-runner" element={wrapSettingsPage(<SkillsRunnerPanel />)} />
<Route
path="screen-awareness-debug"
element={wrapSettingsPage(<ScreenAwarenessDebugPanel />)}
+92
View File
@@ -0,0 +1,92 @@
/**
* SkillNew Phase 6 coverage.
*
* Covers:
* - renders the form (delegates to CreateSkillForm) and the header
* Cancel/Submit buttons.
* - cancel button navigates back to /skills.
* - on a successful submit (createSkill resolves), the page
* navigates to /skills.
* - submit button reflects the form's validity (disabled until both
* required fields are filled).
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import SkillNew from './SkillNew';
const stableT = (key: string) => key;
vi.mock('../lib/i18n/I18nContext', () => ({ useT: () => ({ t: stableT }) }));
const hoisted = vi.hoisted(() => ({ createSkill: vi.fn() }));
vi.mock('../services/api/skillsApi', () => ({ skillsApi: { createSkill: hoisted.createSkill } }));
const renderPage = () =>
render(
<MemoryRouter initialEntries={['/skills/new']}>
<Routes>
<Route path="/skills/new" element={<SkillNew />} />
<Route path="/skills" element={<div data-testid="dashboard-landed">dashboard</div>} />
</Routes>
</MemoryRouter>
);
describe('SkillNew', () => {
beforeEach(() => {
hoisted.createSkill.mockReset();
});
it('renders the form and the header CTAs', () => {
renderPage();
expect(screen.getByTestId('skill-new-cancel')).toBeInTheDocument();
expect(screen.getByTestId('skill-new-submit')).toBeInTheDocument();
// CreateSkillForm renders the name + description inputs.
expect(screen.getByLabelText(/skills.create.name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/skills.create.description/i)).toBeInTheDocument();
});
it('cancel button navigates back to /skills', () => {
renderPage();
fireEvent.click(screen.getByTestId('skill-new-cancel'));
expect(screen.getByTestId('dashboard-landed')).toBeInTheDocument();
});
it('submit is disabled until both required fields are filled', () => {
renderPage();
const submit = screen.getByTestId('skill-new-submit') as HTMLButtonElement;
expect(submit).toBeDisabled();
fireEvent.change(screen.getByLabelText(/skills.create.name/i), {
target: { value: 'New Skill' },
});
expect(submit).toBeDisabled(); // still missing description
fireEvent.change(screen.getByLabelText(/skills.create.description/i), {
target: { value: 'Does something neat.' },
});
expect(submit).not.toBeDisabled();
});
it('navigates to /skills after a successful submit', async () => {
hoisted.createSkill.mockResolvedValue({
id: 'new-skill',
name: 'New Skill',
scope: 'user',
legacy: false,
});
renderPage();
fireEvent.change(screen.getByLabelText(/skills.create.name/i), {
target: { value: 'New Skill' },
});
fireEvent.change(screen.getByLabelText(/skills.create.description/i), {
target: { value: 'Description.' },
});
fireEvent.click(screen.getByTestId('skill-new-submit'));
await waitFor(() => expect(hoisted.createSkill).toHaveBeenCalled());
await screen.findByTestId('dashboard-landed');
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* /skills/new full-page Create-a-Skill authoring view.
*
* Renders `CreateSkillForm` (extracted from CreateSkillModal in
* Phase 5) inside page chrome, so the same flow is available as a
* standalone route entry point for the Skills dashboard's [+ Create
* a Skill] CTA and bookmark-able for users who routinely scaffold
* new SKILL.md drafts.
*
* Behaviour on submit:
* - Success navigate to /skills (dashboard) so the user lands
* somewhere meaningful. We considered /skills/run?skill=<new-id>,
* but new skills aren't auto-scheduled and the runner picker
* pre-select only makes sense once the user has filled in inputs.
* Dashboard sends a clearer "skill created, here's what's
* scheduled" signal.
* - Cancel /skills.
*/
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import CreateSkillForm from '../components/skills/CreateSkillForm';
import { useT } from '../lib/i18n/I18nContext';
import { type SkillSummary } from '../services/api/skillsApi';
const PAGE_FORM_ID = 'create-skill-page-form';
export default function SkillNew() {
const { t } = useT();
const navigate = useNavigate();
const [formValid, setFormValid] = useState(false);
const [submitting, setSubmitting] = useState(false);
const handleStateChange = useCallback((state: { valid: boolean; submitting: boolean }) => {
setFormValid(state.valid);
setSubmitting(state.submitting);
}, []);
const handleCreated = useCallback(
(_skill: SkillSummary) => {
// The dashboard re-fetches the cron list on mount, so any
// schedule the user adds for this new skill will appear there
// automatically — no need to plumb the new id through state.
navigate('/skills?tab=runners');
},
[navigate]
);
return (
<div className="min-h-full flex flex-col">
<div className="flex-1 flex items-start justify-center p-4 pt-6">
<div className="w-full max-w-3xl space-y-4">
{/* Header: title + Cancel/Submit on the right.
The submit button is wired to the form via `form=PAGE_FORM_ID`
so it submits the underlying form even though it sits in the
header rather than inside the form element. */}
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<h1 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{t('skills.new.title')}
</h1>
<p className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
{t('skills.create.subtitle')}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
data-testid="skill-new-cancel"
onClick={() => navigate('/skills?tab=runners')}
disabled={submitting}
className="rounded-lg px-4 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:opacity-40">
{t('common.cancel')}
</button>
<button
type="submit"
form={PAGE_FORM_ID}
data-testid="skill-new-submit"
disabled={!formValid || submitting}
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white shadow-soft transition-colors hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50">
{submitting ? t('skills.create.creating') : t('skills.create.createBtn')}
</button>
</div>
</div>
{/* Form */}
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-soft">
<CreateSkillForm
formId={PAGE_FORM_ID}
onCreated={handleCreated}
onStateChange={handleStateChange}
autoFocus
/>
</div>
</div>
</div>
</div>
);
}
+24 -2
View File
@@ -47,6 +47,7 @@ import type { ToastNotification } from '../types/intelligence';
import { IS_DEV } from '../utils/config';
import { isLocalSessionToken } from '../utils/localSession';
import { openhumanComposioGetMode, subconsciousEscalationsDismiss } from '../utils/tauriCommands';
import SkillsDashboard from './SkillsDashboard';
function channelStatusLabel(status: ChannelConnectionStatus, t: (key: string) => string): string {
switch (status) {
@@ -350,7 +351,7 @@ interface SkillItem {
// ─── Main Skills Page ──────────────────────────────────────────────────────────
type ConnectionsTab = 'channels' | 'composio' | 'mcp';
type ConnectionsTab = 'channels' | 'composio' | 'mcp' | 'runners';
export default function Skills() {
const { t } = useT();
@@ -358,7 +359,16 @@ export default function Skills() {
const location = useLocation();
const navigate = useNavigate();
const isLocalSession = isLocalSessionToken(getCoreStateSnapshot().snapshot.sessionToken);
const [activeTab, setActiveTab] = useState<ConnectionsTab>('composio');
// Honour `?tab=<runners|composio|channels|mcp>` so `/skills?tab=runners`
// lands directly on the Runners sub-tab (used by SkillsRun's back button
// so closing the runner returns to the dashboard, not Composio).
const initialTab: ConnectionsTab = (() => {
const params = new URLSearchParams(location.search);
const t = params.get('tab');
if (t === 'runners' || t === 'composio' || t === 'channels' || t === 'mcp') return t;
return 'composio';
})();
const [activeTab, setActiveTab] = useState<ConnectionsTab>(initialTab);
const dispatch = useAppDispatch();
const [defaultChannelBusy, setDefaultChannelBusy] = useState<ChannelType | null>(null);
const handleSetDefaultChannel = useCallback(
@@ -935,10 +945,22 @@ export default function Skills() {
{ value: 'composio', label: t('skills.tabs.composio') },
{ value: 'channels', label: t('skills.tabs.channels') },
{ value: 'mcp', label: t('skills.tabs.mcp') },
{ value: 'runners', label: t('skills.tabs.runners') },
]}
/>
{
<>
{activeTab === 'runners' && (
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-soft animate-fade-up">
{/* The Runners sub-tab IS the scheduled-skills dashboard:
header + [+ Create a Skill] + [ Run a Skill] CTAs
plus the list of enable/disable cards. The picker +
runner UX itself lives at /skills/run (a focused
single-purpose page reached via the "Run a Skill"
button or a card click). */}
<SkillsDashboard />
</div>
)}
{activeTab === 'channels' && channelsGroup && (
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 shadow-soft animate-fade-up">
<div className="px-1 pb-3 pt-1">
+218
View File
@@ -0,0 +1,218 @@
/**
* SkillsDashboard Phase 3 coverage.
*
* Covers:
* - empty state (no skill-* cron jobs found) renders the empty card +
* Run-a-Skill CTA.
* - non-empty state groups jobs by skill_id and renders one card per
* skill, with the schedule rendered through cronToHuman.
* - card click navigates to /skills/run?skill=<id>.
* - toggle round-trip: clicking flips enabled via openhumanCronUpdate
* and reloads via openhumanCronList; aria-checked reflects the new
* state.
* - load error renders the error card with a retry button.
* - jobs that don't start with `skill-run-` are filtered out (so a
* user's unrelated cron jobs don't leak onto this page).
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import SkillsDashboard from './SkillsDashboard';
const stableT = (key: string) => key;
vi.mock('../lib/i18n/I18nContext', () => ({ useT: () => ({ t: stableT }) }));
const hoisted = vi.hoisted(() => ({ cronList: vi.fn(), cronUpdate: vi.fn() }));
vi.mock('../utils/tauriCommands/cron', () => ({
openhumanCronList: hoisted.cronList,
openhumanCronUpdate: hoisted.cronUpdate,
}));
const renderDashboard = () =>
render(
<MemoryRouter initialEntries={['/skills']}>
<Routes>
<Route path="/skills" element={<SkillsDashboard />} />
<Route
path="/skills/run"
element={<div data-testid="runner-landed">{window.location.hash}</div>}
/>
<Route path="/skills/new" element={<div data-testid="new-landed">new</div>} />
</Routes>
</MemoryRouter>
);
function makeJob(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'job-1',
expression: '*/30 * * * *',
schedule: { kind: 'cron', expr: '*/30 * * * *' },
command: '',
prompt: null,
name: 'skill-run-dev-workflow-repo=owner-repo',
job_type: 'agent',
session_target: 'isolated',
model: null,
enabled: true,
delivery: { mode: 'proactive', best_effort: true },
delete_after_run: false,
created_at: '2026-05-20T10:00:00Z',
next_run: '2026-05-29T03:00:00Z',
last_run: '2026-05-29T02:30:00Z',
last_status: 'ok',
last_output: null,
...overrides,
};
}
describe('SkillsDashboard', () => {
beforeEach(() => {
hoisted.cronList.mockReset();
hoisted.cronUpdate.mockReset();
});
it('renders the empty state when no skill-run-* jobs exist', async () => {
hoisted.cronList.mockResolvedValue({ result: [] });
renderDashboard();
await screen.findByTestId('skills-dashboard-empty');
expect(screen.getByText('skills.dashboard.emptyTitle')).toBeInTheDocument();
// CTA → /skills/run.
fireEvent.click(screen.getByTestId('skills-dashboard-empty-cta'));
expect(screen.getByTestId('runner-landed')).toBeInTheDocument();
});
it('surfaces skill-run-* AND legacy dev-workflow-* crons, drops unrelated ones', async () => {
// The legacy `dev-workflow-<repo>` naming (written by
// DevWorkflowPanel before the unified `skill-run-` convention)
// must surface on the dashboard so users can toggle / edit the
// dev-workflow schedule they already set up. Anything that doesn't
// match either prefix (memory-tree maintenance, etc.) stays out.
hoisted.cronList.mockResolvedValue({
result: [
makeJob({ id: 'j-modern', name: 'skill-run-github-issue-crusher-repo=foo-bar' }),
makeJob({ id: 'j-legacy', name: 'dev-workflow-tinyhumansai-openhuman' }),
makeJob({ id: 'j-unrelated', name: 'memory-tree-maintenance' }),
],
});
renderDashboard();
await screen.findByTestId('skill-card-github-issue-crusher');
expect(screen.queryByTestId('skills-dashboard-empty')).not.toBeInTheDocument();
expect(screen.queryAllByTestId('skill-card-github-issue-crusher')).toHaveLength(1);
// Legacy dev-workflow naming is mapped to skill_id 'dev-workflow'
// and gets its own card.
expect(screen.queryAllByTestId('skill-card-dev-workflow')).toHaveLength(1);
// Unrelated cron doesn't get a card.
expect(screen.queryAllByTestId('skill-card-memory-tree-maintenance')).toHaveLength(0);
});
it('groups multiple jobs for the same skill into one card with an ×N badge', async () => {
hoisted.cronList.mockResolvedValue({
result: [
makeJob({ id: 'a', name: 'skill-run-dev-workflow-repo=owner-foo' }),
makeJob({ id: 'b', name: 'skill-run-dev-workflow-repo=owner-bar', enabled: false }),
],
});
renderDashboard();
await screen.findByTestId('skill-card-dev-workflow');
// Multi-job badge.
expect(screen.getByText('×2')).toBeInTheDocument();
// Picks the enabled job as primary → toggle aria-checked is true.
expect(screen.getByTestId('skill-card-dev-workflow-toggle')).toHaveAttribute(
'aria-checked',
'true'
);
});
it('renders the schedule via cronToHuman', async () => {
hoisted.cronList.mockResolvedValue({
result: [makeJob({ name: 'skill-run-github-issue-crusher-x=1' })],
});
renderDashboard();
await screen.findByTestId('skill-card-github-issue-crusher');
// `*/30 * * * *` → "Every 30 minutes".
expect(screen.getByText('Every 30 minutes')).toBeInTheDocument();
});
it('clicking a card navigates to /skills/run?skill=<id>', async () => {
hoisted.cronList.mockResolvedValue({
result: [makeJob({ name: 'skill-run-dev-workflow-repo=x' })],
});
renderDashboard();
const card = await screen.findByTestId('skill-card-dev-workflow-open');
fireEvent.click(card);
expect(screen.getByTestId('runner-landed')).toBeInTheDocument();
});
it('header CTAs navigate to /skills/new and /skills/run', async () => {
hoisted.cronList.mockResolvedValue({ result: [] });
const { unmount } = renderDashboard();
await screen.findByTestId('skills-dashboard-empty');
fireEvent.click(screen.getByTestId('skills-dashboard-create'));
expect(screen.getByTestId('new-landed')).toBeInTheDocument();
unmount();
hoisted.cronList.mockResolvedValue({ result: [] });
renderDashboard();
await screen.findByTestId('skills-dashboard-empty');
fireEvent.click(screen.getByTestId('skills-dashboard-run'));
expect(screen.getByTestId('runner-landed')).toBeInTheDocument();
});
it('toggle flips enabled via openhumanCronUpdate and reloads the list', async () => {
let listCalls = 0;
hoisted.cronList.mockImplementation(async () => {
listCalls += 1;
// First call: enabled=true. Second call (after update): enabled=false.
return {
result: [
makeJob({ id: 'j-1', name: 'skill-run-dev-workflow-repo=x', enabled: listCalls === 1 }),
],
};
});
hoisted.cronUpdate.mockResolvedValue({
result: makeJob({ id: 'j-1', name: 'skill-run-dev-workflow-repo=x', enabled: false }),
});
renderDashboard();
const toggle = await screen.findByTestId('skill-card-dev-workflow-toggle');
expect(toggle).toHaveAttribute('aria-checked', 'true');
fireEvent.click(toggle);
await waitFor(() => {
expect(hoisted.cronUpdate).toHaveBeenCalledWith('j-1', { enabled: false });
});
// List reloaded (init + post-toggle = 2 calls).
await waitFor(() => {
expect(listCalls).toBe(2);
});
// aria-checked flipped after reload.
await waitFor(() => {
expect(screen.getByTestId('skill-card-dev-workflow-toggle')).toHaveAttribute(
'aria-checked',
'false'
);
});
});
it('renders an error card with retry when cronList fails', async () => {
hoisted.cronList
.mockRejectedValueOnce(new Error('rpc down'))
.mockResolvedValueOnce({ result: [] });
renderDashboard();
await screen.findByTestId('skills-dashboard-error');
expect(screen.getByText(/rpc down/)).toBeInTheDocument();
fireEvent.click(screen.getByText('common.retry'));
await screen.findByTestId('skills-dashboard-empty');
});
});
+286
View File
@@ -0,0 +1,286 @@
/**
* /skills landing dashboard.
*
* Lists the user's currently-scheduled skills as DevWorkflowPanel-style
* "active config" cards (one per cron job whose name starts with the
* SkillsRunnerBody prefix `skill-run-`). Each card shows the skill_id,
* a human-readable schedule, last/next run, and an enable/disable
* toggle that mirrors DevWorkflowPanel:439's update-then-reload pattern
* verbatim. Click anywhere else on the card /skills/run?skill=<id>
* so the user lands in the runner with the right skill pre-picked.
*
* The dashboard *only* surfaces cron-scheduled skills. The catalog of
* available skills, integrations, etc. lives on /skills/run; the
* dashboard is deliberately a "what's running on a schedule" view so
* users can see at a glance what their agent is autonomously doing.
*/
import createDebug from 'debug';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import ScheduledCronCard from '../components/skills/ScheduledCronCard';
import { useT } from '../lib/i18n/I18nContext';
import {
type CoreCronJob,
openhumanCronList,
openhumanCronUpdate,
} from '../utils/tauriCommands/cron';
const log = createDebug('app:pages:SkillsDashboard');
/** Same prefix SkillsRunnerBody.tsx uses to namespace its cron jobs. */
const CRON_NAME_PREFIX = 'skill-run-';
/**
* Legacy: DevWorkflowPanel saved its cron with the literal
* `dev-workflow-<repo>` name (e.g. `dev-workflow-tinyhumansai-openhuman`)
* before SkillsRunnerBody introduced the unified `skill-run-` prefix.
* Recognise both so the dashboard surfaces existing dev-workflow
* schedules without forcing the user to delete + re-save them through
* the new runner UI.
*/
const LEGACY_DEV_WORKFLOW_PREFIX = 'dev-workflow-';
const LEGACY_DEV_WORKFLOW_SKILL_ID = 'dev-workflow';
/**
* Recognise a cron job name as belonging to a skill schedule and return
* its skill_id. Returns `null` for cron jobs that don't belong on the
* Runners dashboard (e.g. memory-tree maintenance, channels polling).
*
* Two name shapes are recognised today:
* - `skill-run-<skill_id>[-input1=v1_input2=v2…]` (current convention,
* written by SkillsRunnerBody for every bundled skill incl. the
* "new" dev-workflow path)
* - `dev-workflow-<repo>` (legacy DevWorkflowPanel naming) mapped
* back to `skill_id = "dev-workflow"`
*/
function recognizeSkillCron(jobName: string): { skillId: string } | null {
if (jobName.startsWith(CRON_NAME_PREFIX)) {
const tail = jobName.slice(CRON_NAME_PREFIX.length);
// Split on the first `-input=` marker (input pairs always contain `=`).
const eqIdx = tail.indexOf('=');
if (eqIdx === -1) return { skillId: tail };
// Walk back from `=` to the last `-` before it — that's the input-pair separator.
const dashBeforeEq = tail.lastIndexOf('-', eqIdx);
if (dashBeforeEq === -1) return { skillId: tail };
return { skillId: tail.slice(0, dashBeforeEq) };
}
if (jobName.startsWith(LEGACY_DEV_WORKFLOW_PREFIX)) {
return { skillId: LEGACY_DEV_WORKFLOW_SKILL_ID };
}
return null;
}
/** Group jobs by skill_id and present a single card per skill (newest first). */
interface SkillGroup {
skillId: string;
jobs: CoreCronJob[];
/** The representative job — the most recently active one. */
primary: CoreCronJob;
}
function groupBySkill(jobs: CoreCronJob[]): SkillGroup[] {
const byId = new Map<string, CoreCronJob[]>();
for (const job of jobs) {
const recognised = recognizeSkillCron(job.name ?? '');
if (!recognised) continue;
const bucket = byId.get(recognised.skillId);
if (bucket) {
bucket.push(job);
} else {
byId.set(recognised.skillId, [job]);
}
}
const groups: SkillGroup[] = [];
for (const [skillId, list] of byId.entries()) {
// Pick "primary": enabled-with-most-recent-last_run beats enabled
// beats disabled, fall back to created_at desc for stability.
const sorted = [...list].sort((a, b) => {
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
const aTs = a.last_run ? new Date(a.last_run).getTime() : 0;
const bTs = b.last_run ? new Date(b.last_run).getTime() : 0;
if (aTs !== bTs) return bTs - aTs;
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
groups.push({ skillId, jobs: sorted, primary: sorted[0] });
}
// Order skills by primary's enabled-then-last_run; matches the
// DevWorkflowPanel sort intent (active surface first).
groups.sort((a, b) => {
if (a.primary.enabled !== b.primary.enabled) return a.primary.enabled ? -1 : 1;
const aTs = a.primary.last_run ? new Date(a.primary.last_run).getTime() : 0;
const bTs = b.primary.last_run ? new Date(b.primary.last_run).getTime() : 0;
return bTs - aTs;
});
return groups;
}
export default function SkillsDashboard() {
const { t } = useT();
const navigate = useNavigate();
const [jobs, setJobs] = useState<CoreCronJob[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Per-job "busy" key so we can disable the toggle while update is in
// flight — mirrors CronJobsPanel's `coreBusyKey` pattern.
const [busyJobId, setBusyJobId] = useState<string | null>(null);
const loadJobs = useCallback(async () => {
setLoading(true);
setError(null);
try {
const resp = await openhumanCronList();
const all = (resp.result ?? []) as CoreCronJob[];
// Accept both the current `skill-run-` prefix and the legacy
// `dev-workflow-` naming DevWorkflowPanel uses, via the shared
// recogniser at the top of the file.
const filtered = all.filter(j => recognizeSkillCron(j.name ?? '') !== null);
log('loaded %d skill cron jobs (of %d total)', filtered.length, all.length);
setJobs(filtered);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
log('loadJobs error: %s', msg);
setError(msg);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadJobs();
}, [loadJobs]);
// Mirror DevWorkflowPanel:439 verbatim — flip enabled, refresh the
// list. We keep this generic on the job rather than the skill so
// it works for any cron-backed skill.
const handleToggle = useCallback(
async (job: CoreCronJob) => {
setBusyJobId(job.id);
try {
await openhumanCronUpdate(job.id, { enabled: !job.enabled });
await loadJobs();
} catch (err: unknown) {
log('toggle error: %s', err instanceof Error ? err.message : String(err));
} finally {
setBusyJobId(null);
}
},
[loadJobs]
);
const groups = useMemo(() => groupBySkill(jobs), [jobs]);
const goCreate = () => navigate('/skills/new');
const goRun = () => navigate('/skills/run');
const goRunSkill = (skillId: string) =>
navigate(`/skills/run?skill=${encodeURIComponent(skillId)}`);
return (
<div className="min-h-full flex flex-col">
<div className="flex-1 flex items-start justify-center p-4 pt-6">
<div className="w-full max-w-3xl space-y-4">
{/* Header + CTAs */}
<div className="flex items-center justify-between gap-2">
<h1 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{t('skills.dashboard.title')}
</h1>
<div className="flex items-center gap-2">
<button
type="button"
data-testid="skills-dashboard-create"
onClick={goCreate}
className="rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-xs font-medium text-stone-700 dark:text-neutral-200 shadow-soft transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800">
+ {t('skills.dashboard.create')}
</button>
<button
type="button"
data-testid="skills-dashboard-run"
onClick={goRun}
className="rounded-lg bg-primary-500 px-3 py-2 text-xs font-semibold text-white shadow-soft transition-colors hover:bg-primary-600">
{t('skills.dashboard.run')}
</button>
</div>
</div>
{/* Section heading — kept above whatever state the list is in */}
<h2 className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400 px-1">
{t('skills.dashboard.scheduledHeading')}
</h2>
{loading && (
<div
data-testid="skills-dashboard-loading"
className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-soft text-sm text-stone-500 dark:text-neutral-400">
{t('common.loading')}
</div>
)}
{!loading && error && (
<div
data-testid="skills-dashboard-error"
className="rounded-2xl border border-coral-200 bg-coral-50 dark:bg-coral-500/10 dark:border-coral-500/30 p-4 text-sm">
<p className="text-coral-800 dark:text-coral-200">
{t('skills.dashboard.loadError')}: {error}
</p>
<button
type="button"
onClick={() => void loadJobs()}
className="mt-2 rounded border border-coral-300 dark:border-coral-500/40 bg-white dark:bg-neutral-900 px-3 py-1.5 text-xs font-medium text-coral-700 dark:text-coral-300 hover:bg-coral-100 dark:hover:bg-coral-500/15">
{t('common.retry')}
</button>
</div>
)}
{!loading && !error && groups.length === 0 && (
<div
data-testid="skills-dashboard-empty"
className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-8 shadow-soft text-center">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('skills.dashboard.emptyTitle')}
</h3>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{t('skills.dashboard.emptyBody')}
</p>
<button
type="button"
data-testid="skills-dashboard-empty-cta"
onClick={goRun}
className="mt-4 rounded-lg bg-primary-500 px-4 py-2 text-xs font-semibold text-white shadow-soft transition-colors hover:bg-primary-600">
{t('skills.dashboard.run')}
</button>
</div>
)}
{!loading && !error && groups.length > 0 && (
<div className="space-y-2">
{groups.map(group => {
const job = group.primary;
const isBusy = busyJobId === job.id;
// testIdRoot keys the rendered testids:
// `skill-card-<id>` — card root
// `skill-card-<id>-open` — clickable surface
// `skill-card-<id>-toggle` — enable/disable switch
// `skill-card-<id>-title` — title/skill_id text
// `skill-card-<id>-schedule` — cronToHuman line
// Used by SkillsDashboard.test.tsx and any future e2e specs.
return (
<ScheduledCronCard
key={group.skillId}
job={job}
title={group.skillId}
badgeCount={group.jobs.length}
onToggle={() => void handleToggle(job)}
onClick={() => goRunSkill(group.skillId)}
testIdRoot={`skill-card-${group.skillId}`}
busy={isBusy}
/>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import SkillsRun from './SkillsRun';
vi.mock('../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
vi.mock('../components/skills/SkillsRunnerBody', () => ({
default: () => <div data-testid="skills-runner-body" />,
}));
describe('SkillsRun', () => {
const render_ = () =>
render(
<MemoryRouter>
<SkillsRun />
</MemoryRouter>
);
it('renders the back button and page heading', () => {
render_();
expect(screen.getByRole('button', { name: 'common.back' })).toBeInTheDocument();
expect(screen.getByText('skills.tabs.runners')).toBeInTheDocument();
});
it('renders SkillsRunnerBody', () => {
render_();
expect(screen.getByTestId('skills-runner-body')).toBeInTheDocument();
});
it('back button fires navigate on click', () => {
render_();
fireEvent.click(screen.getByRole('button', { name: 'common.back' }));
// navigate() called — no assertion needed beyond no-throw
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* /skills/run single-purpose runner page.
*
* The Connections Runners sub-tab (inside Skills.tsx) now shows a
* scheduled-skills *dashboard* (cards with enable/disable toggles plus
* Create / Run CTAs). When the user clicks "▷ Run a Skill" from there,
* or a card-click that wants to take them into the picker for a specific
* skill, they land HERE a focused page that just hosts the
* SkillsRunnerBody picker + form + run-now + save-schedule flow, without
* the 4-tab Skills.tsx chrome.
*
* Bookmark-friendly and shareable via `?skill=<id>` (the body reads the
* query param and pre-selects the skill see SkillsRunnerBody.tsx).
*/
import { useNavigate } from 'react-router-dom';
import SkillsRunnerBody from '../components/skills/SkillsRunnerBody';
import { useT } from '../lib/i18n/I18nContext';
export default function SkillsRun() {
const { t } = useT();
const navigate = useNavigate();
return (
<div className="min-h-full flex flex-col">
<div className="flex-1 flex items-start justify-center p-4 pt-6">
<div className="w-full max-w-3xl space-y-4">
{/* Page header with a "back to dashboard" affordance so the
user can always retreat to the Runners overview without
clicking the bottom-tab. Reuses existing common.back +
skills.tabs.runners keys to avoid an i18n parity churn for
this single page. */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => navigate('/skills?tab=runners')}
aria-label={t('common.back')}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 transition-colors">
<span aria-hidden="true"></span> {t('common.back')}
</button>
<h1 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{t('skills.tabs.runners')}
</h1>
</div>
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-soft animate-fade-up">
<SkillsRunnerBody />
</div>
</div>
</div>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { skillsApi } from './skillsApi';
const mockCallCoreRpc = vi.fn();
vi.mock('../coreRpcClient', () => ({ callCoreRpc: (...a: unknown[]) => mockCallCoreRpc(...a) }));
describe('skillsApi', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
describe('createSkill', () => {
it('includes inputs in params when non-empty', async () => {
mockCallCoreRpc.mockResolvedValue({
skill: { id: 's', name: 'S', description: '', scope: 'user' as const },
});
await skillsApi.createSkill({
name: 'S',
description: 'desc',
inputs: [{ name: 'repo', type: 'string' as const, description: 'repo', required: true }],
});
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({ params: expect.objectContaining({ inputs: expect.any(Array) }) })
);
});
});
describe('describeSkill', () => {
it('calls openhuman.skills_describe with skill_id', async () => {
mockCallCoreRpc.mockResolvedValue({
id: 'dev-workflow',
name: 'Dev Workflow',
description: 'Auto dev',
inputs: [],
});
const result = await skillsApi.describeSkill('dev-workflow');
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
method: 'openhuman.skills_describe',
params: { skill_id: 'dev-workflow' },
})
);
expect(result.id).toBe('dev-workflow');
});
it('unwraps data-envelope shape', async () => {
mockCallCoreRpc.mockResolvedValue({
data: { id: 'x', name: 'X', description: '', inputs: [], skill_id: 'x' },
});
const result = await skillsApi.describeSkill('x');
expect(result.id).toBe('x');
});
});
describe('runSkill', () => {
it('calls openhuman.skills_run with skill_id and inputs', async () => {
mockCallCoreRpc.mockResolvedValue({ run_id: 'run-1', skill_id: 's', log: '/tmp/log' });
const result = await skillsApi.runSkill('s', { repo: 'owner/repo' });
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
method: 'openhuman.skills_run',
params: { skill_id: 's', inputs: { repo: 'owner/repo' } },
})
);
expect(result.run_id).toBe('run-1');
});
});
describe('readRunLog', () => {
it('calls skills_read_run_log with run_id', async () => {
mockCallCoreRpc.mockResolvedValue({
bytes_read: 100,
eof: false,
complete: false,
content: 'log line',
offset: 100,
});
const result = await skillsApi.readRunLog('run-1');
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
method: 'openhuman.skills_read_run_log',
params: expect.objectContaining({ run_id: 'run-1' }),
})
);
expect(result.bytes_read).toBe(100);
});
it('passes offset and max_bytes when provided', async () => {
mockCallCoreRpc.mockResolvedValue({
bytes_read: 0,
eof: true,
complete: true,
content: '',
offset: 500,
});
await skillsApi.readRunLog('run-2', 200, 4096);
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ run_id: 'run-2', offset: 200, max_bytes: 4096 }),
})
);
});
});
describe('recentRuns', () => {
it('returns scanned runs array', async () => {
mockCallCoreRpc.mockResolvedValue({ runs: [] });
const result = await skillsApi.recentRuns();
expect(Array.isArray(result)).toBe(true);
});
it('passes skill_id filter when provided', async () => {
mockCallCoreRpc.mockResolvedValue({ runs: [] });
await skillsApi.recentRuns('dev-workflow', 5);
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ skill_id: 'dev-workflow', limit: 5 }),
})
);
});
});
});
+170
View File
@@ -81,6 +81,20 @@ interface RawSkillsReadResourceResult {
* the JSON-RPC envelope per SKILL.md frontmatter convention (with
* `allowed_tools` accepted as an alias by the Rust deserializer).
*/
/**
* One declared `[[inputs]]` row supplied at create time by
* `CreateSkillForm.tsx`. Mirrors the Rust `SkillCreateInputDef` wire
* shape `description` and `type` are optional; `required` defaults
* to `true` on the Rust side when omitted (we send it explicitly to
* stay loud).
*/
export interface CreateSkillInputDef {
name: string;
description?: string;
required: boolean;
type?: 'string' | 'integer' | 'boolean';
}
export interface CreateSkillInput {
name: string;
description: string;
@@ -89,6 +103,13 @@ export interface CreateSkillInput {
author?: string;
tags?: string[];
allowedTools?: string[];
/**
* Optional list of `[[inputs]]` rows. When non-empty the Rust side
* writes a sibling `skill.toml` next to the generated SKILL.md so
* the Skills Runner can render dynamic form controls per input.
* Omit / pass `[]` to scaffold an input-less skill.
*/
inputs?: CreateSkillInputDef[];
}
interface RawSkillsCreateResult {
@@ -224,6 +245,7 @@ export const skillsApi = {
...(input.author !== undefined ? { author: input.author } : {}),
...(input.tags !== undefined ? { tags: input.tags } : {}),
...(input.allowedTools !== undefined ? { 'allowed-tools': input.allowedTools } : {}),
...(input.inputs !== undefined && input.inputs.length > 0 ? { inputs: input.inputs } : {}),
},
});
const raw = unwrapEnvelope(response);
@@ -293,4 +315,152 @@ export const skillsApi = {
log('uninstallSkill: response name=%s removedPath=%s', normalized.name, normalized.removedPath);
return normalized;
},
/**
* Fetch the declared `[[inputs]]` for a single skill plus its display
* metadata. Lightweight companion to `listSkills` `SkillSummary` rows
* (used by the catalog grid) deliberately don't include input
* declarations, so the Skills Runner panel calls this once when the
* user picks a skill from the dropdown so it can render the right form
* controls.
*/
describeSkill: async (skillId: string): Promise<SkillDescription> => {
log('describeSkill: request skillId=%s', skillId);
const response = await callCoreRpc<Envelope<SkillDescription> | SkillDescription>({
method: 'openhuman.skills_describe',
params: { skill_id: skillId },
});
const raw = unwrapEnvelope(response);
log('describeSkill: response inputs=%d', raw.inputs.length);
return raw;
},
/**
* Fire-and-forget invocation of `openhuman.skills_run`. Returns
* immediately with the new background run's `run_id`, the canonical
* `skill_id`, and the log path the run is streaming into; the actual
* autonomous work continues in the background and finishes with
* status `DONE` / `DEGENERATE` / `FAILED` in the run log.
*/
runSkill: async (skillId: string, inputs: Record<string, unknown>): Promise<SkillRunStarted> => {
log('runSkill: request skillId=%s', skillId);
const response = await callCoreRpc<Envelope<SkillRunStarted> | SkillRunStarted>({
method: 'openhuman.skills_run',
params: { skill_id: skillId, inputs },
});
const raw = unwrapEnvelope(response);
log('runSkill: response runId=%s log=%s', raw.run_id, raw.log);
return raw;
},
/**
* Read a slice of a skill run's streaming log file by run_id. Pass
* `offset` to tail forward the returned `offset` is the cursor for
* the next call. Stop polling once `complete: true` (footer landed).
*/
readRunLog: async (runId: string, offset?: number, maxBytes?: number): Promise<RunLogSlice> => {
log(
'readRunLog: request runId=%s offset=%s maxBytes=%s',
runId,
offset ?? 0,
maxBytes ?? 'default'
);
const params: Record<string, unknown> = { run_id: runId };
if (offset !== undefined) params.offset = offset;
if (maxBytes !== undefined) params.max_bytes = maxBytes;
const response = await callCoreRpc<Envelope<RunLogSlice> | RunLogSlice>({
method: 'openhuman.skills_read_run_log',
params,
});
const raw = unwrapEnvelope(response);
log('readRunLog: response bytes=%d eof=%s complete=%s', raw.bytes_read, raw.eof, raw.complete);
return raw;
},
/**
* Recent autonomous skill runs from `<workspace>/skills/.runs/`. Sorted
* by start time descending. Pass `skillId` to filter to one skill,
* omit for cross-skill. `limit` defaults to 20 (max 100).
*/
recentRuns: async (skillId?: string, limit?: number): Promise<ScannedRun[]> => {
log('recentRuns: request skillId=%s limit=%s', skillId ?? '*', limit ?? 'default');
const params: Record<string, unknown> = {};
if (skillId !== undefined) params.skill_id = skillId;
if (limit !== undefined) params.limit = limit;
const response = await callCoreRpc<Envelope<{ runs: ScannedRun[] }> | { runs: ScannedRun[] }>({
method: 'openhuman.skills_recent_runs',
params,
});
const raw = unwrapEnvelope(response);
log('recentRuns: response count=%d', raw.runs.length);
return raw.runs;
},
};
/**
* One input declaration from a skill's `[[inputs]]` block, returned by
* `openhuman.skills_describe`. The FE renders one form control per entry:
* `string`/`integer`/`boolean` map to text/number/checkbox controls.
*/
export interface SkillInputDescription {
name: string;
description: string;
required: boolean;
/** Type hint from `[[inputs]].type`. */
type: string;
}
/** Wire shape returned by `openhuman.skills_describe`. */
export interface SkillDescription {
id: string;
display_name: string;
when_to_use: string;
inputs: SkillInputDescription[];
}
/** Wire shape returned by `openhuman.skills_run` (fire-and-forget). */
export interface SkillRunStarted {
run_id: string;
status: string; // "started"
skill_id: string;
log: string; // absolute path to the streaming log
}
/**
* Slice of a run log file returned by `openhuman.skills_read_run_log`.
* Mirrors `crate::openhuman::skills::run_log::RunLogSlice`. The FE
* passes the returned `offset` as the next call's `offset` to tail
* forward; polling can stop once `complete: true` (the `--- result ---`
* footer has landed in the file).
*/
export interface RunLogSlice {
/** New read cursor — next call's `offset`. */
offset: number;
bytes_read: number;
content: string;
/** True if the read reached end-of-file (may still be incomplete). */
eof: boolean;
/** True once the run footer landed in the file. FE stops polling. */
complete: boolean;
}
/**
* One run entry returned by `openhuman.skills_recent_runs`. Wire shape
* mirrors `crate::openhuman::skills::run_log::ScannedRun`. `status` is
* `"RUNNING"` while the run hasn't written its `--- result ---` footer
* yet; after the footer lands it becomes `"DONE"` / `"DEGENERATE"` /
* `"FAILED"`.
*/
export interface ScannedRun {
run_id: string;
skill_id: string;
/** RFC3339-with-trailing-`UTC` timestamp from the log header. */
started: string;
status: 'RUNNING' | 'DONE' | 'DEGENERATE' | 'FAILED' | string;
/** Footer `duration: <ms> ms`. Null while running. */
duration_ms: number | null;
/** Footer `finished:` timestamp. Null while running. */
finished: string | null;
/** Absolute path to the streaming log file. */
log_path: string;
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Vitest coverage for the two new cron tauriCommand wrappers added by the
* skills runner PR: openhumanCronRun and openhumanCronRuns.
*
* Follows the same mocking pattern as subconscious.test.ts isTauri()
* guard + callCoreRpc mock, no real Tauri runtime.
*/
import { isTauri } from '@tauri-apps/api/core';
import { afterEach, beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
import { callCoreRpc } from '../../services/coreRpcClient';
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn() }));
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
describe('tauriCommands/cron — openhumanCronRun / openhumanCronRuns', () => {
const mockIsTauri = isTauri as Mock;
const mockCallCoreRpc = callCoreRpc as Mock;
let openhumanCronAdd: typeof import('./cron').openhumanCronAdd;
let openhumanCronRun: typeof import('./cron').openhumanCronRun;
let openhumanCronRuns: typeof import('./cron').openhumanCronRuns;
beforeEach(async () => {
vi.clearAllMocks();
mockIsTauri.mockReturnValue(true);
const m = await vi.importActual<typeof import('./cron')>('./cron');
openhumanCronAdd = m.openhumanCronAdd;
openhumanCronRun = m.openhumanCronRun;
openhumanCronRuns = m.openhumanCronRuns;
});
afterEach(() => vi.restoreAllMocks());
describe('openhumanCronAdd', () => {
const params = { schedule: { kind: 'cron' as const, expr: '*/5 * * * *' }, name: 'test' };
test('throws when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(openhumanCronAdd(params)).rejects.toThrow('Not running in Tauri');
});
test('calls cron_add with params', async () => {
mockCallCoreRpc.mockResolvedValue({ id: 'job-1' });
await openhumanCronAdd(params);
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({ method: 'openhuman.cron_add' })
);
});
});
describe('openhumanCronRun', () => {
test('throws when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(openhumanCronRun('job-1')).rejects.toThrow('Not running in Tauri');
});
test('calls cron_run with job_id', async () => {
mockCallCoreRpc.mockResolvedValue({
job_id: 'job-1',
status: 'ok',
duration_ms: 100,
output: '',
});
await openhumanCronRun('job-1');
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({ method: 'openhuman.cron_run', params: { job_id: 'job-1' } })
);
});
});
describe('openhumanCronRuns', () => {
test('throws when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(openhumanCronRuns('job-1')).rejects.toThrow('Not running in Tauri');
});
test('calls cron_runs with job_id and default limit', async () => {
mockCallCoreRpc.mockResolvedValue({ runs: [] });
await openhumanCronRuns('job-1');
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
method: 'openhuman.cron_runs',
params: expect.objectContaining({ job_id: 'job-1', limit: 20 }),
})
);
});
test('passes custom limit', async () => {
mockCallCoreRpc.mockResolvedValue({ runs: [] });
await openhumanCronRuns('job-1', 5);
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({ params: expect.objectContaining({ limit: 5 }) })
);
});
});
});
+22
View File
@@ -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<CommandResponse<CoreCronJob>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<CoreCronJob>>({ method: 'openhuman.cron_add', params });
}
export async function openhumanCronList(): Promise<CommandResponse<CoreCronJob[]>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
+181
View File
@@ -0,0 +1,181 @@
# Skills Runner Unification
**Status**: Proposed — implementation in progress on `run/codegraph-full`.
**Owner**: codegraph-skills track.
**Related**: PR #2802 (dev-workflow active-config UX), PR #2864 (smart issue selection / cron prompt embedding), the `feat/codegraph-skills` series that introduced `SkillsRunnerBody`.
---
## TL;DR
We currently maintain **two parallel scheduling UIs** for the same underlying primitive (`openhuman.cron_*` RPCs against an agent `prompt`):
1. **`SkillsRunnerBody`** (generic, every bundled skill) — picks any skill, dynamically renders inputs from `openhuman.skills_describe`, can run-now or save-as-cron. Lists saved schedules as a read-only summary with run/remove buttons.
2. **`DevWorkflowPanel`** (dev-workflow-only) — bespoke smart-issue-picker UI for `dev-workflow`, with an "active configuration" card, enable/disable toggle, embedded run history with per-run output viewer.
`DevWorkflowPanel` is just a hard-coded specialization of what `SkillsRunnerBody` already does — same cron RPC, same `agent` job type, same prompt-template scheme. The polish that lives in `DevWorkflowPanel` (toggle, run-history-with-output, active-config card) should be lifted into `SkillsRunnerBody`, and the dev-workflow-specific GitHub issue picker should be extracted as a conditionally-mounted sub-component. Then `DevWorkflowPanel` retires.
---
## Current state
### `app/src/components/skills/SkillsRunnerBody.tsx` (990 lines)
Generic skill runner used by both Settings → Developer Options → Skills Runner AND the `/skills` "Runners" tab.
| Concern | Location |
| --- | --- |
| Per-skill cron-name prefix | line 94 (`CRON_NAME_PREFIX = 'skill-run-'`) |
| Cron-name builder (skill + inputs hash) | lines 100-113 |
| Generic agent-prompt builder (re-fires `skill_run` at tick) | lines 116-127 |
| `scheduledJobs` state (filtered by name prefix) | line 233 |
| Saved-schedule render list | lines 828-875 |
| Per-schedule actions | `handleRunJobNow` (526-536), `handleRemoveJob` (538-548) — **no enable/disable** |
| Recent-runs viewer (cross-skill or scoped) | lines 882-985 |
| Inline log viewer (per run, auto-tail) | lines 440-519 |
### `app/src/components/settings/panels/DevWorkflowPanel.tsx` (792 lines)
| Concern | Location |
| --- | --- |
| `dev-workflow-`-prefixed cron-name | line 376 |
| Fork detection via Composio | lines 190-303 |
| Branch dropdown | lines 707-732 |
| Embedded skill-instructions prompt | lines 338-373 |
| **Active-config card** (rendered at top when any job exists) | lines 486-647 |
| **Enable/disable toggle** | lines 433-444 + render at 502-516 |
| **Run-history rows with per-run expandable output** | lines 591-645 (render), 306-322 (fetch via `openhumanCronRuns`) |
| **`last_output` viewer** | lines 580-589 |
### What's NOT in `SkillsRunnerBody` today
- Enable/disable toggle per schedule (the cron RPC supports `openhumanCronUpdate(id, { enabled })` — DevWorkflowPanel:439 — already wired generically).
- Per-schedule run history (`openhumanCronRuns(jobId, limit)` exists in `cron.ts`; SkillsRunnerBody currently shows recent runs only via `skillsApi.recentRuns()` which scans the skill log directory, not the cron `runs` table).
- "Active config card" — surface the most-recently-active schedule prominently rather than as one row in a list.
- Anything dev-workflow-specific (the GitHub repo / fork / branch picker workflow with smart auto-detection).
---
## Why unify
1. **UX symmetry.** Users with multiple `dev-workflow` schedules across different repos (a real use case Cyrus's PR #2802 was prepping for) need exactly the same UI as users with multiple `pr-review-shepherd` schedules across different repos. Today there's a privileged surface for one skill.
2. **Cron RPCs are already generic.** `openhuman.cron_update`, `cron_runs`, `cron_run` all take a `job_id` — no skill-specific logic in the core. We have zero RPC work to do.
3. **`DevWorkflowPanel` is a hard-coded specialization.** It bakes `dev-workflow-` into the cron name and the prompt template into the file. Both are anti-patterns once the generic runner exists: `SkillsRunnerBody` already builds prompts that re-fire `skill_run`, which routes through the skill registry and gets the up-to-date `system.md`/inputs without re-deploying the panel.
4. **Smaller surface to maintain.** Two views means two test suites, two i18n key namespaces (`settings.devWorkflow.*` and `settings.skillsRunner.*`), two render bugs to fix. Cyrus shipped 8 fixes to DevWorkflowPanel in 4 weeks; every one would have benefited the generic runner if they'd shared code.
---
## Unified information architecture
```
/skills
├─ Library tab (existing)
└─ Runners tab (existing — this is where SkillsRunnerBody lives)
Runners tab body (SkillsRunnerBody, after unification):
┌─ Skill picker ─────────────────────────────────────────────┐
│ Skill: [ dev-workflow ▾ ] │
└────────────────────────────────────────────────────────────┘
┌─ Saved schedules for this skill ───────────────────────────┐
│ │
│ ★ ACTIVE dev-workflow-tinyhumansai-openhuman │
│ every 2 hours · next run in 23m │
│ last: ok · 47s ago [⏵ toggle] │
│ [Run now] [▾ history (5)] [Remove] │
│ ▾ history │
│ 2026-05-29 13:01 ok 51s │
│ <expandable per-run output pre> │
│ 2026-05-29 11:01 ok 49s │
│ │
│ ────────────────────────────────────────────────────── │
│ │
│ ○ dev-workflow-graycyrus-openhuman │
│ daily @ 9am · paused │
│ last: error · 4h ago [⏵ toggle] │
│ [Run now] [▾ history] [Remove] │
│ │
│ [ + Add new schedule for dev-workflow ] │
└────────────────────────────────────────────────────────────┘
┌─ Configure & run ──────────────────────────────────────────┐
│ [skill description — what_to_use] │
│ │
│ Inputs: │
│ <schema-driven form, today> │
│ │
│ When skill_id === 'dev-workflow', ALSO render: │
│ <SmartIssuePicker subcomponent — fork detection, │
│ GitHub-connected repo dropdown, branch dropdown> │
│ │
│ [Run now] [Save as schedule …] │
└────────────────────────────────────────────────────────────┘
┌─ Recent runs (skill-scoped) ───────────────────────────────┐
│ (existing — unchanged; reads skill_log scan) │
└────────────────────────────────────────────────────────────┘
```
Notes:
- The **★ ACTIVE** treatment lifts DevWorkflowPanel's "active configuration" pattern but generalises it: any enabled schedule gets the same emphasis. If multiple are enabled, the most recently run one is "active" — first in the list, larger card.
- The "+ Add new schedule" affordance just reveals the existing inputs form (we don't need a second form; we just gate the existing one behind a disclosure so the saved-schedules list reads cleaner).
- Run history per schedule uses `openhuman.cron_runs` (the same RPC DevWorkflowPanel already uses) — this is *separate* from "Recent runs" at the bottom which uses `skillsApi.recentRuns()` scanning the skill log directory. Both have value: cron-run history is structured per-schedule with status/duration; skill log scan catches run-now invocations that don't go through a schedule.
---
## Migration / deprecation path
### Phase 1 — Design doc (this file).
### Phase 2 — Smoke prototype.
- Add enable/disable toggle to existing `scheduledJobs.map(...)` row in `SkillsRunnerBody.tsx`.
- Mirror `openhumanCronUpdate(jobId, { enabled: !job.enabled })` from DevWorkflowPanel:439 exactly.
- New i18n keys: `settings.skillsRunner.scheduleEnabled`, `settings.skillsRunner.scheduleDisabled` (full 12-locale chunk parity).
- Vitest unit coverage for toggle.
### Phase 3 — Full incremental implementation (one commit per chunk).
1. Per-schedule run-history + expandable output viewer (port DevWorkflowPanel:593-635).
2. Active-config card pattern (port DevWorkflowPanel:502-547).
3. Extract `SmartIssuePicker` subcomponent into `app/src/components/skills/SmartIssuePicker.tsx`; conditionally render in `SkillsRunnerBody` when `skillId === 'dev-workflow'` (with a TODO for a schema-driven `[input].picker = "github-issue"` upgrade — see Open questions).
4. Deprecate `DevWorkflowPanel`: replace its body with a one-line "moved to /skills" notice + nav link; OR strip it from the Settings nav and delete the file. Update or remove `DevWorkflowPanel.test.tsx` accordingly. Decision in Phase 3 chunk 4 once we see whether the Settings nav strip is clean.
5. i18n parity audit (`pnpm i18n:check`) — fold any new keys into all 12 non-English chunk files.
### Phase 4 — Verification.
- Playwright via CDP `http://127.0.0.1:19222` against the running dev app on Vite port 1428.
- Confirm: schedule toggle + expand work; smart-issue picker shows only for `dev-workflow`; switching to `github-issue-crusher` / `pr-review-shepherd` shows the generic form.
- Save `G:/tmp/oh-skills-unified.png`.
- Run `pnpm typecheck` + `pnpm debug unit` on changed files.
---
## Risk + test plan
### Risks
| Risk | Mitigation |
| --- | --- |
| **Breaking existing dev-workflow users** — they have a configured cron job named `dev-workflow-<repo>`; the new generic runner uses `skill-run-<skillId>-<hash>` as the prefix. | Filter saved-schedules list by **both** prefixes when `skillId === 'dev-workflow'` so existing jobs surface. Don't migrate names — they keep working. |
| **i18n drift** — there's already significant pre-existing drift in `en-N.ts` chunks for `settings.skillsRunner.*` keys (audit log shows ~50 keys in `en.ts` not in chunks). | Address chunk drift for the **new** keys this PR adds (toggle, history, smart-picker). Pre-existing drift is out of scope but worth a follow-up. |
| **`DevWorkflowPanel` removal breaks the deep link or settings nav route.** | Check `settings/AppSettings.tsx` (or wherever the nav is wired) and either preserve the route as a redirect to `/skills` or update the nav entry. |
| **Coverage gate (≥80% on changed lines)** on a 990-line component. | Add focused tests per Phase 3 chunk: toggle test (Phase 2), history-expand test (chunk 1), active-card render test (chunk 2), conditional-picker test (chunk 3). |
| **Stale-component test deletion regressing dev-workflow coverage.** | If `DevWorkflowPanel.test.tsx` is deleted, ensure equivalent coverage exists in `SkillsRunnerBody.test.tsx` for the smart-picker path. |
### Test plan
Per phase commit:
- **Phase 2**: `SkillsRunnerBody.test.tsx` — render one saved job, click toggle, assert `openhumanCronUpdate` called with `{ enabled: false }`, assert refresh-list invoked.
- **Phase 3.1**: render one saved job with `runHistory`, click expand, assert per-run output `<pre>` visible. Assert `openhumanCronRuns(jobId, 5)` called on render.
- **Phase 3.2**: render multiple jobs, assert most-recent-active sorted to top with `data-testid="active-schedule"` (or equivalent). Assert non-enabled jobs sorted below.
- **Phase 3.3**: render with `skillId === 'dev-workflow'`, assert `SmartIssuePicker` present. Render with `skillId === 'github-issue-crusher'`, assert it's absent. Subcomponent's own tests cover Composio loading/error paths (move from `DevWorkflowPanel.test.tsx`).
- **Phase 3.4**: if DevWorkflowPanel becomes a redirect, assert nav still routes correctly; if deleted, delete the test file.
---
## Open questions
1. **Schema-driven pickers vs. hard-coded `if (skillId === 'dev-workflow')`.** The clean answer is to extend `skill.toml`'s `[[inputs]]` schema with an optional `picker = "github-issue"` field, and let the runner route to a `SmartIssuePicker` subcomponent based on the picker key. The shortcut answer is the hard-coded `if`. Phase 3 chunk 3 ships the shortcut with a `TODO(picker-schema)` comment. The schema upgrade is a follow-up issue worth filing — it would also benefit `RepoPicker` and `BranchPicker`, which today route by *name convention* (`REPO_INPUT_NAMES` / `BRANCH_INPUT_NAMES` sets in SkillsRunnerBody:42-55), which is brittle.
2. **One enabled schedule per (skill, inputs) combo, or many?** Today DevWorkflowPanel allows only one (it looks up `name?.startsWith('dev-workflow')` and updates in place). `SkillsRunnerBody` allows many (the cron-name hash includes input values, so two different repos produce two jobs). The unified UX should keep "many" — but display only one as the prominent "ACTIVE" card. Pre-existing `dev-workflow-<repo>` jobs will surface in the list once we add the dual-prefix filter.
3. **Run history retention.** DevWorkflowPanel pulls last 5; SkillsRunnerBody's bottom "Recent runs" scans last 10 log files. Unify on a single source? For now, keep both — the per-schedule history is structured cron data, the bottom list is a cross-cutting "what ran lately" surface useful even with no schedules. Worth re-evaluating once both surfaces are in production for a few weeks.
4. **`last_output` field on `CoreCronJob` vs. per-run output on `CoreCronRun`.** Today both exist (`cron.ts:42-43` and `cron.ts:51`). DevWorkflowPanel renders `existingJob.last_output` in the active card AND per-run `run.output` in history rows. After unification, drop the duplicated `last_output` block; the per-run history already shows the most recent run's output. Lightweight change.
5. **Deprecation timing for the Settings → Developer Options → Dev Workflow nav entry.** Strip immediately (Phase 3 chunk 4), or leave as a redirect for one release? Leaning toward strip — the user is the maintainer, the panel is dev-only, and `/skills` is more discoverable than a buried Settings sub-page.
+4
View File
@@ -1488,6 +1488,10 @@ async fn run_server_inner(
),
Err(e) => log::warn!("[boot] whatsapp_data::global init failed: {e}"),
}
// Seed bundled default skills into <workspace>/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!(
@@ -1,7 +1,7 @@
id = "code_executor"
display_name = "Code Executor"
delegate_name = "run_code"
when_to_use = "Sandboxed developer — writes, runs, and debugs code until tests pass. Use for any task that requires producing or modifying source files and exercising them with shell or test commands."
when_to_use = "Code-repo worker — owns the FULL lifecycle of any task scoped to a code repository: clone, navigate via `codegraph_search` / `grep` / `lsp`, read files, edit (`edit` / `apply_patch` / `file_write`), build, run tests/lint, and drive **local `git`** (branch / commit / push / diff) for the working tree. **GitHub state I/O (issues, PRs, comments, reviews, checks, labels) goes through `composio_execute` with the matching `GITHUB_*` tool — never `gh` CLI** (see code_executor prompt.md 'GitHub I/O' section for the full split rule and rationale). Use for ANY repo-scoped work — locating where to edit, investigating a bug, exploring a codebase, and any modify / build / test / git / push / PR step — not only for the literal 'writing code' moment. Keep the entire end-to-end flow inside one `delegate_run_code` call so the worker accumulates context across steps."
temperature = 0.4
max_iterations = 10
max_result_chars = 16000
@@ -25,6 +25,11 @@ hint = "coding"
# OPENHUMAN_LSP_ENABLED — listing it here is harmless when the gate is
# off (the tool is simply not registered).
named = [
# codegraph navigation — preferred first step for locating code in a repo.
# `codegraph_search` auto-indexes on first use; see the prompt's
# "Finding code in a repo — codegraph first" section.
"codegraph_search",
"codegraph_index",
"shell",
"file_read",
"file_write",
@@ -9,6 +9,36 @@ You are the **Code Executor** agent. You write, run, and debug code in a sandbox
- Run tests and interpret results
- Git operations (commit, diff, status)
## Finding code in a repo — codegraph_search FIRST (hard rule)
**Your first navigation tool call in any repository MUST be `codegraph_search`.** Calling `grep` / `glob` / `lsp` / `find` / shell-`grep` / `rg` / `file_read` of the tree *before* `codegraph_search` is a **process error** — back up and call `codegraph_search` first.
`codegraph_search` returns the files most relevant to a query (the symbols, identifiers, error strings, or feature you're changing) and **auto-indexes the repo on its first call** (~3090s on a fresh clone — this is the index build, **not a hang**; do not retry, do not switch tools). Subsequent calls are millisecond-cheap.
After `codegraph_search` returns, inspect the `coverage` flag:
- `coverage: full` → read the top hits with `file_read` and confirm the exact edit site.
- `coverage: partial` → refine with `grep` **scoped to the directories codegraph returned** (not the whole tree), then `file_read` the refined hits.
- `coverage: none` (or zero hits) → only then may you fall back to a blind `grep` / `glob` over the tree.
This applies even for "obvious" string searches like i18n keys, error messages, or literal config names — codegraph returns ranked structural+semantic hits in one call where a blind `grep` returns every occurrence and forces you to re-rank by hand. Use it every time.
## GitHub I/O — Composio for state, local `git` for working tree (hard rule)
When a task involves a GitHub repository, you act through **two distinct surfaces**, never both with the same intent. Mixing them — or shelling `gh` for state ops — is a process error.
| Op | Surface | How |
| --- | --- | --- |
| **Read** issues / PRs / review comments / check runs / labels / commit metadata | **Composio** | `composio_execute({ tool: "GITHUB_GET_PULL_REQUEST" | "GITHUB_LIST_REVIEW_COMMENTS" | "GITHUB_GET_COMBINED_STATUS" | "GITHUB_GET_ISSUE" | "GITHUB_LIST_ISSUES" | … })` |
| **Write** PRs / comments / reviews / labels / branch as remote ref | **Composio** | `composio_execute({ tool: "GITHUB_CREATE_PULL_REQUEST" | "GITHUB_CREATE_ISSUE_COMMENT" | "GITHUB_CREATE_REVIEW" | "GITHUB_ADD_LABELS" | … })` |
| **Working tree**: clone, branch, status, diff, add, commit, push, log, stash, restore | **Local `git`** (shell) | `git clone …`, `git checkout -b …`, `git diff`, `git commit -m …`, `git push origin <branch>` (when push credentials exist) |
| **Tests / build / lint** | **Local shell** | `pnpm test`, `cargo check`, `pytest`, `make`, etc. — run inside the cloned working tree |
| **Code navigation** | **`codegraph_search`** (then `file_read`) | See the section above |
**Do not shell `gh` for GitHub state ops.** `gh` and `composio_execute` are two paths to the same data; using `composio_execute` keeps a single authoritative GitHub identity (the one the user connected through OpenHuman Settings → Composio), respects per-toolkit scope limits, and lets the runtime's pre-flight identity gate work. `gh` bypasses all of that. Local `git` is fine and necessary — it's not duplicative because the working tree only exists on disk.
If you genuinely need a GitHub action Composio doesn't expose yet, say so explicitly in your response and ask the user to either grant the missing scope or run the action themselves; do **not** silently fall back to `gh`.
## Execution environment
Shell commands run through an approval gate under the user's access policy. Keep this in mind so you don't waste turns being blocked:
@@ -21,6 +51,9 @@ Shell commands run through an approval gate under the user's access policy. Keep
## Rules
- **codegraph_search is the FIRST navigation call (hard rule)** — see the "Finding code in a repo" section above. `grep` / `glob` / `lsp` / `file_read` of the tree before `codegraph_search` is a process error; back up and call `codegraph_search` first.
- **GitHub state ops go through `composio_execute`, NOT `gh` (hard rule)** — see the "GitHub I/O" section above. Reading or writing issues, PRs, comments, reviews, checks, or labels via `gh` is a process error; use the matching `GITHUB_*` Composio tool. Local `git` stays for the working tree (clone, branch, commit, push, diff, tests, build, codegraph) — that's not duplication, that's the split.
- **Don't explore forever — commit to an edit** — after at most a few rounds of locate (`codegraph_search``file_read` top hits → confirm), TRANSITION to editing. Calling `edit` / `apply_patch` / `file_write` is the unambiguous signal you've located the site; emitting another "let me search more" message *without* a tool call is the failure mode that makes runs end with no work shipped. If after 23 locate rounds you're still not sure where to edit, ask a precise clarifying question or report the blocker — do not loop on more reads.
- **Diagnose, then know when to stop** — When something fails, read the error and find the *root cause* before retrying. Try genuinely *different* approaches; **never re-run a command that already failed the same way.** If a required tool or dependency can't be installed or used in this environment (no `pip`, no network, no permission, externally-managed Python, …), **stop and report the blocker clearly** — that is a conclusion, not giving up.
- **Run tests** — After writing code, run relevant tests to verify correctness.
- **Stay in scope** — Only do what was asked. Don't refactor unrelated code.
@@ -138,6 +138,15 @@ named = [
# touch files itself.
"todowrite",
"plan_exit",
# Skill chaining: let an in-flight autonomous skill (e.g.
# `github-issue-crusher` after the draft PR is open) spawn another
# bundled skill_run (e.g. `pr-review-shepherd` against that PR) as a
# fresh background job, so each skill stays narrow + composable.
# Thin wrapper over `skills::schemas::spawn_skill_run_background` — the
# same helper the `openhuman.skills_run` JSON-RPC controller uses, so
# RPC callers and tool callers share one spawn path (iter cap,
# transcript isolation, degenerate-response detector all apply).
"run_skill",
# Self-update — lets the orchestrator answer "am I up to date" /
# "update OpenHuman" without sending the user to Settings →
# Developer Options. `update_check` is read-only; `update_apply`
@@ -27,7 +27,7 @@ Follow this sequence for every user message:
- No: continue.
4. **Does this need other specialised execution?**
- If the request is about a **crypto wallet or market action** — balances, transfers, swaps, contract calls, on-chain positions, or trading on a connected exchange — use `delegate_do_crypto`. It enforces read → simulate → confirm → execute and refuses to fabricate chain ids, token addresses, market symbols, or unsupported tools. **Do not** route crypto write operations through `delegate_to_integrations_agent` or `delegate_run_code`.
- If code writing/execution/debugging is required, use `delegate_run_code`.
- **Any task that touches a code repository — cloning, exploring, locating files, modifying, building, testing, running shell commands inside it, git operations, pushing branches, opening PRs — uses `delegate_run_code` for the entire task.** Treat "locate where to edit", "investigate the bug", "find the function", "read the file" as code-repo work the moment they're scoped to a repo: they belong inside the same `delegate_run_code` worker as the edit / build / git steps. **Never** route code-repo work through `tools_agent` / `spawn_worker_thread`; those workers lack `edit` / `apply_patch` / `file_write` / `git_operations` / `codegraph_search` and will silently stall in read-mode. `tools_agent` is for *non-repo* work only — ad-hoc shell against the host, web fetch, memory helpers, etc.
- If web/doc crawling is required, use `research`.
- If the user asks for live/current/time-sensitive facts that are not covered by a direct tool — weather, forecasts, current temperatures, recent news, fresh web facts, or "use Grok/web/live data" — call `research` with a prompt that asks for live sources. Do **not** stop at "on it", and do **not** wait for the exact named provider if it is not wired in. Use the available research tool and then answer with the result.
- If complex multi-step decomposition is required, use `delegate_plan`.
@@ -1,6 +1,6 @@
id = "tools_agent"
display_name = "Tools Agent"
when_to_use = "Generalist specialist for heavyweight ad-hoc execution with built-in OpenHuman tools (shell, file I/O, HTTP, web search, memory). Use only when direct orchestrator handling is insufficient and the task needs substantial tool-driven execution, but does NOT require managed Composio OAuth integrations. For external SaaS, spawn `integrations_agent` with a `toolkit` argument instead."
when_to_use = "Generalist for heavyweight ad-hoc execution that does NOT touch a code repository — host shell, HTTP/web fetch, web search, memory helpers, file READS (`file_read` / `grep` / `glob`). Lacks `edit` / `apply_patch` / `file_write` / `git_operations` / `codegraph_search` — do **not** use for any task scoped to a code repo (cloning, locating, modifying, building, testing, git, push, PR); those route to `delegate_run_code` end-to-end. Do not use for managed Composio OAuth integrations either — those route to `integrations_agent` with a `toolkit` argument."
temperature = 0.4
max_iterations = 10
sandbox_mode = "none"
@@ -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<usize> {
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<F: Future>(cap: usize, fut: F) -> F::Output {
AUTONOMOUS_ITER_CAP.scope(cap, fut).await
}
@@ -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};
@@ -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
+829
View File
@@ -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<usize> {
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<String> {
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<String> {
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<Vec<(String, String)>> {
let mut out = Vec::new();
for line in git(repo_dir, &["ls-files", "-s"])?.lines() {
// `<mode> <sha> <stage>\t<path>`
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<String> {
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<String> = 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::<String>()
.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::<f32>().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<IndexReport> {
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<String> = Vec::new();
let mut pend_tokens: Vec<Vec<String>> = Vec::new();
let mut pend_docs: Vec<String> = 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<f32>> = 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<String>, Vec<f32>)> = 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<Vec<Vec<f32>>> {
// 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::<f32>().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<Vec<Vec<f32>>> {
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<Vec<Vec<f32>>> {
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<AtomicU64>,
invocations: Arc<AtomicU64>,
docs: Arc<AtomicU64>,
}
#[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<Vec<Vec<f32>>> {
let t = std::time::Instant::now();
let out: Vec<Vec<f32>> = 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");
}
}
+29
View File
@@ -0,0 +1,29 @@
//! codegraph — content-addressed code retrieval for coding subagents.
//!
//! The seed engine behind the issue-crusher / pr-reviewer skills. Retrieval is
//! `BM25 (SQLite FTS5) 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: `tree-sitter` for structure, `rusqlite`+FTS5 for lexical, and the
//! `embeddings` domain (cloud by default) for vectors. No Python, no extra
//! services.
//!
//! Layers:
//! - [`store`] — persistent SQLite blob cache + manifests (this commit).
//! - `index` — tree-sitter extract + FTS5 + dense, incremental (next).
//! - `search` — BM25 dense RRF + coverage flag (next).
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};
+289
View File
@@ -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<String>,
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::<f32>().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<usize> {
let n = docs.len() as f32;
let lens: Vec<f32> = docs.iter().map(|d| d.tokens.len() as f32).collect();
let avgdl = (lens.iter().sum::<f32>() / n).max(1.0);
// per-doc term frequency tables
let tfs: Vec<HashMap<&str, f32>> = 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<usize> {
let scores: Vec<f32> = docs
.iter()
.map(|d| d.emb.iter().zip(qv).map(|(a, b)| a * b).sum::<f32>())
.collect();
rank_by_score(&scores)
}
fn rank_by_score(scores: &[f32]) -> Vec<usize> {
let mut idx: Vec<usize> = (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<usize>], k: usize) -> Vec<usize> {
let mut score: HashMap<usize, f32> = 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<SearchOutcome> {
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<Vec<Vec<f32>>> {
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");
}
}
+332
View File
@@ -0,0 +1,332 @@
//! 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<String>,
pub emb: Vec<f32>,
}
/// 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<Self> {
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()))?;
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<bool> {
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<String>, Vec<f32>)],
) -> 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<Vec<BlobEntry>> {
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<u8> = 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<usize> {
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<Vec<String>> {
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::<rusqlite::Result<Vec<_>>>()?)
}
/// Drop cached blobs no live manifest references. Returns rows removed.
pub fn gc(&self) -> Result<usize> {
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);
}
}
+232
View File
@@ -0,0 +1,232 @@
//! Composio connection identity resolution.
//!
//! Single source of truth for "what is the username on this Composio
//! connection?". Used by the skill preflight gate (`[github]
//! identity_match = "strict"`) and by any future caller that needs to
//! compare the connected account against another subsystem (e.g. local
//! `git config user.name`).
//!
//! The lookup goes through the per-toolkit
//! [`ComposioProvider::fetch_user_profile`](crate::openhuman::memory_sync::composio::providers::ComposioProvider::fetch_user_profile)
//! call, which already knows the right Composio action slug for each
//! toolkit (`GITHUB_GET_THE_AUTHENTICATED_USER`,
//! `GMAIL_GET_PROFILE`, …) and the JSON field that holds the username.
//!
//! ## Failure surface
//!
//! Everything in this module is best-effort and returns `Option`:
//!
//! - toolkit not registered → `None`
//! - user not signed in / no active connection for the toolkit → `None`
//! - Composio call fails / returns no username → `None`
//!
//! Callers MUST treat `None` as "couldn't resolve" rather than
//! "username is empty". The preflight gate uses this contract to map
//! `None` into a clear "GitHub identity not resolved — reconnect via
//! `composio_authorize github`" error.
use std::sync::Arc;
use crate::openhuman::config::Config;
use super::ops::fetch_connected_integrations;
use super::providers::{get_provider, ProviderContext};
/// Resolve the connected account's username for the given Composio
/// toolkit, going through the existing per-provider `fetch_user_profile`
/// path.
///
/// Returns `Some(username)` when:
/// 1. The toolkit has a registered provider; AND
/// 2. The toolkit is currently connected (per
/// [`fetch_connected_integrations`]); AND
/// 3. The provider's `fetch_user_profile` call succeeds AND yields a
/// non-empty `username`.
///
/// Returns `None` for any other case. See module docs for the failure
/// contract.
pub async fn connection_identity(config: &Config, toolkit: &str) -> Option<String> {
let toolkit_norm = toolkit.trim().to_ascii_lowercase();
if toolkit_norm.is_empty() {
tracing::debug!("[composio:identity] connection_identity: empty toolkit slug");
return None;
}
// (1) Provider must exist for this toolkit.
let provider = match get_provider(&toolkit_norm) {
Some(p) => p,
None => {
tracing::debug!(
toolkit = %toolkit_norm,
"[composio:identity] no provider registered for toolkit"
);
return None;
}
};
// (2) Toolkit must be in the active integrations set. This is the
// same source of truth Settings → Connections uses.
let connections = fetch_connected_integrations(config).await;
let matching = connections
.iter()
.find(|c| c.toolkit.eq_ignore_ascii_case(&toolkit_norm));
if matching.is_none() {
tracing::debug!(
toolkit = %toolkit_norm,
"[composio:identity] toolkit not in active integrations"
);
return None;
}
// (3) Build a provider context and call fetch_user_profile.
// `ProviderContext::from_config` probes the Composio factory and
// returns `None` when the user isn't signed in at all — same
// short-circuit other consumers rely on.
let ctx = ProviderContext::from_config(Arc::new(config.clone()), &toolkit_norm, None)?;
match provider.fetch_user_profile(&ctx).await {
Ok(profile) => {
let username = profile.username.as_deref().map(str::trim).unwrap_or("");
if username.is_empty() {
tracing::debug!(
toolkit = %toolkit_norm,
"[composio:identity] provider returned empty username"
);
None
} else {
tracing::debug!(
toolkit = %toolkit_norm,
resolved = true,
"[composio:identity] resolved username"
);
Some(username.to_string())
}
}
Err(e) => {
tracing::debug!(
toolkit = %toolkit_norm,
error = %e,
"[composio:identity] fetch_user_profile failed"
);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::memory_sync::composio::providers::{
register_provider, ComposioProvider, ProviderArc, ProviderUserProfile, SyncOutcome,
SyncReason,
};
use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};
/// Test provider that returns a fixed username (or fails, when
/// `fail` is set). We don't go through Composio at all — the
/// preflight gate just needs the provider's `username` field.
struct StubProvider {
slug: &'static str,
username: Option<&'static str>,
fail: bool,
calls: AtomicUsize,
}
impl StubProvider {
fn new(slug: &'static str, username: Option<&'static str>) -> Self {
Self {
slug,
username,
fail: false,
calls: AtomicUsize::new(0),
}
}
fn failing(slug: &'static str) -> Self {
Self {
slug,
username: None,
fail: true,
calls: AtomicUsize::new(0),
}
}
}
#[async_trait]
impl ComposioProvider for StubProvider {
fn toolkit_slug(&self) -> &'static str {
self.slug
}
async fn fetch_user_profile(
&self,
_ctx: &ProviderContext,
) -> Result<ProviderUserProfile, String> {
self.calls.fetch_add(1, Ordering::SeqCst);
if self.fail {
return Err("stub provider: forced failure".to_string());
}
Ok(ProviderUserProfile {
toolkit: self.slug.to_string(),
username: self.username.map(|s| s.to_string()),
..Default::default()
})
}
async fn sync(
&self,
_ctx: &ProviderContext,
reason: SyncReason,
) -> Result<SyncOutcome, String> {
Ok(SyncOutcome {
toolkit: self.slug.to_string(),
reason: reason.as_str().to_string(),
..Default::default()
})
}
}
fn fresh_config_in_workspace(tmp: &std::path::Path) -> Config {
let mut config = Config::default();
config.config_path = tmp.join("config.toml");
config.workspace_dir = tmp.join("workspace");
config.secrets.encrypt = false;
config
}
#[tokio::test]
async fn empty_toolkit_short_circuits_to_none() {
let tmp = tempfile::tempdir().expect("tempdir");
let config = fresh_config_in_workspace(tmp.path());
assert!(connection_identity(&config, "").await.is_none());
assert!(connection_identity(&config, " ").await.is_none());
}
#[tokio::test]
async fn unknown_toolkit_returns_none_without_provider_call() {
let tmp = tempfile::tempdir().expect("tempdir");
let config = fresh_config_in_workspace(tmp.path());
// Toolkit slug that has no registered provider.
assert!(connection_identity(&config, "not-a-real-toolkit-xyz")
.await
.is_none());
}
#[tokio::test]
async fn no_active_connection_short_circuits_before_provider_call() {
// Register a provider but no connections exist for the toolkit
// → identity helper should return None without calling
// fetch_user_profile.
let stub: ProviderArc = Arc::new(StubProvider::new(
"stub-no-active",
Some("would-not-be-returned"),
));
register_provider(stub.clone());
let tmp = tempfile::tempdir().expect("tempdir");
let config = fresh_config_in_workspace(tmp.path());
// Default config has no Composio auth → fetch_connected_integrations
// returns an empty vec, so the toolkit is not "in active".
let username = connection_identity(&config, "stub-no-active").await;
assert!(username.is_none(), "must short-circuit when not connected");
}
}
+2
View File
@@ -43,6 +43,7 @@ pub mod error_mapping;
pub mod execute_dispatch;
pub mod execute_prepare;
pub mod googlecalendar_args;
pub mod identity;
pub mod oauth_handoff;
pub mod ops;
pub mod periodic;
@@ -66,6 +67,7 @@ pub use crate::openhuman::memory_sync::composio::providers::{
};
pub use action_tool::ComposioActionTool;
pub use client::ComposioClient;
pub use identity::connection_identity;
pub use ops::{
cached_active_integrations, connected_set_hash, fetch_connected_integrations,
fetch_connected_integrations_status, fetch_toolkit_actions,
+195 -3
View File
@@ -18,6 +18,7 @@ fn job_id_input(comment: &'static str) -> FieldSchema {
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("add"),
schemas("list"),
schemas("update"),
schemas("remove"),
@@ -28,6 +29,10 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
pub fn all_registered_controllers() -> Vec<RegisteredController> {
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<RegisteredController> {
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,95 @@ pub fn schemas(function: &str) -> ControllerSchema {
}
}
fn handle_add(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let schedule: crate::openhuman::cron::Schedule = read_required(&params, "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,
"isolated" => crate::openhuman::cron::SessionTarget::Isolated,
other => return Err(format!("invalid 'session_target': {other}")),
};
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<crate::openhuman::cron::DeliveryConfig> = match params.get("delivery")
{
None | Some(Value::Null) => None,
Some(v) => Some(
serde_json::from_value(v.clone())
.map_err(|e| format!("invalid 'delivery': {e}"))?,
),
};
let delete_after_run = params
.get("delete_after_run")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Determine job type
let job_type = match params.get("job_type").and_then(|v| v.as_str()) {
Some("shell") => "shell",
Some("agent") => "agent",
Some(other) => return Err(format!("invalid 'job_type': {other}")),
None => {
if prompt.is_some() {
"agent"
} else {
"shell"
}
}
};
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())?
}
"agent" => {
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())?
}
other => return Err(format!("invalid 'job_type': {other}")),
};
to_json(RpcOutcome::single_log(job, "cron job created"))
})
}
fn handle_list(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async {
let config = config_rpc::load_config_with_timeout().await?;
@@ -343,21 +514,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 ───────────────────────────────────────────────
@@ -10,13 +10,11 @@ use crate::openhuman::desktop_companion::pointing::ScreenGeometry;
use crate::openhuman::desktop_companion::session;
use crate::openhuman::desktop_companion::types::*;
use std::sync::Mutex as StdMutex;
/// Serialize tests that touch the process-global session state.
static TEST_MUTEX: StdMutex<()> = StdMutex::new(());
/// Serialize tests that touch the process-global session state. Shared with
/// `session_tests` via `session::lock_test_state()` so transitions in one test
/// module can't race a reset/transition in the other.
fn lock_and_reset() -> std::sync::MutexGuard<'static, ()> {
let guard = TEST_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
let guard = session::lock_test_state();
session::reset_for_test();
session::start_session(&StartCompanionSessionParams {
consent: true,
@@ -338,6 +338,23 @@ pub(crate) fn reset_for_test() {
*guard = None;
}
/// Process-wide serialization lock for tests that mutate the global
/// `ACTIVE_SESSION`. Both `session_tests` and `pipeline_tests` exercise the
/// same process-global state; without a *shared* lock each module's local
/// mutex only serializes its own tests, letting a reset/transition in one
/// module race a transition in the other (e.g. observing `Idle` mid-turn and
/// rejecting `Idle -> Thinking`). Lock this in every test that touches session
/// state so the whole binary serializes those tests.
#[cfg(test)]
pub(crate) static TEST_STATE_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Acquire the shared session-state test lock, recovering from poisoning so a
/// panicking test doesn't cascade into spurious failures in the rest.
#[cfg(test)]
pub(crate) fn lock_test_state() -> std::sync::MutexGuard<'static, ()> {
TEST_STATE_MUTEX.lock().unwrap_or_else(|p| p.into_inner())
}
#[cfg(test)]
#[path = "session_tests.rs"]
mod tests;
@@ -3,13 +3,11 @@
use super::*;
use crate::openhuman::desktop_companion::types::*;
use std::sync::Mutex as StdMutex;
/// Serialize tests that mutate the process-global session state.
static TEST_MUTEX: StdMutex<()> = StdMutex::new(());
/// Serialize tests that mutate the process-global session state. Shared with
/// `pipeline_tests` via `lock_test_state()` (defined in `session`) so a
/// reset/transition here can't race a transition in the pipeline tests.
fn with_clean_session<F: FnOnce()>(f: F) {
let _lock = TEST_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
let _lock = lock_test_state();
reset_for_test();
f();
reset_for_test();
+1
View File
@@ -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,
+23
View File
@@ -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<Box<dyn super::EmbeddingProvider>> {
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"
+1
View File
@@ -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;
@@ -0,0 +1,149 @@
# 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.
## Tool split — Composio for GitHub state, local git for the working tree
GitHub state operations — issues, PRs, labels, assignees, branches as remote refs, repository metadata, AND in this skill the **commit** itself (this skill ships the commit through the GitHub API rather than `git push`, see below) — go through Composio via `composio_execute({tool: "GITHUB_<ACTION>", arguments: {...}})`. The working tree — clone, checkout, edit, `git status`/`diff`, run tests — stays on local `git`. Composio is the single authoritative GitHub identity (the user's connected account, gated by the skill's `[github]` preflight); the local working tree is where the actual code change happens. Do **not** shell out to `gh` for state operations — the preflight checked Composio but not gh's credential store, so a `gh` call may silently route through a different account.
## The two repos
- **Upstream** = `{upstream}` — where issues live and where PRs target (base = `{target_branch}`).
- **Fork** = `{fork_owner}/<repo_name>` — where the fix branch is pushed. (`<repo_name>` is derived from `{upstream}`.)
- You act as the **connected GitHub identity**. **Commit through the GitHub API via Composio** — 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:
```
composio_execute({
"tool": "GITHUB_LIST_REPOSITORY_ISSUES",
"arguments": {
"owner": "<upstream-owner>",
"repo": "<upstream-repo-name>",
"state": "open",
"assignee": "{fork_owner}",
"sort": "created",
"direction": "asc",
"per_page": 30
}
})
```
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. Use the same tool with `"assignee": "none"` and walk the labels by re-issuing with `"labels": "good first issue"` etc.
3. **Self-assign**: Once you pick an unassigned issue, assign it to `{fork_owner}` so no one else picks it up concurrently:
```
composio_execute({
"tool": "GITHUB_ADD_ASSIGNEES_TO_AN_ISSUE",
"arguments": {
"owner": "<upstream-owner>",
"repo": "<upstream-repo-name>",
"issue_number": <picked-issue-number>,
"assignees": ["{fork_owner}"]
}
})
```
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 via Composio. Note the connected login:
```
composio_execute({
"tool": "GITHUB_GET_AN_ISSUE",
"arguments": { "owner": "<upstream-owner>", "repo": "<upstream-repo-name>", "issue_number": <n> }
})
composio_execute({
"tool": "GITHUB_LIST_ISSUE_COMMENTS",
"arguments": { "owner": "<upstream-owner>", "repo": "<upstream-repo-name>", "issue_number": <n> }
})
```
3. **Ensure the fork.** If `{fork_owner}/<repo_name>` exists, use it. Otherwise create a fork of `{upstream}` under `{fork_owner}` via Composio (idempotent — a no-op when the fork is already there):
```
composio_execute({
"tool": "GITHUB_CREATE_A_FORK",
"arguments": { "owner": "<upstream-owner>", "repo": "<upstream-repo-name>" }
})
```
4. **Clone & branch.** Clone `{upstream}` locally — this is a working-tree op so it stays on local git. Create branch `dev-workflow/<issue-number>-<slug>` off `{target_branch}`:
```
git clone https://github.com/{upstream} /tmp/<repo>-<issue>-<rand>
git -C <dir> checkout -b dev-workflow/<issue-number>-<slug> origin/{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 the GitHub API (Composio).** Create the fix branch on the **fork** through Composio (blob → tree → commit → update-ref) — **do not `git push`**, this skill assumes no local push credentials. For each changed file:
```
composio_execute({
"tool": "GITHUB_CREATE_A_BLOB",
"arguments": {
"owner": "{fork_owner}",
"repo": "<repo-name>",
"content": "<file-contents-base64>",
"encoding": "base64"
}
})
```
Compose the new tree from the existing fork tree + the new blobs:
```
composio_execute({
"tool": "GITHUB_CREATE_A_TREE",
"arguments": {
"owner": "{fork_owner}",
"repo": "<repo-name>",
"base_tree": "<base-tree-sha>",
"tree": [ { "path": "<path>", "mode": "100644", "type": "blob", "sha": "<blob-sha>" } ]
}
})
```
Create the commit and update the ref:
```
composio_execute({
"tool": "GITHUB_CREATE_A_COMMIT",
"arguments": {
"owner": "{fork_owner}",
"repo": "<repo-name>",
"message": "<type>(scope): <one-line> (#<issue>)",
"tree": "<new-tree-sha>",
"parents": ["<parent-commit-sha>"]
}
})
composio_execute({
"tool": "GITHUB_UPDATE_A_REFERENCE",
"arguments": {
"owner": "{fork_owner}",
"repo": "<repo-name>",
"ref": "heads/dev-workflow/<issue-number>-<slug>",
"sha": "<new-commit-sha>",
"force": true
}
})
```
10. **Open cross-repo PR via Composio.** Open a PR against `{upstream}:{target_branch}` with head `{fork_owner}:<branch>`. Body must include `Closes #<number>`, a root-cause + fix summary, and verification steps:
```
composio_execute({
"tool": "GITHUB_CREATE_A_PULL_REQUEST",
"arguments": {
"owner": "<upstream-owner>",
"repo": "<upstream-repo-name>",
"title": "<type>(scope): <one-line> (#<issue>)",
"body": "Closes #<issue>\n\n## Root cause\n<para>\n\n## Fix\n<para>\n\n## Verified\n<what you ran>",
"head": "{fork_owner}:dev-workflow/<issue-number>-<slug>",
"base": "{target_branch}",
"draft": true
}
})
```
## Rules
- **GitHub state via Composio, working tree via local git.** Never shell to `gh` — the preflight gates Composio, not `gh`'s credential store, so a `gh` call can silently use the wrong identity.
- **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 Composio GitHub API (blob → tree → commit → update-ref).
- **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 via `GITHUB_CREATE_AN_ISSUE_COMMENT` explaining why and skip.
- Never force-push to upstream. 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.
@@ -0,0 +1,49 @@
# dev-workflow — a DEFAULT skill shipped with OpenHuman.
# Bundled into the binary and seeded into <workspace>/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."
# Preflight gate (see src/openhuman/skills/preflight.rs). dev-workflow
# touches GitHub via Composio AND uses local git for the working tree,
# so both subsystems must be ready before the orchestrator boots:
#
# * Composio GitHub connection is active
# * local `git` is on PATH
# * `git config --global user.{name,email}` are both set
# * (strict) Composio username == local git user.name (case-insensitive)
#
# Strict identity match is the default — if Composio is signed in as
# `alice` but local git claims to be `bob`, the PR you push will be
# authored by `bob` while the OAuth-driven Composio call comes from
# `alice`. That mismatch produces broken commits no fix-up can recover.
[github]
required = true
identity_match = "strict"
[[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
@@ -0,0 +1,104 @@
# GitHub Issue Crusher
Fix the **single** GitHub issue named in the inputs, end to end, then open a **DRAFT** pull request via the **fork workflow** — issue on upstream `{repo}`, fix pushed to a fork, cross-repo draft PR back to `{repo}`. Stay strictly in scope; this is autonomous, so work until the draft PR is open or you hit a real blocker, then stop.
## Tool split — Composio for GitHub state, local git for the working tree
GitHub state operations — issues, PRs, comments, reviews, checks, labels, branches as remote refs, repository metadata — go through Composio via `composio_execute({tool: "GITHUB_<ACTION>", arguments: {...}})`. The working tree — clone, checkout, edit, `git status`/`diff`, run tests, commit locally, push the branch to your fork — stays on local `git`. Composio is the single authoritative GitHub identity (the user's connected account, gated by the skill's `[github]` preflight); the local working tree is where the actual code change happens. Do **not** shell out to `gh` for state operations — the preflight checked Composio but not gh's credential store, so a `gh` call may silently route through a different account.
## The two repos
- **Upstream** = `{repo}` — where `#{issue}` lives and where the draft PR is opened (base = `{pr_base}`, or the upstream's default branch).
- **Fork** = `{fork}` if provided, otherwise the existing fork of `{repo}` under the authed GitHub account. Resolve the authed account once at the top:
```
composio_execute({
"tool": "GITHUB_GET_THE_AUTHENTICATED_USER",
"arguments": {}
})
```
The response's `login` is `<fork-owner>`. If no fork exists yet, create one:
```
composio_execute({
"tool": "GITHUB_CREATE_A_FORK",
"arguments": { "owner": "<upstream-owner>", "repo": "<upstream-repo-name>" }
})
```
## Steps
1. **Read the issue.** Fetch issue `#{issue}` in `{repo}` (title, body, comments) via Composio:
```
composio_execute({
"tool": "GITHUB_GET_AN_ISSUE",
"arguments": { "owner": "<upstream-owner>", "repo": "<upstream-repo-name>", "issue_number": {issue} }
})
composio_execute({
"tool": "GITHUB_LIST_ISSUE_COMMENTS",
"arguments": { "owner": "<upstream-owner>", "repo": "<upstream-repo-name>", "issue_number": {issue} }
})
```
Identify the exact files/changes it asks for.
2. **Ensure the fork.** Resolve `<fork-owner>` via `GITHUB_GET_THE_AUTHENTICATED_USER` (cache for the run). Create the fork via `GITHUB_CREATE_A_FORK` if it doesn't already exist (idempotent — a no-op when the fork is already there).
3. **Clone fresh.** Clone `{repo}` locally to a unique directory (e.g. `/tmp/<repo-name>-{issue}-<rand>`). If the directory already exists from a previous run, remove it first so the clone starts clean. This is a local-git operation:
```
git clone https://github.com/{repo} /tmp/<repo-name>-{issue}-<rand>
```
4. **Pin the local git identity** in the clone so commits are verified under the authed account. Use the `login` and `id` already on hand from step 2 — never `--global`, never clobber the host's global config:
```
git -C <dir> config user.name "<login>"
git -C <dir> config user.email "<id>+<login>@users.noreply.github.com"
```
5. **Locate the cause.** Start with `codegraph_search` on the issue's key symbols / error strings / literal phrases — it auto-indexes on first call (~3090s on a fresh clone, this is normal not a hang). Inspect the result:
- `coverage: full` → read the top hits and confirm the exact edit site.
- `coverage: partial` → refine with `grep` scoped to the directories codegraph returned.
- `coverage: none` or zero hits → fall back to a blind `grep` / `glob`.
6. **Apply the minimal fix.** Edit only the files identified in step 5. Re-read each file or `git diff` to confirm the change matches the intent — never trust memory.
7. **Verify.** Run the test/lint commands that apply to the changed files (e.g. `pnpm i18n:check` for i18n, `cargo test -p <crate>` for Rust, `pnpm test <pattern>` for TS). Skip if the change is pure docs / strings.
8. **Branch, commit, push to the fork** via local git — pushing is a working-tree operation so it stays on git:
```
git -C <dir> checkout -b fix/{issue}-<short-slug>
git -C <dir> add <only-the-changed-files> # never git add -A
git -C <dir> commit -m "<type>(scope): <short description> (#{issue})"
git -C <dir> push -u "https://github.com/<fork-owner>/<repo-name>" fix/{issue}-<short-slug>
```
9. **Open the DRAFT cross-repo PR via Composio.** This is the canonical Composio call for cross-repo PRs — the `head` value `<fork-owner>:<branch>` tells GitHub to take the branch from the fork:
```
composio_execute({
"tool": "GITHUB_CREATE_A_PULL_REQUEST",
"arguments": {
"owner": "<upstream-owner>",
"repo": "<upstream-repo-name>",
"title": "<type>(scope): <short description> (#{issue})",
"body": "Closes #{issue}\n\n## Root cause\n<one paragraph>\n\n## Fix\n<one paragraph>\n\n## Verified\n<what you ran>",
"head": "<fork-owner>:fix/{issue}-<short-slug>",
"base": "{pr_base}",
"draft": true
}
})
```
`draft: true` is non-negotiable for autonomous runs — CI runs and a human reviews before promotion to ready.
10. **Hand off Phase 6 to the shepherd, then exit.** Once the draft PR URL is in hand, invoke the `pr-review-shepherd` skill as a fresh background run so the CI + review loop continues autonomously while *this* skill exits cleanly:
```
run_skill {
"skill_id": "pr-review-shepherd",
"inputs": { "repo": "{repo}", "pr": <pr-number-just-opened> }
}
```
The call returns immediately with the shepherd's `run_id` + `log` path. Include both in your final response so the user can track the shepherd, then stop — do not stay around polling CI yourself, that's the shepherd's job.
## Rules
- **GitHub state via Composio, working tree via local git.** Never shell to `gh` — the preflight gates Composio, not `gh`'s credential store, so a `gh` call can silently use the wrong identity.
- **Scope:** only changes that fix `#{issue}`. No unrelated cleanup, no other issues.
- **Source of truth** is the filesystem + `git` + `codegraph` — re-read / re-search rather than relying on recall.
- **codegraph_search first** for every locate step (it auto-indexes); `grep` / `glob` are refinement or fallback only.
- **DRAFT always** — never open a PR as ready-to-merge from an autonomous run.
- **Stop** when the draft PR is open or surface a real blocker and stop — don't thrash.
@@ -0,0 +1,50 @@
# github-issue-crusher — a DEFAULT skill shipped with OpenHuman.
# Bundled into the binary and seeded into <workspace>/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)."
# Preflight gate (see src/openhuman/skills/preflight.rs). issue-crusher
# touches GitHub via Composio AND pushes branches to a fork via local
# git, so both subsystems must be ready before the orchestrator boots:
#
# * Composio GitHub connection is active
# * local `git` is on PATH
# * `git config --global user.{name,email}` are both set
# * (strict) Composio username == local git user.name (case-insensitive)
#
# Strict match is important here: a cross-repo PR's fork-side push uses
# whatever local git is configured as, while the PR itself is opened
# through Composio under a (potentially different) GitHub account. The
# gate refuses the run loudly rather than silently producing a PR
# authored under one identity and pushed under another.
[github]
required = true
identity_match = "strict"
[[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
@@ -0,0 +1,140 @@
# PR Review Shepherd
Drive a single open GitHub PR all the way to **ready-for-merge** — CI green, every actionable reviewer/bot comment addressed, approvals in. This is autonomous Phase-6 work: iterate the **check → fix → push → re-check** loop until both gates close, or surface a real blocker and stop.
## Tool split — Composio for GitHub state, local git for the working tree
GitHub state operations — PR details, comments (top-level and inline review), check runs, status rollups, comment replies, labels — go through Composio via `composio_execute({tool: "GITHUB_<ACTION>", arguments: {...}})`. The working tree — clone the fork branch, edit files, run tests, commit, force-with-lease push to the fork — stays on local `git`. Composio is the single authoritative GitHub identity (the user's connected account, gated by the skill's `[github]` preflight); the local working tree is where the actual fix lands. Do **not** shell out to `gh` for state operations — the preflight checked Composio but not gh's credential store, so a `gh` call may silently route through a different account.
## When this skill is "done"
Both must hold:
1. **CI green** — every required check on PR `#{pr}` is `success` (or explicitly waived by a maintainer in the thread).
2. **All actionable comments resolved** — every comment from a human reviewer or bot (CodeRabbit, Codecov, etc.) is either (a) addressed by a follow-up commit AND replied to on the thread, or (b) intentionally deferred with a one-line reason replied on the thread.
Also stop if the PR is **merged** (success) or **closed without merge** (note the reason and report).
## Steps
1. **Snapshot the PR state** for `#{pr}` on `{repo}` via Composio. Issue these in parallel where the model can — each is a read-only state op:
```
composio_execute({
"tool": "GITHUB_GET_A_PULL_REQUEST",
"arguments": { "owner": "<upstream-owner>", "repo": "<upstream-repo-name>", "pull_number": {pr} }
})
composio_execute({
"tool": "GITHUB_LIST_REVIEW_COMMENTS_ON_A_PULL_REQUEST",
"arguments": { "owner": "<upstream-owner>", "repo": "<upstream-repo-name>", "pull_number": {pr} }
})
composio_execute({
"tool": "GITHUB_LIST_ISSUE_COMMENTS",
"arguments": { "owner": "<upstream-owner>", "repo": "<upstream-repo-name>", "issue_number": {pr} }
})
composio_execute({
"tool": "GITHUB_LIST_REVIEWS_FOR_A_PULL_REQUEST",
"arguments": { "owner": "<upstream-owner>", "repo": "<upstream-repo-name>", "pull_number": {pr} }
})
composio_execute({
"tool": "GITHUB_GET_THE_COMBINED_STATUS_FOR_A_SPECIFIC_REFERENCE",
"arguments": { "owner": "<upstream-owner>", "repo": "<upstream-repo-name>", "ref": "<head-sha-from-pull-request>" }
})
composio_execute({
"tool": "GITHUB_LIST_CHECK_RUNS_FOR_A_GIT_REFERENCE",
"arguments": { "owner": "<upstream-owner>", "repo": "<upstream-repo-name>", "ref": "<head-sha-from-pull-request>" }
})
```
PRs in the GitHub API are addressable both as `pull_number` (the PR-specific endpoints) and `issue_number` (top-level comments live on the issue surface). The check-rollup endpoints take the head SHA from the PR response.
Derive `<fork-owner>` from the PR's `head.repo.owner.login` (or use `{fork}` if provided). Note the head branch name as `<branch>`. Record: failing-check ids, unresolved comment threads (with their body + author + path/line if inline), approval count, merge state, PR state (`OPEN` / `MERGED` / `CLOSED`).
_TODO(composio-catalog): if the exact slugs above drift in the Composio catalog, swap to whatever the current name is. The shapes (owner/repo/pull_number/issue_number/ref) are stable; the slug casing is what occasionally changes._
2. **Check terminal conditions first.**
- PR `state` is `MERGED` → report `"merged: <url>"` and stop.
- PR `state` is `CLOSED` (not merged) → report `"closed: <one-line reason from the latest comment>"` and stop.
- All required checks `success` AND zero unresolved actionable threads AND at least one approval → report `"ready for merge: <url>"` and stop.
- Otherwise → continue.
3. **Clone the fork branch fresh** to a unique local directory (skip this if the directory from a prior round in this same run already exists and is on the right HEAD). Clone + identity-pin are local-git working-tree ops:
```
git clone --branch <branch> https://github.com/<fork-owner>/<repo-name> /tmp/<repo-name>-pr{pr}-<rand>
```
Pin the local git identity in the clone so any new commits are verified under the authed account. Use the `login` and `id` from a one-time `GITHUB_GET_THE_AUTHENTICATED_USER` call:
```
composio_execute({ "tool": "GITHUB_GET_THE_AUTHENTICATED_USER", "arguments": {} })
# then with <login> + <id> from the response:
git -C <dir> config user.name "<login>"
git -C <dir> config user.email "<id>+<login>@users.noreply.github.com"
```
4. **Address each signal in turn.** Process every open item before pushing — group changes into one push per round:
- **CI check failed** — read the failure detail from the check-runs response in step 1 (look at `output.summary` / `output.text` on the failed run). Locate the cause (start with `codegraph_search` on the failing test name or error string), apply the minimal fix, run the targeted test locally to confirm green (`cargo test -p <crate> <name>` / `pnpm test <pattern>` etc.), commit with a message that names the failing check:
```
git -C <dir> add <only-the-fixed-files>
git -C <dir> commit -m "fix(<scope>): <one line> (CI: <check-name>)"
```
Do **not** bypass with `--no-verify` unless the failure is verifiably unrelated to this PR.
- **Reviewer asks for a code change (actionable, human or bot)** — make the edit, commit referencing the comment: `git commit -m "address review: <one-line> (#{pr} review)"`. The reply on the thread happens after the push in step 6.
- **Bot comment (CodeRabbit / Codecov / etc.)** — treat as actionable by default. If clearly a false positive, plan a thread reply (in step 6) with a one-line reason instead of a spurious code change.
- **Reviewer requests deferral / accepts a known limitation** — plan a thread reply acknowledging, file a follow-up issue if appropriate, and persist it as "deferred" in the round summary.
5. **Push the round's fixes** to the fork in one push. Pushing the branch is a working-tree op, so it stays on local git:
```
git -C <dir> push --force-with-lease "https://github.com/<fork-owner>/<repo-name>" <branch>
```
Use `--force-with-lease` (never plain `--force`) so a concurrent push from someone else aborts the push instead of clobbering. If `--force-with-lease` refuses because the remote moved, re-run step 1 (the remote diverged — handle the new commits before pushing).
6. **Reply to every addressed comment** by id so reviewers know it's been handled — even when the fix is obvious from the diff. All replies are Composio calls:
- **Inline review comment** (file:line, has `id` from step 1):
```
composio_execute({
"tool": "GITHUB_CREATE_A_REPLY_FOR_A_REVIEW_COMMENT",
"arguments": {
"owner": "<upstream-owner>",
"repo": "<upstream-repo-name>",
"pull_number": {pr},
"comment_id": <comment-id>,
"body": "Fixed in <short-sha>. <one-line description>"
}
})
```
- **Top-level review or general thread**:
```
composio_execute({
"tool": "GITHUB_CREATE_AN_ISSUE_COMMENT",
"arguments": {
"owner": "<upstream-owner>",
"repo": "<upstream-repo-name>",
"issue_number": {pr},
"body": "<reply>"
}
})
```
- **Deferred / disagreed**: reply with the one-line reason instead of a code change, using the same `GITHUB_CREATE_A_REPLY_FOR_A_REVIEW_COMMENT` (inline) or `GITHUB_CREATE_AN_ISSUE_COMMENT` (top-level) call.
7. **Wait for CI to re-run on the new commits** before declaring the round done. Polling is a Composio loop — re-issue the check-runs read every ~30s on the new head SHA until every required check has reached a terminal state:
```
composio_execute({
"tool": "GITHUB_LIST_CHECK_RUNS_FOR_A_GIT_REFERENCE",
"arguments": { "owner": "<upstream-owner>", "repo": "<upstream-repo-name>", "ref": "<new-head-sha>" }
})
```
Stop polling when every required check's `status` is `completed` (look at `conclusion` to decide success/failure). Cap the poll at ~30 minutes per round so a stuck CI doesn't pin this run indefinitely.
8. **Re-loop to step 1.** If `{max_rounds}` rounds (default 5) have run without both gates closing, exit with `"blocked after N rounds — surfacing for human review"` plus the still-failing checks and still-open comment ids.
## Rules
- **GitHub state via Composio, working tree via local git.** Never shell to `gh` — the preflight gates Composio, not `gh`'s credential store, so a `gh` call can silently use the wrong identity.
- **Scope:** only fixes for *this PR's* review feedback or CI failures. No unrelated refactors, no scope creep, no other issues.
- **`--force-with-lease`, never `--force`.** Preserve anyone else's pushes.
- **Don't bypass CI** with `--no-verify` unless the failure is verifiably unrelated to this PR AND that's been justified in the round summary.
- **Reply to every actionable signal** — addressed-and-pushed comments still need a thread reply so the reviewer knows.
- **CI green ≠ done.** Comments still matter; both gates must close.
- **Approvals don't auto-merge.** Note the approval and keep monitoring until the PR is actually merged or closed.
- **Don't push to upstream.** Pushes go to the fork only.
- **Stop** when both gates close, the PR is merged/closed, the round cap is hit, or you've identified a blocker that needs a human — report status plainly either way.
@@ -0,0 +1,54 @@
# pr-review-shepherd — a DEFAULT skill shipped with OpenHuman.
# Bundled into the binary and seeded into <workspace>/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.
#
# The Phase-6 companion to github-issue-crusher: takes a single open PR and
# iterates check → fix → push → re-check until both gates close (CI green AND
# every actionable reviewer/bot comment addressed), surfaces a real blocker,
# or notices the PR was merged / closed.
id = "pr-review-shepherd"
when_to_use = "Drive a single open GitHub PR to ready-for-merge — iterate CI failures + reviewer / bot (CodeRabbit, Codecov) comments until every required check is green AND every actionable thread is addressed or explicitly replied-to. Use after a PR is opened (e.g. by `github-issue-crusher`) and stop when both gates close, the PR is merged/closed, or a real blocker needs human review."
# Preflight gate (see src/openhuman/skills/preflight.rs). pr-review-shepherd
# reads PR state via Composio AND pushes fix-up commits to the fork via
# local git, so both subsystems must be ready before the orchestrator
# boots:
#
# * Composio GitHub connection is active
# * local `git` is on PATH
# * `git config --global user.{name,email}` are both set
# * (strict) Composio username == local git user.name (case-insensitive)
#
# Mirrors github-issue-crusher's gate: both skills round-trip through
# the same fork-and-PR boundary, so they share the same identity
# coherence requirement. A mismatch silently mixes commit authorship
# (local) with PR ownership (Composio) and is much harder to spot
# after the fact than at the gate.
[github]
required = true
identity_match = "strict"
[[inputs]]
name = "repo"
description = "The UPSTREAM repo the PR lives on, as owner/name (e.g. acme/web)."
required = true
[[inputs]]
name = "pr"
description = "PR number on the upstream repo to shepherd to mergeable state."
required = true
type = "integer"
[[inputs]]
name = "fork"
description = "Fork that owns the PR's head branch, as owner/name. Omit to derive from the PR's headRepositoryOwner (or default to the authed account)."
required = false
[[inputs]]
name = "max_rounds"
description = "Safety cap on push-and-re-check rounds before surfacing for human review. Default 5."
required = false
type = "integer"
+3
View File
@@ -8,6 +8,9 @@ pub mod ops_discover;
pub mod ops_install;
pub mod ops_parse;
pub mod ops_types;
pub mod preflight;
pub mod registry;
pub mod run_log;
pub mod schemas;
pub mod types;
+1 -1
View File
@@ -29,7 +29,7 @@
// Re-export everything that was previously public from this file so external
// callers are unaffected.
pub use super::ops_create::{create_skill, CreateSkillParams};
pub use super::ops_create::{create_skill, CreateSkillParams, SkillCreateInputDef};
pub use super::ops_discover::{
discover_skills, init_skills_dir, is_workspace_trusted, load_skills, read_skill_resource,
};
+203 -2
View File
@@ -1,6 +1,6 @@
//! Skill creation: scaffolding new SKILL.md-based skills on disk.
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::path::Path;
use super::ops_discover::{discover_skills_inner, is_workspace_trusted};
@@ -8,6 +8,41 @@ use super::ops_types::{
Skill, SkillScope, MAX_DESCRIPTION_LEN, MAX_NAME_LEN, RESOURCE_DIRS, SKILL_MD,
};
/// One declared `[[inputs]]` entry as supplied at create time by the
/// Create-a-Skill form.
///
/// Wire shape (kebab-case-free, mirrors what
/// `crate::openhuman::skills::registry::SkillInput` expects when the
/// emitted `skill.toml` is parsed back at run time):
///
/// ```json
/// { "name": "repo", "description": "owner/name", "required": true, "type": "string" }
/// ```
///
/// `description` and `type` are optional; when omitted the on-disk
/// `[[inputs]]` entry leaves them absent (the registry's
/// `SkillInput` defaults already cover this — `description = ""`,
/// `kind = None`). `required` defaults to `true` because that is the
/// only sensible default for a user who bothered to add a row.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillCreateInputDef {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default = "default_required")]
pub required: bool,
/// Type hint — accepted values are `"string"` (default), `"integer"`,
/// and `"boolean"`. The registry parser stores this verbatim in
/// `SkillInput.kind`; it is the Skills Runner that uses it to pick
/// the right form control (text / number / checkbox).
#[serde(default, rename = "type")]
pub type_: Option<String>,
}
fn default_required() -> bool {
true
}
/// Input for [`create_skill`]. Mirrors the `skills.create` JSON-RPC payload.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct CreateSkillParams {
@@ -30,6 +65,12 @@ pub struct CreateSkillParams {
/// Optional tool hints (written to frontmatter `allowed-tools`).
#[serde(default, rename = "allowed-tools", alias = "allowed_tools")]
pub allowed_tools: Vec<String>,
/// Declared `[[inputs]]` for the skill. When non-empty,
/// `create_skill_inner` writes a sibling `skill.toml` next to the
/// generated `SKILL.md` so the Skills Runner can render dynamic
/// form controls for the inputs at run time.
#[serde(default)]
pub inputs: Vec<SkillCreateInputDef>,
}
/// Scaffold a new SKILL.md-based skill on disk.
@@ -68,7 +109,7 @@ pub fn create_skill(workspace_dir: &Path, params: CreateSkillParams) -> Result<S
pub(crate) fn create_skill_inner(
home_dir: Option<&Path>,
workspace_dir: &Path,
params: CreateSkillParams,
mut params: CreateSkillParams,
) -> Result<Skill, String> {
tracing::debug!(
name = %params.name,
@@ -77,6 +118,8 @@ pub(crate) fn create_skill_inner(
"[skills] create_skill: entry"
);
validate_inputs(&mut params.inputs)?;
let display_name = params.name.trim();
if display_name.is_empty() {
return Err("name must not be empty".to_string());
@@ -161,6 +204,18 @@ pub(crate) fn create_skill_inner(
std::fs::write(&skill_md_path, skill_md)
.map_err(|e| format!("failed to write {}: {e}", skill_md_path.display()))?;
// Emit a sibling skill.toml when the user declared `[[inputs]]` at
// create time. The Skills Runner reads this to render dynamic form
// controls (text / number / checkbox) per declared input. Skills
// without inputs don't need a skill.toml — the registry happily
// parses SKILL.md-only skills.
if !params.inputs.is_empty() {
let skill_toml_path = skill_dir.join("skill.toml");
let skill_toml = render_skill_toml(&slug, description, &params.inputs);
std::fs::write(&skill_toml_path, skill_toml)
.map_err(|e| format!("failed to write {}: {e}", skill_toml_path.display()))?;
}
for sub in RESOURCE_DIRS {
let sub_path = skill_dir.join(sub);
std::fs::create_dir_all(&sub_path)
@@ -182,6 +237,29 @@ pub(crate) fn create_skill_inner(
Ok(created)
}
/// Validate the declared `[[inputs]]` before any on-disk write.
///
/// For each entry this trims the `name` in place, rejects empty /
/// whitespace-only names, and enforces case-insensitive uniqueness across
/// all input names so the emitted `skill.toml` never carries a blank or
/// duplicate `[[inputs]]` key. Names are trimmed in place so every later
/// consumer (e.g. [`render_skill_toml`]) sees the validated value.
fn validate_inputs(inputs: &mut [SkillCreateInputDef]) -> Result<(), String> {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for input in inputs.iter_mut() {
let trimmed = input.name.trim();
if trimmed.is_empty() {
return Err("input name must not be empty".to_string());
}
if !seen.insert(trimmed.to_ascii_lowercase()) {
return Err(format!("duplicate input name '{trimmed}'"));
}
let trimmed = trimmed.to_string();
input.name = trimmed;
}
Ok(())
}
/// Convert a human-readable skill name to a filesystem-safe slug.
///
/// Rules:
@@ -303,3 +381,126 @@ pub(crate) fn yaml_scalar(s: &str) -> String {
.replace('\t', "\\t");
format!("\"{escaped}\"")
}
/// Render the sibling `skill.toml` next to a freshly scaffolded SKILL.md
/// when the user declared `[[inputs]]` at create time. Emits the
/// minimal set the registry parser needs to discover and render the
/// inputs at run time: `id`, `when_to_use`, plus one `[[inputs]]` entry
/// per declared input. Field shape mirrors the existing bundled skills
/// (e.g. `src/openhuman/skills/defaults/github-issue-crusher/skill.toml`)
/// so `discover_skills_inner` parses the new file identically.
pub(crate) fn render_skill_toml(
slug: &str,
description: &str,
inputs: &[SkillCreateInputDef],
) -> String {
let mut out = String::new();
out.push_str(&format!("id = {}\n", toml_string_literal(slug)));
out.push_str(&format!(
"when_to_use = {}\n",
toml_string_literal(description)
));
for input in inputs {
out.push_str("\n[[inputs]]\n");
out.push_str(&format!("name = {}\n", toml_string_literal(&input.name)));
if let Some(d) = input.description.as_deref().filter(|s| !s.is_empty()) {
out.push_str(&format!("description = {}\n", toml_string_literal(d)));
}
out.push_str(&format!("required = {}\n", input.required));
if let Some(t) = input.type_.as_deref().filter(|s| !s.is_empty()) {
out.push_str(&format!("type = {}\n", toml_string_literal(t)));
}
}
out
}
/// Emit a TOML basic-string literal: wraps in `"..."` and escapes the
/// minimum set TOML requires inside basic strings (`\`, `"`, control
/// chars). Multi-line strings are not used; new-lines inside a value
/// are escaped to `\n` so the literal stays single-line and round-trips
/// through the TOML parser unchanged.
fn toml_string_literal(s: &str) -> String {
let mut escaped = String::with_capacity(s.len() + 2);
for ch in s.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
c => escaped.push(c),
}
}
format!("\"{escaped}\"")
}
#[cfg(test)]
mod render_skill_toml_tests {
use super::*;
#[test]
fn no_inputs_returns_header_only() {
let out = render_skill_toml("my-skill", "Does the thing.", &[]);
assert!(out.contains("id = \"my-skill\""));
assert!(out.contains("when_to_use = \"Does the thing.\""));
assert!(!out.contains("[[inputs]]"));
}
#[test]
fn one_input_with_all_fields_roundtrips() {
let inputs = vec![SkillCreateInputDef {
name: "repo".into(),
description: Some("owner/name".into()),
required: true,
type_: Some("string".into()),
}];
let out = render_skill_toml("my-skill", "Does the thing.", &inputs);
// Parse it back through the actual TOML parser to prove the
// output is well-formed — the registry uses `toml::from_str` so
// any round-trip failure here would surface at skill discovery.
let parsed: toml::Value = toml::from_str(&out).expect("emitted skill.toml must parse");
let inputs_arr = parsed["inputs"].as_array().expect("[[inputs]] is an array");
assert_eq!(inputs_arr.len(), 1);
let entry = &inputs_arr[0];
assert_eq!(entry["name"].as_str(), Some("repo"));
assert_eq!(entry["description"].as_str(), Some("owner/name"));
assert_eq!(entry["required"].as_bool(), Some(true));
assert_eq!(entry["type"].as_str(), Some("string"));
}
#[test]
fn optional_fields_omitted_when_empty() {
let inputs = vec![SkillCreateInputDef {
name: "n".into(),
description: None,
required: false,
type_: None,
}];
let out = render_skill_toml("my-skill", "x", &inputs);
let parsed: toml::Value = toml::from_str(&out).expect("parse");
let entry = &parsed["inputs"].as_array().unwrap()[0];
assert_eq!(entry["name"].as_str(), Some("n"));
assert_eq!(entry["required"].as_bool(), Some(false));
assert!(entry.get("description").is_none());
assert!(entry.get("type").is_none());
}
#[test]
fn escapes_dangerous_chars_in_strings() {
let inputs = vec![SkillCreateInputDef {
name: "n".into(),
description: Some("has \"quotes\" and \\ backslash\nand newline".into()),
required: true,
type_: None,
}];
let out = render_skill_toml("my-skill", "x", &inputs);
// Must still parse cleanly — the escape logic is what we're
// exercising here; the round-trip assertion below is the contract.
let parsed: toml::Value = toml::from_str(&out).expect("escaped strings must parse");
let entry = &parsed["inputs"].as_array().unwrap()[0];
assert_eq!(
entry["description"].as_str(),
Some("has \"quotes\" and \\ backslash\nand newline")
);
}
}
+138
View File
@@ -550,6 +550,7 @@ fn create_skill_user_scope_scaffolds_skill_md_and_resource_dirs() {
author: Some("Jane Dev".to_string()),
tags: vec!["demo".to_string(), "greeting".to_string()],
allowed_tools: vec!["shell".to_string()],
inputs: Vec::new(),
};
let created = create_skill_inner(Some(home.path()), ws.path(), params)
@@ -1170,3 +1171,140 @@ fn uninstall_skill_rejects_symlinked_skills_root() {
"target must survive"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// `[[inputs]]` editor — Phase 1: schema round-trip.
//
// The Create-a-Skill form lets the user declare zero-or-more skill inputs at
// create time. These tests pin the wire shape and the params round-trip so the
// payload from `skillsApi.createSkill` lands intact in `CreateSkillParams.inputs`
// and is identical after TOML emission + re-parse via the registry's
// `SkillInput` (see Phase 2 for the actual on-disk emit; Phase 1 is JSON only).
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn skill_create_input_def_deserializes_full_row_from_json() {
let row: crate::openhuman::skills::ops_create::SkillCreateInputDef =
serde_json::from_value(serde_json::json!({
"name": "repo",
"description": "owner/name slug",
"required": true,
"type": "string",
}))
.unwrap();
assert_eq!(row.name, "repo");
assert_eq!(row.description.as_deref(), Some("owner/name slug"));
assert!(row.required);
assert_eq!(row.type_.as_deref(), Some("string"));
}
#[test]
fn skill_create_input_def_required_defaults_to_true() {
// The form sends `required` per row, but other callers (CLI, future
// RPC clients) may omit it. The serde default keeps the safer
// semantic — a row the user bothered to declare is required.
let row: crate::openhuman::skills::ops_create::SkillCreateInputDef =
serde_json::from_value(serde_json::json!({
"name": "topic",
}))
.unwrap();
assert_eq!(row.name, "topic");
assert!(row.description.is_none());
assert!(row.required, "required must default to true");
assert!(row.type_.is_none());
}
#[test]
fn create_skill_params_defaults_inputs_to_empty_vec() {
// Old clients that don't know about `inputs` keep working — the
// field defaults to an empty vec at deserialise time and `Default`
// produces an empty vec too.
let params: CreateSkillParams = serde_json::from_value(serde_json::json!({
"name": "Hello",
"description": "Says hi",
"scope": "user",
}))
.unwrap();
assert!(params.inputs.is_empty());
assert!(CreateSkillParams::default().inputs.is_empty());
}
#[test]
fn create_skill_params_carries_inputs_through_deserialise() {
let params: CreateSkillParams = serde_json::from_value(serde_json::json!({
"name": "Issue Crusher",
"description": "Fix one issue end to end.",
"scope": "user",
"inputs": [
{ "name": "repo", "description": "owner/name", "required": true, "type": "string" },
{ "name": "issue", "description": "issue #", "required": true, "type": "integer" },
{ "name": "pr_base", "description": "base branch", "required": false }
],
}))
.unwrap();
assert_eq!(params.inputs.len(), 3);
assert_eq!(params.inputs[1].name, "issue");
assert_eq!(params.inputs[1].type_.as_deref(), Some("integer"));
assert!(params.inputs[1].required);
assert!(!params.inputs[2].required);
assert!(params.inputs[2].type_.is_none());
}
#[test]
fn skill_create_input_def_round_trips_through_registry_skill_input() {
// Asserts that what the form emits and what the registry parser
// accepts are the same shape over TOML — the "parser will accept
// what you emit" contract called out in the Phase-1 brief. We
// serialise the form-supplied row(s) into a synthetic skill.toml
// body, parse it back through the registry's `SkillDefinition`,
// and check every field survived.
let rows = vec![
crate::openhuman::skills::ops_create::SkillCreateInputDef {
name: "repo".into(),
description: Some("owner/name slug".into()),
required: true,
type_: Some("string".into()),
},
crate::openhuman::skills::ops_create::SkillCreateInputDef {
name: "issue".into(),
description: Some("issue #".into()),
required: true,
type_: Some("integer".into()),
},
crate::openhuman::skills::ops_create::SkillCreateInputDef {
name: "pr_base".into(),
description: None,
required: false,
type_: None,
},
];
// Hand-build a minimal skill.toml the registry can parse: id +
// when_to_use are the only AgentDefinition fields without defaults.
let mut toml = String::from("id = \"round-trip\"\nwhen_to_use = \"trip\"\n");
for r in &rows {
toml.push_str("\n[[inputs]]\n");
toml.push_str(&format!("name = \"{}\"\n", r.name));
if let Some(d) = &r.description {
toml.push_str(&format!("description = \"{}\"\n", d));
}
toml.push_str(&format!("required = {}\n", r.required));
if let Some(t) = &r.type_ {
toml.push_str(&format!("type = \"{}\"\n", t));
}
}
let parsed: crate::openhuman::skills::registry::SkillDefinition =
toml::from_str(&toml).expect("registry must accept what the form emits");
assert_eq!(parsed.inputs.len(), 3);
assert_eq!(parsed.inputs[0].name, "repo");
assert_eq!(parsed.inputs[0].description, "owner/name slug");
assert!(parsed.inputs[0].required);
assert_eq!(parsed.inputs[0].kind.as_deref(), Some("string"));
assert_eq!(parsed.inputs[1].kind.as_deref(), Some("integer"));
// `description` defaults to "" in `SkillInput`, not Option::None —
// the registry parser flattens missing into empty for back-compat.
assert_eq!(parsed.inputs[2].description, "");
assert!(!parsed.inputs[2].required);
assert!(parsed.inputs[2].kind.is_none());
}
+632
View File
@@ -0,0 +1,632 @@
//! Skill preflight gates — run BEFORE the orchestrator boots for a
//! `skills_run`, so failures surface as a plain `Err` from
//! [`super::schemas::spawn_skill_run_background`] (and from there into
//! the dashboard card / runner page UI) instead of leaking through as
//! cryptic orchestrator output.
//!
//! The first gate this module ships is the **GitHub gate**: when a
//! skill's `[github]` block sets `required = true`, we assert that
//!
//! 1. The Composio GitHub integration is connected (`toolkit ==
//! "github"` AND `is_active() == true`).
//! 2. Local `git` is on PATH (`git --version` exits zero).
//! 3. `git config --global user.name` AND `git config --global
//! user.email` are both set to non-empty trimmed values.
//! 4. When `identity_match == Strict`, the Composio GitHub username
//! equals `git config user.name` case-insensitively.
//!
//! Each check has its own [`GithubGateError`] variant carrying enough
//! context for a user-readable explanation (which check failed, what
//! the remediation is). The gate decision is also serialised into the
//! run-log header by the caller so failures appear in the existing
//! in-app log viewer.
//!
//! ## Testability
//!
//! The gate is built around a [`PreflightProbes`] trait, so unit tests
//! can swap a stub probe in for the four side effects (Composio
//! lookup, `git --version`, `git config user.name`, `git config
//! user.email`) without spinning up a real git binary or a live
//! Composio connection. This matches the established
//! "inject-the-async-closure" pattern used elsewhere in the skills
//! domain (see e.g. `registry.rs`'s `cfg(test)` indirections) — no new
//! mocking framework is introduced.
use std::time::Duration;
use async_trait::async_trait;
use crate::openhuman::composio::{self};
use crate::openhuman::config::Config;
use super::registry::{IdentityMatch, SkillGithubConfig};
/// Hard cap on each local `git` subprocess probe so a wedged git
/// (e.g. credential prompt, hung filesystem) can't stall the preflight
/// gate indefinitely.
const GIT_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
/// One reason the GitHub preflight gate refused to start a skill_run.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GithubGateError {
/// No active Composio connection found whose `toolkit == "github"`.
ComposioGithubMissing,
/// `git --version` failed (binary missing, non-zero exit, …). The
/// payload carries the underlying error message for the run log.
GitBinaryMissing(String),
/// `git config --global user.name` was empty / unset.
GitUserNameMissing,
/// `git config --global user.email` was empty / unset.
GitUserEmailMissing,
/// `identity_match = "strict"` was on, both names existed, but
/// `local != composio` (case-insensitive after trim).
IdentityMismatch {
composio_username: String,
git_username: String,
},
/// `identity_match = "strict"` was on but the Composio side
/// couldn't resolve a username (no profile reachable). Distinct
/// from `ComposioGithubMissing` because the connection IS there —
/// the identity lookup just failed.
ComposioIdentityUnresolved,
}
impl GithubGateError {
/// Render the gate failure as a single user-readable string with
/// concrete remediation. This is what `spawn_skill_run_background`
/// puts in the `Err(String)` it returns.
pub fn to_user_message(&self, log_path: Option<&str>) -> String {
let body = match self {
GithubGateError::ComposioGithubMissing => {
"GitHub preflight failed: no active Composio GitHub connection. \
Connect via `composio_authorize github` (or Settings \
Integrations GitHub) and re-run."
.to_string()
}
GithubGateError::GitBinaryMissing(err) => format!(
"GitHub preflight failed: local `git` is not available ({err}). \
Install Git and make sure it's on PATH, then re-run."
),
GithubGateError::GitUserNameMissing => {
"GitHub preflight failed: `git config --global user.name` is empty. \
Run `git config --global user.name \"<your name>\"` and re-run."
.to_string()
}
GithubGateError::GitUserEmailMissing => {
"GitHub preflight failed: `git config --global user.email` is empty. \
Run `git config --global user.email \"<you@example.com>\"` and re-run."
.to_string()
}
GithubGateError::IdentityMismatch {
composio_username,
git_username,
} => format!(
"GitHub preflight failed: identity mismatch — Composio github connection is \
`{composio_username}` but `git config user.name` is `{git_username}`. \
Either reconnect Composio under the right GitHub account or update \
`git config --global user.name` to match."
),
GithubGateError::ComposioIdentityUnresolved => {
"GitHub preflight failed: Composio GitHub connection is present but the \
connected username could not be resolved. Try reconnecting via \
`composio_authorize github`, then re-run."
.to_string()
}
};
match log_path {
Some(p) if !p.is_empty() => format!("{body} (gate log: {p})"),
_ => body,
}
}
/// Short tag suitable for the run-log header — keeps each failure
/// reason grep-friendly.
pub fn tag(&self) -> &'static str {
match self {
GithubGateError::ComposioGithubMissing => "composio_github_missing",
GithubGateError::GitBinaryMissing(_) => "git_binary_missing",
GithubGateError::GitUserNameMissing => "git_user_name_missing",
GithubGateError::GitUserEmailMissing => "git_user_email_missing",
GithubGateError::IdentityMismatch { .. } => "identity_mismatch",
GithubGateError::ComposioIdentityUnresolved => "composio_identity_unresolved",
}
}
}
/// Side-effect probes the preflight needs. Production wires these to
/// real Composio + `git` calls (see [`LivePreflightProbes`]); tests
/// substitute a deterministic stub.
#[async_trait]
pub trait PreflightProbes: Send + Sync {
/// True iff there is currently an active Composio connection for
/// the given toolkit (per `fetch_connected_integrations`).
async fn composio_toolkit_active(&self, toolkit: &str) -> bool;
/// The connected account's identity (e.g. GitHub username) for the
/// given toolkit. `None` ⇒ couldn't resolve (provider not
/// registered, profile lookup failed, empty username).
async fn composio_identity(&self, toolkit: &str) -> Option<String>;
/// `git --version` outcome. `Ok(())` when git is on PATH and exits
/// zero; `Err(message)` otherwise.
async fn git_version(&self) -> Result<(), String>;
/// `git config --global user.name` trimmed value, or empty when
/// unset / blank.
async fn git_user_name(&self) -> String;
/// `git config --global user.email` trimmed value, or empty when
/// unset / blank.
async fn git_user_email(&self) -> String;
}
/// Production probes — hits real Composio (via
/// `composio::connection_identity` /
/// `composio::fetch_connected_integrations`) and the local `git`
/// binary via `tokio::process::Command`.
pub struct LivePreflightProbes<'a> {
pub config: &'a Config,
}
impl<'a> LivePreflightProbes<'a> {
pub fn new(config: &'a Config) -> Self {
Self { config }
}
}
#[async_trait]
impl<'a> PreflightProbes for LivePreflightProbes<'a> {
async fn composio_toolkit_active(&self, toolkit: &str) -> bool {
let connections = composio::fetch_connected_integrations(self.config).await;
connections
.iter()
.any(|c| c.toolkit.eq_ignore_ascii_case(toolkit))
}
async fn composio_identity(&self, toolkit: &str) -> Option<String> {
composio::connection_identity(self.config, toolkit).await
}
async fn git_version(&self) -> Result<(), String> {
let fut = tokio::process::Command::new("git")
.arg("--version")
.output();
match tokio::time::timeout(GIT_PROBE_TIMEOUT, fut).await {
Ok(Ok(output)) if output.status.success() => Ok(()),
Ok(Ok(output)) => Err(format!(
"`git --version` exited {}",
output.status.code().unwrap_or(-1)
)),
Ok(Err(e)) => Err(format!("`git --version` failed to spawn: {e}")),
Err(_) => Err(format!(
"`git --version` timed out after {}s",
GIT_PROBE_TIMEOUT.as_secs()
)),
}
}
async fn git_user_name(&self) -> String {
git_config_value("user.name").await
}
async fn git_user_email(&self) -> String {
git_config_value("user.email").await
}
}
async fn git_config_value(key: &str) -> String {
let fut = tokio::process::Command::new("git")
.args(["config", "--global", key])
.output();
match tokio::time::timeout(GIT_PROBE_TIMEOUT, fut).await {
Ok(Ok(output)) if output.status.success() => {
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
// Non-zero exit, spawn failure, or timeout all collapse to the
// existing empty-string fallback (treated as "unset").
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => String::new(),
}
}
/// Run the GitHub preflight gate against the supplied [`PreflightProbes`].
///
/// Returns `Ok(())` when the gate accepts the run, or `Err(first
/// failure)` when any check fails. Checks run in the documented order
/// (Composio connection → git binary → git user.name → git user.email
/// → identity match) so the user sees the most foundational failure
/// first; we do not try to enumerate every problem at once.
///
/// The gate is a no-op (immediate `Ok(())`) when the `[github]` block
/// is absent (`cfg: None`) or `required = false`.
pub async fn run_github_preflight<P: PreflightProbes>(
cfg: Option<&SkillGithubConfig>,
probes: &P,
) -> Result<(), GithubGateError> {
let Some(cfg) = cfg else {
tracing::debug!("[skills:preflight] github gate skipped: no [github] block");
return Ok(());
};
if !cfg.required {
tracing::debug!("[skills:preflight] github gate skipped: required = false");
return Ok(());
}
// (1) Composio GitHub integration must be connected.
if !probes.composio_toolkit_active("github").await {
tracing::warn!("[skills:preflight] github gate fail: composio_github_missing");
return Err(GithubGateError::ComposioGithubMissing);
}
// (2) git binary present.
if let Err(e) = probes.git_version().await {
tracing::warn!(error = %e, "[skills:preflight] github gate fail: git_binary_missing");
return Err(GithubGateError::GitBinaryMissing(e));
}
// (3a) git user.name set.
let git_name = probes.git_user_name().await;
if git_name.is_empty() {
tracing::warn!("[skills:preflight] github gate fail: git_user_name_missing");
return Err(GithubGateError::GitUserNameMissing);
}
// (3b) git user.email set.
let git_email = probes.git_user_email().await;
if git_email.is_empty() {
tracing::warn!("[skills:preflight] github gate fail: git_user_email_missing");
return Err(GithubGateError::GitUserEmailMissing);
}
// (4) Identity match, only when Strict.
match cfg.identity_match {
IdentityMatch::None => {
tracing::debug!(
"[skills:preflight] github gate pass (identity_match=none, reachability only)"
);
Ok(())
}
IdentityMatch::Any => {
// "any" still requires the Composio side to surface an
// identity — confirms the connection is genuinely usable.
match probes.composio_identity("github").await {
Some(_) => {
tracing::debug!("[skills:preflight] github gate pass (identity_match=any)");
Ok(())
}
None => {
tracing::warn!(
"[skills:preflight] github gate fail: composio_identity_unresolved (identity_match=any)"
);
Err(GithubGateError::ComposioIdentityUnresolved)
}
}
}
IdentityMatch::Strict => {
let composio_name = match probes.composio_identity("github").await {
Some(n) => n,
None => {
tracing::warn!(
"[skills:preflight] github gate fail: composio_identity_unresolved (identity_match=strict)"
);
return Err(GithubGateError::ComposioIdentityUnresolved);
}
};
if composio_name.trim().eq_ignore_ascii_case(git_name.trim()) {
tracing::debug!(
composio = %composio_name,
git = %git_name,
"[skills:preflight] github gate pass (identity_match=strict)"
);
Ok(())
} else {
tracing::warn!(
composio = %composio_name,
git = %git_name,
"[skills:preflight] github gate fail: identity_mismatch"
);
Err(GithubGateError::IdentityMismatch {
composio_username: composio_name,
git_username: git_name,
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// Configurable stub probe — every method backed by a field the
/// test sets up front. Default = "everything aligns".
struct StubProbes {
composio_active: bool,
composio_username: Option<String>,
git_version_ok: Result<(), String>,
git_name: String,
git_email: String,
/// Count of calls per method, for assertion in tests that need
/// to confirm short-circuit semantics.
calls: Mutex<Vec<&'static str>>,
}
impl StubProbes {
fn happy() -> Self {
Self {
composio_active: true,
composio_username: Some("alice".to_string()),
git_version_ok: Ok(()),
git_name: "Alice".to_string(),
git_email: "alice@example.com".to_string(),
calls: Mutex::new(Vec::new()),
}
}
fn track(&self, m: &'static str) {
self.calls.lock().unwrap().push(m);
}
}
#[async_trait]
impl PreflightProbes for StubProbes {
async fn composio_toolkit_active(&self, toolkit: &str) -> bool {
self.track("composio_toolkit_active");
assert_eq!(toolkit, "github");
self.composio_active
}
async fn composio_identity(&self, toolkit: &str) -> Option<String> {
self.track("composio_identity");
assert_eq!(toolkit, "github");
self.composio_username.clone()
}
async fn git_version(&self) -> Result<(), String> {
self.track("git_version");
self.git_version_ok.clone()
}
async fn git_user_name(&self) -> String {
self.track("git_user_name");
self.git_name.clone()
}
async fn git_user_email(&self) -> String {
self.track("git_user_email");
self.git_email.clone()
}
}
fn strict_cfg() -> SkillGithubConfig {
SkillGithubConfig {
required: true,
identity_match: IdentityMatch::Strict,
}
}
// ── Gate skip paths ─────────────────────────────────────────────
#[tokio::test]
async fn gate_skipped_when_no_github_block() {
let probes = StubProbes::happy();
// None ⇒ no gate.
let res = run_github_preflight(None, &probes).await;
assert!(res.is_ok(), "no [github] block ⇒ pass: {res:?}");
// No probe was even consulted.
assert!(probes.calls.lock().unwrap().is_empty());
}
#[tokio::test]
async fn gate_skipped_when_required_is_false() {
let cfg = SkillGithubConfig {
required: false,
identity_match: IdentityMatch::Strict,
};
let probes = StubProbes::happy();
let res = run_github_preflight(Some(&cfg), &probes).await;
assert!(res.is_ok(), "required=false ⇒ pass: {res:?}");
assert!(probes.calls.lock().unwrap().is_empty());
}
// ── Individual failure modes ────────────────────────────────────
#[tokio::test]
async fn gate_fails_when_composio_github_missing() {
let cfg = strict_cfg();
let mut probes = StubProbes::happy();
probes.composio_active = false;
let err = run_github_preflight(Some(&cfg), &probes).await.unwrap_err();
assert_eq!(err, GithubGateError::ComposioGithubMissing);
// Subsequent checks must NOT run (composio fail short-circuits).
let calls = probes.calls.lock().unwrap();
assert_eq!(calls.as_slice(), &["composio_toolkit_active"]);
}
#[tokio::test]
async fn gate_fails_when_local_git_binary_missing() {
let cfg = strict_cfg();
let mut probes = StubProbes::happy();
probes.git_version_ok = Err("not found".into());
let err = run_github_preflight(Some(&cfg), &probes).await.unwrap_err();
match err {
GithubGateError::GitBinaryMissing(msg) => assert!(msg.contains("not found")),
other => panic!("expected GitBinaryMissing, got {other:?}"),
}
}
#[tokio::test]
async fn gate_fails_when_git_user_name_missing() {
let cfg = strict_cfg();
let mut probes = StubProbes::happy();
probes.git_name = " ".into(); // whitespace-only counts as empty? we read trimmed
// The Live probes return trimmed strings; StubProbes returns as-is,
// but the gate compares to empty AFTER the StubProbes returns the
// raw value. Real probes trim. Emulate by clearing.
probes.git_name = "".into();
let err = run_github_preflight(Some(&cfg), &probes).await.unwrap_err();
assert_eq!(err, GithubGateError::GitUserNameMissing);
}
#[tokio::test]
async fn gate_fails_when_git_user_email_missing() {
let cfg = strict_cfg();
let mut probes = StubProbes::happy();
probes.git_email = "".into();
let err = run_github_preflight(Some(&cfg), &probes).await.unwrap_err();
assert_eq!(err, GithubGateError::GitUserEmailMissing);
}
#[tokio::test]
async fn gate_fails_on_strict_identity_mismatch_with_both_names_in_error() {
let cfg = strict_cfg();
let mut probes = StubProbes::happy();
probes.composio_username = Some("octo-alice".into());
probes.git_name = "Alice".into();
let err = run_github_preflight(Some(&cfg), &probes).await.unwrap_err();
match err {
GithubGateError::IdentityMismatch {
composio_username,
git_username,
} => {
assert_eq!(composio_username, "octo-alice");
assert_eq!(git_username, "Alice");
}
other => panic!("expected IdentityMismatch, got {other:?}"),
}
}
#[tokio::test]
async fn gate_fails_when_strict_but_composio_identity_unresolved() {
let cfg = strict_cfg();
let mut probes = StubProbes::happy();
probes.composio_username = None;
let err = run_github_preflight(Some(&cfg), &probes).await.unwrap_err();
assert_eq!(err, GithubGateError::ComposioIdentityUnresolved);
}
// ── Happy paths ─────────────────────────────────────────────────
#[tokio::test]
async fn gate_passes_when_everything_aligns_strict() {
let cfg = strict_cfg();
let probes = StubProbes::happy();
let res = run_github_preflight(Some(&cfg), &probes).await;
assert!(res.is_ok(), "expected pass, got {res:?}");
}
#[tokio::test]
async fn gate_passes_strict_with_case_insensitive_match() {
let cfg = strict_cfg();
let mut probes = StubProbes::happy();
probes.composio_username = Some("ALICE".into());
probes.git_name = "alice".into();
let res = run_github_preflight(Some(&cfg), &probes).await;
assert!(res.is_ok(), "case-insensitive match must pass: {res:?}");
}
#[tokio::test]
async fn gate_passes_any_with_identity_present_no_match_needed() {
let cfg = SkillGithubConfig {
required: true,
identity_match: IdentityMatch::Any,
};
let mut probes = StubProbes::happy();
probes.composio_username = Some("not-the-same".into());
probes.git_name = "completely-different".into();
let res = run_github_preflight(Some(&cfg), &probes).await;
assert!(
res.is_ok(),
"identity_match=any: presence is enough: {res:?}"
);
}
#[tokio::test]
async fn gate_fails_any_when_composio_identity_missing() {
let cfg = SkillGithubConfig {
required: true,
identity_match: IdentityMatch::Any,
};
let mut probes = StubProbes::happy();
probes.composio_username = None;
let err = run_github_preflight(Some(&cfg), &probes).await.unwrap_err();
assert_eq!(err, GithubGateError::ComposioIdentityUnresolved);
}
#[tokio::test]
async fn gate_passes_none_without_consulting_identity() {
let cfg = SkillGithubConfig {
required: true,
identity_match: IdentityMatch::None,
};
let mut probes = StubProbes::happy();
probes.composio_username = None; // would fail strict/any
let res = run_github_preflight(Some(&cfg), &probes).await;
assert!(
res.is_ok(),
"identity_match=none: reachability only: {res:?}"
);
let calls = probes.calls.lock().unwrap();
// The identity probe must not have been called.
assert!(
!calls.iter().any(|c| *c == "composio_identity"),
"identity_match=none must not probe identity, got {calls:?}"
);
}
// ── Error rendering ─────────────────────────────────────────────
#[tokio::test]
async fn user_message_includes_log_path_when_present() {
let err = GithubGateError::GitUserNameMissing;
let msg = err.to_user_message(Some("/tmp/run.log"));
assert!(msg.contains("git config --global user.name"));
assert!(msg.contains("/tmp/run.log"));
}
#[tokio::test]
async fn user_message_omits_log_path_when_absent() {
let err = GithubGateError::GitUserNameMissing;
let msg = err.to_user_message(None);
assert!(!msg.contains("gate log:"));
}
#[tokio::test]
async fn user_message_for_mismatch_carries_both_names() {
let err = GithubGateError::IdentityMismatch {
composio_username: "octo-alice".into(),
git_username: "Alice".into(),
};
let msg = err.to_user_message(None);
assert!(msg.contains("octo-alice"));
assert!(msg.contains("Alice"));
}
#[test]
fn gate_error_tags_are_stable() {
// The tag goes into the run-log header line — keep them
// grep-friendly and don't rename casually.
assert_eq!(
GithubGateError::ComposioGithubMissing.tag(),
"composio_github_missing"
);
assert_eq!(
GithubGateError::GitBinaryMissing("x".into()).tag(),
"git_binary_missing"
);
assert_eq!(
GithubGateError::GitUserNameMissing.tag(),
"git_user_name_missing"
);
assert_eq!(
GithubGateError::GitUserEmailMissing.tag(),
"git_user_email_missing"
);
assert_eq!(
GithubGateError::IdentityMismatch {
composio_username: "a".into(),
git_username: "b".into()
}
.tag(),
"identity_mismatch"
);
assert_eq!(
GithubGateError::ComposioIdentityUnresolved.tag(),
"composio_identity_unresolved"
);
}
}
+464
View File
@@ -0,0 +1,464 @@
//! 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<String>,
}
/// How strictly the [`SkillGithubConfig`] preflight gate should compare
/// the Composio-connected GitHub identity with the local `git config
/// user.name`. Default: [`IdentityMatch::Strict`].
///
/// | Variant | Behaviour at preflight |
/// |---------|------------------------|
/// | `Strict` | The Composio-connected GitHub username MUST equal `git config user.name` (case-insensitive after trimming). Mismatch → gate fail. |
/// | `Any` | Both must exist (Composio github connection AND local git identity) but they don't have to match. |
/// | `None` | Skip the identity comparison entirely — only assert both subsystems are reachable. |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum IdentityMatch {
#[default]
Strict,
Any,
None,
}
/// `[github]` block in `skill.toml`. Optional; absent ⇒ no GitHub
/// preflight gate runs for this skill. Present + `required = true` ⇒
/// the preflight described in [`crate::openhuman::skills::schemas`]'s
/// `preflight_github_gate` runs before the orchestrator boots.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SkillGithubConfig {
/// When true, the gate runs. When false (default), the gate is
/// skipped even if other fields are populated — the gate is opt-in
/// per skill.
#[serde(default)]
pub required: bool,
/// How strictly to compare the Composio GitHub identity against
/// local `git config user.name`. See [`IdentityMatch`].
#[serde(default)]
pub identity_match: IdentityMatch,
}
impl Default for SkillGithubConfig {
fn default() -> Self {
Self {
required: false,
identity_match: IdentityMatch::default(),
}
}
}
/// 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<SkillInput>,
/// Optional GitHub preflight gate. When `Some(..)` with
/// `required = true`, the preflight runs before the orchestrator
/// boots — see
/// [`crate::openhuman::skills::schemas::spawn_skill_run_background`].
#[serde(default)]
pub github: Option<SkillGithubConfig>,
}
/// 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<String> {
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 `<workspace>/skills/<id>/` 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"),
),
// Phase-6 companion to github-issue-crusher: takes a single open PR and
// iterates check → fix → push → re-check until both gates close (CI
// green AND every actionable reviewer/bot comment addressed), surfaces a
// real blocker, or notices the PR was merged / closed.
(
"pr-review-shepherd",
include_str!("defaults/pr-review-shepherd/skill.toml"),
include_str!("defaults/pr-review-shepherd/SKILL.md"),
),
// Cron-friendly autonomous-developer skill: pick an issue assigned to
// the user on the upstream repo and ship a PR. Designed to be wired
// behind the DevWorkflowPanel + cron schedule (#2802) for unattended
// recurring runs. Distinct from github-issue-crusher in that the issue
// number is *picked* rather than passed in.
(
"dev-workflow",
include_str!("defaults/dev-workflow/skill.toml"),
include_str!("defaults/dev-workflow/SKILL.md"),
),
];
/// Seed the bundled [`DEFAULT_SKILLS`] into `<workspace>/skills/<id>/` 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;
}
let _ = std::fs::write(dir.join("skill.toml"), skill_toml);
let _ = std::fs::write(dir.join("SKILL.md"), skill_md);
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
/// `<workspace>/skills/<id>/{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<SkillDefinition> {
// 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<SkillDefinition> = Vec::new();
if let Ok(builtins) = crate::openhuman::agent::agents::load_builtins() {
for definition in builtins {
skills.push(SkillDefinition {
definition,
inputs: Vec::new(),
github: None,
});
}
}
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<SkillDefinition> {
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<SkillInput> {
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 skill_github_config_defaults_when_absent() {
// No [github] block in skill.toml → `github` deserialises to None,
// which the preflight reads as "gate disabled, skip silently".
let toml = "id = \"x\"\nwhen_to_use = \"y\"\n";
let parsed: SkillDefinition = toml::from_str(toml).expect("parse");
assert!(parsed.github.is_none(), "no [github] block ⇒ None");
}
#[test]
fn skill_github_config_parses_full_block() {
let toml = "id = \"x\"\nwhen_to_use = \"y\"\n\
[github]\nrequired = true\nidentity_match = \"strict\"\n";
let parsed: SkillDefinition = toml::from_str(toml).expect("parse");
let gh = parsed.github.expect("github block present");
assert!(gh.required);
assert_eq!(gh.identity_match, IdentityMatch::Strict);
}
#[test]
fn skill_github_config_required_defaults_to_false() {
// Block present but required not set ⇒ required = false (default).
let toml = "id = \"x\"\nwhen_to_use = \"y\"\n\
[github]\nidentity_match = \"any\"\n";
let parsed: SkillDefinition = toml::from_str(toml).expect("parse");
let gh = parsed.github.expect("github block present");
assert!(!gh.required, "required defaults to false");
assert_eq!(gh.identity_match, IdentityMatch::Any);
}
#[test]
fn skill_github_config_identity_match_defaults_to_strict() {
let toml = "id = \"x\"\nwhen_to_use = \"y\"\n\
[github]\nrequired = true\n";
let parsed: SkillDefinition = toml::from_str(toml).expect("parse");
let gh = parsed.github.expect("github block present");
assert_eq!(
gh.identity_match,
IdentityMatch::Strict,
"default is Strict"
);
}
#[test]
fn skill_github_config_accepts_all_identity_match_variants() {
for (variant, expected) in [
("strict", IdentityMatch::Strict),
("any", IdentityMatch::Any),
("none", IdentityMatch::None),
] {
let toml = format!(
"id = \"x\"\nwhen_to_use = \"y\"\n\
[github]\nrequired = true\nidentity_match = \"{variant}\"\n"
);
let parsed: SkillDefinition = toml::from_str(&toml).expect("parse");
assert_eq!(
parsed.github.expect("github block present").identity_match,
expected,
"variant {variant} → {expected:?}",
);
}
}
#[test]
fn skill_github_config_serializes_lowercase() {
let gh = SkillGithubConfig {
required: true,
identity_match: IdentityMatch::Strict,
};
let s = toml::to_string(&gh).expect("serialize");
assert!(s.contains("required = true"));
assert!(
s.contains("identity_match = \"strict\""),
"lowercase serialization: got {s}"
);
}
#[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:?}"),
}
}
}
+656
View File
@@ -0,0 +1,656 @@
//! Per-run streaming logs for `skills_run`.
//!
//! Each run writes a human-readable trace to
//! `<workspace>/skills/.runs/<skill>_<UTC-ts>_<run>.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* (`<workspace>/
//! skills/<id>/`) 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;
/// `<workspace>/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)
}
/// `<runs_dir>/<skill>_<UTC ts>_<short run id>.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::<String>())
} 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<String> {
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<AgentProgress>, path: PathBuf) {
while let Some(ev) = rx.recv().await {
if let Some(line) = format_event(&ev) {
let _ = append(&path, &line).await;
}
}
}
/// Detect the degenerate "model emitted the same paragraph many times in one
/// generation" final-response failure mode we keep seeing on autonomous runs
/// (e.g. `"Now I understand the structure..." × 23`, `"Good, the repo is
/// cloned. Let me narrow down..." × 8`). When this fires we don't want the
/// autonomous-skill path to mark the run `DONE` and have callers treat the
/// degenerate text as a real result — we want it surfaced as `DEGENERATE` with
/// the offending line attached, so the caller can retry / fail loud.
///
/// Splits on line boundaries (each repeat we've observed lands on its own
/// line or paragraph), trims, counts non-trivial lines (`>= min_len` chars),
/// and returns the most-repeated line if its count reaches `min_count`.
pub fn detect_repeated_line(
text: &str,
min_len: usize,
min_count: usize,
) -> Option<(String, usize)> {
use std::collections::HashMap;
let mut counts: HashMap<&str, usize> = HashMap::new();
for line in text.lines() {
let t = line.trim();
if t.len() >= min_len {
*counts.entry(t).or_insert(0) += 1;
}
}
counts
.into_iter()
.filter(|(_, c)| *c >= min_count)
.max_by_key(|(_, c)| *c)
.map(|(line, count)| (line.to_string(), count))
}
/// One run extracted from a `.runs/<skill>_<utc>_<run>.log` file. Built by
/// [`scan_runs`] for the `openhuman.skills_recent_runs` RPC + the Skills
/// Runner panel's "Recent runs" section. Status is `RUNNING` until the
/// footer block (`--- result ---` + `status: …` + `duration: … ms`) lands.
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
pub struct ScannedRun {
pub run_id: String,
pub skill_id: String,
/// Header `started:` timestamp (RFC3339); empty if header was malformed.
pub started: String,
/// `"DONE"` / `"DEGENERATE"` / `"FAILED"` / `"RUNNING"` (running ⇔ no footer yet).
pub status: String,
/// Footer `duration: <ms> ms`, parsed; `None` while running.
pub duration_ms: Option<u64>,
/// Footer `finished:` timestamp; `None` while running.
pub finished: Option<String>,
/// Absolute path to the streaming log file — what the FE shows for
/// "view full log" or future tail-streaming.
pub log_path: String,
}
/// Scan `<workspace>/skills/.runs/` for run-log files, parse their header +
/// footer, and return a vec sorted by `started` *descending* (most-recent
/// first). When `skill_id` is `Some(_)`, only entries whose header
/// `skill_id` matches are returned. `limit` caps the result (post-filter,
/// post-sort) so the panel can render a short list cheaply. Malformed
/// files are skipped silently — never blocks the response.
pub fn scan_runs(workspace: &Path, skill_id: Option<&str>, limit: usize) -> Vec<ScannedRun> {
let dir = runs_dir(workspace);
let mut runs: Vec<ScannedRun> = Vec::new();
let Ok(entries) = std::fs::read_dir(&dir) else {
return runs;
};
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.ends_with(".log") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let mut sid = String::new();
let mut rid = String::new();
let mut started = String::new();
let mut status = String::from("RUNNING");
let mut duration_ms: Option<u64> = None;
let mut finished: Option<String> = None;
let mut seen_result = false;
// The on-disk format from `write_header` / `write_footer` is
// label-then-colon-then-value, with the labels right-padded for
// visual alignment in the log — e.g. `status : DONE`,
// `duration: 617236 ms`, `run_id : <uuid>`. Splitting on the FIRST
// `:` and trimming both halves is robust to that padding without
// hand-tracking each label's exact whitespace.
for line in text.lines() {
if line.starts_with("==== skill_run:") {
// Header banner: `==== skill_run: <id> ====`
sid = line
.trim_start_matches("==== skill_run:")
.trim()
.trim_end_matches('=')
.trim()
.to_string();
continue;
}
if line.starts_with("--- result ---") {
seen_result = true;
continue;
}
let Some((label_raw, value_raw)) = line.split_once(':') else {
continue;
};
let label = label_raw.trim();
let value = value_raw.trim();
match (label, seen_result) {
// Header fields (before --- result ---)
("run_id", false) => rid = value.to_string(),
("started", false) => started = value.to_string(),
// Footer fields (after --- result ---)
("status", true) => status = value.to_string(),
("duration", true) => {
// Format: "<n> ms"
let num = value.trim_end_matches(" ms").trim();
if let Ok(n) = num.parse::<u64>() {
duration_ms = Some(n);
}
}
("finished", true) => {
finished = Some(value.trim_end_matches(" UTC").trim().to_string());
}
_ => {}
}
}
if sid.is_empty() || rid.is_empty() {
// Malformed header — skip rather than show a half-row.
continue;
}
if let Some(want) = skill_id {
if sid != want {
continue;
}
}
runs.push(ScannedRun {
run_id: rid,
skill_id: sid,
started,
status,
duration_ms,
finished,
log_path: path.to_string_lossy().into_owned(),
});
}
// Sort most-recent first by `started` (RFC3339 sorts lexicographically).
runs.sort_by(|a, b| b.started.cmp(&a.started));
runs.truncate(limit);
runs
}
/// Look up the on-disk log path for a given `run_id` by scanning the
/// `<workspace>/skills/.runs/` directory. Used by
/// `openhuman.skills_read_run_log` to resolve a stable id back to a path
/// without trusting the caller to send one (no path-traversal surface).
pub fn find_run_log_path(workspace: &Path, run_id: &str) -> Option<PathBuf> {
if run_id.is_empty() {
return None;
}
let dir = runs_dir(workspace);
let entries = std::fs::read_dir(&dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.ends_with(".log") {
continue;
}
// File names are `<skill>_<utc>_<run-id-prefix>.log`. The run-id
// prefix is the first 8 chars of the uuid (see
// `runs_dir`/`run_log_path` + `short` helper). Match against the
// prefix to avoid having to read the file's header.
let short = run_id.get(..8).unwrap_or(run_id);
if name.contains(&format!("_{short}.log")) {
return Some(path);
}
}
None
}
/// Read a slice of a run log file. Returns the bytes from `offset`
/// forward, capped at `max_bytes`, plus `eof` (true if we hit end-of-
/// file) and a flag indicating whether the `--- result ---` footer is
/// present in the file as a whole (so the FE can stop polling). Used by
/// `openhuman.skills_read_run_log` for the chat-style log viewer's
/// scroll + tail behaviour.
pub fn read_run_log_slice(
path: &Path,
offset: u64,
max_bytes: usize,
) -> std::io::Result<RunLogSlice> {
use std::io::{Read, Seek, SeekFrom};
let mut f = std::fs::File::open(path)?;
let file_size = f.metadata()?.len();
if offset >= file_size {
// No new bytes. Still return a (cheap) check for footer presence
// so the FE knows whether to keep polling.
let complete = file_has_footer(path)?;
return Ok(RunLogSlice {
offset,
bytes_read: 0,
content: String::new(),
eof: true,
complete,
});
}
f.seek(SeekFrom::Start(offset))?;
let want = ((file_size - offset).min(max_bytes as u64)) as usize;
let mut buf = vec![0u8; want];
f.read_exact(&mut buf)?;
let content = String::from_utf8_lossy(&buf).into_owned();
let bytes_read = buf.len() as u64;
let new_offset = offset + bytes_read;
let eof = new_offset >= file_size;
let complete = if eof {
// If we read to EOF, the slice itself tells us if the footer
// landed in our current chunk — otherwise re-scan from disk.
content.contains("\n--- result ---\n")
|| content.starts_with("--- result ---\n")
|| file_has_footer(path)?
} else {
// Mid-file read — cheap re-scan to know if we should keep polling.
file_has_footer(path)?
};
Ok(RunLogSlice {
offset: new_offset,
bytes_read,
content,
eof,
complete,
})
}
/// One slice of a run log file. `offset` is the *new* read cursor (the
/// FE uses it as the next call's `offset` so successive reads tail
/// cleanly). `complete` is true once the run footer landed — the FE can
/// then stop the polling timer.
#[derive(Debug, Clone, serde::Serialize)]
pub struct RunLogSlice {
pub offset: u64,
pub bytes_read: u64,
pub content: String,
pub eof: bool,
pub complete: bool,
}
/// Cheap check for whether `path` contains the `--- result ---` footer
/// anywhere. Reads the file once. Used to decide if the FE should keep
/// polling.
fn file_has_footer(path: &Path) -> std::io::Result<bool> {
let text = std::fs::read_to_string(path)?;
Ok(text.contains("\n--- result ---\n"))
}
/// 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 detect_repeated_line_catches_real_failure_modes() {
// The exact text shapes we observed in run adcd2dfd (×23) and
// dffae55d (×8). With defaults (min_len=30, min_count=4) both must
// trip and the worst offender is returned.
let adcd = std::iter::repeat(
"Now I understand the structure. The keys need to go into the chunk files.",
)
.take(23)
.collect::<Vec<_>>()
.join("\n");
let (line, n) = detect_repeated_line(&adcd, 30, 4).expect("must trip");
assert_eq!(n, 23);
assert!(line.contains("Now I understand the structure"));
let dffae = std::iter::repeat("Good, the repo is cloned. Let me narrow down the search.")
.take(8)
.collect::<Vec<_>>()
.join("\n");
let (_, n2) = detect_repeated_line(&dffae, 30, 4).expect("must trip");
assert_eq!(n2, 8);
}
#[test]
fn scan_runs_parses_header_footer_and_status() {
// Mirror the on-disk layout: <workspace>/skills/.runs/<file>.log
let tmp = tempfile::TempDir::new().expect("tempdir");
let runs = runs_dir(tmp.path());
std::fs::create_dir_all(&runs).unwrap();
// (a) finished run — full footer
let done = "==== skill_run: github-issue-crusher ====\n\
run_id : aaaaaaaa-1111-2222-3333-444444444444\n\
started: 2026-05-28T07:51:13.604134255+00:00 UTC\n\
inputs : {}\n\n\
--- task prompt ---\nfoo\n\
--- steps ---\nstep 1\n\
--- result ---\n\
status : DONE\n\
duration: 617236 ms\n\
finished: 2026-05-28T08:01:30.944918997+00:00 UTC\n\n\
body...\n";
std::fs::write(
runs.join("github-issue-crusher_20260528T075113Z_aaaaaaaa.log"),
done,
)
.unwrap();
// (b) still-running — no footer yet
let running = "==== skill_run: pr-review-shepherd ====\n\
run_id : bbbbbbbb-1111-2222-3333-444444444444\n\
started: 2026-05-28T09:00:00.000000000+00:00 UTC\n\
inputs : {}\n\n\
--- task prompt ---\nfoo\n\
--- steps ---\nstep 1\n";
std::fs::write(
runs.join("pr-review-shepherd_20260528T090000Z_bbbbbbbb.log"),
running,
)
.unwrap();
let all = scan_runs(tmp.path(), None, 10);
assert_eq!(all.len(), 2, "both runs visible");
// Newest first — (b) started later than (a).
assert_eq!(all[0].run_id, "bbbbbbbb-1111-2222-3333-444444444444");
assert_eq!(all[0].status, "RUNNING");
assert_eq!(all[0].duration_ms, None);
assert_eq!(all[1].status, "DONE");
assert_eq!(all[1].duration_ms, Some(617236));
assert!(all[1]
.finished
.as_deref()
.unwrap()
.starts_with("2026-05-28T08:01:30"));
// Filter by skill_id
let only_pr = scan_runs(tmp.path(), Some("pr-review-shepherd"), 10);
assert_eq!(only_pr.len(), 1);
assert_eq!(only_pr[0].skill_id, "pr-review-shepherd");
// Limit caps the result post-sort
let one = scan_runs(tmp.path(), None, 1);
assert_eq!(one.len(), 1);
assert_eq!(one[0].run_id, "bbbbbbbb-1111-2222-3333-444444444444");
}
#[test]
fn read_run_log_slice_pages_and_detects_footer_completion() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let runs = runs_dir(tmp.path());
std::fs::create_dir_all(&runs).unwrap();
// (a) Still-running file — no footer. read should return content
// with complete=false so the FE keeps polling.
let running = "==== skill_run: pr-review-shepherd ====\n\
run_id : 11111111-aaaa-bbbb-cccc-dddddddddddd\n\
started: 2026-05-28T09:00:00.000000000+00:00 UTC\n\n\
--- task prompt ---\nfoo\n\
--- steps ---\nstep 1\nstep 2\n";
std::fs::write(
runs.join("pr-review-shepherd_20260528T090000Z_11111111.log"),
running,
)
.unwrap();
let path = find_run_log_path(tmp.path(), "11111111-aaaa-bbbb-cccc-dddddddddddd")
.expect("must find log by run id");
let s1 = read_run_log_slice(&path, 0, 1024).expect("read ok");
assert!(s1.bytes_read > 0);
assert!(s1.eof, "small file fits in one read");
assert!(!s1.complete, "no footer ⇒ keep polling");
assert!(s1.content.contains("step 2"));
// Second call from the cursor returns zero bytes + still incomplete.
let s2 = read_run_log_slice(&path, s1.offset, 1024).expect("tail ok");
assert_eq!(s2.bytes_read, 0);
assert!(s2.eof);
assert!(!s2.complete);
// (b) Append the footer — next read should flip complete=true.
let mut more = String::new();
more.push_str("\n--- result ---\n");
more.push_str("status : DONE\nduration: 1234 ms\nfinished: 2026-05-28T09:00:01.000000000+00:00 UTC\n\nfinal output here\n");
let full = format!("{running}{more}");
std::fs::write(&path, &full).unwrap();
let s3 = read_run_log_slice(&path, s1.offset, 4096).expect("read tail ok");
assert!(s3.bytes_read > 0);
assert!(s3.complete, "footer landed ⇒ FE stops polling");
assert!(s3.content.contains("status : DONE"));
}
#[test]
fn find_run_log_path_returns_none_for_unknown_id() {
let tmp = tempfile::TempDir::new().expect("tempdir");
std::fs::create_dir_all(runs_dir(tmp.path())).unwrap();
assert!(find_run_log_path(tmp.path(), "ffffffff-no-such-id").is_none());
// Empty id is always None — handler rejects later for clarity.
assert!(find_run_log_path(tmp.path(), "").is_none());
}
#[test]
fn scan_runs_skips_malformed_files() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let runs = runs_dir(tmp.path());
std::fs::create_dir_all(&runs).unwrap();
// Empty header — no `==== skill_run: ` line ⇒ skip silently.
std::fs::write(runs.join("garbage_x_y.log"), "hi i'm not a run log\n").unwrap();
let scanned = scan_runs(tmp.path(), None, 10);
assert!(scanned.is_empty(), "malformed files must be skipped");
}
#[test]
fn detect_repeated_line_does_not_false_positive_on_legitimate_output() {
// Normal prose with each sentence on its own line and no repeats
// should not trip. Also short lines (`OK`, `Done`) under min_len
// must be ignored even when repeated, so a verbose log of "OK"
// markers doesn't look like degeneracy.
let prose = "First, I read the issue and identified the failing test.\n\
Then I edited src/foo.rs to add a None-guard around the dereference.\n\
Finally I ran cargo test -p foo and confirmed the fix.\n\
OK\nOK\nOK\nOK\nOK\nOK\nOK\nOK";
assert!(detect_repeated_line(prose, 30, 4).is_none());
}
#[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"));
}
}
+600 -1
View File
@@ -28,10 +28,19 @@ use crate::openhuman::config::Config;
use crate::openhuman::skills::ops::{
create_skill, discover_skills, install_skill_from_url, is_workspace_trusted,
read_skill_resource, uninstall_skill, CreateSkillParams, InstallSkillFromUrlParams, Skill,
SkillScope, UninstallSkillParams,
SkillCreateInputDef, SkillScope, UninstallSkillParams,
};
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::{preflight, 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,
@@ -58,6 +67,14 @@ struct SkillsCreateParams {
tags: Vec<String>,
#[serde(default, rename = "allowed-tools", alias = "allowed_tools")]
allowed_tools: Vec<String>,
/// Declared `[[inputs]]` entries supplied by the Create-a-Skill form.
/// Empty when the user added no rows; otherwise written into a sibling
/// `skill.toml` alongside `SKILL.md` so the Skills Runner can render
/// dynamic form controls at run time. Wire-shape per row:
/// `{ name, description?, required, type? }` — see
/// [`SkillCreateInputDef`] in `ops_create.rs`.
#[serde(default)]
inputs: Vec<SkillCreateInputDef>,
}
impl From<SkillsCreateParams> for CreateSkillParams {
@@ -70,6 +87,7 @@ impl From<SkillsCreateParams> for CreateSkillParams {
author: p.author,
tags: p.tags,
allowed_tools: p.allowed_tools,
inputs: p.inputs,
}
}
}
@@ -180,10 +198,14 @@ struct SkillsUninstallResult {
pub fn all_skills_controller_schemas() -> Vec<ControllerSchema> {
vec![
skills_schemas("skills_list"),
skills_schemas("skills_describe"),
skills_schemas("skills_recent_runs"),
skills_schemas("skills_read_run_log"),
skills_schemas("skills_read_resource"),
skills_schemas("skills_create"),
skills_schemas("skills_install_from_url"),
skills_schemas("skills_uninstall"),
skills_schemas("skills_run"),
]
}
@@ -193,6 +215,18 @@ pub fn all_skills_registered_controllers() -> Vec<RegisteredController> {
schema: skills_schemas("skills_list"),
handler: handle_skills_list,
},
RegisteredController {
schema: skills_schemas("skills_describe"),
handler: handle_skills_describe,
},
RegisteredController {
schema: skills_schemas("skills_recent_runs"),
handler: handle_skills_recent_runs,
},
RegisteredController {
schema: skills_schemas("skills_read_run_log"),
handler: handle_skills_read_run_log,
},
RegisteredController {
schema: skills_schemas("skills_read_resource"),
handler: handle_skills_read_resource,
@@ -209,6 +243,10 @@ pub fn all_skills_registered_controllers() -> Vec<RegisteredController> {
schema: skills_schemas("skills_uninstall"),
handler: handle_skills_uninstall,
},
RegisteredController {
schema: skills_schemas("skills_run"),
handler: handle_skills_run,
},
]
}
@@ -226,6 +264,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 (<workspace>/skills/.runs/<skill>_<ts>.log).",
required: true,
},
],
},
"skills_read_resource" => ControllerSchema {
namespace: "skills",
function: "read_resource",
@@ -318,6 +401,12 @@ pub fn skills_schemas(function: &str) -> ControllerSchema {
comment: "Optional tool hints (maps to frontmatter.allowed-tools).",
required: false,
},
FieldSchema {
name: "inputs",
ty: TypeSchema::Json,
comment: "Optional declared `[[inputs]]` entries (each `{ name, description, required, type }`). When non-empty, a sibling `skill.toml` is written alongside `SKILL.md` so the Skills Runner can render dynamic form controls at run time.",
required: false,
},
],
outputs: vec![FieldSchema {
name: "skill",
@@ -371,6 +460,130 @@ pub fn skills_schemas(function: &str) -> ControllerSchema {
},
],
},
"skills_read_run_log" => ControllerSchema {
namespace: "skills",
function: "read_run_log",
description: "Read a slice of a skill run's streaming log file by run_id. The FE Skills Runner panel opens this on click of a Recent Runs row and re-calls it every 2s while the run's `status` is RUNNING to tail new bytes (use the returned `offset` as the next call's `offset`). The run id resolves to a path internally — callers don't supply a path, so no traversal surface. `max_bytes` is clamped to 262144 (256 KiB) per call; pages by re-issuing with the returned `offset`.",
inputs: vec![
FieldSchema {
name: "run_id",
ty: TypeSchema::String,
comment: "Run id from `skills_recent_runs.runs[].run_id` (matched by 8-char prefix against the log filename).",
required: true,
},
FieldSchema {
name: "offset",
ty: TypeSchema::U64,
comment: "Byte offset to start reading from. Default 0 (read from start); the FE passes the previous response's `offset` for tail-mode polling.",
required: false,
},
FieldSchema {
name: "max_bytes",
ty: TypeSchema::U64,
comment: "Max bytes to return in this slice. Default 65536 (64 KiB), capped at 262144 (256 KiB).",
required: false,
},
],
outputs: vec![
FieldSchema {
name: "offset",
ty: TypeSchema::U64,
comment: "New read cursor — pass this as the next call's `offset` to tail forward.",
required: true,
},
FieldSchema {
name: "bytes_read",
ty: TypeSchema::U64,
comment: "Number of bytes returned in this slice.",
required: true,
},
FieldSchema {
name: "content",
ty: TypeSchema::String,
comment: "The slice contents (UTF-8, lossy-decoded so a partial multibyte tail doesn't error).",
required: true,
},
FieldSchema {
name: "eof",
ty: TypeSchema::Bool,
comment: "True if the read reached end-of-file. May still be FALSE-complete (run still streaming).",
required: true,
},
FieldSchema {
name: "complete",
ty: TypeSchema::Bool,
comment: "True once the run footer (`--- result ---`) has landed in the file. The FE stops polling when this flips true.",
required: true,
},
],
},
"skills_recent_runs" => ControllerSchema {
namespace: "skills",
function: "recent_runs",
description: "List recent autonomous skill runs by scanning `<workspace>/skills/.runs/`. Returns one entry per log file (header: skill_id, run_id, started; footer: status, duration_ms, finished) sorted by `started` descending. `status` is `RUNNING` while the footer hasn't landed yet, then `DONE` / `DEGENERATE` / `FAILED`. Optionally filter by `skill_id` to scope to one skill; `limit` (default 20, max 100) caps the result. Cheap: reads the files top-to-bottom and short-circuits — no schema parsing of the streaming body.",
inputs: vec![
FieldSchema {
name: "skill_id",
ty: TypeSchema::String,
comment: "Optional: restrict results to runs of one skill (e.g. \"github-issue-crusher\"). Omit to return runs across every skill.",
required: false,
},
FieldSchema {
name: "limit",
ty: TypeSchema::U64,
comment: "Cap on the number of entries returned. Default 20, clamped to 100.",
required: false,
},
],
outputs: vec![FieldSchema {
name: "runs",
ty: TypeSchema::Json,
comment: "Array of `{ run_id, skill_id, started, status, duration_ms, finished, log_path }` — see crate::openhuman::skills::run_log::ScannedRun.",
required: true,
}],
},
"skills_describe" => ControllerSchema {
namespace: "skills",
function: "describe",
description: "Describe a single skill by id — returns its display name, summary, and the declared `[[inputs]]` block. Used by the Settings → Skills Runner panel to render dynamic input controls and let the user fill in the right fields before clicking Run Now or scheduling a cron. `skills_list` does NOT carry `inputs` (it stays the lightweight enumeration); call this once per skill the user picks.",
inputs: vec![FieldSchema {
name: "skill_id",
ty: TypeSchema::String,
comment: "Skill id from `skills_list` (e.g. \"github-issue-crusher\", \"pr-review-shepherd\", \"dev-workflow\").",
required: true,
}],
outputs: vec![
FieldSchema {
name: "id",
ty: TypeSchema::String,
comment: "Echo of the resolved skill id.",
required: true,
},
FieldSchema {
name: "display_name",
ty: TypeSchema::String,
comment: "Human-friendly display name (falls back to the id when unset).",
required: true,
},
FieldSchema {
name: "when_to_use",
ty: TypeSchema::String,
comment: "Short one-line summary from skill.toml `when_to_use` — what the skill does and when to pick it.",
required: true,
},
// Wire shape: array of objects. `handle_skills_describe`
// serialises this as a real array of `SkillInputDescription`
// objects — `{name, description, required, type}` per entry —
// so the controller-catalog type is `Json`, matching the
// payload rather than coercing it to a scalar string.
FieldSchema {
name: "inputs",
ty: TypeSchema::Json,
comment: "Array of `[[inputs]]` entries; each entry: `{ name, description, required, type }`. Renderable as a dynamic form.",
required: true,
},
],
},
"skills_uninstall" => ControllerSchema {
namespace: "skills",
function: "uninstall",
@@ -439,6 +652,392 @@ fn handle_skills_list(params: Map<String, Value>) -> ControllerFuture {
})
}
#[derive(serde::Deserialize)]
struct SkillsDescribeParams {
skill_id: String,
}
/// One input declaration as serialised over the wire to the FE form
/// renderer. Mirrors `registry::SkillInput` but with a fully-explicit
/// `type` field (the FE renders different controls per kind) and stable
/// JSON keys regardless of frontmatter casing.
#[derive(serde::Serialize)]
struct SkillInputDescription {
name: String,
description: String,
required: bool,
#[serde(rename = "type")]
kind: String,
}
#[derive(serde::Serialize)]
struct SkillsDescribeResult {
id: String,
display_name: String,
when_to_use: String,
inputs: Vec<SkillInputDescription>,
}
/// `openhuman.skills_describe` — return a single skill's display metadata
/// and its declared `[[inputs]]` so the Skills Runner panel can render
/// the right form controls. `skills_list` deliberately stays the cheap
/// enumeration without input declarations (its `Skill` source struct
/// predates `[[inputs]]`); on the user picking one we fetch the full
/// `SkillDefinition` (which carries inputs) and project the small,
/// FE-shaped subset they need.
fn handle_skills_describe(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = deserialize_params::<SkillsDescribeParams>(params)?;
let workspace = resolve_workspace_dir().await;
let skill = registry::get_skill(&workspace, &payload.skill_id)
.ok_or_else(|| format!("skills_describe: unknown skill '{}'", payload.skill_id))?;
let inputs = skill
.inputs
.iter()
.map(|i| SkillInputDescription {
name: i.name.clone(),
description: i.description.clone(),
required: i.required,
kind: i.kind.clone().unwrap_or_else(|| "string".to_string()),
})
.collect();
let display_name = skill
.definition
.display_name
.clone()
.unwrap_or_else(|| skill.definition.id.clone());
to_json(RpcOutcome::new(
SkillsDescribeResult {
id: skill.definition.id.clone(),
display_name,
when_to_use: skill.definition.when_to_use.clone(),
inputs,
},
Vec::new(),
))
})
}
#[derive(serde::Deserialize)]
struct SkillsReadRunLogParams {
run_id: String,
#[serde(default)]
offset: Option<u64>,
#[serde(default)]
max_bytes: Option<u64>,
}
/// `openhuman.skills_read_run_log` — return a slice of a skill run's
/// log file, identified by `run_id` (NOT a path — no traversal surface).
/// FE Skills Runner panel uses this to render the streaming log inline
/// when the user clicks a Recent Runs row, and tails it every 2s while
/// `complete` is false.
fn handle_skills_read_run_log(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = deserialize_params::<SkillsReadRunLogParams>(params)?;
let workspace = resolve_workspace_dir().await;
let path = run_log::find_run_log_path(&workspace, &payload.run_id)
.ok_or_else(|| format!("skills_read_run_log: unknown run_id '{}'", payload.run_id))?;
let offset = payload.offset.unwrap_or(0);
// 64 KiB default per-call slice, hard cap at 256 KiB to keep the
// RPC response sane; the FE re-issues with the returned offset
// to page through larger logs.
let max_bytes = payload.max_bytes.unwrap_or(64 * 1024).min(256 * 1024) as usize;
match run_log::read_run_log_slice(&path, offset, max_bytes) {
Ok(slice) => to_json(RpcOutcome::new(slice, Vec::new())),
Err(e) => Err(format!("skills_read_run_log: read failed: {e}")),
}
})
}
#[derive(serde::Deserialize)]
struct SkillsRecentRunsParams {
#[serde(default)]
skill_id: Option<String>,
#[serde(default)]
limit: Option<u32>,
}
#[derive(serde::Serialize)]
struct SkillsRecentRunsResult {
runs: Vec<run_log::ScannedRun>,
}
/// `openhuman.skills_recent_runs` — list runs from `<workspace>/skills/.runs/`
/// (most-recent first), optionally filtered to one skill, capped by `limit`.
/// Powers the Skills Runner panel's "Recent runs" section + future live-log
/// tail. Delegates the actual scan + parse to `run_log::scan_runs`.
fn handle_skills_recent_runs(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = deserialize_params::<SkillsRecentRunsParams>(params)?;
let limit = payload.limit.unwrap_or(20).min(100) as usize;
let workspace = resolve_workspace_dir().await;
let runs = run_log::scan_runs(&workspace, payload.skill_id.as_deref(), limit);
tracing::debug!(
count = runs.len(),
filter = ?payload.skill_id,
limit,
"[skills][rpc] recent_runs"
);
to_json(RpcOutcome::new(SkillsRecentRunsResult { runs }, Vec::new()))
})
}
#[derive(serde::Deserialize)]
struct SkillsRunParams {
skill_id: String,
#[serde(default)]
inputs: Option<Value>,
}
/// Outcome of [`spawn_skill_run_background`]: the new run's `run_id`, the
/// canonical `skill_id` the registry resolved it to, and the path of the
/// streaming log file every step + the footer get written to.
pub(crate) struct SkillRunStarted {
pub run_id: String,
pub skill_id: String,
pub log_path: std::path::PathBuf,
}
/// Spawn a single autonomous skill_run as a detached `tokio::spawn`. Used by
/// both the `openhuman.skills_run` JSON-RPC controller and the `run_skill`
/// agent tool (which lets the orchestrator chain one skill into another —
/// e.g. `github-issue-crusher` → `pr-review-shepherd` once the draft PR is
/// open).
///
/// Returns immediately with the run handle; the actual work runs in the
/// background until DONE / DEGENERATE / FAILED. Errors (unknown skill,
/// missing required inputs) surface as `Err(String)` *before* the spawn so
/// callers can reject malformed invocations synchronously.
pub(crate) async fn spawn_skill_run_background(
skill_id_param: String,
inputs_param: Option<Value>,
) -> Result<SkillRunStarted, String> {
let workspace = resolve_workspace_dir().await;
let skill = registry::get_skill(&workspace, &skill_id_param)
.ok_or_else(|| format!("skill_run: unknown skill '{skill_id_param}'"))?;
let inputs = inputs_param.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(", ")
));
}
// ── Preflight gates ─────────────────────────────────────────────
// Run BEFORE the orchestrator is built so failures surface
// synchronously to the caller (skills_run RPC or the run_skill
// agent tool) instead of leaking through as cryptic orchestrator
// output. Today only the [github] gate exists; future gates can
// chain here.
if let Some(github_cfg) = skill.github.as_ref() {
let config_snapshot = match Config::load_or_init().await {
Ok(c) => c,
Err(e) => {
return Err(format!(
"skill_run preflight: failed to load config to gate `{}`: {e:#}",
skill.definition.id
));
}
};
let probes = preflight::LivePreflightProbes::new(&config_snapshot);
if let Err(gate_err) = preflight::run_github_preflight(Some(github_cfg), &probes).await {
let tag = gate_err.tag();
// Materialise a run-log entry on disk so the gate failure
// shows up in `<workspace>/skills/.runs/` (and therefore
// in the FE's "Recent runs" list / log viewer) even though
// the orchestrator never booted. We write a header then a
// matching FAILED footer so `scan_runs` parses it cleanly.
let gate_run_id = uuid::Uuid::new_v4().to_string();
let gate_log_path =
run_log::run_log_path(&workspace, &skill.definition.id, &gate_run_id);
let body = gate_err.to_user_message(Some(&gate_log_path.display().to_string()));
let header_prompt = format!(
"preflight gate: github\n\
gate decision: FAILED ({tag})\n\
detail: {body}"
);
if let Err(e) = run_log::write_header(
&gate_log_path,
&skill.definition.id,
&gate_run_id,
&inputs,
&header_prompt,
)
.await
{
tracing::warn!(
error = %e,
"[skills] preflight gate: failed to write run-log header"
);
}
if let Err(e) = run_log::write_footer(&gate_log_path, "FAILED", 0, &body).await {
tracing::warn!(
error = %e,
"[skills] preflight gate: failed to write run-log footer"
);
}
tracing::warn!(
skill_id = %skill.definition.id,
gate = "github",
tag = %tag,
gate_log = %gate_log_path.display(),
"[skills] spawn_skill_run_background: preflight gate failed"
);
return Err(format!("[preflight:github:{tag}] {body}"));
}
tracing::info!(
skill_id = %skill.definition.id,
"[skills] spawn_skill_run_background: github preflight passed"
);
}
// 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 guidelines = match &skill.definition.system_prompt {
crate::openhuman::agent::harness::definition::PromptSource::Inline(s) => s.clone(),
_ => String::new(),
};
let inputs_block = registry::render_inputs_block(&skill.inputs, &inputs);
let skill_id = skill.definition.id.clone();
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] spawn_skill_run_background: starting orchestrator run"
);
// Detached: build the orchestrator Agent inside the spawn so config /
// toolchain are loaded fresh per run; the parent returns the handle
// immediately. Same flow handle_skills_run used to inline — extracted
// so the `run_skill` agent tool can re-use it for skill chaining.
{
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] 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;
}
};
config.agent.max_tool_iterations = SKILL_RUN_MAX_ITERATIONS;
// Only apply the permissive wildcard default when the operator
// hasn't configured an explicit allow-list — preserve any
// configured egress policy instead of unconditionally widening it.
if config.http_request.allowed_domains.is_empty() {
config.http_request.allowed_domains = vec!["*".to_string()];
}
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");
agent.set_agent_definition_name(format!(
"orchestrator-skill-{}",
&run_id.get(..8).unwrap_or(&run_id)
));
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();
let result =
with_autonomous_iter_cap(SKILL_RUN_MAX_ITERATIONS, agent.run_single(&task_prompt))
.await;
agent.set_on_progress(None);
drop(agent);
let _ = bridge.await;
let ms = started.elapsed().as_millis() as u64;
match result {
Ok(out) => {
if let Some((line, count)) = run_log::detect_repeated_line(&out, 30, 4) {
let preview = line.chars().take(160).collect::<String>();
let body = format!(
"degenerate-response: autonomous run halted before marking DONE.\n\
the model's final assistant message repeats the same line {count}× \
this is the known one-generation low-entropy loop failure mode, not a real result.\n\n\
repeated line (truncated to 160 chars):\n {preview}\n\n\
full final output follows below for forensic review:\n\n{out}",
);
let _ = run_log::write_footer(&log_path, "DEGENERATE", ms, &body).await;
tracing::warn!(
run_id = %run_id,
repeats = count,
"[skills] skill_run: degenerate final response rejected"
);
} else {
let _ = run_log::write_footer(&log_path, "DONE", ms, &out).await;
tracing::info!(run_id = %run_id, "[skills] 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] skill_run: failed");
}
}
});
}
Ok(SkillRunStarted {
run_id,
skill_id,
log_path,
})
}
fn handle_skills_run(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = deserialize_params::<SkillsRunParams>(params)?;
let started = match spawn_skill_run_background(payload.skill_id, payload.inputs).await {
Ok(s) => s,
Err(e) => return Err(e),
};
to_json(RpcOutcome::new(
serde_json::json!({
"run_id": started.run_id,
"status": "started",
"skill_id": started.skill_id,
"log": started.log_path.display().to_string(),
}),
Vec::new(),
))
})
}
fn handle_skills_read_resource(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = deserialize_params::<SkillsReadResourceParams>(params)?;
+2
View File
@@ -4,6 +4,7 @@ mod delegate;
mod dispatch;
mod plan_exit;
pub mod remember_preference;
mod run_skill;
pub mod save_preference;
mod skill_delegation;
mod spawn_parallel_agents;
@@ -18,6 +19,7 @@ pub use ask_clarification::AskClarificationTool;
pub use delegate::DelegateTool;
pub use plan_exit::{PlanExitTool, PLAN_EXIT_MARKER};
pub use remember_preference::RememberPreferenceTool;
pub use run_skill::{RunSkillTool, RUN_SKILL_TOOL_NAME};
pub use save_preference::SavePreferenceTool;
pub use skill_delegation::SkillDelegationTool;
pub use spawn_parallel_agents::SpawnParallelAgentsTool;
+166
View File
@@ -0,0 +1,166 @@
//! Tool: `run_skill` — let the orchestrator kick off another bundled
//! `skill_run` as a fresh autonomous background job.
//!
//! Use case: skill chaining. `github-issue-crusher` opens a draft PR at
//! step 9, then at step 10 it calls `run_skill` with `skill_id =
//! "pr-review-shepherd"` and `pr = <number>` so the shepherd takes over
//! the Phase-6 (CI + review) loop. The issue-crusher returns immediately
//! with the shepherd's `run_id` + log path; the two runs are independent
//! background tokio tasks with their own logs and their own autonomous
//! iter caps, so the issue-crusher exits cleanly while the shepherd keeps
//! driving the PR to mergeable.
//!
//! Implementation simply delegates to
//! `crate::openhuman::skills::schemas::spawn_skill_run_background` — the
//! same helper `openhuman.skills_run` JSON-RPC uses. Errors before the
//! spawn (unknown skill, missing required inputs) come back to the
//! orchestrator as a normal `ToolResult::error` so the model can correct
//! and retry. After the spawn succeeds the tool returns a small JSON
//! object with `run_id`, `skill_id`, and `log` for the orchestrator to
//! surface in its final response.
use async_trait::async_trait;
use serde_json::json;
use crate::openhuman::skills::schemas::spawn_skill_run_background;
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
/// Tool name surfaced to the LLM's function-calling schema.
pub const RUN_SKILL_TOOL_NAME: &str = "run_skill";
/// `run_skill` agent tool — orchestrator-callable spawn of another bundled
/// skill_run.
pub struct RunSkillTool;
impl Default for RunSkillTool {
fn default() -> Self {
Self::new()
}
}
impl RunSkillTool {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Tool for RunSkillTool {
fn name(&self) -> &str {
RUN_SKILL_TOOL_NAME
}
fn description(&self) -> &str {
"Spawn another bundled skill as a fresh autonomous background run. \
Fire-and-forget: returns immediately with the new run's `run_id` and \
streaming `log` path; the spawned run continues independently to DONE \
/ DEGENERATE / FAILED. \
Use this to chain skills together for example, after \
`github-issue-crusher` opens a draft PR, call `run_skill` with \
`skill_id: \"pr-review-shepherd\"` and the new PR number so the \
shepherd takes over the CI + review loop while the crusher exits \
cleanly. Arguments mirror the `openhuman.skills_run` JSON-RPC: \
`skill_id` (string, required) names a skill from `skills_list`; \
`inputs` (object, required) is the same input map that skill would \
take via the RPC. Errors (unknown skill, missing required inputs) \
come back synchronously so you can fix and retry without spawning \
anything."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"skill_id": {
"type": "string",
"description": "Id of the bundled skill to spawn (must \
appear in `skills_list`)."
},
"inputs": {
"type": "object",
"description": "Input object passed to the spawned skill, \
same shape as the `inputs` field of \
`openhuman.skills_run`. Required keys are \
declared by the target skill's [[inputs]] \
block."
}
},
"required": ["skill_id", "inputs"]
})
}
fn permission_level(&self) -> PermissionLevel {
// Spawning another autonomous skill_run carries the same blast radius
// as the parent skill_run that's calling it (background tokio task,
// no approval gate). The parent is already inside an autonomous
// context, so promoting `run_skill` past the gate would be
// double-counting — keep it at None (no extra prompt) and let the
// target skill's SKILL.md govern what its run is allowed to do.
PermissionLevel::None
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let skill_id = match args.get("skill_id").and_then(|v| v.as_str()) {
Some(s) if !s.trim().is_empty() => s.to_string(),
_ => {
return Ok(ToolResult::error(
"run_skill: missing required argument `skill_id` (non-empty string)",
));
}
};
let inputs = args.get("inputs").cloned();
tracing::debug!(skill_id = %skill_id, "[run_skill] dispatching spawn_skill_run_background");
match spawn_skill_run_background(skill_id.clone(), inputs).await {
Ok(started) => {
tracing::debug!(
skill_id = %started.skill_id,
run_id = %started.run_id,
"[run_skill] spawn succeeded"
);
Ok(ToolResult::success(
serde_json::json!({
"run_id": started.run_id,
"status": "started",
"skill_id": started.skill_id,
"log": started.log_path.display().to_string(),
})
.to_string(),
))
}
Err(e) => {
tracing::debug!(skill_id = %skill_id, error = %e, "[run_skill] spawn failed");
Ok(ToolResult::error(format!("run_skill: {e}")))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_and_schema_basics() {
let t = RunSkillTool::new();
assert_eq!(t.name(), "run_skill");
let schema = t.parameters_schema();
let required = schema
.get("required")
.and_then(|v| v.as_array())
.expect("required array");
assert!(required.iter().any(|v| v.as_str() == Some("skill_id")));
assert!(required.iter().any(|v| v.as_str() == Some("inputs")));
}
#[tokio::test]
async fn missing_skill_id_returns_tool_error_not_panic() {
let t = RunSkillTool::new();
let res = t
.execute(serde_json::json!({"inputs": {}}))
.await
.expect("Ok(ToolResult)");
assert!(res.is_error, "expected ToolResult::error");
assert!(res.output().contains("skill_id"));
}
}
+331
View File
@@ -0,0 +1,331 @@
//! 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, PathBuf};
use std::sync::Arc;
use anyhow::{anyhow, Context};
use async_trait::async_trait;
use serde_json::Value;
use tracing::debug;
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())
}
/// Resolve a caller-provided `path` against the workspace, refusing anything
/// outside it. Both the requested path and the workspace are canonicalized
/// (resolving `..`/symlinks), then `repo_dir` must live under the canonical
/// workspace — otherwise an agent could index/search arbitrary repos on disk.
fn resolve_repo_dir(path: &str, workspace_dir: &Path) -> anyhow::Result<PathBuf> {
let repo_dir = std::fs::canonicalize(path)
.with_context(|| format!("codegraph: cannot resolve repo path `{path}`"))?;
let workspace = std::fs::canonicalize(workspace_dir).with_context(|| {
format!(
"codegraph: cannot resolve workspace dir `{}`",
workspace_dir.display()
)
})?;
if !repo_dir.starts_with(&workspace) {
return Err(anyhow!(
"codegraph: repo path `{}` is outside the workspace `{}`",
repo_dir.display(),
workspace.display()
));
}
Ok(repo_dir)
}
/// `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<Config>,
workspace_dir: std::path::PathBuf,
}
impl CodegraphIndexTool {
pub fn new(config: Arc<Config>, 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<ToolResult> {
let path = match arg_str(&args, "path") {
Some(p) => p,
None => {
debug!("[codegraph:index] missing `path` arg; returning error");
return Ok(ToolResult::error(
"codegraph_index: `path` (repo working dir) is required",
));
}
};
let repo_dir = resolve_repo_dir(path, &self.workspace_dir)?;
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 rid = repo_id(&repo_dir);
debug!(
repo = %rid,
git_ref = %git_ref,
?mode,
"[codegraph:index] resolved mode; indexing"
);
let provider = match embeddings::provider_from_config(&self.config) {
Ok(p) => p,
Err(e) => {
debug!(repo = %rid, error = %e, "[codegraph:index] provider error");
return Err(e);
}
};
let mut store = match CodegraphStore::open(&codegraph_db(&self.workspace_dir)) {
Ok(s) => s,
Err(e) => {
debug!(repo = %rid, error = %e, "[codegraph:index] store open error");
return Err(e);
}
};
let report = match index_ref(
&mut store,
&rid,
&repo_dir,
Some(&git_ref),
&*provider,
mode,
)
.await
{
Ok(r) => r,
Err(e) => {
debug!(repo = %rid, git_ref = %git_ref, error = %e, "[codegraph:index] index_ref failed");
return Err(e);
}
};
debug!(
repo = %rid,
git_ref = %git_ref,
?mode,
files = report.files,
computed = report.computed,
cached = report.cached,
skipped = report.skipped,
"[codegraph:index] success"
);
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<Config>,
workspace_dir: std::path::PathBuf,
}
impl CodegraphSearchTool {
pub fn new(config: Arc<Config>, 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<ToolResult> {
let query = match arg_str(&args, "query") {
Some(q) => q,
None => {
debug!("[codegraph:search] missing `query` arg; returning error");
return Ok(ToolResult::error("codegraph_search: `query` is required"));
}
};
let path = match arg_str(&args, "path") {
Some(p) => p,
None => {
debug!("[codegraph:search] missing `path` arg; returning error");
return Ok(ToolResult::error(
"codegraph_search: `path` (repo working dir) is required",
));
}
};
let repo_dir = resolve_repo_dir(path, &self.workspace_dir)?;
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 rid = repo_id(&repo_dir);
let provider = match embeddings::provider_from_config(&self.config) {
Ok(p) => p,
Err(e) => {
debug!(repo = %rid, error = %e, "[codegraph:search] provider error");
return Err(e);
}
};
let mut store = match CodegraphStore::open(&codegraph_db(&self.workspace_dir)) {
Ok(s) => s,
Err(e) => {
debug!(repo = %rid, error = %e, "[codegraph:search] store open error");
return Err(e);
}
};
// 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);
debug!(
repo = %rid,
git_ref = %git_ref,
?mode,
"[codegraph:search] no manifest; auto-indexing before search"
);
if let Err(e) = index_ref(
&mut store,
&rid,
&repo_dir,
Some(&git_ref),
&*provider,
mode,
)
.await
{
debug!(repo = %rid, git_ref = %git_ref, error = %e, "[codegraph:search] auto-index failed");
return Err(e);
}
}
let outcome = match search_ref(&mut store, &rid, &git_ref, query, &*provider, k).await {
Ok(o) => o,
Err(e) => {
debug!(repo = %rid, git_ref = %git_ref, error = %e, "[codegraph:search] search_ref failed");
return Err(e);
}
};
debug!(
repo = %rid,
git_ref = %git_ref,
coverage = ?outcome.coverage,
indexed = outcome.indexed,
total = outcome.total,
hits = outcome.hits.len(),
"[codegraph:search] success"
);
Ok(ToolResult::success(serde_json::to_string_pretty(&outcome)?))
}
}
+2
View File
@@ -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::*;
@@ -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}");
}
}
+16
View File
@@ -157,7 +157,23 @@ pub fn all_tools_with_runtime(
// follow-up; the tool emits a stable marker today.
Box::new(TodoTool::new()),
Box::new(PlanExitTool::new()),
// Skill chaining: let an in-flight autonomous skill (e.g.
// `github-issue-crusher`) kick off another bundled skill_run as a
// fresh background job (e.g. `pr-review-shepherd` against the PR it
// just opened) so each skill stays narrow + composable. Thin
// wrapper over `skills::schemas::spawn_skill_run_background` — the
// same helper `openhuman.skills_run` JSON-RPC uses, so RPC callers
// and tool callers share one spawn path.
Box::new(RunSkillTool::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())),