mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
sanil-23
Claude Opus 4.7
sanil-23
cyrus@tinyhumans.ai <cyrus@tinyhumans.ai>
Steven Enamakel
parent
a41d9132ba
commit
db3fdc2e6c
+5
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 ?? '');
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 isn’t 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;
|
||||
|
||||
@@ -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 isn’t 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;
|
||||
|
||||
@@ -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 isn’t 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;
|
||||
|
||||
@@ -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 isn’t 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;
|
||||
|
||||
@@ -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 isn’t 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;
|
||||
|
||||
@@ -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 isn’t 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;
|
||||
|
||||
@@ -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 isn’t 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;
|
||||
|
||||
@@ -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 isn’t 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;
|
||||
|
||||
@@ -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 isn’t 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;
|
||||
|
||||
@@ -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 isn’t 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;
|
||||
|
||||
@@ -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 isn’t 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;
|
||||
|
||||
@@ -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 isn’t 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;
|
||||
|
||||
@@ -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 isn’t 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;
|
||||
|
||||
@@ -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 isn’t 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
@@ -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 isn’t 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;
|
||||
|
||||
@@ -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 />)}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
@@ -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** (~30–90s 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 2–3 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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¶ms, "schedule")?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let command = params
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let prompt = params
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let session_target_str = params
|
||||
.get("session_target")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("isolated");
|
||||
let session_target = match session_target_str {
|
||||
"main" => crate::openhuman::cron::SessionTarget::Main,
|
||||
"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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (~30–90s 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"
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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, ¶ms.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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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)?))
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())),
|
||||
|
||||
Reference in New Issue
Block a user