From d22d9c987ec2ca20ce950dbc20965b8b25d4d87c Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Sun, 7 Jun 2026 15:10:50 +0530 Subject: [PATCH] =?UTF-8?q?refactor(workflows):=20complete=20skills?= =?UTF-8?q?=E2=86=92workflows=20rename=20(internal=20+=20wire/FE)=20[#3324?= =?UTF-8?q?=20follow-up]=20(#3412)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude --- .../components/intelligence/WorkflowsTab.tsx | 14 +- .../__tests__/WorkflowsTab.test.tsx | 41 +- .../components/skills/CreateSkillModal.tsx | 6 +- .../components/skills/CreateWorkflowForm.tsx | 34 +- .../components/skills/InstallSkillDialog.tsx | 36 +- .../components/skills/SkillsExplorerTab.tsx | 24 +- .../skills/UninstallSkillConfirmDialog.tsx | 16 +- .../components/skills/WorkflowRunnerBody.tsx | 72 +-- .../__tests__/CreateSkillModal.test.tsx | 36 +- .../__tests__/CreateWorkflowForm.test.tsx | 92 ++-- .../__tests__/InstallSkillDialog.test.tsx | 46 +- .../__tests__/SkillsExplorerTab.test.tsx | 70 +-- .../UninstallSkillConfirmDialog.test.tsx | 52 +-- .../__tests__/WorkflowRunnerBody.test.tsx | 130 +++--- .../components/skills/preflightGate.test.ts | 30 +- app/src/components/skills/preflightGate.ts | 8 +- app/src/pages/WorkflowNew.test.tsx | 14 +- app/src/pages/WorkflowNew.tsx | 4 +- .../__tests__/Skills.channels-grid.test.tsx | 8 +- .../__tests__/Skills.mcp-coming-soon.test.tsx | 8 +- .../__tests__/Skills.meetings-tab.test.tsx | 8 +- ...skillsApi.test.ts => workflowsApi.test.ts} | 415 ++++++++++++------ ...skillsApi.test.ts => workflowsApi.test.ts} | 54 +-- .../api/{skillsApi.ts => workflowsApi.ts} | 197 +++++---- src/core/jsonrpc.rs | 10 + src/openhuman/agent/debug/mod.rs | 2 +- src/openhuman/agent/harness/fork_context.rs | 6 +- .../agent/harness/harness_gap_tests.rs | 2 +- .../agent/harness/session/builder/factory.rs | 2 +- .../agent/harness/session/builder/setters.rs | 8 +- .../agent/harness/session/runtime.rs | 6 +- .../agent/harness/session/runtime_tests.rs | 4 +- .../agent/harness/session/turn/context.rs | 2 +- .../agent/harness/session/turn/core.rs | 8 +- .../agent/harness/session/turn/tools.rs | 2 +- .../agent/harness/session/turn_tests.rs | 4 +- src/openhuman/agent/harness/session/types.rs | 4 +- .../harness/subagent_runner/ops/runner.rs | 2 +- .../harness/subagent_runner/ops_tests.rs | 2 +- .../agent/harness/test_support_tests.rs | 2 +- src/openhuman/agent/profiles.rs | 2 +- src/openhuman/agent/prompts/mod_tests.rs | 30 +- src/openhuman/agent/prompts/render_helpers.rs | 4 +- src/openhuman/agent/prompts/types.rs | 2 +- src/openhuman/agent/tools/run_workflow.rs | 43 ++ src/openhuman/agent/triage/escalation.rs | 2 +- src/openhuman/agent/triage/evaluator.rs | 2 +- .../agent_orchestration/ops_tests.rs | 2 +- .../tools/spawn_parallel_agents_tests.rs | 2 +- .../tools/spawn_worker_thread.rs | 2 +- .../tools/tools_e2e_tests.rs | 2 +- .../agent_registry/agents/archivist/prompt.rs | 2 +- .../agents/code_executor/prompt.rs | 2 +- .../agent_registry/agents/critic/prompt.rs | 2 +- .../agents/crypto_agent/prompt.rs | 4 +- .../agents/desktop_control_agent/prompt.rs | 2 +- .../agent_registry/agents/help/prompt.rs | 2 +- .../agents/integrations_agent/prompt.rs | 2 +- src/openhuman/agent_registry/agents/loader.rs | 2 +- .../agents/markets_agent/prompt.rs | 4 +- .../agent_registry/agents/mcp_setup/prompt.rs | 2 +- .../agents/morning_briefing/prompt.rs | 2 +- .../agents/orchestrator/prompt.rs | 2 +- .../agent_registry/agents/planner/prompt.rs | 2 +- .../agents/presentation_agent/prompt.rs | 2 +- .../agents/researcher/prompt.rs | 2 +- .../agents/scheduler_agent/prompt.rs | 2 +- .../agents/skill_creator/prompt.rs | 2 +- .../agents/summarizer/prompt.rs | 2 +- .../agents/task_manager_agent/prompt.rs | 2 +- .../agents/tool_maker/prompt.rs | 2 +- .../agents/tools_agent/prompt.rs | 2 +- .../agents/trigger_reactor/prompt.rs | 2 +- .../agents/trigger_triage/prompt.rs | 2 +- src/openhuman/channels/runtime/startup.rs | 14 +- src/openhuman/learning/prompt_sections.rs | 2 +- src/openhuman/memory_tools/prompt.rs | 2 +- src/openhuman/workflows/bus.rs | 57 ++- src/openhuman/workflows/inject.rs | 17 +- src/openhuman/workflows/ops_parse.rs | 4 +- src/openhuman/workflows/preflight.rs | 24 +- src/openhuman/workflows/schemas/handlers.rs | 10 +- src/openhuman/workflows/schemas/wire_types.rs | 6 +- ...rchivist_debug_round21_raw_coverage_e2e.rs | 2 +- tests/agent_builder_public.rs | 2 +- ...gent_harness_leftovers_raw_coverage_e2e.rs | 6 +- tests/agent_harness_public.rs | 2 +- tests/agent_harness_raw_coverage_e2e.rs | 4 +- tests/agent_large_round25_raw_coverage_e2e.rs | 2 +- ...agent_prompts_subagent_raw_coverage_e2e.rs | 4 +- tests/agent_round26_raw_coverage_e2e.rs | 2 +- .../agent_session_round24_raw_coverage_e2e.rs | 2 +- tests/agent_session_turn_raw_coverage_e2e.rs | 2 +- tests/calendar_grounding_e2e.rs | 2 +- ...io_list_tools_stack_overflow_regression.rs | 2 +- tests/inference_agent_raw_coverage_e2e.rs | 6 +- tests/json_rpc_e2e.rs | 10 +- tests/monitor_agent_e2e.rs | 2 +- tests/personality_e2e.rs | 2 +- ...gent_credentials_state_raw_coverage_e2e.rs | 4 +- 100 files changed, 1033 insertions(+), 783 deletions(-) rename app/src/services/api/__tests__/{skillsApi.test.ts => workflowsApi.test.ts} (50%) rename app/src/services/api/{skillsApi.test.ts => workflowsApi.test.ts} (77%) rename app/src/services/api/{skillsApi.ts => workflowsApi.ts} (73%) diff --git a/app/src/components/intelligence/WorkflowsTab.tsx b/app/src/components/intelligence/WorkflowsTab.tsx index ac4ea0ee4..dcffa5630 100644 --- a/app/src/components/intelligence/WorkflowsTab.tsx +++ b/app/src/components/intelligence/WorkflowsTab.tsx @@ -5,7 +5,7 @@ * The Intelligence page's "Workflows" tab — the single home for installed * workflows (the unified primitive: a goal + the procedure to reach it, * authored as SKILL.md bundles and served by the `workflows_*` JSON-RPC via - * `skillsApi`). + * `workflowsApi`). * * Owns the full workflow surface that used to live on the Connections page: * - lists discovered workflows as cards, @@ -21,7 +21,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useT } from '../../lib/i18n/I18nContext'; -import { skillsApi, type SkillSummary } from '../../services/api/skillsApi'; +import { workflowsApi, type WorkflowSummary } from '../../services/api/workflowsApi'; import type { ToastNotification } from '../../types/intelligence'; import CreateSkillModal from '../skills/CreateSkillModal'; import UnifiedSkillCard from '../skills/SkillCard'; @@ -34,10 +34,10 @@ const log = debug('intelligence:workflows'); export default function WorkflowsTab() { const { t } = useT(); const navigate = useNavigate(); - const [workflows, setWorkflows] = useState([]); + const [workflows, setWorkflows] = useState([]); const [loading, setLoading] = useState(true); const [createModalOpen, setCreateModalOpen] = useState(false); - const [uninstallCandidate, setUninstallCandidate] = useState(null); + const [uninstallCandidate, setUninstallCandidate] = useState(null); const [loadError, setLoadError] = useState(null); const [toasts, setToasts] = useState([]); @@ -49,9 +49,9 @@ export default function WorkflowsTab() { setToasts(prev => prev.filter(toast => toast.id !== id)); }, []); - const refresh = useCallback(async (): Promise => { + const refresh = useCallback(async (): Promise => { try { - const list = await skillsApi.listSkills(); + const list = await workflowsApi.listWorkflows(); log('listWorkflows ok count=%d', list.length); setLoadError(null); setWorkflows(list); @@ -100,7 +100,7 @@ export default function WorkflowsTab() { - {/* Load error — shown instead of the empty state when listSkills fails, + {/* Load error — shown instead of the empty state when listWorkflows fails, so an outage doesn't read as "you have no workflows". */} {loadError && !loading ? (
({ useT: () => ({ t: (k: string) => k }) })); @@ -18,7 +18,7 @@ vi.mock('../../skills/CreateSkillModal', () => ({ onCreated, onClose, }: { - onCreated: (wf: SkillSummary) => void; + onCreated: (wf: WorkflowSummary) => void; onClose: () => void; }) => (
@@ -62,7 +62,7 @@ vi.mock('../../skills/UninstallSkillConfirmDialog', () => ({ ), })); -const seeded = (overrides: Partial): SkillSummary => ({ +const seeded = (overrides: Partial): WorkflowSummary => ({ id: 'wf-1', name: 'WF 1', description: 'A workflow.', @@ -82,22 +82,25 @@ const seeded = (overrides: Partial): SkillSummary => ({ ...overrides, }); -const listSkills = vi.fn(); -vi.mock('../../../services/api/skillsApi', async () => { - const actual = await vi.importActual( - '../../../services/api/skillsApi' +const listWorkflows = vi.fn(); +vi.mock('../../../services/api/workflowsApi', async () => { + const actual = await vi.importActual( + '../../../services/api/workflowsApi' ); - return { ...actual, skillsApi: { ...actual.skillsApi, listSkills: () => listSkills() } }; + return { + ...actual, + workflowsApi: { ...actual.workflowsApi, listWorkflows: () => listWorkflows() }, + }; }); describe('WorkflowsTab', () => { beforeEach(() => { navigate.mockReset(); - listSkills.mockReset(); + listWorkflows.mockReset(); }); - it('lists workflows from skillsApi with the create entry point', async () => { - listSkills.mockResolvedValue([ + it('lists workflows from workflowsApi with the create entry point', async () => { + listWorkflows.mockResolvedValue([ seeded({ id: 'user-wf', name: 'User WF', scope: 'user' }), seeded({ id: 'project-wf', name: 'Project WF', scope: 'project' }), ]); @@ -115,7 +118,7 @@ describe('WorkflowsTab', () => { }); it('navigates to the locked runner page when a card is opened', async () => { - listSkills.mockResolvedValue([seeded({ id: 'user-wf', name: 'User WF', scope: 'user' })]); + listWorkflows.mockResolvedValue([seeded({ id: 'user-wf', name: 'User WF', scope: 'user' })]); render(); await waitFor(() => expect(screen.getByTestId('workflow-open-user-wf')).toBeInTheDocument()); @@ -124,7 +127,7 @@ describe('WorkflowsTab', () => { }); it('opens create and lands on the new workflow runner after create', async () => { - listSkills.mockResolvedValue([]); + listWorkflows.mockResolvedValue([]); render(); await waitFor(() => expect(screen.getByTestId('workflows-create-btn')).toBeInTheDocument()); @@ -133,8 +136,8 @@ describe('WorkflowsTab', () => { expect(navigate).toHaveBeenCalledWith('/workflows/run?workflow=new-wf&lock=1'); }); - it('shows an error panel with retry when listSkills fails (not the empty state)', async () => { - listSkills.mockRejectedValueOnce(new Error('backend down')); + it('shows an error panel with retry when listWorkflows fails (not the empty state)', async () => { + listWorkflows.mockRejectedValueOnce(new Error('backend down')); render(); await waitFor(() => expect(screen.getByTestId('workflows-load-error')).toBeInTheDocument()); @@ -142,13 +145,15 @@ describe('WorkflowsTab', () => { expect(screen.queryByText('workflows.empty.title')).not.toBeInTheDocument(); // Retry re-fetches and renders the list. - listSkills.mockResolvedValueOnce([seeded({ id: 'user-wf', name: 'User WF', scope: 'user' })]); + listWorkflows.mockResolvedValueOnce([ + seeded({ id: 'user-wf', name: 'User WF', scope: 'user' }), + ]); fireEvent.click(screen.getByText('common.retry')); await waitFor(() => expect(screen.getByText('User WF')).toBeInTheDocument()); }); it('removes the workflow from the list after uninstall, keyed by id', async () => { - listSkills.mockResolvedValue([seeded({ id: 'user-wf', name: 'User WF', scope: 'user' })]); + listWorkflows.mockResolvedValue([seeded({ id: 'user-wf', name: 'User WF', scope: 'user' })]); render(); await waitFor(() => expect(screen.getByTestId('workflow-card-user-wf')).toBeInTheDocument()); @@ -162,7 +167,7 @@ describe('WorkflowsTab', () => { }); it('renders the empty state when no workflows are installed', async () => { - listSkills.mockResolvedValue([]); + listWorkflows.mockResolvedValue([]); render(); await waitFor(() => expect(screen.getByText('workflows.empty.title')).toBeInTheDocument()); expect(screen.queryByTestId('workflows-list')).not.toBeInTheDocument(); diff --git a/app/src/components/skills/CreateSkillModal.tsx b/app/src/components/skills/CreateSkillModal.tsx index 5b6ca0b93..36dbb9936 100644 --- a/app/src/components/skills/CreateSkillModal.tsx +++ b/app/src/components/skills/CreateSkillModal.tsx @@ -20,7 +20,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useT } from '../../lib/i18n/I18nContext'; -import { type SkillSummary } from '../../services/api/skillsApi'; +import { type WorkflowSummary } from '../../services/api/workflowsApi'; import CreateWorkflowForm from './CreateWorkflowForm'; const log = debug('skills:create-modal'); @@ -29,9 +29,9 @@ const CREATE_FORM_ID = 'create-skill-modal-form'; interface Props { onClose: () => void; - onCreated: (skill: SkillSummary) => void; + onCreated: (skill: WorkflowSummary) => void; /** When set, the modal edits this workflow instead of creating a new one. */ - editing?: SkillSummary; + editing?: WorkflowSummary; } export default function CreateSkillModal({ onClose, onCreated, editing }: Props) { diff --git a/app/src/components/skills/CreateWorkflowForm.tsx b/app/src/components/skills/CreateWorkflowForm.tsx index 5c34af320..2633da787 100644 --- a/app/src/components/skills/CreateWorkflowForm.tsx +++ b/app/src/components/skills/CreateWorkflowForm.tsx @@ -9,7 +9,7 @@ * - 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 + * - Submit handler that calls `workflowsApi.createWorkflow` and surfaces * the result via `onCreated(skill)` / error string via inline * `
`. * @@ -41,12 +41,12 @@ import { import { useT } from '../../lib/i18n/I18nContext'; import { - type CreateSkillInput, - type CreateSkillInputDef, - type SkillScope, - type SkillSummary, - skillsApi, -} from '../../services/api/skillsApi'; + type CreateWorkflowInput, + type CreateWorkflowInputDef, + type WorkflowScope, + type WorkflowSummary, + workflowsApi, +} from '../../services/api/workflowsApi'; /** Mirrors `SkillCreateInputDef` shape used as wire payload, with one * extra `localId` for stable React keys across re-renders (the wire @@ -80,7 +80,7 @@ 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. */ + /** True while workflowsApi.createWorkflow is in flight. */ isSubmitting: () => boolean; /** Imperatively trigger submit. Resolves once the round-trip finishes. */ submit: () => Promise; @@ -94,7 +94,7 @@ export interface CreateSkillFormProps { */ formId: string; /** Called with the freshly-created skill on success. */ - onCreated: (skill: SkillSummary) => void; + onCreated: (skill: WorkflowSummary) => void; /** * Called whenever validity / submission state changes so the * wrapper can sync its submit button's disabled state without @@ -110,7 +110,7 @@ export interface CreateSkillFormProps { * allowed-tools (not exposed as editable fields) are carried through so * they're preserved on save. */ - editing?: SkillSummary; + editing?: WorkflowSummary; } /** @@ -156,7 +156,7 @@ const CreateWorkflowForm = forwardRef(null); const [inputs, setInputs] = useState([]); @@ -217,7 +217,7 @@ const CreateWorkflowForm = forwardRef { try { - const desc = await skillsApi.describeSkill(editing.id); + const desc = await workflowsApi.describeWorkflow(editing.id); if (cancelled) return; if (desc.when_to_use) setWhenToUse(desc.when_to_use); setInputs( @@ -243,7 +243,7 @@ const CreateWorkflowForm = forwardRef { if (!formValid) return; - const payload: CreateSkillInput = { + const payload: CreateWorkflowInput = { name: name.trim(), description: description.trim(), scope, @@ -264,8 +264,8 @@ const CreateWorkflowForm = forwardRef 0) { - payload.inputs = inputs.map((r) => { - const def: CreateSkillInputDef = { + payload.inputs = inputs.map((r) => { + const def: CreateWorkflowInputDef = { name: r.name.trim(), required: r.required, }; @@ -282,8 +282,8 @@ const CreateWorkflowForm = forwardRef void; + onInstalled: (result: InstallWorkflowFromUrlResult) => void; /** * Optional: used only for symmetry with `CreateSkillModal`. When * supplied and the caller wants to auto-open the detail drawer for a - * specific skill, they can resolve the full `SkillSummary` and call + * specific skill, they can resolve the full `WorkflowSummary` and call * this directly. Not invoked by the dialog itself. */ - onSelectSkill?: (skill: SkillSummary) => void; + onSelectSkill?: (skill: WorkflowSummary) => void; } /** @@ -151,7 +151,7 @@ export default function InstallSkillDialog({ onClose, onInstalled }: Props) { const [timeoutSecs, setTimeoutSecs] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - const [result, setResult] = useState(null); + const [result, setResult] = useState(null); const firstFieldRef = useRef(null); const previousFocusRef = useRef(null); @@ -201,14 +201,14 @@ export default function InstallSkillDialog({ onClose, onInstalled }: Props) { setSubmitting(true); setError(null); try { - const installed = await skillsApi.installSkillFromUrl(payload); + const installed = await workflowsApi.installWorkflowFromUrl(payload); log( 'submit-ok new=%d stdout=%d stderr=%d', - installed.newSkills.length, + installed.newWorkflows.length, installed.stdout.length, installed.stderr.length ); - for (const skillId of installed.newSkills) { + for (const skillId of installed.newWorkflows) { trackEvent('skill_install', { skill_id: skillId }); } setResult(installed); @@ -383,16 +383,16 @@ export default function InstallSkillDialog({ onClose, onInstalled }: Props) {

{t('skills.install.installComplete')}

- {result.newSkills.length > 0 + {result.newWorkflows.length > 0 ? t('skills.install.successDiscovered').replace( '{count}', - String(result.newSkills.length) + String(result.newWorkflows.length) ) : t('skills.install.successNoNewIds')}

- {result.newSkills.length > 0 ? ( + {result.newWorkflows.length > 0 ? (
    - {result.newSkills.map(id => ( + {result.newWorkflows.map(id => (
  • {id}
  • ))}
diff --git a/app/src/components/skills/SkillsExplorerTab.tsx b/app/src/components/skills/SkillsExplorerTab.tsx index f03e021f5..d69e284ec 100644 --- a/app/src/components/skills/SkillsExplorerTab.tsx +++ b/app/src/components/skills/SkillsExplorerTab.tsx @@ -7,10 +7,10 @@ import { type CatalogEntry, } from '../../services/api/skillRegistryApi'; import { - skillsApi, - type InstallSkillFromUrlResult, - type SkillSummary, -} from '../../services/api/skillsApi'; + workflowsApi, + type InstallWorkflowFromUrlResult, + type WorkflowSummary, +} from '../../services/api/workflowsApi'; import EmptyStateCard from '../EmptyStateCard'; import InstallSkillDialog from './InstallSkillDialog'; import UninstallSkillConfirmDialog from './UninstallSkillConfirmDialog'; @@ -67,7 +67,7 @@ function SourceBadge({ sourceId }: { sourceId: string }) { } interface SkillTileProps { - skill: SkillSummary; + skill: WorkflowSummary; onUninstall: () => void; } @@ -267,7 +267,7 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) { const { t } = useT(); const [view, setView] = useState('registry'); - const [skills, setSkills] = useState([]); + const [skills, setSkills] = useState([]); const [skillsLoading, setSkillsLoading] = useState(true); const [skillsError, setSkillsError] = useState(null); @@ -279,14 +279,14 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) { const [searchQuery, setSearchQuery] = useState(''); const [formatFilter, setFormatFilter] = useState('all'); const [installDialogOpen, setInstallDialogOpen] = useState(false); - const [uninstallTarget, setUninstallTarget] = useState(null); + const [uninstallTarget, setUninstallTarget] = useState(null); const fetchSkills = useCallback(async () => { log('fetchSkills: start'); setSkillsLoading(true); setSkillsError(null); try { - const result = await skillsApi.listSkills(); + const result = await workflowsApi.listWorkflows(); log('fetchSkills: count=%d', result.length); setSkills(result); } catch (err) { @@ -368,16 +368,16 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) { }, [catalog]); const handleInstalled = useCallback( - (result: InstallSkillFromUrlResult) => { - log('handleInstalled: newSkills=%d', result.newSkills.length); + (result: InstallWorkflowFromUrlResult) => { + log('handleInstalled: newSkills=%d', result.newWorkflows.length); void fetchSkills(); - if (result.newSkills.length > 0) { + if (result.newWorkflows.length > 0) { onToast?.({ type: 'success', title: t('skills.install.installComplete'), message: t('skills.install.successDiscovered').replace( '{count}', - String(result.newSkills.length) + String(result.newWorkflows.length) ), }); } diff --git a/app/src/components/skills/UninstallSkillConfirmDialog.tsx b/app/src/components/skills/UninstallSkillConfirmDialog.tsx index 9e27dac70..79674b60a 100644 --- a/app/src/components/skills/UninstallSkillConfirmDialog.tsx +++ b/app/src/components/skills/UninstallSkillConfirmDialog.tsx @@ -3,7 +3,7 @@ * --------------------------- * * Small centered confirm modal for destructive uninstall of a user-scope - * SKILL.md skill. Wraps `skillsApi.uninstallSkill` which calls + * SKILL.md skill. Wraps `workflowsApi.uninstallWorkflow` which calls * `openhuman.workflows_uninstall` on the Rust side — that RPC only accepts * user-scope installs (`~/.openhuman/skills//`) and refuses project * and legacy scopes. The card that opens this dialog is responsible for @@ -29,23 +29,23 @@ import debug from 'debug'; import { useT } from '../../lib/i18n/I18nContext'; import { - skillsApi, - type SkillSummary, - type UninstallSkillResult, -} from '../../services/api/skillsApi'; + workflowsApi, + type WorkflowSummary, + type UninstallWorkflowResult, +} from '../../services/api/workflowsApi'; import { trackEvent } from '../../services/analytics'; const log = debug('skills:uninstall-dialog'); interface Props { - skill: SkillSummary; + skill: WorkflowSummary; onClose: () => void; /** * Fires when the backend reports the uninstall succeeded. Parent is * responsible for refetching the skills list and closing any detail * panels that were showing this skill. */ - onUninstalled: (result: UninstallSkillResult) => void; + onUninstalled: (result: UninstallWorkflowResult) => void; } export default function UninstallSkillConfirmDialog({ skill, onClose, onUninstalled }: Props) { @@ -82,7 +82,7 @@ export default function UninstallSkillConfirmDialog({ skill, onClose, onUninstal // `skill.id` is the on-disk slug (directory under ~/.openhuman/skills/). // `skill.name` is the frontmatter display name and may diverge from the // slug — the backend resolves by slug, so pass `id`. - const result = await skillsApi.uninstallSkill(skill.id); + const result = await workflowsApi.uninstallWorkflow(skill.id); log('confirm: done removedPath=%s', result.removedPath); trackEvent('skill_uninstall', { skill_id: skill.id }); onUninstalled(result); diff --git a/app/src/components/skills/WorkflowRunnerBody.tsx b/app/src/components/skills/WorkflowRunnerBody.tsx index 7a76e26b8..4ed3bfbd6 100644 --- a/app/src/components/skills/WorkflowRunnerBody.tsx +++ b/app/src/components/skills/WorkflowRunnerBody.tsx @@ -21,11 +21,11 @@ import { SCHEDULE_PRESETS } from '../../lib/cron/schedulePresets'; import { type RunLogSlice, type ScannedRun, - type SkillDescription, - type SkillRunStarted, - type SkillSummary, - skillsApi, -} from '../../services/api/skillsApi'; + type WorkflowDescription, + type WorkflowRunStarted, + type WorkflowSummary, + workflowsApi, +} from '../../services/api/workflowsApi'; import { type CoreCronJob, type CoreCronRun, @@ -38,7 +38,7 @@ import { import CreateSkillModal from './CreateSkillModal'; import BranchPicker from './inputs/BranchPicker'; import RepoPicker from './inputs/RepoPicker'; -import { isGithubGateFailure, parseSkillRunError } from './preflightGate'; +import { isGithubGateFailure, parseWorkflowRunError } from './preflightGate'; import ScheduledCronCard from './ScheduledCronCard'; import SmartIssuePicker from './SmartIssuePicker'; @@ -95,7 +95,7 @@ type InputValue = string | number | boolean; interface RunState { status: 'idle' | 'submitting' | 'started' | 'error'; message?: string; - result?: SkillRunStarted; + result?: WorkflowRunStarted; } @@ -165,7 +165,7 @@ export function parseScheduledInputs( /** * Default form value for an input based on its declared type. Strings/ * integers default to empty (renders as placeholder); booleans to false. - * `runSkill` later trims and drops empty optional fields before sending + * `runWorkflow` later trims and drops empty optional fields before sending * them over the wire. */ function defaultForType(type: string): InputValue { @@ -175,13 +175,13 @@ function defaultForType(type: string): InputValue { } /** - * Project the form-state map back into the JSON inputs shape `skills_run` + * Project the form-state map back into the JSON inputs shape `workflows_run` * expects: trim strings, coerce integer-typed fields to numbers, drop * empty optional fields entirely (so the backend sees them as "not * provided" rather than `""`). */ function buildInputsPayload( - description: SkillDescription, + description: WorkflowDescription, values: Record ): Record { const out: Record = {}; @@ -225,7 +225,7 @@ export interface SkillsRunnerBodyProps { * the skill picker. Defaults to the Settings-panel description so * the original placement is unchanged. (Named `headerText` rather * than `description` to avoid shadowing the internal `description` - * state that holds the resolved `SkillDescription` for the picked + * state that holds the resolved `WorkflowDescription` for the picked * skill.) */ headerText?: string; @@ -241,7 +241,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr const { t } = useT(); // Skill catalog (loaded once on mount) - const [skills, setSkills] = useState([]); + const [skills, setSkills] = useState([]); const [skillsLoading, setSkillsLoading] = useState(false); const [skillsError, setSkillsError] = useState(null); // Edit-this-workflow modal (only meaningful when locked to a workflow). @@ -263,7 +263,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr !!searchParams.get('workflow') && (searchParams.get('lock') === '1' || searchParams.get('focus') === 'schedule'); const selectedWorkflow = skills.find(s => s.id === selectedSkillId); - const [description, setDescription] = useState(null); + const [description, setDescription] = useState(null); const [descLoading, setDescLoading] = useState(false); const [descError, setDescError] = useState(null); @@ -449,13 +449,13 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr return () => clearTimeout(timer); }, [searchParams, selectedSkillId]); - // ── Initial load: skills_list ────────────────────────────────────── + // ── Initial load: workflows_list ────────────────────────────────────── useEffect(() => { let cancelled = false; setSkillsLoading(true); setSkillsError(null); - skillsApi - .listSkills() + workflowsApi + .listWorkflows() .then((list) => { if (cancelled) return; // Hide the codegraph-smoke skill — internal smoke-test only. @@ -466,7 +466,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr .catch((err: unknown) => { if (cancelled) return; const msg = err instanceof Error ? err.message : String(err); - log('listSkills error: %s', msg); + log('listWorkflows error: %s', msg); setSkillsError(msg); }) .finally(() => { @@ -477,7 +477,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr }; }, []); - // ── On selection: skills_describe ────────────────────────────────── + // ── On selection: workflows_describe ────────────────────────────────── useEffect(() => { if (!selectedSkillId) { setDescription(null); @@ -488,8 +488,8 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr setDescLoading(true); setDescError(null); setRun({ status: 'idle' }); - skillsApi - .describeSkill(selectedSkillId) + workflowsApi + .describeWorkflow(selectedSkillId) .then((desc) => { if (cancelled) return; setDescription(desc); @@ -504,7 +504,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr .catch((err: unknown) => { if (cancelled) return; const msg = err instanceof Error ? err.message : String(err); - log('describeSkill error: %s', msg); + log('describeWorkflow error: %s', msg); setDescError(msg); }) .finally(() => { @@ -540,7 +540,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr const handleStopRun = useCallback(async (runId: string) => { log('stop run runId=%s', runId); try { - await skillsApi.cancelRun(runId); + await workflowsApi.cancelRun(runId); } catch (err) { log('cancelRun error: %s', err instanceof Error ? err.message : String(err)); } @@ -552,7 +552,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr // Re-entry guard: a second click before React applies the disabled state // would otherwise fire `workflows_run` twice and spawn two real runs. if (runSubmitGuardRef.current) { - log('runSkill: ignoring re-entrant click while a run is starting'); + log('runWorkflow: ignoring re-entrant click while a run is starting'); return; } if (missingRequired.length > 0) { @@ -567,8 +567,8 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr setRun({ status: 'submitting' }); try { const inputs = buildInputsPayload(description, formValues); - log('runSkill %s inputs=%o', description.id, inputs); - const result = await skillsApi.runSkill(description.id, inputs); + log('runWorkflow %s inputs=%o', description.id, inputs); + const result = await workflowsApi.runWorkflow(description.id, inputs); setRun({ status: 'started', result }); // Surface the new run in "Recent runs" without a manual refresh, and // hold the guard through a short cooldown so a second click can't spawn @@ -577,7 +577,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr releaseRunGuard(2500); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - log('runSkill error: %s', msg); + log('runWorkflow error: %s', msg); setRun({ status: 'error', message: msg }); releaseRunGuard(0); // allow immediate retry on failure } @@ -587,7 +587,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr useEffect(() => { let cancelled = false; setRecentRunsLoading(true); - skillsApi + workflowsApi .recentRuns(selectedSkillId || undefined, 10) .then((list) => { if (cancelled) return; @@ -701,7 +701,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr error: null, }, })); - const slice: RunLogSlice = await skillsApi.readRunLog(runId, fromOffset); + const slice: RunLogSlice = await workflowsApi.readRunLog(runId, fromOffset); if (cancelled) return; setViewer((prev) => { const prior = prev[runId]?.content ?? ''; @@ -784,7 +784,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr ); try { log('runJobNow: running %s directly with %o', selectedSkillId, inputs); - await skillsApi.runSkill(selectedSkillId, inputs); + await workflowsApi.runWorkflow(selectedSkillId, inputs); scheduleRecentRunsRefresh(); releaseRunGuard(2500); } catch (err: unknown) { @@ -908,7 +908,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr // instead of a plain text input. Falls through to the type-based // string/integer/boolean handling for everything else. const renderField = ( - inp: SkillDescription['inputs'][number], + inp: WorkflowDescription['inputs'][number], value: InputValue, onChange: (next: InputValue) => void ) => { @@ -1065,7 +1065,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr )} {skillsError && (

- {t('settings.skillsRunner.error.listSkills')} {skillsError} + {t('settings.skillsRunner.error.listWorkflows')} {skillsError}

)}
@@ -1199,7 +1199,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr // failed" pill above the body so the user knows // this isn't a generic crash — there's a concrete // remediation the body describes. - const parsed = parseSkillRunError(run.message); + const parsed = parseWorkflowRunError(run.message); const isGateFailure = isGithubGateFailure(parsed); return (
setEditOpen(false)} onCreated={() => { setEditOpen(false); - void skillsApi - .listSkills() + void workflowsApi + .listWorkflows() .then((list) => setSkills(list.filter((s) => s.id !== 'codegraph-smoke'))) .catch(() => {}); - void skillsApi - .describeSkill(selectedSkillId) + void workflowsApi + .describeWorkflow(selectedSkillId) .then(setDescription) .catch(() => {}); }} diff --git a/app/src/components/skills/__tests__/CreateSkillModal.test.tsx b/app/src/components/skills/__tests__/CreateSkillModal.test.tsx index 0a4f98be9..e8caf1f64 100644 --- a/app/src/components/skills/__tests__/CreateSkillModal.test.tsx +++ b/app/src/components/skills/__tests__/CreateSkillModal.test.tsx @@ -6,7 +6,7 @@ * - Escape key closes (but not while submitting). * - Backdrop click closes (but not while submitting). * - Submit is disabled when name or description is empty. - * - Submit rekeys `allowedTools` → `'allowed-tools'` via skillsApi.createSkill. + * - Submit rekeys `allowedTools` → `'allowed-tools'` via workflowsApi.createWorkflow. * - Submit calls `onCreated` with the returned skill. * - Submit failure surfaces an error banner and re-enables the button. * - Slug preview updates as the name changes. @@ -14,18 +14,18 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { SkillSummary } from '../../../services/api/skillsApi'; +import type { WorkflowSummary } from '../../../services/api/workflowsApi'; import CreateSkillModal from '../CreateSkillModal'; -vi.mock('../../../services/api/skillsApi', () => ({ - skillsApi: { - createSkill: vi.fn(), - updateSkill: vi.fn(), - describeSkill: vi.fn().mockResolvedValue({ id: 'e', name: 'e', when_to_use: '', inputs: [] }), +vi.mock('../../../services/api/workflowsApi', () => ({ + workflowsApi: { + createWorkflow: vi.fn(), + updateWorkflow: vi.fn(), + describeWorkflow: vi.fn().mockResolvedValue({ id: 'e', name: 'e', when_to_use: '', inputs: [] }), }, })); -function builtSkill(overrides: Partial = {}): SkillSummary { +function builtSkill(overrides: Partial = {}): WorkflowSummary { return { id: 'my-skill', name: 'My Skill', @@ -49,8 +49,8 @@ function builtSkill(overrides: Partial = {}): SkillSummary { describe('CreateSkillModal', () => { beforeEach(async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.createSkill).mockReset(); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.createWorkflow).mockReset(); }); it('renders title and required fields', () => { @@ -94,13 +94,13 @@ describe('CreateSkillModal', () => { // form is now Name + Description + the `[[inputs]]` editor only — see // ScheduledCronCard / CreateWorkflowForm.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 → + // `workflowsApi.createWorkflow` (services/api/workflowsApi.ts → params build) and + // is covered by the workflowsApi unit tests; this test now just guards the + // modal's submit-pipeline shape: name + description → createWorkflow → // onCreated. - const { skillsApi } = await import('../../../services/api/skillsApi'); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); const created = builtSkill(); - vi.mocked(skillsApi.createSkill).mockResolvedValueOnce(created); + vi.mocked(workflowsApi.createWorkflow).mockResolvedValueOnce(created); const onCreated = vi.fn(); const onClose = vi.fn(); @@ -114,7 +114,7 @@ describe('CreateSkillModal', () => { fireEvent.click(submit); }); - expect(vi.mocked(skillsApi.createSkill)).toHaveBeenCalledWith( + expect(vi.mocked(workflowsApi.createWorkflow)).toHaveBeenCalledWith( expect.objectContaining({ name: 'My Skill', description: 'does stuff', @@ -125,8 +125,8 @@ describe('CreateSkillModal', () => { }); it('surfaces error and re-enables submit on failure', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.createSkill).mockRejectedValueOnce(new Error('slug already exists')); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.createWorkflow).mockRejectedValueOnce(new Error('slug already exists')); render(); fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'dup' } }); diff --git a/app/src/components/skills/__tests__/CreateWorkflowForm.test.tsx b/app/src/components/skills/__tests__/CreateWorkflowForm.test.tsx index 7a24e714c..9d195de1b 100644 --- a/app/src/components/skills/__tests__/CreateWorkflowForm.test.tsx +++ b/app/src/components/skills/__tests__/CreateWorkflowForm.test.tsx @@ -6,7 +6,7 @@ * and the /skills/new page can rely on it. * * Covers: - * - submit calls skillsApi.createSkill with the trimmed/normalised + * - submit calls workflowsApi.createWorkflow 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. @@ -20,16 +20,16 @@ const stableT = (key: string) => key; vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: stableT }) })); const hoisted = vi.hoisted(() => ({ - createSkill: vi.fn(), - updateSkill: vi.fn(), - describeSkill: vi.fn(), + createWorkflow: vi.fn(), + updateWorkflow: vi.fn(), + describeWorkflow: vi.fn(), })); -vi.mock('../../../services/api/skillsApi', () => ({ - skillsApi: { - createSkill: hoisted.createSkill, - updateSkill: hoisted.updateSkill, - describeSkill: hoisted.describeSkill, +vi.mock('../../../services/api/workflowsApi', () => ({ + workflowsApi: { + createWorkflow: hoisted.createWorkflow, + updateWorkflow: hoisted.updateWorkflow, + describeWorkflow: hoisted.describeWorkflow, }, })); @@ -57,7 +57,7 @@ describe('previewSlug', () => { describe('CreateWorkflowForm', () => { beforeEach(() => { - hoisted.createSkill.mockReset(); + hoisted.createWorkflow.mockReset(); }); it('renders required fields and the slug preview updates as the name changes', () => { @@ -98,7 +98,7 @@ describe('CreateWorkflowForm', () => { // 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); + hoisted.createWorkflow.mockResolvedValue(created); const onCreated = vi.fn(); render(); @@ -116,7 +116,7 @@ describe('CreateWorkflowForm', () => { fireEvent.submit(document.getElementById(FORM_ID)!); await waitFor(() => { - expect(hoisted.createSkill).toHaveBeenCalledWith({ + expect(hoisted.createWorkflow).toHaveBeenCalledWith({ name: 'My Skill', description: 'Does the thing.', scope: 'user', @@ -126,7 +126,7 @@ describe('CreateWorkflowForm', () => { }); it('includes whenToUse in the payload when the trigger field is filled', async () => { - hoisted.createSkill.mockResolvedValue({ id: 'wf', name: 'wf', scope: 'user', legacy: false }); + hoisted.createWorkflow.mockResolvedValue({ id: 'wf', name: 'wf', scope: 'user', legacy: false }); render(); fireEvent.change(screen.getByLabelText(/skills.create.name/i), { @@ -141,7 +141,7 @@ describe('CreateWorkflowForm', () => { fireEvent.submit(document.getElementById(FORM_ID)!); await waitFor(() => { - expect(hoisted.createSkill).toHaveBeenCalledWith({ + expect(hoisted.createWorkflow).toHaveBeenCalledWith({ name: 'Triage', description: 'Summarise the inbox.', scope: 'user', @@ -150,8 +150,8 @@ describe('CreateWorkflowForm', () => { }); }); - it('surfaces the Rust error message in an alert when createSkill rejects', async () => { - hoisted.createSkill.mockRejectedValue(new Error('slug already exists')); + it('surfaces the Rust error message in an alert when createWorkflow rejects', async () => { + hoisted.createWorkflow.mockRejectedValue(new Error('slug already exists')); render(); fireEvent.change(screen.getByLabelText(/skills.create.name/i), { @@ -166,18 +166,18 @@ describe('CreateWorkflowForm', () => { expect(alert).toHaveTextContent('slug already exists'); }); - it('does not call createSkill if the form is invalid (no name)', async () => { + it('does not call createWorkflow if the form is invalid (no name)', async () => { render(); fireEvent.submit(document.getElementById(FORM_ID)!); // Give the microtask queue a tick — should still be 0. await Promise.resolve(); - expect(hoisted.createSkill).not.toHaveBeenCalled(); + expect(hoisted.createWorkflow).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 + // `inputs` field on the createWorkflow 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. */ @@ -199,23 +199,23 @@ describe('CreateWorkflowForm', () => { } it('zero inputs submits a payload without an `inputs` field', async () => { - hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false }); + hoisted.createWorkflow.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false }); render(); fillRequiredFields(); fireEvent.submit(document.getElementById(FORM_ID)!); await waitFor(() => { - expect(hoisted.createSkill).toHaveBeenCalledWith({ + expect(hoisted.createWorkflow).toHaveBeenCalledWith({ name: 'My Skill', description: 'Does the thing.', scope: 'user', }); }); - const payload = hoisted.createSkill.mock.calls[0]![0] as Record; + const payload = hoisted.createWorkflow.mock.calls[0]![0] as Record; expect(payload).not.toHaveProperty('inputs'); }); it('one filled input row ships in the payload — additional inputs default to required: false', async () => { - hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false }); + hoisted.createWorkflow.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false }); render(); fillRequiredFields(); @@ -227,7 +227,7 @@ describe('CreateWorkflowForm', () => { fireEvent.submit(document.getElementById(FORM_ID)!); await waitFor(() => { - expect(hoisted.createSkill).toHaveBeenCalledWith({ + expect(hoisted.createWorkflow).toHaveBeenCalledWith({ name: 'My Skill', description: 'Does the thing.', scope: 'user', @@ -238,7 +238,7 @@ describe('CreateWorkflowForm', () => { }); it('blocks submission when an added input row has no description', async () => { - hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false }); + hoisted.createWorkflow.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false }); render(); fillRequiredFields(); @@ -249,7 +249,7 @@ describe('CreateWorkflowForm', () => { fireEvent.change(nameInput, { target: { value: 'repo' } }); fireEvent.submit(document.getElementById(FORM_ID)!); await Promise.resolve(); - expect(hoisted.createSkill).not.toHaveBeenCalled(); + expect(hoisted.createWorkflow).not.toHaveBeenCalled(); expect(screen.getByText(/descriptionError/i)).toBeInTheDocument(); // Fill the description → now it submits. @@ -257,12 +257,12 @@ describe('CreateWorkflowForm', () => { fireEvent.change(descInput, { target: { value: 'owner/name' } }); fireEvent.submit(document.getElementById(FORM_ID)!); await waitFor(() => { - expect(hoisted.createSkill).toHaveBeenCalledTimes(1); + expect(hoisted.createWorkflow).toHaveBeenCalledTimes(1); }); }); it('ticking Required flips the row to required: true', async () => { - hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false }); + hoisted.createWorkflow.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false }); render(); fillRequiredFields(); @@ -277,7 +277,7 @@ describe('CreateWorkflowForm', () => { fireEvent.submit(document.getElementById(FORM_ID)!); await waitFor(() => { - expect(hoisted.createSkill).toHaveBeenCalledWith( + expect(hoisted.createWorkflow).toHaveBeenCalledWith( expect.objectContaining({ inputs: [{ name: 'repo', required: true, description: 'owner/name' }], }) @@ -286,7 +286,7 @@ describe('CreateWorkflowForm', () => { }); it('blocks submission while any row has an invalid name', async () => { - hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false }); + hoisted.createWorkflow.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false }); render(); fillRequiredFields(); @@ -294,7 +294,7 @@ describe('CreateWorkflowForm', () => { // 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(); + expect(hoisted.createWorkflow).not.toHaveBeenCalled(); // Fill the name with an invalid character (leading digit). const row = lastRow(); @@ -302,14 +302,14 @@ describe('CreateWorkflowForm', () => { fireEvent.change(nameInput, { target: { value: '2repo' } }); fireEvent.submit(document.getElementById(FORM_ID)!); await Promise.resolve(); - expect(hoisted.createSkill).not.toHaveBeenCalled(); + expect(hoisted.createWorkflow).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 }); + hoisted.createWorkflow.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false }); render(); fillRequiredFields(); @@ -324,14 +324,14 @@ describe('CreateWorkflowForm', () => { // `inputs` field at all (back to the no-inputs payload shape). fireEvent.submit(document.getElementById(FORM_ID)!); await waitFor(() => { - expect(hoisted.createSkill).toHaveBeenCalledTimes(1); + expect(hoisted.createWorkflow).toHaveBeenCalledTimes(1); }); - const payload = hoisted.createSkill.mock.calls[0]![0] as Record; + const payload = hoisted.createWorkflow.mock.calls[0]![0] as Record; expect(payload).not.toHaveProperty('inputs'); }); it('integer + required=false (the default) carry through the type + required flags', async () => { - hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false }); + hoisted.createWorkflow.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false }); render(); fillRequiredFields(); @@ -347,7 +347,7 @@ describe('CreateWorkflowForm', () => { fireEvent.submit(document.getElementById(FORM_ID)!); await waitFor(() => { - expect(hoisted.createSkill).toHaveBeenCalledWith( + expect(hoisted.createWorkflow).toHaveBeenCalledWith( expect.objectContaining({ inputs: [ { name: 'issue', required: false, type: 'integer', description: 'Issue number to work on' }, @@ -360,9 +360,9 @@ describe('CreateWorkflowForm', () => { describe('CreateWorkflowForm — edit mode', () => { beforeEach(() => { - hoisted.createSkill.mockReset(); - hoisted.updateSkill.mockReset(); - hoisted.describeSkill.mockReset(); + hoisted.createWorkflow.mockReset(); + hoisted.updateWorkflow.mockReset(); + hoisted.describeWorkflow.mockReset(); }); const editingSummary = { @@ -384,14 +384,14 @@ describe('CreateWorkflowForm — edit mode', () => { warnings: [], }; - it('prefills from the summary + describe, then submits via updateSkill', async () => { - hoisted.describeSkill.mockResolvedValue({ + it('prefills from the summary + describe, then submits via updateWorkflow', async () => { + hoisted.describeWorkflow.mockResolvedValue({ id: 'wf-edit', name: 'wf-edit', when_to_use: 'edit trigger', inputs: [{ name: 'repo', type: 'string', required: true, description: 'r' }], }); - hoisted.updateSkill.mockResolvedValue({ + hoisted.updateWorkflow.mockResolvedValue({ id: 'wf-edit', name: 'wf-edit', scope: 'user', @@ -402,13 +402,13 @@ describe('CreateWorkflowForm — edit mode', () => { render(); // Prefill: describe is fetched and name is populated from the summary. - await waitFor(() => expect(hoisted.describeSkill).toHaveBeenCalledWith('wf-edit')); + await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalledWith('wf-edit')); const nameInput = screen.getByLabelText(/skills.create.name/i) as HTMLInputElement; await waitFor(() => expect(nameInput.value).toBe('wf-edit')); fireEvent.submit(document.getElementById(FORM_ID)!); await waitFor(() => - expect(hoisted.updateSkill).toHaveBeenCalledWith( + expect(hoisted.updateWorkflow).toHaveBeenCalledWith( expect.objectContaining({ name: 'wf-edit', whenToUse: 'edit trigger', @@ -418,7 +418,7 @@ describe('CreateWorkflowForm — edit mode', () => { }) ) ); - expect(hoisted.createSkill).not.toHaveBeenCalled(); + expect(hoisted.createWorkflow).not.toHaveBeenCalled(); await waitFor(() => expect(onCreated).toHaveBeenCalled()); }); }); diff --git a/app/src/components/skills/__tests__/InstallSkillDialog.test.tsx b/app/src/components/skills/__tests__/InstallSkillDialog.test.tsx index 6b27d9f26..a66e2c5df 100644 --- a/app/src/components/skills/__tests__/InstallSkillDialog.test.tsx +++ b/app/src/components/skills/__tests__/InstallSkillDialog.test.tsx @@ -6,8 +6,8 @@ * - Submit disabled until a well-formed https URL is entered. * - Shows inline error for non-https URLs. * - Rejects timeout outside 1–600. - * - Submit forwards timeoutSecs to skillsApi.installSkillFromUrl. - * - Success panel renders newSkills list + calls onInstalled. + * - Submit forwards timeoutSecs to workflowsApi.installWorkflowFromUrl. + * - Success panel renders newWorkflows list + calls onInstalled. * - Error panel categorizes known prefixes and shows the raw error in * a details expander; unknown errors fall back to a generic title. */ @@ -16,16 +16,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import InstallSkillDialog from '../InstallSkillDialog'; -vi.mock('../../../services/api/skillsApi', () => ({ - skillsApi: { - installSkillFromUrl: vi.fn(), +vi.mock('../../../services/api/workflowsApi', () => ({ + workflowsApi: { + installWorkflowFromUrl: vi.fn(), }, })); describe('InstallSkillDialog', () => { beforeEach(async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.installSkillFromUrl).mockReset(); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.installWorkflowFromUrl).mockReset(); }); it('renders title and URL input', () => { @@ -72,13 +72,13 @@ describe('InstallSkillDialog', () => { expect(submit.disabled).toBe(false); }); - it('forwards timeoutSecs to skillsApi and fires onInstalled on success', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.installSkillFromUrl).mockResolvedValueOnce({ + it('forwards timeoutSecs to workflowsApi and fires onInstalled on success', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.installWorkflowFromUrl).mockResolvedValueOnce({ url: 'https://raw.githubusercontent.com/owner/repo/main/SKILL.md', stdout: 'added my-skill', stderr: '', - newSkills: ['my-skill'], + newWorkflows: ['my-skill'], }); const onInstalled = vi.fn(); @@ -93,7 +93,7 @@ describe('InstallSkillDialog', () => { fireEvent.click(screen.getByRole('button', { name: /Install/ })); }); - expect(vi.mocked(skillsApi.installSkillFromUrl)).toHaveBeenCalledWith({ + expect(vi.mocked(workflowsApi.installWorkflowFromUrl)).toHaveBeenCalledWith({ url: 'https://raw.githubusercontent.com/owner/repo/main/SKILL.md', timeoutSecs: 120, }); @@ -102,17 +102,17 @@ describe('InstallSkillDialog', () => { }); expect(screen.getByText('my-skill')).toBeInTheDocument(); expect(onInstalled).toHaveBeenCalledWith( - expect.objectContaining({ newSkills: ['my-skill'] }) + expect.objectContaining({ newWorkflows: ['my-skill'] }) ); }); it('omits timeoutSecs when field is blank', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.installSkillFromUrl).mockResolvedValueOnce({ + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.installWorkflowFromUrl).mockResolvedValueOnce({ url: 'https://raw.githubusercontent.com/owner/repo/main/SKILL.md', stdout: '', stderr: '', - newSkills: [], + newWorkflows: [], }); render(); @@ -124,14 +124,14 @@ describe('InstallSkillDialog', () => { fireEvent.click(screen.getByRole('button', { name: /Install/ })); }); - expect(vi.mocked(skillsApi.installSkillFromUrl)).toHaveBeenCalledWith({ + expect(vi.mocked(workflowsApi.installWorkflowFromUrl)).toHaveBeenCalledWith({ url: 'https://raw.githubusercontent.com/owner/repo/main/SKILL.md', }); }); it('shows generic title with raw error text on unknown error and re-enables submit', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.installSkillFromUrl).mockRejectedValueOnce( + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.installWorkflowFromUrl).mockRejectedValueOnce( new Error('unexpected: something weird happened') ); @@ -155,8 +155,8 @@ describe('InstallSkillDialog', () => { }); it('categorizes "invalid SKILL.md:" errors with a friendly title and hint', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.installSkillFromUrl).mockRejectedValueOnce( + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.installWorkflowFromUrl).mockRejectedValueOnce( new Error('invalid SKILL.md: missing required field `description`') ); @@ -177,8 +177,8 @@ describe('InstallSkillDialog', () => { }); it('categorizes "unsupported url form:" errors', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.installSkillFromUrl).mockRejectedValueOnce( + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.installWorkflowFromUrl).mockRejectedValueOnce( new Error('unsupported url form: path must end in .md, got "https://example.com/foo"') ); diff --git a/app/src/components/skills/__tests__/SkillsExplorerTab.test.tsx b/app/src/components/skills/__tests__/SkillsExplorerTab.test.tsx index fac0b69bc..df724fcc8 100644 --- a/app/src/components/skills/__tests__/SkillsExplorerTab.test.tsx +++ b/app/src/components/skills/__tests__/SkillsExplorerTab.test.tsx @@ -2,14 +2,14 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { CatalogEntry } from '../../../services/api/skillRegistryApi'; -import type { SkillSummary } from '../../../services/api/skillsApi'; +import type { WorkflowSummary } from '../../../services/api/workflowsApi'; import SkillsExplorerTab from '../SkillsExplorerTab'; -vi.mock('../../../services/api/skillsApi', () => ({ - skillsApi: { - listSkills: vi.fn(), - installSkillFromUrl: vi.fn(), - uninstallSkill: vi.fn(), +vi.mock('../../../services/api/workflowsApi', () => ({ + workflowsApi: { + listWorkflows: vi.fn(), + installWorkflowFromUrl: vi.fn(), + uninstallWorkflow: vi.fn(), }, })); @@ -22,7 +22,7 @@ vi.mock('../../../services/api/skillRegistryApi', () => ({ }, })); -const MOCK_SKILL: SkillSummary = { +const MOCK_SKILL: WorkflowSummary = { id: 'test-skill', name: 'Test Skill', description: 'A test skill for unit testing', @@ -41,7 +41,7 @@ const MOCK_SKILL: SkillSummary = { warnings: [], }; -const MOCK_PROJECT_SKILL: SkillSummary = { +const MOCK_PROJECT_SKILL: WorkflowSummary = { ...MOCK_SKILL, id: 'project-skill', name: 'Project Skill', @@ -72,23 +72,23 @@ async function switchToInstalled() { describe('SkillsExplorerTab', () => { beforeEach(async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); const { skillRegistryApi } = await import( '../../../services/api/skillRegistryApi' ); - vi.mocked(skillsApi.listSkills).mockReset(); - vi.mocked(skillsApi.uninstallSkill).mockReset(); + vi.mocked(workflowsApi.listWorkflows).mockReset(); + vi.mocked(workflowsApi.uninstallWorkflow).mockReset(); vi.mocked(skillRegistryApi.browse).mockReset(); vi.mocked(skillRegistryApi.install).mockReset(); vi.mocked(skillRegistryApi.browse).mockResolvedValue([]); }); it('defaults to registry view and shows catalog entries', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); const { skillRegistryApi } = await import( '../../../services/api/skillRegistryApi' ); - vi.mocked(skillsApi.listSkills).mockResolvedValue([]); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]); render(); @@ -101,8 +101,8 @@ describe('SkillsExplorerTab', () => { }); it('shows installed skills when switching to installed tab', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.listSkills).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]); render(); @@ -119,8 +119,8 @@ describe('SkillsExplorerTab', () => { }); it('shows empty state when no installed skills', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.listSkills).mockResolvedValue([]); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); render(); await switchToInstalled(); @@ -134,8 +134,8 @@ describe('SkillsExplorerTab', () => { const { skillRegistryApi } = await import( '../../../services/api/skillRegistryApi' ); - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.listSkills).mockResolvedValue([]); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); vi.mocked(skillRegistryApi.browse).mockRejectedValue(new Error('Network error')); render(); @@ -147,8 +147,8 @@ describe('SkillsExplorerTab', () => { }); it('filters installed skills by search query', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.listSkills).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]); render(); await switchToInstalled(); @@ -165,8 +165,8 @@ describe('SkillsExplorerTab', () => { }); it('shows install from URL button', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.listSkills).mockResolvedValue([]); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); render(); @@ -176,8 +176,8 @@ describe('SkillsExplorerTab', () => { }); it('shows uninstall button only for user-scope skills', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.listSkills).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]); render(); await switchToInstalled(); @@ -191,8 +191,8 @@ describe('SkillsExplorerTab', () => { }); it('displays version and tags in installed view', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.listSkills).mockResolvedValue([MOCK_SKILL]); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]); render(); await switchToInstalled(); @@ -205,8 +205,8 @@ describe('SkillsExplorerTab', () => { }); it('displays scope badges', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.listSkills).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]); render(); await switchToInstalled(); @@ -219,12 +219,12 @@ describe('SkillsExplorerTab', () => { }); it('shows skill warnings when present', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); const skillWithWarning = { ...MOCK_SKILL, warnings: ['Missing required field: author'], }; - vi.mocked(skillsApi.listSkills).mockResolvedValue([skillWithWarning]); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([skillWithWarning]); render(); await switchToInstalled(); @@ -235,12 +235,12 @@ describe('SkillsExplorerTab', () => { }); it('shows "Installed" badge for already-installed catalog entries', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); const { skillRegistryApi } = await import( '../../../services/api/skillRegistryApi' ); const installedSkill = { ...MOCK_SKILL, id: 'registry-skill-1' }; - vi.mocked(skillsApi.listSkills).mockResolvedValue([installedSkill]); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([installedSkill]); vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]); render(); @@ -252,8 +252,8 @@ describe('SkillsExplorerTab', () => { }); it('has an install from URL button', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.listSkills).mockResolvedValue([]); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); render(); diff --git a/app/src/components/skills/__tests__/UninstallSkillConfirmDialog.test.tsx b/app/src/components/skills/__tests__/UninstallSkillConfirmDialog.test.tsx index e6c17f8d4..96be81dce 100644 --- a/app/src/components/skills/__tests__/UninstallSkillConfirmDialog.test.tsx +++ b/app/src/components/skills/__tests__/UninstallSkillConfirmDialog.test.tsx @@ -4,7 +4,7 @@ * Verifies: * - Renders skill name + on-disk path + destructive confirm copy. * - Cancel button fires onClose, does NOT hit the RPC. - * - Confirm fires `skillsApi.uninstallSkill(name)` and forwards the result + * - Confirm fires `workflowsApi.uninstallWorkflow(name)` and forwards the result * to `onUninstalled`, then closes. * - RPC error is surfaced inline and the dialog stays open (no onClose). * - While in-flight, both buttons disable and Esc no-ops (handled by @@ -14,15 +14,15 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import UninstallSkillConfirmDialog from '../UninstallSkillConfirmDialog'; -import type { SkillSummary } from '../../../services/api/skillsApi'; +import type { WorkflowSummary } from '../../../services/api/workflowsApi'; -vi.mock('../../../services/api/skillsApi', () => ({ - skillsApi: { - uninstallSkill: vi.fn(), +vi.mock('../../../services/api/workflowsApi', () => ({ + workflowsApi: { + uninstallWorkflow: vi.fn(), }, })); -const fixture: SkillSummary = { +const fixture: WorkflowSummary = { id: 'weather-helper', name: 'weather-helper', description: 'Weather forecasts', @@ -43,8 +43,8 @@ const fixture: SkillSummary = { describe('UninstallSkillConfirmDialog', () => { beforeEach(async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.uninstallSkill).mockReset(); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.uninstallWorkflow).mockReset(); }); it('renders skill name, path (stripped of /SKILL.md), and confirm copy', () => { @@ -68,14 +68,14 @@ describe('UninstallSkillConfirmDialog', () => { // by slug — the UI must pass `skill.id` (the slug). const onClose = vi.fn(); const onUninstalled = vi.fn(); - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.uninstallSkill).mockResolvedValueOnce({ + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.uninstallWorkflow).mockResolvedValueOnce({ name: 'weather-helper', removedPath: '/Users/me/.openhuman/skills/weather-helper', scope: 'user', }); - const divergent: SkillSummary = { + const divergent: WorkflowSummary = { ...fixture, id: 'weather-helper', name: 'Weather Helper (Pro)', @@ -90,14 +90,14 @@ describe('UninstallSkillConfirmDialog', () => { fireEvent.click(screen.getByTestId('uninstall-skill-confirm')); await waitFor(() => { - expect(vi.mocked(skillsApi.uninstallSkill)).toHaveBeenCalledWith('weather-helper'); + expect(vi.mocked(workflowsApi.uninstallWorkflow)).toHaveBeenCalledWith('weather-helper'); }); - expect(vi.mocked(skillsApi.uninstallSkill)).not.toHaveBeenCalledWith('Weather Helper (Pro)'); + expect(vi.mocked(workflowsApi.uninstallWorkflow)).not.toHaveBeenCalledWith('Weather Helper (Pro)'); }); it('Cancel fires onClose without calling the RPC', async () => { const onClose = vi.fn(); - const { skillsApi } = await import('../../../services/api/skillsApi'); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); render( { ); fireEvent.click(screen.getByRole('button', { name: /Cancel/ })); expect(onClose).toHaveBeenCalledTimes(1); - expect(vi.mocked(skillsApi.uninstallSkill)).not.toHaveBeenCalled(); + expect(vi.mocked(workflowsApi.uninstallWorkflow)).not.toHaveBeenCalled(); }); - it('Confirm calls skillsApi.uninstallSkill and forwards result to onUninstalled', async () => { + it('Confirm calls workflowsApi.uninstallWorkflow and forwards result to onUninstalled', async () => { const onClose = vi.fn(); const onUninstalled = vi.fn(); - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.uninstallSkill).mockResolvedValueOnce({ + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.uninstallWorkflow).mockResolvedValueOnce({ name: 'weather-helper', removedPath: '/Users/me/.openhuman/skills/weather-helper', scope: 'user', @@ -130,12 +130,12 @@ describe('UninstallSkillConfirmDialog', () => { fireEvent.click(screen.getByTestId('uninstall-skill-confirm')); await waitFor(() => { - expect(vi.mocked(skillsApi.uninstallSkill)).toHaveBeenCalledWith('weather-helper'); + expect(vi.mocked(workflowsApi.uninstallWorkflow)).toHaveBeenCalledWith('weather-helper'); }); // Assert the caller passed the slug (`id`) — not the frontmatter // display name. Regression guard for the #781 fix that swapped // `skill.name` → `skill.id` in the confirm handler. - expect(vi.mocked(skillsApi.uninstallSkill)).toHaveBeenCalledWith(fixture.id); + expect(vi.mocked(workflowsApi.uninstallWorkflow)).toHaveBeenCalledWith(fixture.id); await waitFor(() => { expect(onUninstalled).toHaveBeenCalledWith({ name: 'weather-helper', @@ -151,8 +151,8 @@ describe('UninstallSkillConfirmDialog', () => { it('surfaces RPC errors inline and keeps the dialog open', async () => { const onClose = vi.fn(); const onUninstalled = vi.fn(); - const { skillsApi } = await import('../../../services/api/skillsApi'); - vi.mocked(skillsApi.uninstallSkill).mockRejectedValueOnce( + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.uninstallWorkflow).mockRejectedValueOnce( new Error("skill 'weather-helper' is not installed") ); @@ -177,18 +177,18 @@ describe('UninstallSkillConfirmDialog', () => { }); it('disables buttons while the RPC is in flight', async () => { - const { skillsApi } = await import('../../../services/api/skillsApi'); + const { workflowsApi } = await import('../../../services/api/workflowsApi'); type UninstallResolve = (v: { name: string; removedPath: string; - scope: SkillSummary['scope']; + scope: WorkflowSummary['scope']; }) => void; const deferred: { resolve?: UninstallResolve } = {}; - vi.mocked(skillsApi.uninstallSkill).mockReturnValueOnce( + vi.mocked(workflowsApi.uninstallWorkflow).mockReturnValueOnce( new Promise<{ name: string; removedPath: string; - scope: SkillSummary['scope']; + scope: WorkflowSummary['scope']; }>(resolve => { deferred.resolve = resolve; }) diff --git a/app/src/components/skills/__tests__/WorkflowRunnerBody.test.tsx b/app/src/components/skills/__tests__/WorkflowRunnerBody.test.tsx index 1e9831ced..c3a8992be 100644 --- a/app/src/components/skills/__tests__/WorkflowRunnerBody.test.tsx +++ b/app/src/components/skills/__tests__/WorkflowRunnerBody.test.tsx @@ -9,7 +9,7 @@ * * Covered here: * - Mount with one saved schedule for the picked skill (mocking - * skills_list, skills_describe, cron_list, recent_runs). + * workflows_list, workflows_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. @@ -32,9 +32,9 @@ const hoisted = vi.hoisted(() => ({ cronRun: vi.fn(), cronUpdate: vi.fn(), cronRuns: vi.fn(), - listSkills: vi.fn(), - describeSkill: vi.fn(), - runSkill: vi.fn(), + listWorkflows: vi.fn(), + describeWorkflow: vi.fn(), + runWorkflow: vi.fn(), recentRuns: vi.fn(), readRunLog: vi.fn(), cancelRun: vi.fn(), @@ -50,11 +50,11 @@ vi.mock('../../../utils/tauriCommands/cron', () => ({ openhumanCronRuns: hoisted.cronRuns, })); -vi.mock('../../../services/api/skillsApi', () => ({ - skillsApi: { - listSkills: hoisted.listSkills, - describeSkill: hoisted.describeSkill, - runSkill: hoisted.runSkill, +vi.mock('../../../services/api/workflowsApi', () => ({ + workflowsApi: { + listWorkflows: hoisted.listWorkflows, + describeWorkflow: hoisted.describeWorkflow, + runWorkflow: hoisted.runWorkflow, recentRuns: hoisted.recentRuns, readRunLog: hoisted.readRunLog, cancelRun: hoisted.cancelRun, @@ -158,8 +158,8 @@ describe('WorkflowRunnerBody — saved-schedule toggle', () => { beforeEach(() => { Object.values(hoisted).forEach((fn) => fn.mockReset()); - hoisted.listSkills.mockResolvedValue(skillsList); - hoisted.describeSkill.mockResolvedValue(skillDescription); + hoisted.listWorkflows.mockResolvedValue(skillsList); + hoisted.describeWorkflow.mockResolvedValue(skillDescription); hoisted.recentRuns.mockResolvedValue([]); hoisted.cronList.mockResolvedValue({ result: [makeJob({ enabled: true })] }); hoisted.cronUpdate.mockResolvedValue({ result: makeJob({ enabled: false }) }); @@ -170,8 +170,8 @@ describe('WorkflowRunnerBody — saved-schedule toggle', () => { const Body = await importBody(); renderBody(Body); - // Wait for skills_list to resolve and populate the dropdown. - await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled()); + // Wait for workflows_list to resolve and populate the dropdown. + await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled()); // Pick the skill so the schedule list mounts. const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement; @@ -192,7 +192,7 @@ describe('WorkflowRunnerBody — saved-schedule toggle', () => { const Body = await importBody(); renderBody(Body); - await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled()); + await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled()); const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement; fireEvent.change(select, { target: { value: SKILL_ID } }); await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled()); @@ -226,7 +226,7 @@ describe('WorkflowRunnerBody — saved-schedule toggle', () => { const Body = await importBody(); renderBody(Body); - await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled()); + await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled()); const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement; fireEvent.change(select, { target: { value: SKILL_ID } }); await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled()); @@ -262,8 +262,8 @@ function makeRun( describe('WorkflowRunnerBody — per-job history viewer', () => { beforeEach(() => { Object.values(hoisted).forEach((fn) => fn.mockReset()); - hoisted.listSkills.mockResolvedValue(skillsList); - hoisted.describeSkill.mockResolvedValue(skillDescription); + hoisted.listWorkflows.mockResolvedValue(skillsList); + hoisted.describeWorkflow.mockResolvedValue(skillDescription); hoisted.recentRuns.mockResolvedValue([]); hoisted.cronList.mockResolvedValue({ result: [makeJob({ enabled: true })] }); hoisted.cronRuns.mockResolvedValue({ result: { runs: [makeRun(1), makeRun(2)] } }); @@ -272,7 +272,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => { it('loads cron_runs and renders history rows on first toggle', async () => { const Body = await importBody(); renderBody(Body); - await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled()); + await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled()); const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement; fireEvent.change(select, { target: { value: SKILL_ID } }); await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled()); @@ -288,7 +288,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => { 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()); + await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled()); const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement; fireEvent.change(select, { target: { value: SKILL_ID } }); await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled()); @@ -329,7 +329,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => { const Body = await importBody(); renderBody(Body); - await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled()); + await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled()); const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement; fireEvent.change(select, { target: { value: SKILL_ID } }); await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled()); @@ -374,7 +374,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => { }); const Body = await importBody(); renderBody(Body); - await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled()); + await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled()); const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement; fireEvent.change(select, { target: { value: SKILL_ID } }); await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled()); @@ -387,7 +387,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => { hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } }); const Body = await importBody(); renderBody(Body); - await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled()); + await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled()); const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement; fireEvent.change(select, { target: { value: SKILL_ID } }); await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled()); @@ -403,8 +403,8 @@ describe('WorkflowRunnerBody — per-job history viewer', () => { describe('WorkflowRunnerBody — schedule frequency + save', () => { beforeEach(() => { Object.values(hoisted).forEach((fn) => fn.mockReset()); - hoisted.listSkills.mockResolvedValue(skillsList); - hoisted.describeSkill.mockResolvedValue(skillDescription); + hoisted.listWorkflows.mockResolvedValue(skillsList); + hoisted.describeWorkflow.mockResolvedValue(skillDescription); hoisted.recentRuns.mockResolvedValue([]); hoisted.cronList.mockResolvedValue({ result: [] }); hoisted.cronAdd.mockResolvedValue({ result: makeJob() }); @@ -414,7 +414,7 @@ describe('WorkflowRunnerBody — schedule frequency + save', () => { it('changes schedule frequency and calls openhumanCronAdd on save', async () => { const Body = await importBody(); renderBody(Body); - await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled()); + await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled()); const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement; fireEvent.change(select, { target: { value: SKILL_ID } }); await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled()); @@ -444,8 +444,8 @@ describe('WorkflowRunnerBody — SmartIssuePicker conditional mount', () => { }); it('renders SmartIssuePicker when the picked skill is dev-workflow', async () => { - hoisted.listSkills.mockResolvedValue([{ id: 'dev-workflow', name: 'Dev Workflow' }]); - hoisted.describeSkill.mockResolvedValue({ + hoisted.listWorkflows.mockResolvedValue([{ id: 'dev-workflow', name: 'Dev Workflow' }]); + hoisted.describeWorkflow.mockResolvedValue({ id: 'dev-workflow', name: 'Dev Workflow', when_to_use: 'Autonomous developer.', @@ -459,7 +459,7 @@ describe('WorkflowRunnerBody — SmartIssuePicker conditional mount', () => { const Body = await importBody(); renderBody(Body); - await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled()); + await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled()); const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement; fireEvent.change(select, { target: { value: 'dev-workflow' } }); @@ -470,10 +470,10 @@ describe('WorkflowRunnerBody — SmartIssuePicker conditional mount', () => { }); it('does NOT render SmartIssuePicker for generic skills', async () => { - hoisted.listSkills.mockResolvedValue([ + hoisted.listWorkflows.mockResolvedValue([ { id: 'github-issue-crusher', name: 'GitHub Issue Crusher' }, ]); - hoisted.describeSkill.mockResolvedValue({ + hoisted.describeWorkflow.mockResolvedValue({ id: 'github-issue-crusher', name: 'GitHub Issue Crusher', when_to_use: 'Crush issues.', @@ -485,11 +485,11 @@ describe('WorkflowRunnerBody — SmartIssuePicker conditional mount', () => { const Body = await importBody(); renderBody(Body); - await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled()); + await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled()); const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement; fireEvent.change(select, { target: { value: 'github-issue-crusher' } }); - await waitFor(() => expect(hoisted.describeSkill).toHaveBeenCalled()); + await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalled()); expect(screen.queryByTestId('smart-issue-picker-stub')).not.toBeInTheDocument(); // The generic schema-driven repo field IS rendered via the // existing RepoPicker stub. @@ -502,11 +502,11 @@ describe('WorkflowRunnerBody — SmartIssuePicker conditional mount', () => { describe('WorkflowRunnerBody — URL ?workflow= preselect', () => { beforeEach(() => { Object.values(hoisted).forEach((fn) => fn.mockReset()); - hoisted.listSkills.mockResolvedValue([ + hoisted.listWorkflows.mockResolvedValue([ { id: 'dev-workflow', name: 'Dev Workflow' }, { id: 'github-issue-crusher', name: 'GitHub Issue Crusher' }, ]); - hoisted.describeSkill.mockResolvedValue({ + hoisted.describeWorkflow.mockResolvedValue({ id: 'dev-workflow', name: 'Dev Workflow', when_to_use: 'Autonomous developer.', @@ -521,17 +521,17 @@ describe('WorkflowRunnerBody — URL ?workflow= preselect', () => { const Body = await importBody(); renderBody(Body, '/workflows/run?workflow=dev-workflow'); - await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled()); + await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled()); // The picker should already be pointing at dev-workflow without any // user interaction. We assert this two ways: (a) the ) const repoInput = await screen.findByTestId('repo-picker-stub'); @@ -656,7 +656,7 @@ describe('WorkflowRunnerBody — Run Now flow', () => { fireEvent.click(runBtn.closest('button')!); await waitFor(() => - expect(hoisted.runSkill).toHaveBeenCalledWith( + expect(hoisted.runWorkflow).toHaveBeenCalledWith( 'pr-review-shepherd', expect.objectContaining({ repo: 'owner/myrepo' }) ) @@ -669,11 +669,11 @@ describe('WorkflowRunnerBody — Run Now flow', () => { // click Run again and spawn a second run. const Body = await importBody(); renderBody(Body); - await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled()); + await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled()); const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement; fireEvent.change(select, { target: { value: 'pr-review-shepherd' } }); - await waitFor(() => expect(hoisted.describeSkill).toHaveBeenCalled()); + await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalled()); const repoInput = await screen.findByTestId('repo-picker-stub'); fireEvent.change(repoInput, { target: { value: 'owner/myrepo' } }); @@ -684,22 +684,22 @@ describe('WorkflowRunnerBody — Run Now flow', () => { const callsBefore = hoisted.recentRuns.mock.calls.length; fireEvent.click(runBtn.closest('button')!); - await waitFor(() => expect(hoisted.runSkill).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(hoisted.runWorkflow).toHaveBeenCalledTimes(1)); // The post-run refresh burst re-scans recentRuns on its own. await waitFor(() => expect(hoisted.recentRuns.mock.calls.length).toBeGreaterThan(callsBefore) ); }); - it('surfaces error when runSkill rejects', async () => { - hoisted.runSkill.mockRejectedValue(new Error('backend error')); + it('surfaces error when runWorkflow rejects', async () => { + hoisted.runWorkflow.mockRejectedValue(new Error('backend error')); const Body = await importBody(); renderBody(Body); - await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled()); + await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled()); const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement; fireEvent.change(select, { target: { value: 'pr-review-shepherd' } }); - await waitFor(() => expect(hoisted.describeSkill).toHaveBeenCalled()); + await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalled()); const repoInput = await screen.findByTestId('repo-picker-stub'); fireEvent.change(repoInput, { target: { value: 'owner/myrepo' } }); @@ -762,14 +762,14 @@ describe('parseScheduledInputs', () => { describe('WorkflowRunnerBody — Stop / Edit / scheduled run-now', () => { beforeEach(() => { Object.values(hoisted).forEach((fn) => fn.mockReset()); - hoisted.listSkills.mockResolvedValue([ + hoisted.listWorkflows.mockResolvedValue([ { id: SKILL_ID, name: 'GitHub Issue Crusher', scope: 'user', legacy: false }, ]); - hoisted.describeSkill.mockResolvedValue(skillDescription); + hoisted.describeWorkflow.mockResolvedValue(skillDescription); hoisted.recentRuns.mockResolvedValue([]); hoisted.cronList.mockResolvedValue({ result: [] }); hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } }); - hoisted.runSkill.mockResolvedValue({ run_id: 'r-new', workflow_id: SKILL_ID, log: '/tmp/l' }); + hoisted.runWorkflow.mockResolvedValue({ run_id: 'r-new', workflow_id: SKILL_ID, log: '/tmp/l' }); hoisted.cancelRun.mockResolvedValue(true); }); @@ -820,7 +820,7 @@ describe('WorkflowRunnerBody — Stop / Edit / scheduled run-now', () => { // The card's "Run" runs the workflow directly with those inputs. fireEvent.click(screen.getByText('settings.skillsRunner.schedule.runNow')); await waitFor(() => - expect(hoisted.runSkill).toHaveBeenCalledWith(SKILL_ID, { channel: 'team-product' }) + expect(hoisted.runWorkflow).toHaveBeenCalledWith(SKILL_ID, { channel: 'team-product' }) ); }); }); diff --git a/app/src/components/skills/preflightGate.test.ts b/app/src/components/skills/preflightGate.test.ts index 70c35ebdc..c58ec7081 100644 --- a/app/src/components/skills/preflightGate.test.ts +++ b/app/src/components/skills/preflightGate.test.ts @@ -1,25 +1,25 @@ import { describe, expect, it } from 'vitest'; -import { isGithubGateFailure, parseSkillRunError } from './preflightGate'; +import { isGithubGateFailure, parseWorkflowRunError } from './preflightGate'; -describe('parseSkillRunError', () => { +describe('parseWorkflowRunError', () => { it('returns the raw body unchanged when no preflight prefix is present', () => { - const out = parseSkillRunError('Run failed because foo'); + const out = parseWorkflowRunError('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(''); + expect(parseWorkflowRunError(null).body).toBe(''); + expect(parseWorkflowRunError(undefined).body).toBe(''); + expect(parseWorkflowRunError('').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); + const out = parseWorkflowRunError(raw); expect(out.gate).toBe('github'); expect(out.tag).toBe('identity_mismatch'); expect(out.body).toContain('GitHub preflight failed'); @@ -37,7 +37,7 @@ describe('parseSkillRunError', () => { ]; for (const tag of tags) { const raw = `[preflight:github:${tag}] body for ${tag}`; - const out = parseSkillRunError(raw); + const out = parseWorkflowRunError(raw); expect(out.gate).toBe('github'); expect(out.tag).toBe(tag); expect(out.body).toBe(`body for ${tag}`); @@ -46,15 +46,15 @@ describe('parseSkillRunError', () => { 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); + const once = parseWorkflowRunError(raw); + const twice = parseWorkflowRunError(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'); + const out = parseWorkflowRunError('[preflight:GITHUB:Identity_Mismatch] body'); expect(out.gate).toBe('github'); expect(out.tag).toBe('identity_mismatch'); expect(out.body).toBe('body'); @@ -64,7 +64,7 @@ describe('parseSkillRunError', () => { // 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); + const out = parseWorkflowRunError(raw); expect(out.gate).toBeNull(); expect(out.body).toBe(raw); }); @@ -72,17 +72,17 @@ describe('parseSkillRunError', () => { describe('isGithubGateFailure', () => { it('returns true for a github-gate parsed error', () => { - const err = parseSkillRunError('[preflight:github:identity_mismatch] x'); + const err = parseWorkflowRunError('[preflight:github:identity_mismatch] x'); expect(isGithubGateFailure(err)).toBe(true); }); it('returns false for a free-form error', () => { - const err = parseSkillRunError('Something else failed'); + const err = parseWorkflowRunError('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'); + const err = parseWorkflowRunError('[preflight:slack:scope_missing] body'); expect(isGithubGateFailure(err)).toBe(false); expect(err.gate).toBe('slack'); }); diff --git a/app/src/components/skills/preflightGate.ts b/app/src/components/skills/preflightGate.ts index bdf2dda50..e9b9d5483 100644 --- a/app/src/components/skills/preflightGate.ts +++ b/app/src/components/skills/preflightGate.ts @@ -23,7 +23,7 @@ const PREFLIGHT_PREFIX_RE = /^\[preflight:([a-z0-9_-]+):([a-z0-9_-]+)\]\s+/i; /** Parsed shape of a backend RPC error returned by `openhuman.workflows_run`. */ -export interface SkillRunError { +export interface WorkflowRunError { /** `'github'` when this is a github-gate failure; `null` for any other error. */ gate: string | null; /** @@ -37,7 +37,7 @@ export interface SkillRunError { /** * Parse the message string returned by the `openhuman.workflows_run` RPC - * error path (or thrown by `skillsApi.runSkill`). Anything that matches + * error path (or thrown by `workflowsApi.runWorkflow`). Anything that matches * the `[preflight::]` prefix becomes a structured gate * failure; anything else falls through with `gate: null` so the caller * can render the raw text. @@ -45,7 +45,7 @@ export interface SkillRunError { * 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 { +export function parseWorkflowRunError(message: string | undefined | null): WorkflowRunError { const raw = (message ?? '').toString(); const m = PREFLIGHT_PREFIX_RE.exec(raw); if (!m) { @@ -63,6 +63,6 @@ export function parseSkillRunError(message: string | undefined | null): SkillRun * the rendering layer that wants a single boolean rather than * matching on the `gate` string. */ -export function isGithubGateFailure(err: SkillRunError): boolean { +export function isGithubGateFailure(err: WorkflowRunError): boolean { return err.gate === 'github'; } diff --git a/app/src/pages/WorkflowNew.test.tsx b/app/src/pages/WorkflowNew.test.tsx index ef33a50c1..96f4de9b0 100644 --- a/app/src/pages/WorkflowNew.test.tsx +++ b/app/src/pages/WorkflowNew.test.tsx @@ -5,7 +5,7 @@ * - renders the form (delegates to CreateWorkflowForm) and the header * Cancel/Submit buttons. * - cancel button navigates back to /skills. - * - on a successful submit (createSkill resolves), the page + * - on a successful submit (createWorkflow resolves), the page * navigates to /skills. * - submit button reflects the form's validity (disabled until both * required fields are filled). @@ -19,9 +19,11 @@ import WorkflowNew from './WorkflowNew'; const stableT = (key: string) => key; vi.mock('../lib/i18n/I18nContext', () => ({ useT: () => ({ t: stableT }) })); -const hoisted = vi.hoisted(() => ({ createSkill: vi.fn() })); +const hoisted = vi.hoisted(() => ({ createWorkflow: vi.fn() })); -vi.mock('../services/api/skillsApi', () => ({ skillsApi: { createSkill: hoisted.createSkill } })); +vi.mock('../services/api/workflowsApi', () => ({ + workflowsApi: { createWorkflow: hoisted.createWorkflow }, +})); const renderPage = () => render( @@ -35,7 +37,7 @@ const renderPage = () => describe('WorkflowNew', () => { beforeEach(() => { - hoisted.createSkill.mockReset(); + hoisted.createWorkflow.mockReset(); }); it('renders the form and the header CTAs', () => { @@ -70,7 +72,7 @@ describe('WorkflowNew', () => { }); it('navigates to /skills after a successful submit', async () => { - hoisted.createSkill.mockResolvedValue({ + hoisted.createWorkflow.mockResolvedValue({ id: 'new-skill', name: 'New Skill', scope: 'user', @@ -86,7 +88,7 @@ describe('WorkflowNew', () => { }); fireEvent.click(screen.getByTestId('skill-new-submit')); - await waitFor(() => expect(hoisted.createSkill).toHaveBeenCalled()); + await waitFor(() => expect(hoisted.createWorkflow).toHaveBeenCalled()); await screen.findByTestId('dashboard-landed'); }); }); diff --git a/app/src/pages/WorkflowNew.tsx b/app/src/pages/WorkflowNew.tsx index 4939f8924..2d2f924a2 100644 --- a/app/src/pages/WorkflowNew.tsx +++ b/app/src/pages/WorkflowNew.tsx @@ -21,7 +21,7 @@ import { useNavigate } from 'react-router-dom'; import CreateWorkflowForm from '../components/skills/CreateWorkflowForm'; import { useT } from '../lib/i18n/I18nContext'; -import { type SkillSummary } from '../services/api/skillsApi'; +import { type WorkflowSummary } from '../services/api/workflowsApi'; const PAGE_FORM_ID = 'create-skill-page-form'; @@ -38,7 +38,7 @@ export default function WorkflowNew() { }, []); const handleCreated = useCallback( - (_skill: SkillSummary) => { + (_skill: WorkflowSummary) => { // 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. diff --git a/app/src/pages/__tests__/Skills.channels-grid.test.tsx b/app/src/pages/__tests__/Skills.channels-grid.test.tsx index e9e88a4b4..19aa96c48 100644 --- a/app/src/pages/__tests__/Skills.channels-grid.test.tsx +++ b/app/src/pages/__tests__/Skills.channels-grid.test.tsx @@ -32,13 +32,13 @@ vi.mock('../../hooks/useChannelDefinitions', () => ({ }), })); -vi.mock('../../services/api/skillsApi', async () => { - const actual = await vi.importActual( - '../../services/api/skillsApi' +vi.mock('../../services/api/workflowsApi', async () => { + const actual = await vi.importActual( + '../../services/api/workflowsApi' ); return { ...actual, - skillsApi: { ...actual.skillsApi, listSkills: vi.fn().mockResolvedValue([]) }, + workflowsApi: { ...actual.workflowsApi, listWorkflows: vi.fn().mockResolvedValue([]) }, }; }); diff --git a/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx b/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx index dcafec946..5e5f7c3b5 100644 --- a/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx +++ b/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx @@ -9,13 +9,13 @@ vi.mock('../../hooks/useChannelDefinitions', () => ({ useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }), })); -vi.mock('../../services/api/skillsApi', async () => { - const actual = await vi.importActual( - '../../services/api/skillsApi' +vi.mock('../../services/api/workflowsApi', async () => { + const actual = await vi.importActual( + '../../services/api/workflowsApi' ); return { ...actual, - skillsApi: { ...actual.skillsApi, listSkills: vi.fn().mockResolvedValue([]) }, + workflowsApi: { ...actual.workflowsApi, listWorkflows: vi.fn().mockResolvedValue([]) }, }; }); diff --git a/app/src/pages/__tests__/Skills.meetings-tab.test.tsx b/app/src/pages/__tests__/Skills.meetings-tab.test.tsx index 29527373e..d11535a4b 100644 --- a/app/src/pages/__tests__/Skills.meetings-tab.test.tsx +++ b/app/src/pages/__tests__/Skills.meetings-tab.test.tsx @@ -13,13 +13,13 @@ vi.mock('../../hooks/useChannelDefinitions', () => ({ useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }), })); -vi.mock('../../services/api/skillsApi', async () => { - const actual = await vi.importActual( - '../../services/api/skillsApi' +vi.mock('../../services/api/workflowsApi', async () => { + const actual = await vi.importActual( + '../../services/api/workflowsApi' ); return { ...actual, - skillsApi: { ...actual.skillsApi, listSkills: vi.fn().mockResolvedValue([]) }, + workflowsApi: { ...actual.workflowsApi, listWorkflows: vi.fn().mockResolvedValue([]) }, }; }); diff --git a/app/src/services/api/__tests__/skillsApi.test.ts b/app/src/services/api/__tests__/workflowsApi.test.ts similarity index 50% rename from app/src/services/api/__tests__/skillsApi.test.ts rename to app/src/services/api/__tests__/workflowsApi.test.ts index 3dc1decf7..2964b5ee1 100644 --- a/app/src/services/api/__tests__/skillsApi.test.ts +++ b/app/src/services/api/__tests__/workflowsApi.test.ts @@ -1,19 +1,243 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { skillsApi } from '../skillsApi'; +import { workflowsApi } from '../workflowsApi'; vi.mock('../../coreRpcClient', () => ({ callCoreRpc: vi.fn() })); -describe('skillsApi.listSkills', () => { +describe('workflowsApi.createWorkflow', () => { beforeEach(async () => { const { callCoreRpc } = await import('../../coreRpcClient'); vi.mocked(callCoreRpc).mockReset(); }); - it('normalizes snake_case and legacy discovered skill fields', async () => { + it('forwards inputs to workflows_create and rekeys allowedTools', async () => { const { callCoreRpc } = await import('../../coreRpcClient'); vi.mocked(callCoreRpc).mockResolvedValueOnce({ - skills: [ + workflow: { + id: 'my-skill', + name: 'my-skill', + description: 'does stuff', + version: '', + author: null, + tags: ['alpha'], + tools: ['mcp/fs'], + prompts: [], + location: '/home/u/.openhuman/skills/my-skill/SKILL.md', + resources: [], + scope: 'user', + legacy: false, + warnings: [], + }, + }); + + const result = await workflowsApi.createWorkflow({ + name: 'My Skill', + description: 'does stuff', + scope: 'user', + tags: ['alpha'], + allowedTools: ['mcp/fs'], + }); + + expect(callCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.workflows_create', + params: { + name: 'My Skill', + description: 'does stuff', + scope: 'user', + tags: ['alpha'], + 'allowed-tools': ['mcp/fs'], + }, + }); + expect(result.id).toBe('my-skill'); + expect(result.scope).toBe('user'); + }); + + it('omits optional fields when not provided', async () => { + const { callCoreRpc } = await import('../../coreRpcClient'); + vi.mocked(callCoreRpc).mockResolvedValueOnce({ + workflow: { + id: 'minimal', + name: 'minimal', + description: 'd', + version: '', + author: null, + tags: [], + tools: [], + prompts: [], + location: null, + resources: [], + scope: 'user', + legacy: false, + warnings: [], + }, + }); + + await workflowsApi.createWorkflow({ name: 'minimal', description: 'd' }); + + const call = vi.mocked(callCoreRpc).mock.calls[0][0]; + expect(call.params).toEqual({ name: 'minimal', description: 'd' }); + }); + + it('unwraps an envelope response', async () => { + const { callCoreRpc } = await import('../../coreRpcClient'); + vi.mocked(callCoreRpc).mockResolvedValueOnce({ + data: { + workflow: { + id: 'env', + name: 'env', + description: 'e', + version: '', + author: null, + tags: [], + tools: [], + prompts: [], + location: null, + resources: [], + scope: 'project', + legacy: false, + warnings: [], + }, + }, + }); + const result = await workflowsApi.createWorkflow({ name: 'env', description: 'e' }); + expect(result.id).toBe('env'); + expect(result.scope).toBe('project'); + }); +}); + +describe('workflowsApi.installWorkflowFromUrl', () => { + beforeEach(async () => { + const { callCoreRpc } = await import('../../coreRpcClient'); + vi.mocked(callCoreRpc).mockReset(); + }); + + it('forwards url and rekeys timeoutSecs to timeout_secs', async () => { + const { callCoreRpc } = await import('../../coreRpcClient'); + vi.mocked(callCoreRpc).mockResolvedValueOnce({ + url: 'https://example.com/my-skill.tgz', + stdout: 'added my-skill', + stderr: '', + new_workflows: ['my-skill'], + }); + + const result = await workflowsApi.installWorkflowFromUrl({ + url: 'https://example.com/my-skill.tgz', + timeoutSecs: 120, + }); + + expect(callCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.workflows_install_from_url', + params: { url: 'https://example.com/my-skill.tgz', timeout_secs: 120 }, + }); + expect(result.newWorkflows).toEqual(['my-skill']); + expect(result.stdout).toBe('added my-skill'); + }); + + it('omits timeout_secs when not provided and normalizes missing new_workflows', async () => { + const { callCoreRpc } = await import('../../coreRpcClient'); + vi.mocked(callCoreRpc).mockResolvedValueOnce({ + url: 'https://example.com/x', + stdout: '', + stderr: '', + new_workflows: undefined, + }); + + const result = await workflowsApi.installWorkflowFromUrl({ url: 'https://example.com/x' }); + + const call = vi.mocked(callCoreRpc).mock.calls[0][0]; + expect(call.params).toEqual({ url: 'https://example.com/x' }); + expect(result.newWorkflows).toEqual([]); + }); + + it('unwraps an envelope response', async () => { + const { callCoreRpc } = await import('../../coreRpcClient'); + vi.mocked(callCoreRpc).mockResolvedValueOnce({ + data: { + url: 'https://example.com/y', + stdout: 'ok', + stderr: 'warn', + new_workflows: ['y-skill'], + }, + }); + const result = await workflowsApi.installWorkflowFromUrl({ url: 'https://example.com/y' }); + expect(result.newWorkflows).toEqual(['y-skill']); + expect(result.stderr).toBe('warn'); + }); +}); + +describe('workflowsApi.updateWorkflow', () => { + beforeEach(async () => { + const { callCoreRpc } = await import('../../coreRpcClient'); + vi.mocked(callCoreRpc).mockReset(); + }); + + it('forwards every optional field and rekeys allowedTools', async () => { + const { callCoreRpc } = await import('../../coreRpcClient'); + vi.mocked(callCoreRpc).mockResolvedValueOnce({ + workflow: { id: 'wf', name: 'WF', description: 'd', scope: 'user' as const }, + }); + + await workflowsApi.updateWorkflow({ + name: 'WF', + description: 'd', + whenToUse: 'when X happens', + scope: 'user', + license: 'MIT', + author: 'me', + tags: ['t'], + allowedTools: ['mcp/fs'], + inputs: [{ name: 'n', required: true }], + }); + + expect(callCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.workflows_update', + params: { + name: 'WF', + description: 'd', + when_to_use: 'when X happens', + scope: 'user', + license: 'MIT', + author: 'me', + tags: ['t'], + 'allowed-tools': ['mcp/fs'], + inputs: [{ name: 'n', required: true }], + }, + }); + }); +}); + +describe('workflowsApi.listWorkflows', () => { + beforeEach(async () => { + const { callCoreRpc } = await import('../../coreRpcClient'); + vi.mocked(callCoreRpc).mockReset(); + }); + + it('reads the `workflows` result field', async () => { + const { callCoreRpc } = await import('../../coreRpcClient'); + vi.mocked(callCoreRpc).mockResolvedValueOnce({ + workflows: [ + { id: 'a', name: 'A', description: '', scope: 'user' as const }, + { id: 'b', name: 'B', description: '', scope: 'project' as const }, + ], + }); + + const result = await workflowsApi.listWorkflows(); + + expect(callCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.workflows_list' }); + expect(result.map(w => w.id)).toEqual(['a', 'b']); + }); + + it('unwraps an envelope and defaults to [] when the field is absent', async () => { + const { callCoreRpc } = await import('../../coreRpcClient'); + vi.mocked(callCoreRpc).mockResolvedValueOnce({ data: {} }); + const result = await workflowsApi.listWorkflows(); + expect(result).toEqual([]); + }); + + it('normalizes snake_case and legacy discovered workflow fields', async () => { + const { callCoreRpc } = await import('../../coreRpcClient'); + vi.mocked(callCoreRpc).mockResolvedValueOnce({ + workflows: [ { id: 'hermes-demo', name: 'Hermes Demo', @@ -49,7 +273,7 @@ describe('skillsApi.listSkills', () => { ], }); - const result = await skillsApi.listSkills(); + const result = await workflowsApi.listWorkflows(); expect(callCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.workflows_list' }); expect(result[0].relatedSkills).toEqual(['browser-automation']); @@ -59,158 +283,63 @@ describe('skillsApi.listSkills', () => { }); }); -describe('skillsApi.createSkill', () => { +describe('workflowsApi.readWorkflowResource', () => { beforeEach(async () => { const { callCoreRpc } = await import('../../coreRpcClient'); vi.mocked(callCoreRpc).mockReset(); }); - it('forwards inputs to skills_create and rekeys allowedTools', async () => { + it('rekeys params to snake_case and normalizes the response', async () => { const { callCoreRpc } = await import('../../coreRpcClient'); vi.mocked(callCoreRpc).mockResolvedValueOnce({ - skill: { - id: 'my-skill', - name: 'my-skill', - description: 'does stuff', - version: '', - author: null, - tags: ['alpha'], - tools: ['mcp/fs'], - prompts: [], - location: '/home/u/.openhuman/skills/my-skill/SKILL.md', - resources: [], - scope: 'user', - legacy: false, - warnings: [], - }, + workflow_id: 'wf', + relative_path: 'scripts/run.sh', + content: '#!/bin/sh\n', + bytes: 10, }); - const result = await skillsApi.createSkill({ - name: 'My Skill', - description: 'does stuff', + const result = await workflowsApi.readWorkflowResource({ + workflowId: 'wf', + relativePath: 'scripts/run.sh', + }); + + expect(callCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.workflows_read_resource', + params: { workflow_id: 'wf', relative_path: 'scripts/run.sh' }, + }); + expect(result).toEqual({ + workflowId: 'wf', + relativePath: 'scripts/run.sh', + content: '#!/bin/sh\n', + bytes: 10, + }); + }); +}); + +describe('workflowsApi.uninstallWorkflow', () => { + beforeEach(async () => { + const { callCoreRpc } = await import('../../coreRpcClient'); + vi.mocked(callCoreRpc).mockReset(); + }); + + it('forwards the name and normalizes removed_path → removedPath', async () => { + const { callCoreRpc } = await import('../../coreRpcClient'); + vi.mocked(callCoreRpc).mockResolvedValueOnce({ + name: 'weather-helper', + removed_path: '/home/u/.openhuman/skills/weather-helper', scope: 'user', - tags: ['alpha'], - allowedTools: ['mcp/fs'], }); + const result = await workflowsApi.uninstallWorkflow('weather-helper'); + expect(callCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.workflows_create', - params: { - name: 'My Skill', - description: 'does stuff', - scope: 'user', - tags: ['alpha'], - 'allowed-tools': ['mcp/fs'], - }, + method: 'openhuman.workflows_uninstall', + params: { name: 'weather-helper' }, }); - expect(result.id).toBe('my-skill'); - expect(result.scope).toBe('user'); - }); - - it('omits optional fields when not provided', async () => { - const { callCoreRpc } = await import('../../coreRpcClient'); - vi.mocked(callCoreRpc).mockResolvedValueOnce({ - skill: { - id: 'minimal', - name: 'minimal', - description: 'd', - version: '', - author: null, - tags: [], - tools: [], - prompts: [], - location: null, - resources: [], - scope: 'user', - legacy: false, - warnings: [], - }, + expect(result).toEqual({ + name: 'weather-helper', + removedPath: '/home/u/.openhuman/skills/weather-helper', + scope: 'user', }); - - await skillsApi.createSkill({ name: 'minimal', description: 'd' }); - - const call = vi.mocked(callCoreRpc).mock.calls[0][0]; - expect(call.params).toEqual({ name: 'minimal', description: 'd' }); - }); - - it('unwraps an envelope response', async () => { - const { callCoreRpc } = await import('../../coreRpcClient'); - vi.mocked(callCoreRpc).mockResolvedValueOnce({ - data: { - skill: { - id: 'env', - name: 'env', - description: 'e', - version: '', - author: null, - tags: [], - tools: [], - prompts: [], - location: null, - resources: [], - scope: 'project', - legacy: false, - warnings: [], - }, - }, - }); - const result = await skillsApi.createSkill({ name: 'env', description: 'e' }); - expect(result.id).toBe('env'); - expect(result.scope).toBe('project'); - }); -}); - -describe('skillsApi.installSkillFromUrl', () => { - beforeEach(async () => { - const { callCoreRpc } = await import('../../coreRpcClient'); - vi.mocked(callCoreRpc).mockReset(); - }); - - it('forwards url and rekeys timeoutSecs to timeout_secs', async () => { - const { callCoreRpc } = await import('../../coreRpcClient'); - vi.mocked(callCoreRpc).mockResolvedValueOnce({ - url: 'https://example.com/my-skill.tgz', - stdout: 'added my-skill', - stderr: '', - new_skills: ['my-skill'], - }); - - const result = await skillsApi.installSkillFromUrl({ - url: 'https://example.com/my-skill.tgz', - timeoutSecs: 120, - }); - - expect(callCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.workflows_install_from_url', - params: { url: 'https://example.com/my-skill.tgz', timeout_secs: 120 }, - }); - expect(result.newSkills).toEqual(['my-skill']); - expect(result.stdout).toBe('added my-skill'); - }); - - it('omits timeout_secs when not provided and normalizes missing new_skills', async () => { - const { callCoreRpc } = await import('../../coreRpcClient'); - vi.mocked(callCoreRpc).mockResolvedValueOnce({ - url: 'https://example.com/x', - stdout: '', - stderr: '', - new_skills: undefined, - }); - - const result = await skillsApi.installSkillFromUrl({ url: 'https://example.com/x' }); - - const call = vi.mocked(callCoreRpc).mock.calls[0][0]; - expect(call.params).toEqual({ url: 'https://example.com/x' }); - expect(result.newSkills).toEqual([]); - }); - - it('unwraps an envelope response', async () => { - const { callCoreRpc } = await import('../../coreRpcClient'); - vi.mocked(callCoreRpc).mockResolvedValueOnce({ - data: { url: 'https://example.com/y', stdout: 'ok', stderr: 'warn', new_skills: ['y-skill'] }, - }); - const result = await skillsApi.installSkillFromUrl({ url: 'https://example.com/y' }); - expect(result.newSkills).toEqual(['y-skill']); - expect(result.stderr).toBe('warn'); }); }); diff --git a/app/src/services/api/skillsApi.test.ts b/app/src/services/api/workflowsApi.test.ts similarity index 77% rename from app/src/services/api/skillsApi.test.ts rename to app/src/services/api/workflowsApi.test.ts index 1ed6c1727..f77c336d5 100644 --- a/app/src/services/api/skillsApi.test.ts +++ b/app/src/services/api/workflowsApi.test.ts @@ -1,21 +1,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { skillsApi } from './skillsApi'; +import { workflowsApi } from './workflowsApi'; const mockCallCoreRpc = vi.fn(); vi.mock('../coreRpcClient', () => ({ callCoreRpc: (...a: unknown[]) => mockCallCoreRpc(...a) })); -describe('skillsApi', () => { +describe('workflowsApi', () => { beforeEach(() => { mockCallCoreRpc.mockReset(); }); - describe('createSkill', () => { + describe('createWorkflow', () => { it('includes inputs in params when non-empty', async () => { mockCallCoreRpc.mockResolvedValue({ - skill: { id: 's', name: 'S', description: '', scope: 'user' as const }, + workflow: { id: 's', name: 'S', description: '', scope: 'user' as const }, }); - await skillsApi.createSkill({ + await workflowsApi.createWorkflow({ name: 'S', description: 'desc', inputs: [{ name: 'repo', type: 'string' as const, description: 'repo', required: true }], @@ -26,7 +26,7 @@ describe('skillsApi', () => { }); }); - describe('describeSkill', () => { + describe('describeWorkflow', () => { it('calls openhuman.workflows_describe with workflow_id', async () => { mockCallCoreRpc.mockResolvedValue({ id: 'dev-workflow', @@ -34,7 +34,7 @@ describe('skillsApi', () => { description: 'Auto dev', inputs: [], }); - const result = await skillsApi.describeSkill('dev-workflow'); + const result = await workflowsApi.describeWorkflow('dev-workflow'); expect(mockCallCoreRpc).toHaveBeenCalledWith( expect.objectContaining({ method: 'openhuman.workflows_describe', @@ -48,15 +48,15 @@ describe('skillsApi', () => { mockCallCoreRpc.mockResolvedValue({ data: { id: 'x', name: 'X', description: '', inputs: [], workflow_id: 'x' }, }); - const result = await skillsApi.describeSkill('x'); + const result = await workflowsApi.describeWorkflow('x'); expect(result.id).toBe('x'); }); }); - describe('runSkill', () => { + describe('runWorkflow', () => { it('calls openhuman.workflows_run with workflow_id and inputs', async () => { mockCallCoreRpc.mockResolvedValue({ run_id: 'run-1', workflow_id: 's', log: '/tmp/log' }); - const result = await skillsApi.runSkill('s', { repo: 'owner/repo' }); + const result = await workflowsApi.runWorkflow('s', { repo: 'owner/repo' }); expect(mockCallCoreRpc).toHaveBeenCalledWith( expect.objectContaining({ method: 'openhuman.workflows_run', @@ -68,7 +68,7 @@ describe('skillsApi', () => { }); describe('readRunLog', () => { - it('calls skills_read_run_log with run_id', async () => { + it('calls workflows_read_run_log with run_id', async () => { mockCallCoreRpc.mockResolvedValue({ bytes_read: 100, eof: false, @@ -76,7 +76,7 @@ describe('skillsApi', () => { content: 'log line', offset: 100, }); - const result = await skillsApi.readRunLog('run-1'); + const result = await workflowsApi.readRunLog('run-1'); expect(mockCallCoreRpc).toHaveBeenCalledWith( expect.objectContaining({ method: 'openhuman.workflows_read_run_log', @@ -94,7 +94,7 @@ describe('skillsApi', () => { content: '', offset: 500, }); - await skillsApi.readRunLog('run-2', 200, 4096); + await workflowsApi.readRunLog('run-2', 200, 4096); expect(mockCallCoreRpc).toHaveBeenCalledWith( expect.objectContaining({ params: expect.objectContaining({ run_id: 'run-2', offset: 200, max_bytes: 4096 }), @@ -106,13 +106,13 @@ describe('skillsApi', () => { describe('recentRuns', () => { it('returns scanned runs array', async () => { mockCallCoreRpc.mockResolvedValue({ runs: [] }); - const result = await skillsApi.recentRuns(); + const result = await workflowsApi.recentRuns(); expect(Array.isArray(result)).toBe(true); }); it('passes workflow_id filter when provided', async () => { mockCallCoreRpc.mockResolvedValue({ runs: [] }); - await skillsApi.recentRuns('dev-workflow', 5); + await workflowsApi.recentRuns('dev-workflow', 5); expect(mockCallCoreRpc).toHaveBeenCalledWith( expect.objectContaining({ params: expect.objectContaining({ workflow_id: 'dev-workflow', limit: 5 }), @@ -121,12 +121,12 @@ describe('skillsApi', () => { }); }); - describe('createSkill (optional fields)', () => { + describe('createWorkflow (optional fields)', () => { it('forwards when_to_use, scope, license, author, tags, allowed-tools', async () => { mockCallCoreRpc.mockResolvedValue({ - skill: { id: 's', name: 'S', description: '', scope: 'user' as const }, + workflow: { id: 's', name: 'S', description: '', scope: 'user' as const }, }); - await skillsApi.createSkill({ + await workflowsApi.createWorkflow({ name: 'S', description: 'desc', whenToUse: 'when asked', @@ -153,20 +153,20 @@ describe('skillsApi', () => { it('omits when_to_use when blank', async () => { mockCallCoreRpc.mockResolvedValue({ - skill: { id: 's', name: 'S', description: '', scope: 'user' as const }, + workflow: { id: 's', name: 'S', description: '', scope: 'user' as const }, }); - await skillsApi.createSkill({ name: 'S', description: 'd', whenToUse: ' ' }); + await workflowsApi.createWorkflow({ name: 'S', description: 'd', whenToUse: ' ' }); const params = mockCallCoreRpc.mock.calls[0][0].params; expect(params).not.toHaveProperty('when_to_use'); }); }); - describe('updateSkill', () => { + describe('updateWorkflow', () => { it('calls openhuman.workflows_update and returns the skill', async () => { mockCallCoreRpc.mockResolvedValue({ - skill: { id: 'wf', name: 'WF', description: 'd', scope: 'user' as const }, + workflow: { id: 'wf', name: 'WF', description: 'd', scope: 'user' as const }, }); - const result = await skillsApi.updateSkill({ + const result = await workflowsApi.updateWorkflow({ name: 'WF', description: 'd', whenToUse: 'edit trigger', @@ -187,9 +187,9 @@ describe('skillsApi', () => { it('unwraps the data-envelope shape', async () => { mockCallCoreRpc.mockResolvedValue({ - data: { skill: { id: 'wf2', name: 'WF2', description: '', scope: 'user' as const } }, + data: { workflow: { id: 'wf2', name: 'WF2', description: '', scope: 'user' as const } }, }); - const result = await skillsApi.updateSkill({ name: 'WF2', description: 'd' }); + const result = await workflowsApi.updateWorkflow({ name: 'WF2', description: 'd' }); expect(result.id).toBe('wf2'); }); }); @@ -197,7 +197,7 @@ describe('skillsApi', () => { describe('cancelRun', () => { it('calls openhuman.workflows_cancel with run_id and returns cancelled', async () => { mockCallCoreRpc.mockResolvedValue({ cancelled: true }); - const result = await skillsApi.cancelRun('run-9'); + const result = await workflowsApi.cancelRun('run-9'); expect(mockCallCoreRpc).toHaveBeenCalledWith( expect.objectContaining({ method: 'openhuman.workflows_cancel', @@ -209,7 +209,7 @@ describe('skillsApi', () => { it('returns false when the run was not live (envelope shape)', async () => { mockCallCoreRpc.mockResolvedValue({ data: { cancelled: false } }); - const result = await skillsApi.cancelRun('gone'); + const result = await workflowsApi.cancelRun('gone'); expect(result).toBe(false); }); }); diff --git a/app/src/services/api/skillsApi.ts b/app/src/services/api/workflowsApi.ts similarity index 73% rename from app/src/services/api/skillsApi.ts rename to app/src/services/api/workflowsApi.ts index e5a115ab9..4fd4dc583 100644 --- a/app/src/services/api/skillsApi.ts +++ b/app/src/services/api/workflowsApi.ts @@ -2,15 +2,15 @@ import debug from 'debug'; import { callCoreRpc } from '../coreRpcClient'; -const log = debug('skillsApi'); +const log = debug('workflowsApi'); /** * Scope a skill was discovered in. * - * Mirrors `openhuman::skills::ops::SkillScope` on the Rust side — serialized + * Mirrors `openhuman::skills::ops::WorkflowScope` on the Rust side — serialized * as a lowercase string (`"user" | "project" | "legacy"`). */ -export type SkillScope = 'user' | 'project' | 'legacy'; +export type WorkflowScope = 'user' | 'project' | 'legacy'; /** * Wire-format representation of a discovered skill returned by @@ -19,7 +19,7 @@ export type SkillScope = 'user' | 'project' | 'legacy'; * Paths are intentionally serialized as strings (not URLs) to avoid lossy * conversions on non-UTF-8 filesystems. */ -export interface SkillSummary { +export interface WorkflowSummary { /** Stable identifier — equal to `name` on the Rust side. */ id: string; /** Display name, from frontmatter or directory. */ @@ -47,18 +47,18 @@ export interface SkillSummary { /** Bundled resource files, relative to the skill root. */ resources: string[]; /** Where the skill came from. */ - scope: SkillScope; + scope: WorkflowScope; /** True when loaded from the legacy `skills/` layout. */ legacy: boolean; /** Non-fatal parse warnings to surface in the UI. */ warnings: string[]; } -interface SkillsListResult { - skills: RawSkillSummary[]; +interface WorkflowsListResult { + workflows: RawWorkflowSummary[]; } -type RawSkillSummary = Omit & { +type RawWorkflowSummary = Omit & { platforms?: string[]; related_skills?: string[]; relatedSkills?: string[]; @@ -69,9 +69,9 @@ type RawSkillSummary = Omit { @@ -204,7 +204,7 @@ function unwrapEnvelope(response: Envelope | T): T { return response as T; } -function normalizeSkillSummary(raw: RawSkillSummary): SkillSummary { +function normalizeWorkflowSummary(raw: RawWorkflowSummary): WorkflowSummary { return { ...raw, platforms: raw.platforms ?? [], @@ -213,17 +213,17 @@ function normalizeSkillSummary(raw: RawSkillSummary): SkillSummary { }; } -export const skillsApi = { +export const workflowsApi = { /** Enumerate SKILL.md / legacy skills visible in the active workspace. */ - listSkills: async (): Promise => { - log('listSkills: request'); - const response = await callCoreRpc | SkillsListResult>({ + listWorkflows: async (): Promise => { + log('listWorkflows: request'); + const response = await callCoreRpc | WorkflowsListResult>({ method: 'openhuman.workflows_list', }); const result = unwrapEnvelope(response); - const skills = (result?.skills ?? []).map(normalizeSkillSummary); - log('listSkills: response count=%d', skills.length); - return skills; + const workflows = (result?.workflows ?? []).map(normalizeWorkflowSummary); + log('listWorkflows: response count=%d', workflows.length); + return workflows; }, /** @@ -231,28 +231,28 @@ export const skillsApi = { * traversal, symlink escape, non-UTF-8 payloads, or files larger than * 128 KB — the caller surfaces the error string verbatim in the drawer. */ - readSkillResource: async ({ - skillId, + readWorkflowResource: async ({ + workflowId, relativePath, }: { - skillId: string; + workflowId: string; relativePath: string; - }): Promise => { - log('readSkillResource: request skillId=%s path=%s', skillId, relativePath); + }): Promise => { + log('readWorkflowResource: request workflowId=%s path=%s', workflowId, relativePath); const response = await callCoreRpc< - Envelope | RawSkillsReadResourceResult + Envelope | RawWorkflowsReadResourceResult >({ method: 'openhuman.workflows_read_resource', - params: { workflow_id: skillId, relative_path: relativePath }, + params: { workflow_id: workflowId, relative_path: relativePath }, }); const raw = unwrapEnvelope(response); - const normalized: SkillResourceContent = { - skillId: raw.workflow_id, + const normalized: WorkflowResourceContent = { + workflowId: raw.workflow_id, relativePath: raw.relative_path, content: raw.content, bytes: raw.bytes, }; - log('readSkillResource: response bytes=%d', normalized.bytes); + log('readWorkflowResource: response bytes=%d', normalized.bytes); return normalized; }, @@ -260,12 +260,14 @@ export const skillsApi = { * Scaffold a new SKILL.md skill via `openhuman.workflows_create`. * * The Rust side slugifies the name, writes `SKILL.md` with the supplied - * frontmatter, and returns the freshly-discovered `SkillSummary` so the + * frontmatter, and returns the freshly-discovered `WorkflowSummary` so the * caller can insert the new row into the grid without a full refetch. */ - createSkill: async (input: CreateSkillInput): Promise => { - log('createSkill: request name=%s scope=%s', input.name, input.scope ?? 'default'); - const response = await callCoreRpc | RawSkillsCreateResult>({ + createWorkflow: async (input: CreateWorkflowInput): Promise => { + log('createWorkflow: request name=%s scope=%s', input.name, input.scope ?? 'default'); + const response = await callCoreRpc< + Envelope | RawWorkflowsCreateResult + >({ method: 'openhuman.workflows_create', params: { name: input.name, @@ -282,9 +284,9 @@ export const skillsApi = { }, }); const raw = unwrapEnvelope(response); - const skill = normalizeSkillSummary(raw.skill); - log('createSkill: response id=%s', skill.id); - return skill; + const workflow = normalizeWorkflowSummary(raw.workflow); + log('createWorkflow: response id=%s', workflow.id); + return workflow; }, /** @@ -293,9 +295,11 @@ export const skillsApi = { * slug — rewriting frontmatter + workflow.toml while preserving the * hand-authored SKILL.md/WORKFLOW.md body. */ - updateSkill: async (input: CreateSkillInput): Promise => { - log('updateSkill: request name=%s scope=%s', input.name, input.scope ?? 'default'); - const response = await callCoreRpc | RawSkillsCreateResult>({ + updateWorkflow: async (input: CreateWorkflowInput): Promise => { + log('updateWorkflow: request name=%s scope=%s', input.name, input.scope ?? 'default'); + const response = await callCoreRpc< + Envelope | RawWorkflowsCreateResult + >({ method: 'openhuman.workflows_update', params: { name: input.name, @@ -312,9 +316,9 @@ export const skillsApi = { }, }); const raw = unwrapEnvelope(response); - const skill = normalizeSkillSummary(raw.skill); - log('updateSkill: response id=%s', skill.id); - return skill; + const workflow = normalizeWorkflowSummary(raw.workflow); + log('updateWorkflow: response id=%s', workflow.id); + return workflow; }, /** @@ -327,12 +331,12 @@ export const skillsApi = { * is normalised to its `raw.githubusercontent.com` equivalent. Size is * capped at 1 MiB; timeout default 60s, max 600s. */ - installSkillFromUrl: async ( - input: InstallSkillFromUrlInput - ): Promise => { - log('installSkillFromUrl: request url=%s', input.url); + installWorkflowFromUrl: async ( + input: InstallWorkflowFromUrlInput + ): Promise => { + log('installWorkflowFromUrl: request url=%s', input.url); const response = await callCoreRpc< - Envelope | RawInstallSkillFromUrlResult + Envelope | RawInstallWorkflowFromUrlResult >({ method: 'openhuman.workflows_install_from_url', params: { @@ -341,15 +345,15 @@ export const skillsApi = { }, }); const raw = unwrapEnvelope(response); - const normalized: InstallSkillFromUrlResult = { + const normalized: InstallWorkflowFromUrlResult = { url: raw.url, stdout: raw.stdout, stderr: raw.stderr, - newSkills: raw.new_skills ?? [], + newWorkflows: raw.new_workflows ?? [], }; log( - 'installSkillFromUrl: response new=%d stdout=%d stderr=%d', - normalized.newSkills.length, + 'installWorkflowFromUrl: response new=%d stdout=%d stderr=%d', + normalized.newWorkflows.length, normalized.stdout.length, normalized.stderr.length ); @@ -365,37 +369,41 @@ export const skillsApi = { * canonicalises paths and refuses names with separators / traversal * sequences / anything outside the skills root. */ - uninstallSkill: async (name: string): Promise => { - log('uninstallSkill: request name=%s', name); - const response = await callCoreRpc | RawUninstallSkillResult>( - { method: 'openhuman.workflows_uninstall', params: { name } } - ); + uninstallWorkflow: async (name: string): Promise => { + log('uninstallWorkflow: request name=%s', name); + const response = await callCoreRpc< + Envelope | RawUninstallWorkflowResult + >({ method: 'openhuman.workflows_uninstall', params: { name } }); const raw = unwrapEnvelope(response); - const normalized: UninstallSkillResult = { + const normalized: UninstallWorkflowResult = { name: raw.name, removedPath: raw.removed_path, scope: raw.scope, }; - log('uninstallSkill: response name=%s removedPath=%s', normalized.name, normalized.removedPath); + log( + 'uninstallWorkflow: 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 + * metadata. Lightweight companion to `listWorkflows` — `WorkflowSummary` 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 => { - log('describeSkill: request skillId=%s', skillId); - const response = await callCoreRpc | SkillDescription>({ + describeWorkflow: async (workflowId: string): Promise => { + log('describeWorkflow: request workflowId=%s', workflowId); + const response = await callCoreRpc | WorkflowDescription>({ method: 'openhuman.workflows_describe', - params: { workflow_id: skillId }, + params: { workflow_id: workflowId }, }); const raw = unwrapEnvelope(response); - log('describeSkill: response inputs=%d', raw.inputs.length); + log('describeWorkflow: response inputs=%d', raw.inputs.length); return raw; }, @@ -406,14 +414,17 @@ export const skillsApi = { * autonomous work continues in the background and finishes with * status `DONE` / `DEGENERATE` / `FAILED` in the run log. */ - runSkill: async (skillId: string, inputs: Record): Promise => { - log('runSkill: request skillId=%s', skillId); - const response = await callCoreRpc | SkillRunStarted>({ + runWorkflow: async ( + workflowId: string, + inputs: Record + ): Promise => { + log('runWorkflow: request workflowId=%s', workflowId); + const response = await callCoreRpc | WorkflowRunStarted>({ method: 'openhuman.workflows_run', - params: { workflow_id: skillId, inputs }, + params: { workflow_id: workflowId, inputs }, }); const raw = unwrapEnvelope(response); - log('runSkill: response runId=%s log=%s', raw.run_id, raw.log); + log('runWorkflow: response runId=%s log=%s', raw.run_id, raw.log); return raw; }, @@ -459,13 +470,13 @@ export const skillsApi = { /** * Recent autonomous skill runs from `/skills/.runs/`. Sorted - * by start time descending. Pass `skillId` to filter to one skill, + * by start time descending. Pass `workflowId` to filter to one skill, * omit for cross-skill. `limit` defaults to 20 (max 100). */ - recentRuns: async (skillId?: string, limit?: number): Promise => { - log('recentRuns: request skillId=%s limit=%s', skillId ?? '*', limit ?? 'default'); + recentRuns: async (workflowId?: string, limit?: number): Promise => { + log('recentRuns: request workflowId=%s limit=%s', workflowId ?? '*', limit ?? 'default'); const params: Record = {}; - if (skillId !== undefined) params.workflow_id = skillId; + if (workflowId !== undefined) params.workflow_id = workflowId; if (limit !== undefined) params.limit = limit; const response = await callCoreRpc | { runs: ScannedRun[] }>({ method: 'openhuman.workflows_recent_runs', @@ -482,7 +493,7 @@ export const skillsApi = { * `openhuman.workflows_describe`. The FE renders one form control per entry: * `string`/`integer`/`boolean` map to text/number/checkbox controls. */ -export interface SkillInputDescription { +export interface WorkflowInputDescription { name: string; description: string; required: boolean; @@ -491,15 +502,15 @@ export interface SkillInputDescription { } /** Wire shape returned by `openhuman.workflows_describe`. */ -export interface SkillDescription { +export interface WorkflowDescription { id: string; display_name: string; when_to_use: string; - inputs: SkillInputDescription[]; + inputs: WorkflowInputDescription[]; } /** Wire shape returned by `openhuman.workflows_run` (fire-and-forget). */ -export interface SkillRunStarted { +export interface WorkflowRunStarted { run_id: string; status: string; // "started" workflow_id: string; diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 04f194297..5c07de6fc 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -2116,6 +2116,16 @@ pub async fn bootstrap_core_runtime(host_kind: crate::core::types::HostKind) { action_dir, ); + // --- Triggered-workflow subscriber --- + // Install on the always-run serve boot, not only inside `start_channels` + // (skipped for web-chat-only cores with no messaging integrations, and when + // `OPENHUMAN_DISABLE_CHANNEL_LISTENERS=1`). Without this, any workflow + // declaring `triggers:` was silently ignored on web-chat-only desktop + // installs. Idempotent — shares a process-global OnceLock with the + // `start_channels` site so it registers exactly once regardless of which + // path runs first. (Matching only for now; activation handoff still pending.) + crate::openhuman::workflows::bus::ensure_triggered_workflow_subscriber(&workspace_dir); + // --- Approval gate (#1339) --- // ON by default; opt out with `OPENHUMAN_APPROVAL_GATE=0` (or `false`). // Prompt-class `external_effect()` tool calls route through diff --git a/src/openhuman/agent/debug/mod.rs b/src/openhuman/agent/debug/mod.rs index 5375f2ad9..bcf48791e 100644 --- a/src/openhuman/agent/debug/mod.rs +++ b/src/openhuman/agent/debug/mod.rs @@ -418,7 +418,7 @@ async fn render_integrations_agent(config: &Config, toolkit: &str) -> Result>, + /// Workflows loaded into the parent. Sub-agents that don't strip the + /// workflows catalog inherit this list. + pub workflows: Arc>, /// Memory context loaded for the current turn. Auto-injected into /// subagent prompts so they have access to conversation history and diff --git a/src/openhuman/agent/harness/harness_gap_tests.rs b/src/openhuman/agent/harness/harness_gap_tests.rs index e849f6f54..c6007e364 100644 --- a/src/openhuman/agent/harness/harness_gap_tests.rs +++ b/src/openhuman/agent/harness/harness_gap_tests.rs @@ -531,7 +531,7 @@ fn datetime_section_output_matches_iso8601_date_and_utc_offset_pattern() { model_name: "test-model", agent_id: "", tools: EMPTY_TOOLS, - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: crate::openhuman::agent::prompts::LearnedContextData::default(), visible_tool_names: &EMPTY_FILTER, diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 02877e254..9f5ca83e7 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -1050,7 +1050,7 @@ impl Agent { .temperature(effective_temperature) .workspace_dir(config.workspace_dir.clone()) .action_dir(config.action_dir.clone()) - .skills(crate::openhuman::workflows::load_workflow_metadata( + .workflows(crate::openhuman::workflows::load_workflow_metadata( &config.workspace_dir, )) .auto_save(config.memory.auto_save) diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs index 1d05fccaf..de65e73ec 100644 --- a/src/openhuman/agent/harness/session/builder/setters.rs +++ b/src/openhuman/agent/harness/session/builder/setters.rs @@ -32,7 +32,7 @@ impl AgentBuilder { temperature: None, workspace_dir: None, action_dir: None, - skills: None, + workflows: None, auto_save: None, post_turn_hooks: Vec::new(), learning_enabled: false, @@ -160,8 +160,8 @@ impl AgentBuilder { } /// Sets the skills available to the agent. - pub fn skills(mut self, skills: Vec) -> Self { - self.skills = Some(skills); + pub fn workflows(mut self, skills: Vec) -> Self { + self.workflows = Some(skills); self } @@ -517,7 +517,7 @@ impl AgentBuilder { temperature: self.temperature.unwrap_or(0.7), workspace_dir, action_dir, - skills: self.skills.unwrap_or_default(), + workflows: self.workflows.unwrap_or_default(), auto_save: self.auto_save.unwrap_or(false), last_memory_context: None, last_turn_citations: Vec::new(), diff --git a/src/openhuman/agent/harness/session/runtime.rs b/src/openhuman/agent/harness/session/runtime.rs index ecfecf94f..d913926e0 100644 --- a/src/openhuman/agent/harness/session/runtime.rs +++ b/src/openhuman/agent/harness/session/runtime.rs @@ -109,9 +109,9 @@ impl Agent { self.temperature } - /// The agent's loaded skills, if any. - pub fn skills(&self) -> &[crate::openhuman::workflows::Workflow] { - &self.skills + /// The agent's loaded workflows, if any. + pub fn workflows(&self) -> &[crate::openhuman::workflows::Workflow] { + &self.workflows } /// Active Composio integrations fetched at session start. diff --git a/src/openhuman/agent/harness/session/runtime_tests.rs b/src/openhuman/agent/harness/session/runtime_tests.rs index 76f578d05..252f7f23d 100644 --- a/src/openhuman/agent/harness/session/runtime_tests.rs +++ b/src/openhuman/agent/harness/session/runtime_tests.rs @@ -271,7 +271,7 @@ fn accessors_and_history_reset_expose_agent_runtime_state() { }); let mut agent = make_agent(provider); agent.history = vec![ConversationMessage::Chat(ChatMessage::system("sys"))]; - agent.skills = vec![crate::openhuman::workflows::Workflow { + agent.workflows = vec![crate::openhuman::workflows::Workflow { name: "demo".into(), ..Default::default() }]; @@ -283,7 +283,7 @@ fn accessors_and_history_reset_expose_agent_runtime_state() { assert_eq!(agent.workspace_dir(), agent.workspace_dir.as_path()); assert_eq!(agent.model_name(), agent.model_name); assert_eq!(agent.temperature(), agent.temperature); - assert_eq!(agent.skills().len(), 1); + assert_eq!(agent.workflows().len(), 1); assert_eq!( agent.agent_config().max_tool_iterations, agent.config.max_tool_iterations diff --git a/src/openhuman/agent/harness/session/turn/context.rs b/src/openhuman/agent/harness/session/turn/context.rs index 85cbbd2f9..490b7667f 100644 --- a/src/openhuman/agent/harness/session/turn/context.rs +++ b/src/openhuman/agent/harness/session/turn/context.rs @@ -293,7 +293,7 @@ impl Agent { model_name: &self.model_name, agent_id: &self.agent_definition_name, tools: &prompt_tools, - skills: &self.skills, + workflows: &self.workflows, dispatcher_instructions: &instructions, learned, visible_tool_names: &prompt_visible_tool_names, diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index 28f945d63..049315fee 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -273,11 +273,11 @@ impl Agent { // heuristic and size cap rationale. let enriched = { use crate::openhuman::workflows::inject; - let matches = inject::match_workflows(&self.skills, user_message); + let matches = inject::match_workflows(&self.workflows, user_message); if matches.is_empty() { log::debug!( - "[skills:inject] no skill matches for user message (skill_catalog_len={})", - self.skills.len() + "[workflows:inject] no skill matches for user message (skill_catalog_len={})", + self.workflows.len() ); enriched } else { @@ -288,7 +288,7 @@ impl Agent { ); let matched_count = injection.decisions.iter().filter(|d| d.matched).count(); log::info!( - "[skills:inject] summary candidates={} matched={} injected_bytes={} truncated_any={}", + "[workflows:inject] summary candidates={} matched={} injected_bytes={} truncated_any={}", injection.decisions.len(), matched_count, injection.injected_bytes, diff --git a/src/openhuman/agent/harness/session/turn/tools.rs b/src/openhuman/agent/harness/session/turn/tools.rs index 4a4d13f50..64ae82dc1 100644 --- a/src/openhuman/agent/harness/session/turn/tools.rs +++ b/src/openhuman/agent/harness/session/turn/tools.rs @@ -124,7 +124,7 @@ impl Agent { workspace_dir: self.workspace_dir.clone(), memory: Arc::clone(&self.memory), agent_config: self.config.clone(), - skills: Arc::new(self.skills.clone()), + workflows: Arc::new(self.workflows.clone()), memory_context: Arc::new(self.last_memory_context.clone()), session_id: self.event_session_id().to_string(), channel: self.event_channel().to_string(), diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index a19e3fe8c..81a7f3466 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -447,7 +447,7 @@ fn trim_history_snaps_past_orphaned_tool_results() { fn build_parent_context_and_sanitize_helpers_cover_snapshot_paths() { let mut agent = make_agent(None); agent.last_memory_context = Some("remember this".into()); - agent.skills = vec![crate::openhuman::workflows::Workflow { + agent.workflows = vec![crate::openhuman::workflows::Workflow { name: "demo".into(), ..Default::default() }]; @@ -458,7 +458,7 @@ fn build_parent_context_and_sanitize_helpers_cover_snapshot_paths() { assert_eq!(parent.memory_context.as_deref(), Some("remember this")); assert_eq!(parent.session_id, "turn-test-session"); assert_eq!(parent.channel, "turn-test-channel"); - assert_eq!(parent.skills.len(), 1); + assert_eq!(parent.workflows.len(), 1); assert_eq!(sanitize_learned_entry(" "), ""); assert_eq!( diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index 230b748c0..4610eaae5 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -56,7 +56,7 @@ pub struct Agent { pub(super) temperature: f64, pub(super) workspace_dir: std::path::PathBuf, pub(super) action_dir: std::path::PathBuf, - pub(super) skills: Vec, + pub(super) workflows: Vec, /// Agent workflows discovered at session start. pub(super) auto_save: bool, /// Last memory context loaded for the current turn. Stored so it can @@ -275,7 +275,7 @@ pub struct AgentBuilder { pub(super) temperature: Option, pub(super) workspace_dir: Option, pub(super) action_dir: Option, - pub(super) skills: Option>, + pub(super) workflows: Option>, /// Agent workflows to surface in the prompt. Populated from `load_workflows` /// at session start; defaults to empty when not explicitly set. pub(super) auto_save: Option, diff --git a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs index cebd41cb9..28bf252e7 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs @@ -563,7 +563,7 @@ async fn run_typed_mode( model_name: &model, agent_id: &definition.id, tools: &prompt_tools, - skills: &parent.skills, + workflows: &parent.workflows, dispatcher_instructions: &dispatcher_instructions, learned: crate::openhuman::context::prompt::LearnedContextData::default(), visible_tool_names: &visible_tool_names, diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index a91ca35af..c4f00afad 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -326,7 +326,7 @@ fn make_parent(provider: Arc, tools: Vec>) -> Parent workspace_dir: std::env::temp_dir(), memory: noop_memory(), agent_config: crate::openhuman::config::AgentConfig::default(), - skills: Arc::new(vec![]), + workflows: Arc::new(vec![]), memory_context: Arc::new(None), session_id: "test-session".into(), channel: "test".into(), diff --git a/src/openhuman/agent/harness/test_support_tests.rs b/src/openhuman/agent/harness/test_support_tests.rs index 8d00bb4ef..7dfb47f7b 100644 --- a/src/openhuman/agent/harness/test_support_tests.rs +++ b/src/openhuman/agent/harness/test_support_tests.rs @@ -1599,7 +1599,7 @@ async fn orchestrator_prompt_drives_composio_call_via_delegation_chain() { model_name: "test", agent_id: "orchestrator", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: EMPTY.get_or_init(HashSet::new), diff --git a/src/openhuman/agent/profiles.rs b/src/openhuman/agent/profiles.rs index 12a79cf42..b5faa5f03 100644 --- a/src/openhuman/agent/profiles.rs +++ b/src/openhuman/agent/profiles.rs @@ -870,7 +870,7 @@ mod tests { model_name: "test-model", agent_id: "orchestrator", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible_tool_names, diff --git a/src/openhuman/agent/prompts/mod_tests.rs b/src/openhuman/agent/prompts/mod_tests.rs index aeec8f7c2..ef7ed096d 100644 --- a/src/openhuman/agent/prompts/mod_tests.rs +++ b/src/openhuman/agent/prompts/mod_tests.rs @@ -58,7 +58,7 @@ fn prompt_builder_assembles_sections() { model_name: "test-model", agent_id: "", tools: &prompt_tools, - skills: &[], + workflows: &[], dispatcher_instructions: "instr", learned: LearnedContextData::default(), visible_tool_names: &NO_FILTER, @@ -92,7 +92,7 @@ fn identity_section_creates_missing_workspace_files() { model_name: "test-model", agent_id: "", tools: &prompt_tools, - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &NO_FILTER, @@ -135,7 +135,7 @@ fn datetime_section_includes_timestamp_and_timezone() { model_name: "test-model", agent_id: "", tools: &prompt_tools, - skills: &[], + workflows: &[], dispatcher_instructions: "instr", learned: LearnedContextData::default(), visible_tool_names: &NO_FILTER, @@ -180,7 +180,7 @@ fn ctx_with_identity(identity: Option) -> PromptContext<'static> { model_name: "test-model", agent_id: "", tools: EMPTY_TOOLS, - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: visible, @@ -322,7 +322,7 @@ fn tools_section_pformat_renders_signature_not_schema() { model_name: "test-model", agent_id: "", tools: &prompt_tools, - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &NO_FILTER, @@ -365,7 +365,7 @@ fn tools_section_uses_pformat_signature_for_text_dispatchers() { model_name: "test-model", agent_id: "", tools: &prompt_tools, - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &NO_FILTER, @@ -415,7 +415,7 @@ fn user_memory_section_renders_namespaces_with_headings() { model_name: "test-model", agent_id: "", tools: &prompt_tools, - skills: &[], + workflows: &[], dispatcher_instructions: "", learned, visible_tool_names: &NO_FILTER, @@ -494,7 +494,7 @@ fn user_memory_section_returns_empty_when_no_summaries() { model_name: "test-model", agent_id: "", tools: &prompt_tools, - skills: &[], + workflows: &[], dispatcher_instructions: "", learned, visible_tool_names: &NO_FILTER, @@ -1142,7 +1142,7 @@ fn for_subagent_builder_injects_user_files_even_when_identity_omitted() { model_name: "test-model", agent_id: "", tools: &prompt_tools, - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &NO_FILTER, @@ -1188,7 +1188,7 @@ fn for_subagent_builder_injects_user_files_even_when_identity_omitted() { model_name: "test-model", agent_id: "", tools: &prompt_tools, - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &NO_FILTER, @@ -1265,7 +1265,7 @@ fn prompt_tool_constructors_and_user_memory_skip_empty_bodies() { model_name: "model", agent_id: "", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData { tree_root_summaries: vec![ns_summary("user", "kept"), ns_summary("empty", " ")], @@ -1296,7 +1296,7 @@ fn ctx_with_learned(learned: LearnedContextData) -> PromptContext<'static> { model_name: "test-model", agent_id: "", tools: prompt_tools, - skills: &[], + workflows: &[], dispatcher_instructions: "", learned, visible_tool_names: &NO_FILTER, @@ -1435,7 +1435,7 @@ fn tools_section_empty_for_native() { model_name: "test-model", agent_id: "", tools: &prompt_tools, - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &NO_FILTER, @@ -1468,7 +1468,7 @@ fn tools_section_nonempty_for_pformat() { model_name: "test-model", agent_id: "", tools: &prompt_tools, - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &NO_FILTER, @@ -1503,7 +1503,7 @@ fn tools_section_native_with_dispatcher_instructions_returns_instructions() { model_name: "test-model", agent_id: "", tools: &prompt_tools, - skills: &[], + workflows: &[], dispatcher_instructions: "## Tool Use Protocol\n\nUse native tool calling.", learned: LearnedContextData::default(), visible_tool_names: &NO_FILTER, diff --git a/src/openhuman/agent/prompts/render_helpers.rs b/src/openhuman/agent/prompts/render_helpers.rs index 36bd8be27..c09380731 100644 --- a/src/openhuman/agent/prompts/render_helpers.rs +++ b/src/openhuman/agent/prompts/render_helpers.rs @@ -640,7 +640,7 @@ pub fn default_workspace_file_content(filename: &str) -> &'static str { /// manufacture a full context when they only need the static text. fn empty_prompt_context_for_static_sections() -> PromptContext<'static> { static EMPTY_TOOLS: &[PromptTool<'static>] = &[]; - static EMPTY_SKILLS: &[crate::openhuman::workflows::Workflow] = &[]; + static EMPTY_WORKFLOWS: &[crate::openhuman::workflows::Workflow] = &[]; static EMPTY_INTEGRATIONS: &[ConnectedIntegration] = &[]; // SAFETY: the &HashSet reference must outlive the returned context; // a leaked OnceLock-style allocation gives us a permanent 'static @@ -652,7 +652,7 @@ fn empty_prompt_context_for_static_sections() -> PromptContext<'static> { model_name: "", agent_id: "", tools: EMPTY_TOOLS, - skills: EMPTY_SKILLS, + workflows: EMPTY_WORKFLOWS, dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: visible, diff --git a/src/openhuman/agent/prompts/types.rs b/src/openhuman/agent/prompts/types.rs index b7019722c..fd79baf78 100644 --- a/src/openhuman/agent/prompts/types.rs +++ b/src/openhuman/agent/prompts/types.rs @@ -339,7 +339,7 @@ pub struct PromptContext<'a> { /// Id of the agent this prompt is being built for. pub agent_id: &'a str, pub tools: &'a [PromptTool<'a>], - pub skills: &'a [Workflow], + pub workflows: &'a [Workflow], pub dispatcher_instructions: &'a str, /// Pre-fetched learned context (empty when learning is disabled). pub learned: LearnedContextData, diff --git a/src/openhuman/agent/tools/run_workflow.rs b/src/openhuman/agent/tools/run_workflow.rs index 53fdcf143..e1eb7901c 100644 --- a/src/openhuman/agent/tools/run_workflow.rs +++ b/src/openhuman/agent/tools/run_workflow.rs @@ -99,6 +99,14 @@ mod guard { Ok(()) } + /// Test-only reader for the process-lifetime spawn counter. Used by the + /// regression test that asserts a rejected spawn (e.g. unknown workflow + /// id) doesn't consume a backstop slot. + #[cfg(test)] + pub fn total_spawns() -> u64 { + TOTAL_SPAWNS.load(Ordering::SeqCst) + } + /// Acquire an await slot + re-entrancy lock for `key` (a workflow-id + /// inputs fingerprint, or `await:` for re-attach). `Err` if too /// many awaits are in flight (nesting/fan-out cap) or the same key is @@ -555,6 +563,41 @@ mod tests { super::guard::acquire_await("cap-test-9".to_string()).expect("a freed slot is reusable"); } + #[tokio::test] + async fn unknown_workflow_id_does_not_burn_a_spawn_slot() { + // Regression: a rejected spawn (unknown workflow id) must NOT consume a + // slot against the process-lifetime backstop. `account_spawn` runs only + // in the `Ok(started)` arm — after `spawn_workflow_run_background` + // succeeds — so an unknown id (which fails synchronously) never accounts + // a spawn. Without this ordering, an agent retrying a bad id would + // exhaust the 500-spawn budget for legitimate runs. Asserts the counter + // DELTA is zero (the counter is global + monotonic, so absolute value is + // shared with the backstop test — hence the serial lock + delta check). + let _s = guard_serial().lock().unwrap(); + let before = super::guard::total_spawns(); + let t = RunWorkflowTool::new(); + // wait_seconds: 0 → fire-and-forget path (no await slot taken); the + // unknown id makes the spawn fail before accounting. + let res = t + .execute(json!({ + "workflow_id": "definitely-not-a-real-workflow-zzz", + "wait_seconds": 0 + })) + .await + .expect("Ok(ToolResult)"); + assert!(res.is_error, "unknown workflow id must return a tool error"); + assert!( + res.output().contains("unknown") || res.output().contains("workflow"), + "error should reference the unknown workflow: {}", + res.output() + ); + let after = super::guard::total_spawns(); + assert_eq!( + before, after, + "a rejected spawn must not increment the spawn backstop counter" + ); + } + #[test] fn account_spawn_trips_the_process_backstop() { let _s = guard_serial().lock().unwrap(); diff --git a/src/openhuman/agent/triage/escalation.rs b/src/openhuman/agent/triage/escalation.rs index c0eaa3fe2..9524cea88 100644 --- a/src/openhuman/agent/triage/escalation.rs +++ b/src/openhuman/agent/triage/escalation.rs @@ -280,7 +280,7 @@ async fn dispatch_target_agent(agent_id: &str, prompt: &str) -> anyhow::Result Option { model_name: "", agent_id: &def.id, tools: &empty_tools, - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &empty_visible, diff --git a/src/openhuman/agent_orchestration/ops_tests.rs b/src/openhuman/agent_orchestration/ops_tests.rs index 430903cdd..35bc87387 100644 --- a/src/openhuman/agent_orchestration/ops_tests.rs +++ b/src/openhuman/agent_orchestration/ops_tests.rs @@ -86,7 +86,7 @@ fn parent_context(provider: Arc) -> ParentExecutionContext { workspace_dir: std::env::temp_dir(), memory: Arc::new(NoopMemory), agent_config: AgentConfig::default(), - skills: Arc::new(Vec::new()), + workflows: Arc::new(Vec::new()), memory_context: Arc::new(None), session_id: "orchestrator-session".to_string(), channel: "test".to_string(), diff --git a/src/openhuman/agent_orchestration/tools/spawn_parallel_agents_tests.rs b/src/openhuman/agent_orchestration/tools/spawn_parallel_agents_tests.rs index 34b13cc1e..631ecb066 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_parallel_agents_tests.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_parallel_agents_tests.rs @@ -201,7 +201,7 @@ fn parent_context_with_provider( workspace_dir: std::env::temp_dir(), memory: Arc::new(NoopMemory), agent_config, - skills: Arc::new(Vec::new()), + workflows: Arc::new(Vec::new()), memory_context: Arc::new(None), session_id: "session-test".into(), channel: "test".into(), diff --git a/src/openhuman/agent_orchestration/tools/spawn_worker_thread.rs b/src/openhuman/agent_orchestration/tools/spawn_worker_thread.rs index 91b5d9a22..b8d37dccd 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_worker_thread.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_worker_thread.rs @@ -385,7 +385,7 @@ mod tests { channel: "test".into(), all_tools: Arc::new(vec![]), all_tool_specs: Arc::new(vec![]), - skills: Arc::new(vec![]), + workflows: Arc::new(vec![]), memory_context: std::sync::Arc::new(None), connected_integrations: vec![], on_progress: None, diff --git a/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs b/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs index 3267460a8..df2e871a0 100644 --- a/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs +++ b/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs @@ -193,7 +193,7 @@ fn parent_context( workspace_dir: workspace_dir.to_path_buf(), memory: Arc::new(NoopMemory), agent_config: Default::default(), - skills: Arc::new(Vec::new()), + workflows: Arc::new(Vec::new()), memory_context: Arc::new(None), session_id: "tools-e2e-session".into(), channel: "test".into(), diff --git a/src/openhuman/agent_registry/agents/archivist/prompt.rs b/src/openhuman/agent_registry/agents/archivist/prompt.rs index 25161b090..47a600d0a 100644 --- a/src/openhuman/agent_registry/agents/archivist/prompt.rs +++ b/src/openhuman/agent_registry/agents/archivist/prompt.rs @@ -52,7 +52,7 @@ mod tests { model_name: "test", agent_id: "archivist", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible, diff --git a/src/openhuman/agent_registry/agents/code_executor/prompt.rs b/src/openhuman/agent_registry/agents/code_executor/prompt.rs index 149ada447..096dadcab 100644 --- a/src/openhuman/agent_registry/agents/code_executor/prompt.rs +++ b/src/openhuman/agent_registry/agents/code_executor/prompt.rs @@ -56,7 +56,7 @@ mod tests { model_name: "test", agent_id: "code_executor", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible, diff --git a/src/openhuman/agent_registry/agents/critic/prompt.rs b/src/openhuman/agent_registry/agents/critic/prompt.rs index 45a2e8c58..ef2c5252e 100644 --- a/src/openhuman/agent_registry/agents/critic/prompt.rs +++ b/src/openhuman/agent_registry/agents/critic/prompt.rs @@ -51,7 +51,7 @@ mod tests { model_name: "test", agent_id: "critic", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible, diff --git a/src/openhuman/agent_registry/agents/crypto_agent/prompt.rs b/src/openhuman/agent_registry/agents/crypto_agent/prompt.rs index 4e736dace..efcf6861e 100644 --- a/src/openhuman/agent_registry/agents/crypto_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/crypto_agent/prompt.rs @@ -19,7 +19,7 @@ pub fn build(ctx: &PromptContext<'_>) -> Result { agent_id = ctx.agent_id, model = ctx.model_name, tool_count = ctx.tools.len(), - skill_count = ctx.skills.len(), + workflow_count = ctx.workflows.len(), "[agent_prompt][crypto_agent] build_start" ); @@ -77,7 +77,7 @@ mod tests { model_name: "test", agent_id: "crypto_agent", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new), diff --git a/src/openhuman/agent_registry/agents/desktop_control_agent/prompt.rs b/src/openhuman/agent_registry/agents/desktop_control_agent/prompt.rs index 3908d8c0b..f5e0e81e7 100644 --- a/src/openhuman/agent_registry/agents/desktop_control_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/desktop_control_agent/prompt.rs @@ -47,7 +47,7 @@ mod tests { model_name: "test", agent_id: "desktop_control_agent", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible, diff --git a/src/openhuman/agent_registry/agents/help/prompt.rs b/src/openhuman/agent_registry/agents/help/prompt.rs index d1084cdbd..cca53738c 100644 --- a/src/openhuman/agent_registry/agents/help/prompt.rs +++ b/src/openhuman/agent_registry/agents/help/prompt.rs @@ -51,7 +51,7 @@ mod tests { model_name: "test", agent_id: "help", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible, diff --git a/src/openhuman/agent_registry/agents/integrations_agent/prompt.rs b/src/openhuman/agent_registry/agents/integrations_agent/prompt.rs index 6ff55dca2..c3e0ae5de 100644 --- a/src/openhuman/agent_registry/agents/integrations_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/integrations_agent/prompt.rs @@ -185,7 +185,7 @@ mod tests { model_name: "test", agent_id: "integrations_agent", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new), diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index fe2d9aba4..1d264b96c 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -467,7 +467,7 @@ mod tests { model_name: "test", agent_id: &def.id, tools: &empty_tools, - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &empty_visible, diff --git a/src/openhuman/agent_registry/agents/markets_agent/prompt.rs b/src/openhuman/agent_registry/agents/markets_agent/prompt.rs index 123c1a80b..794931bfc 100644 --- a/src/openhuman/agent_registry/agents/markets_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/markets_agent/prompt.rs @@ -21,7 +21,7 @@ pub fn build(ctx: &PromptContext<'_>) -> Result { agent_id = ctx.agent_id, model = ctx.model_name, tool_count = ctx.tools.len(), - skill_count = ctx.skills.len(), + workflow_count = ctx.workflows.len(), "[agent_prompt][markets_agent] build_start" ); @@ -79,7 +79,7 @@ mod tests { model_name: "test", agent_id: "markets_agent", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new), diff --git a/src/openhuman/agent_registry/agents/mcp_setup/prompt.rs b/src/openhuman/agent_registry/agents/mcp_setup/prompt.rs index 5d3c40e9a..ddc8c10d5 100644 --- a/src/openhuman/agent_registry/agents/mcp_setup/prompt.rs +++ b/src/openhuman/agent_registry/agents/mcp_setup/prompt.rs @@ -52,7 +52,7 @@ mod tests { model_name: "test", agent_id: "mcp_setup", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: visible, diff --git a/src/openhuman/agent_registry/agents/morning_briefing/prompt.rs b/src/openhuman/agent_registry/agents/morning_briefing/prompt.rs index b1e5779eb..cae83f568 100644 --- a/src/openhuman/agent_registry/agents/morning_briefing/prompt.rs +++ b/src/openhuman/agent_registry/agents/morning_briefing/prompt.rs @@ -67,7 +67,7 @@ mod tests { model_name: "test", agent_id: "morning_briefing", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: visible, diff --git a/src/openhuman/agent_registry/agents/orchestrator/prompt.rs b/src/openhuman/agent_registry/agents/orchestrator/prompt.rs index ba8f475f3..8695e38bc 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/prompt.rs +++ b/src/openhuman/agent_registry/agents/orchestrator/prompt.rs @@ -200,7 +200,7 @@ mod tests { model_name: "test", agent_id: "orchestrator", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new), diff --git a/src/openhuman/agent_registry/agents/planner/prompt.rs b/src/openhuman/agent_registry/agents/planner/prompt.rs index 8ff3c31d2..466bb09c7 100644 --- a/src/openhuman/agent_registry/agents/planner/prompt.rs +++ b/src/openhuman/agent_registry/agents/planner/prompt.rs @@ -58,7 +58,7 @@ mod tests { model_name: "test", agent_id: "planner", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible, diff --git a/src/openhuman/agent_registry/agents/presentation_agent/prompt.rs b/src/openhuman/agent_registry/agents/presentation_agent/prompt.rs index 38ee71ba5..c1d149009 100644 --- a/src/openhuman/agent_registry/agents/presentation_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/presentation_agent/prompt.rs @@ -47,7 +47,7 @@ mod tests { model_name: "test", agent_id: "presentation_agent", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible, diff --git a/src/openhuman/agent_registry/agents/researcher/prompt.rs b/src/openhuman/agent_registry/agents/researcher/prompt.rs index c8028cfd6..6e699a329 100644 --- a/src/openhuman/agent_registry/agents/researcher/prompt.rs +++ b/src/openhuman/agent_registry/agents/researcher/prompt.rs @@ -52,7 +52,7 @@ mod tests { model_name: "test", agent_id: "researcher", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible, diff --git a/src/openhuman/agent_registry/agents/scheduler_agent/prompt.rs b/src/openhuman/agent_registry/agents/scheduler_agent/prompt.rs index 9351b3927..872f3047a 100644 --- a/src/openhuman/agent_registry/agents/scheduler_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/scheduler_agent/prompt.rs @@ -47,7 +47,7 @@ mod tests { model_name: "test", agent_id: "scheduler_agent", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible, diff --git a/src/openhuman/agent_registry/agents/skill_creator/prompt.rs b/src/openhuman/agent_registry/agents/skill_creator/prompt.rs index 7daadc4a5..b9a3ee231 100644 --- a/src/openhuman/agent_registry/agents/skill_creator/prompt.rs +++ b/src/openhuman/agent_registry/agents/skill_creator/prompt.rs @@ -101,7 +101,7 @@ mod tests { model_name: "test", agent_id: "skill_creator", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible, diff --git a/src/openhuman/agent_registry/agents/summarizer/prompt.rs b/src/openhuman/agent_registry/agents/summarizer/prompt.rs index 0c36aa915..5e306eefc 100644 --- a/src/openhuman/agent_registry/agents/summarizer/prompt.rs +++ b/src/openhuman/agent_registry/agents/summarizer/prompt.rs @@ -52,7 +52,7 @@ mod tests { model_name: "test", agent_id: "summarizer", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible, diff --git a/src/openhuman/agent_registry/agents/task_manager_agent/prompt.rs b/src/openhuman/agent_registry/agents/task_manager_agent/prompt.rs index ba71922f0..8e2c001c1 100644 --- a/src/openhuman/agent_registry/agents/task_manager_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/task_manager_agent/prompt.rs @@ -51,7 +51,7 @@ mod tests { model_name: "test", agent_id: "task_manager_agent", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible, diff --git a/src/openhuman/agent_registry/agents/tool_maker/prompt.rs b/src/openhuman/agent_registry/agents/tool_maker/prompt.rs index 1308a99e2..08ca3010a 100644 --- a/src/openhuman/agent_registry/agents/tool_maker/prompt.rs +++ b/src/openhuman/agent_registry/agents/tool_maker/prompt.rs @@ -56,7 +56,7 @@ mod tests { model_name: "test", agent_id: "tool_maker", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible, diff --git a/src/openhuman/agent_registry/agents/tools_agent/prompt.rs b/src/openhuman/agent_registry/agents/tools_agent/prompt.rs index 97c2e8d5b..43de1cf82 100644 --- a/src/openhuman/agent_registry/agents/tools_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/tools_agent/prompt.rs @@ -53,7 +53,7 @@ mod tests { model_name: "test", agent_id: "tools_agent", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible, diff --git a/src/openhuman/agent_registry/agents/trigger_reactor/prompt.rs b/src/openhuman/agent_registry/agents/trigger_reactor/prompt.rs index ab6aaba2c..25180519e 100644 --- a/src/openhuman/agent_registry/agents/trigger_reactor/prompt.rs +++ b/src/openhuman/agent_registry/agents/trigger_reactor/prompt.rs @@ -52,7 +52,7 @@ mod tests { model_name: "test", agent_id: "trigger_reactor", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible, diff --git a/src/openhuman/agent_registry/agents/trigger_triage/prompt.rs b/src/openhuman/agent_registry/agents/trigger_triage/prompt.rs index e1ad8e049..4437187f6 100644 --- a/src/openhuman/agent_registry/agents/trigger_triage/prompt.rs +++ b/src/openhuman/agent_registry/agents/trigger_triage/prompt.rs @@ -52,7 +52,7 @@ mod tests { model_name: "test", agent_id: "trigger_triage", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible, diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index 22a35e19e..e6a6f9881 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -322,16 +322,10 @@ pub async fn start_channels(mut config: Config) -> Result<()> { // Install the triggered-workflow subscriber now that workflows are // discovered — otherwise any workflow declaring `triggers:` is silently - // ignored in the channel runtime. The handle is parked in a process static - // so the RAII SubscriptionHandle isn't dropped (which would cancel it). - { - use crate::core::event_bus::SubscriptionHandle; - use std::sync::OnceLock; - static TRIGGERED_WORKFLOW_HANDLE: OnceLock> = OnceLock::new(); - TRIGGERED_WORKFLOW_HANDLE.get_or_init(|| { - crate::openhuman::workflows::bus::register_triggered_workflow_subscriber(&skills) - }); - } + // ignored. Idempotent + shares a process-global OnceLock with the + // `bootstrap_core_runtime` site, so it registers exactly once regardless of + // which startup path runs first (web-chat-only cores never reach here). + crate::openhuman::workflows::bus::ensure_triggered_workflow_subscriber(&workspace); // Collect tool descriptions for the prompt let mut tool_descs: Vec<(&str, &str)> = vec![ diff --git a/src/openhuman/learning/prompt_sections.rs b/src/openhuman/learning/prompt_sections.rs index f6858f2af..75d32476f 100644 --- a/src/openhuman/learning/prompt_sections.rs +++ b/src/openhuman/learning/prompt_sections.rs @@ -296,7 +296,7 @@ mod tests { model_name: "test-model", agent_id: "", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned, visible_tool_names, diff --git a/src/openhuman/memory_tools/prompt.rs b/src/openhuman/memory_tools/prompt.rs index 1709433fb..32ede2081 100644 --- a/src/openhuman/memory_tools/prompt.rs +++ b/src/openhuman/memory_tools/prompt.rs @@ -232,7 +232,7 @@ mod tests { model_name: "test", agent_id: "test", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names: &visible, diff --git a/src/openhuman/workflows/bus.rs b/src/openhuman/workflows/bus.rs index 486f54baa..af7f367b0 100644 --- a/src/openhuman/workflows/bus.rs +++ b/src/openhuman/workflows/bus.rs @@ -14,7 +14,7 @@ use crate::core::event_bus::{subscribe_global, DomainEvent, EventHandler, SubscriptionHandle}; use crate::openhuman::workflows::Workflow; use async_trait::async_trait; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; // ── Trigger pattern ─────────────────────────────────────────────────────────── @@ -109,7 +109,7 @@ impl TriggeredWorkflowIndex { let p = TriggerPattern::parse(t); if p.is_none() { log::warn!( - "[skills::triggered] skill '{}': malformed trigger {:?} — skipping", + "[workflows::triggered] skill '{}': malformed trigger {:?} — skipping", skill.name, t ); @@ -184,7 +184,7 @@ impl EventHandler for TriggeredSkillSubscriber { tracing::debug!( domain = event.domain(), skills = ?matched, - "[skills::triggered] event matches {} skill trigger(s); \ + "[workflows::triggered] event matches {} skill trigger(s); \ activation handoff to integration layer pending", matched.len() ); @@ -213,7 +213,7 @@ pub fn register_triggered_workflow_subscriber(skills: &[Workflow]) -> Option Option> = OnceLock::new(); + +/// Idempotently install the triggered-workflow subscriber. +/// +/// Loads workflow metadata from `workspace` and registers the subscriber on the +/// **first** call; subsequent calls are no-ops (the handle is parked in +/// [`TRIGGERED_WORKFLOW_HANDLE`] so the RAII guard isn't dropped). Safe to call +/// from every startup path. +/// +/// Both [`crate::openhuman::channels::start_channels`] (messaging cores) and +/// [`crate::core::jsonrpc::bootstrap_core_runtime`] (always-run serve boot) +/// invoke this. `start_channels` is skipped for web-chat-only desktop installs +/// (no messaging integration connected) and when +/// `OPENHUMAN_DISABLE_CHANNEL_LISTENERS=1`; registering from +/// `bootstrap_core_runtime` too means those cores still honour workflow +/// `triggers:`. The shared `OnceLock` guarantees a single registration +/// regardless of which path runs first. +/// +/// NOTE: the subscriber currently only *matches* triggers and logs — the +/// activation handoff to the integration layer is still pending (see +/// [`TriggeredSkillSubscriber::handle`]). Registering on web-chat-only cores +/// enables matching, not yet activation. +pub fn ensure_triggered_workflow_subscriber(workspace: &std::path::Path) { + TRIGGERED_WORKFLOW_HANDLE.get_or_init(|| { + let workflows = crate::openhuman::workflows::load_workflow_metadata(workspace); + register_triggered_workflow_subscriber(&workflows) + }); +} + /// Legacy no-op retained while call-sites migrate to /// [`register_triggered_workflow_subscriber`]. Safe to call multiple times. pub fn register_workflow_cleanup_subscriber() {} @@ -242,6 +275,22 @@ mod tests { } } + // ── ensure_triggered_workflow_subscriber (C1 boot-path helper) ─────────── + + #[test] + fn ensure_triggered_workflow_subscriber_is_idempotent_and_safe() { + // Covers the boot-path helper called from both `start_channels` and + // `bootstrap_core_runtime`: it loads workflow metadata from the + // workspace and registers the subscriber exactly once via the + // process-global OnceLock. An empty temp workspace yields no triggered + // workflows, so registration resolves to `None`; the call must not + // panic and must be safe to repeat (the OnceLock makes the second call + // a no-op). + let tmp = tempfile::tempdir().expect("tempdir"); + ensure_triggered_workflow_subscriber(tmp.path()); + ensure_triggered_workflow_subscriber(tmp.path()); + } + // ── TriggerPattern::parse ──────────────────────────────────────────────── #[test] diff --git a/src/openhuman/workflows/inject.rs b/src/openhuman/workflows/inject.rs index bfcd059d7..d60a62986 100644 --- a/src/openhuman/workflows/inject.rs +++ b/src/openhuman/workflows/inject.rs @@ -52,7 +52,7 @@ //! //! ## Logging //! -//! Every candidate emits a grep-friendly `[skills:inject]` log line +//! Every candidate emits a grep-friendly `[workflows:inject]` log line //! with `matched=`, reason, and injected bytes (see //! [`render_injection`]). A summary line lives in the caller //! (`Agent::turn`). @@ -265,7 +265,10 @@ fn contains_whole_word(haystack_lower: &str, needle_lower: &str) -> bool { /// Match installed skills against a user message per the heuristic /// documented at the top of this module. -pub fn match_workflows<'a>(skills: &'a [Workflow], user_message: &str) -> Vec> { +pub fn match_workflows<'a>( + workflows: &'a [Workflow], + user_message: &str, +) -> Vec> { let mentions = extract_mentions(user_message); let mention_set: HashSet = mentions.iter().map(|(n, _)| n.clone()).collect(); let mention_index = |skill_norm: &str| -> Option { @@ -278,7 +281,7 @@ pub fn match_workflows<'a>(skills: &'a [Workflow], user_message: &str) -> Vec> = Vec::new(); - for skill in skills { + for skill in workflows { let normalised_name = normalise(&skill.name); let user_invocable = is_user_invocable(skill); @@ -385,7 +388,7 @@ where Some(b) => b, None => { log::warn!( - "[skills:inject] matched={} reason={} name={} skipped=body_unavailable", + "[workflows:inject] matched={} reason={} name={} skipped=body_unavailable", false, "body_unavailable", name @@ -415,7 +418,7 @@ where let min_truncated = header_len + footer_trunc_len + 1; if remaining < min_truncated { log::info!( - "[skills:inject] matched={} reason={} name={} skipped=budget_exhausted remaining_bytes={}", + "[workflows:inject] matched={} reason={} name={} skipped=budget_exhausted remaining_bytes={}", false, "budget_exhausted", name, @@ -439,7 +442,7 @@ where rendered.push_str(&footer_full); let injected = header_len + body.len() + footer_full_len; log::debug!( - "[skills:inject] matched={} reason={} name={} injected_bytes={} truncated={}", + "[workflows:inject] matched={} reason={} name={} injected_bytes={} truncated={}", true, m.reason.as_str(), name, @@ -469,7 +472,7 @@ where truncated_any = true; let injected = header_len + truncated_body.len() + footer_trunc_len; log::warn!( - "[skills:inject] matched={} reason={} name={} injected_bytes={} truncated={} body_bytes_total={} body_bytes_kept={}", + "[workflows:inject] matched={} reason={} name={} injected_bytes={} truncated={} body_bytes_total={} body_bytes_kept={}", true, m.reason.as_str(), name, diff --git a/src/openhuman/workflows/ops_parse.rs b/src/openhuman/workflows/ops_parse.rs index 6a336c5d1..bcff408ab 100644 --- a/src/openhuman/workflows/ops_parse.rs +++ b/src/openhuman/workflows/ops_parse.rs @@ -302,7 +302,7 @@ impl Workflow { pub fn read_body(&self) -> Option { if self.legacy { log::debug!( - "[skills:inject] read_body skipped for legacy skill.json skill name={}", + "[workflows:inject] read_body skipped for legacy skill.json skill name={}", self.name ); return None; @@ -312,7 +312,7 @@ impl Workflow { Some((_, body, _)) => Some(body), None => { log::warn!( - "[skills:inject] read_body failed to parse {} for skill {}", + "[workflows:inject] read_body failed to parse {} for skill {}", path.display(), self.name ); diff --git a/src/openhuman/workflows/preflight.rs b/src/openhuman/workflows/preflight.rs index 526ddad5d..1df5acc2d 100644 --- a/src/openhuman/workflows/preflight.rs +++ b/src/openhuman/workflows/preflight.rs @@ -243,36 +243,36 @@ pub async fn run_github_preflight( probes: &P, ) -> Result<(), GithubGateError> { let Some(cfg) = cfg else { - tracing::debug!("[skills:preflight] github gate skipped: no [github] block"); + tracing::debug!("[workflows:preflight] github gate skipped: no [github] block"); return Ok(()); }; if !cfg.required { - tracing::debug!("[skills:preflight] github gate skipped: required = false"); + tracing::debug!("[workflows: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"); + tracing::warn!("[workflows: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"); + tracing::warn!(error = %e, "[workflows: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"); + tracing::warn!("[workflows: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"); + tracing::warn!("[workflows:preflight] github gate fail: git_user_email_missing"); return Err(GithubGateError::GitUserEmailMissing); } @@ -280,7 +280,7 @@ pub async fn run_github_preflight( match cfg.identity_match { IdentityMatch::None => { tracing::debug!( - "[skills:preflight] github gate pass (identity_match=none, reachability only)" + "[workflows:preflight] github gate pass (identity_match=none, reachability only)" ); Ok(()) } @@ -289,12 +289,12 @@ pub async fn run_github_preflight( // identity — confirms the connection is genuinely usable. match probes.composio_identity("github").await { Some(_) => { - tracing::debug!("[skills:preflight] github gate pass (identity_match=any)"); + tracing::debug!("[workflows:preflight] github gate pass (identity_match=any)"); Ok(()) } None => { tracing::warn!( - "[skills:preflight] github gate fail: composio_identity_unresolved (identity_match=any)" + "[workflows:preflight] github gate fail: composio_identity_unresolved (identity_match=any)" ); Err(GithubGateError::ComposioIdentityUnresolved) } @@ -305,7 +305,7 @@ pub async fn run_github_preflight( Some(n) => n, None => { tracing::warn!( - "[skills:preflight] github gate fail: composio_identity_unresolved (identity_match=strict)" + "[workflows:preflight] github gate fail: composio_identity_unresolved (identity_match=strict)" ); return Err(GithubGateError::ComposioIdentityUnresolved); } @@ -314,14 +314,14 @@ pub async fn run_github_preflight( tracing::debug!( composio = %composio_name, git = %git_name, - "[skills:preflight] github gate pass (identity_match=strict)" + "[workflows:preflight] github gate pass (identity_match=strict)" ); Ok(()) } else { tracing::warn!( composio = %composio_name, git = %git_name, - "[skills:preflight] github gate fail: identity_mismatch" + "[workflows:preflight] github gate fail: identity_mismatch" ); Err(GithubGateError::IdentityMismatch { composio_username: composio_name, diff --git a/src/openhuman/workflows/schemas/handlers.rs b/src/openhuman/workflows/schemas/handlers.rs index e69eeb7a7..6fdac7d2e 100644 --- a/src/openhuman/workflows/schemas/handlers.rs +++ b/src/openhuman/workflows/schemas/handlers.rs @@ -43,7 +43,9 @@ pub(super) fn handle_workflows_list(params: Map) -> ControllerFut ); let summaries = skills.into_iter().map(WorkflowSummary::from).collect(); to_json(RpcOutcome::new( - WorkflowsListResult { skills: summaries }, + WorkflowsListResult { + workflows: summaries, + }, Vec::new(), )) }) @@ -231,7 +233,7 @@ pub(super) fn handle_workflows_create(params: Map) -> ControllerF ); to_json(RpcOutcome::new( WorkflowsCreateResult { - skill: WorkflowSummary::from(skill), + workflow: WorkflowSummary::from(skill), }, Vec::new(), )) @@ -261,7 +263,7 @@ pub(super) fn handle_workflows_update(params: Map) -> ControllerF match create_workflow(workspace.as_path(), create_params) { Ok(skill) => to_json(RpcOutcome::new( WorkflowsCreateResult { - skill: WorkflowSummary::from(skill), + workflow: WorkflowSummary::from(skill), }, Vec::new(), )), @@ -296,7 +298,7 @@ pub(super) fn handle_workflows_install_from_url(params: Map) -> C url: outcome.url, stdout: outcome.stdout, stderr: outcome.stderr, - new_skills: outcome.new_skills, + new_workflows: outcome.new_skills, }, Vec::new(), )) diff --git a/src/openhuman/workflows/schemas/wire_types.rs b/src/openhuman/workflows/schemas/wire_types.rs index 1cdb29592..1c6f20bec 100644 --- a/src/openhuman/workflows/schemas/wire_types.rs +++ b/src/openhuman/workflows/schemas/wire_types.rs @@ -193,7 +193,7 @@ impl From for WorkflowSummary { #[derive(Debug, Serialize)] pub(super) struct WorkflowsListResult { - pub(super) skills: Vec, + pub(super) workflows: Vec, } #[derive(Debug, Serialize)] @@ -206,7 +206,7 @@ pub(super) struct WorkflowsReadResourceResult { #[derive(Debug, Serialize)] pub(super) struct WorkflowsCreateResult { - pub(super) skill: WorkflowSummary, + pub(super) workflow: WorkflowSummary, } #[derive(Debug, Serialize)] @@ -214,7 +214,7 @@ pub(super) struct WorkflowsInstallFromUrlResult { pub(super) url: String, pub(super) stdout: String, pub(super) stderr: String, - pub(super) new_skills: Vec, + pub(super) new_workflows: Vec, } #[derive(Debug, Serialize)] diff --git a/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs b/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs index 2b58f7a31..92a2de541 100644 --- a/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs +++ b/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs @@ -281,7 +281,7 @@ fn parent_context(workspace: &Path, provider: Arc) -> ParentEx workspace_dir: workspace.to_path_buf(), memory: Arc::new(StubMemory), agent_config: AgentConfig::default(), - skills: Arc::new(Vec::new()), + workflows: Arc::new(Vec::new()), memory_context: Arc::new(Some("parent memory".to_string())), session_id: "round21-parent-session".to_string(), channel: "round21-channel".to_string(), diff --git a/tests/agent_builder_public.rs b/tests/agent_builder_public.rs index 36536c146..464699446 100644 --- a/tests/agent_builder_public.rs +++ b/tests/agent_builder_public.rs @@ -182,7 +182,7 @@ fn builder_applies_defaults_and_exposes_public_accessors() { ); assert_eq!(agent.temperature(), 0.7); assert_eq!(agent.workspace_dir(), std::path::Path::new(".")); - assert!(agent.skills().is_empty()); + assert!(agent.workflows().is_empty()); assert!(agent.history().is_empty()); assert_eq!(agent.agent_config().max_tool_iterations, 10); } diff --git a/tests/agent_harness_leftovers_raw_coverage_e2e.rs b/tests/agent_harness_leftovers_raw_coverage_e2e.rs index e5608501f..fd618d27c 100644 --- a/tests/agent_harness_leftovers_raw_coverage_e2e.rs +++ b/tests/agent_harness_leftovers_raw_coverage_e2e.rs @@ -296,7 +296,7 @@ fn build_agent( .model_name("round19-model".to_string()) .temperature(0.0) .workspace_dir(workspace.to_path_buf()) - .skills(Vec::new()) + .workflows(Vec::new()) .auto_save(false) .event_context("round19-session", "round19-channel") .agent_definition_name("round19_agent") @@ -320,7 +320,7 @@ fn prompt_context<'a>( model_name: "round19-model", agent_id: "round19_agent", tools, - skills: &[], + workflows: &[], dispatcher_instructions: "dispatcher guidance", learned, visible_tool_names: visible, @@ -389,7 +389,7 @@ fn parent_context(workspace: PathBuf, provider: Arc) -> Parent workspace_dir: workspace, memory: Arc::new(StubMemory::default()), agent_config: agent_config(3), - skills: Arc::new(Vec::new()), + workflows: Arc::new(Vec::new()), memory_context: Arc::new(Some("parent memory context".to_string())), session_id: "round19-parent-session".to_string(), channel: "round19-channel".to_string(), diff --git a/tests/agent_harness_public.rs b/tests/agent_harness_public.rs index 989ea68cd..cf6111f04 100644 --- a/tests/agent_harness_public.rs +++ b/tests/agent_harness_public.rs @@ -138,7 +138,7 @@ fn stub_parent_context() -> ParentExecutionContext { workspace_dir: std::path::PathBuf::from("/tmp"), memory: Arc::new(StubMemory), agent_config: AgentConfig::default(), - skills: Arc::new(vec![]), + workflows: Arc::new(vec![]), memory_context: Arc::new(Some("ctx".into())), session_id: "test-session".into(), channel: "test-channel".into(), diff --git a/tests/agent_harness_raw_coverage_e2e.rs b/tests/agent_harness_raw_coverage_e2e.rs index 0cd5269b3..ae7a542b3 100644 --- a/tests/agent_harness_raw_coverage_e2e.rs +++ b/tests/agent_harness_raw_coverage_e2e.rs @@ -250,7 +250,7 @@ fn build_agent_with_tools( .model_name("coverage-model".to_string()) .temperature(0.0) .workspace_dir(workspace.to_path_buf()) - .skills(Vec::new()) + .workflows(Vec::new()) .auto_save(false) .event_context("coverage-session", "coverage-channel") .agent_definition_name(agent_name) @@ -281,7 +281,7 @@ fn parent_context(workspace: PathBuf, provider: Arc) -> Parent workspace_dir: workspace, memory: Arc::new(StubMemory), agent_config: agent_config(), - skills: Arc::new(Vec::new()), + workflows: Arc::new(Vec::new()), memory_context: Arc::new(Some("parent memory context".to_string())), session_id: "parent-session".to_string(), channel: "coverage-channel".to_string(), diff --git a/tests/agent_large_round25_raw_coverage_e2e.rs b/tests/agent_large_round25_raw_coverage_e2e.rs index 393a92d3f..d9a5b738c 100644 --- a/tests/agent_large_round25_raw_coverage_e2e.rs +++ b/tests/agent_large_round25_raw_coverage_e2e.rs @@ -322,7 +322,7 @@ fn parent(workspace_dir: PathBuf, provider: Arc) -> ParentExec workspace_dir, memory: Arc::new(StubMemory), agent_config: AgentConfig::default(), - skills: Arc::new(Vec::new()), + workflows: Arc::new(Vec::new()), memory_context: Arc::new(Some("round25 inherited parent context".to_string())), session_id: "round25-session".to_string(), channel: "round25".to_string(), diff --git a/tests/agent_prompts_subagent_raw_coverage_e2e.rs b/tests/agent_prompts_subagent_raw_coverage_e2e.rs index 67345fc08..1983ef08a 100644 --- a/tests/agent_prompts_subagent_raw_coverage_e2e.rs +++ b/tests/agent_prompts_subagent_raw_coverage_e2e.rs @@ -287,7 +287,7 @@ fn parent(workspace: PathBuf, provider: Arc) -> ParentExecutio workspace_dir: workspace, memory: Arc::new(StubMemory), agent_config: AgentConfig::default(), - skills: Arc::new(Vec::new()), + workflows: Arc::new(Vec::new()), memory_context: Arc::new(Some("parent memory survives when allowed".to_string())), session_id: "round18-session".to_string(), channel: "round18".to_string(), @@ -310,7 +310,7 @@ fn prompt_context<'a>( model_name: "round18-model", agent_id: "round18_agent", tools, - skills: &[], + workflows: &[], dispatcher_instructions: "dispatcher rules", learned: LearnedContextData { reflections: vec!["Prefer direct answers.".to_string(), " ".to_string()], diff --git a/tests/agent_round26_raw_coverage_e2e.rs b/tests/agent_round26_raw_coverage_e2e.rs index a35c94191..189db8289 100644 --- a/tests/agent_round26_raw_coverage_e2e.rs +++ b/tests/agent_round26_raw_coverage_e2e.rs @@ -240,7 +240,7 @@ fn prompt_context<'a>( model_name: "round26-model", agent_id: "round26-agent", tools, - skills: &[] as &[Workflow], + workflows: &[] as &[Workflow], dispatcher_instructions: "round26 dispatcher instructions", learned: LearnedContextData { reflections: vec![ diff --git a/tests/agent_session_round24_raw_coverage_e2e.rs b/tests/agent_session_round24_raw_coverage_e2e.rs index 52763584f..1473c2a16 100644 --- a/tests/agent_session_round24_raw_coverage_e2e.rs +++ b/tests/agent_session_round24_raw_coverage_e2e.rs @@ -384,7 +384,7 @@ fn prompt_ctx<'a>( model_name: "round24-model", agent_id: "round24-agent", tools, - skills: &[], + workflows: &[], dispatcher_instructions: "", learned, visible_tool_names: &NO_FILTER, diff --git a/tests/agent_session_turn_raw_coverage_e2e.rs b/tests/agent_session_turn_raw_coverage_e2e.rs index 256ff59c8..c8b48493f 100644 --- a/tests/agent_session_turn_raw_coverage_e2e.rs +++ b/tests/agent_session_turn_raw_coverage_e2e.rs @@ -907,7 +907,7 @@ async fn subagent_runner_parent_context_filters_tools_caps_output_and_reports_er max_tool_iterations: 5, ..AgentConfig::default() }, - skills: Arc::new(Vec::new()), + workflows: Arc::new(Vec::new()), memory_context: Arc::new(Some("parent memory context".to_string())), session_id: "round17-parent-session".to_string(), channel: "round17-parent-channel".to_string(), diff --git a/tests/calendar_grounding_e2e.rs b/tests/calendar_grounding_e2e.rs index c9f76ce77..91762624e 100644 --- a/tests/calendar_grounding_e2e.rs +++ b/tests/calendar_grounding_e2e.rs @@ -151,7 +151,7 @@ async fn test_integrations_agent_has_current_date_context() -> Result<()> { workspace_dir: std::env::temp_dir(), memory: Arc::new(StubMemory), agent_config: openhuman_core::openhuman::config::AgentConfig::default(), - skills: Arc::new(vec![]), + workflows: Arc::new(vec![]), memory_context: Arc::new(None), session_id: "test-session".into(), channel: "test".into(), diff --git a/tests/composio_list_tools_stack_overflow_regression.rs b/tests/composio_list_tools_stack_overflow_regression.rs index 9fe45079f..2667f85de 100644 --- a/tests/composio_list_tools_stack_overflow_regression.rs +++ b/tests/composio_list_tools_stack_overflow_regression.rs @@ -347,7 +347,7 @@ async fn drive_subagent() { workspace_dir: std::env::temp_dir(), memory: Arc::new(StubMemory), agent_config: AgentConfig::default(), - skills: Arc::new(vec![]), + workflows: Arc::new(vec![]), memory_context: Arc::new(None), session_id: "stack-regression-session".into(), channel: "test".into(), diff --git a/tests/inference_agent_raw_coverage_e2e.rs b/tests/inference_agent_raw_coverage_e2e.rs index 48083e495..cb7922a1e 100644 --- a/tests/inference_agent_raw_coverage_e2e.rs +++ b/tests/inference_agent_raw_coverage_e2e.rs @@ -1301,7 +1301,7 @@ fn agent_builder_public_paths_cover_required_fields_defaults_and_filters() { ); assert_eq!(agent.temperature(), 0.7); assert_eq!(agent.workspace_dir(), std::path::Path::new(".")); - assert!(agent.skills().is_empty()); + assert!(agent.workflows().is_empty()); assert!(agent.history().is_empty()); assert_eq!(agent.agent_config().max_tool_iterations, 10); assert_eq!(agent.tools_arc().len(), 2); @@ -3308,7 +3308,7 @@ fn agent_pformat_and_prompt_renderers_cover_public_paths() { model_name: "agentic-v1", agent_id: "planner", tools: &prompt_tools, - skills: &skills, + workflows: &skills, dispatcher_instructions: "Use tool calls when useful.", learned, visible_tool_names: &visible_tool_names, @@ -3417,7 +3417,7 @@ fn agent_builtin_prompt_builders_cover_all_registered_archetypes() { model_name: "agentic-v1", agent_id: builtin.id, tools: &prompt_tools, - skills: &skills, + workflows: &skills, dispatcher_instructions: "Use available tools when needed.", learned: LearnedContextData::default(), visible_tool_names: &visible_tool_names, diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 22efd5b2a..5d1544e64 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -10480,7 +10480,9 @@ async fn json_rpc_workflows_lifecycle_round_trip() { ) .await; let create_result = assert_no_jsonrpc_error(&create, "workflows_create"); - let wf = create_result.get("skill").expect("skill in create result"); + let wf = create_result + .get("workflow") + .expect("workflow in create result"); // `id` is the on-disk slug (WorkflowSummary maps dir_name → id). The // persisted frontmatter `name` is the slug too (create slugifies it). assert_eq!(wf.get("id").and_then(Value::as_str), Some("bug-triage")); @@ -10490,9 +10492,9 @@ async fn json_rpc_workflows_lifecycle_round_trip() { let list = post_json_rpc(&rpc_base, 9202, "openhuman.workflows_list", json!({})).await; let list_result = assert_no_jsonrpc_error(&list, "workflows_list"); let workflows = list_result - .get("skills") + .get("workflows") .and_then(Value::as_array) - .expect("skills array in list result"); + .expect("workflows array in list result"); assert_eq!(workflows.len(), 1, "exactly one workflow after create"); assert_eq!( workflows[0].get("id").and_then(Value::as_str), @@ -10547,7 +10549,7 @@ async fn json_rpc_workflows_lifecycle_round_trip() { let after_result = assert_no_jsonrpc_error(&after, "workflows_list"); assert!( after_result - .get("skills") + .get("workflows") .and_then(Value::as_array) .expect("workflows array") .is_empty(), diff --git a/tests/monitor_agent_e2e.rs b/tests/monitor_agent_e2e.rs index f3119a43f..8b4a218fd 100644 --- a/tests/monitor_agent_e2e.rs +++ b/tests/monitor_agent_e2e.rs @@ -286,7 +286,7 @@ fn build_agent( .model_name("monitor-e2e-model".to_string()) .temperature(0.0) .workspace_dir(workspace.to_path_buf()) - .skills(Vec::new()) + .workflows(Vec::new()) .auto_save(false) .event_context("monitor-e2e-session", "monitor-e2e-channel") .agent_definition_name("orchestrator") diff --git a/tests/personality_e2e.rs b/tests/personality_e2e.rs index 79f28ffa5..69e6740a8 100644 --- a/tests/personality_e2e.rs +++ b/tests/personality_e2e.rs @@ -70,7 +70,7 @@ fn empty_prompt_context<'a>(workspace_dir: &'a std::path::Path) -> PromptContext model_name: "test-model", agent_id: "orchestrator", tools: &[], - skills: &[], + workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names, diff --git a/tests/tools_agent_credentials_state_raw_coverage_e2e.rs b/tests/tools_agent_credentials_state_raw_coverage_e2e.rs index fa7fdb39b..5b6f2205c 100644 --- a/tests/tools_agent_credentials_state_raw_coverage_e2e.rs +++ b/tests/tools_agent_credentials_state_raw_coverage_e2e.rs @@ -369,7 +369,7 @@ fn parent_context(workspace: PathBuf, provider: Arc) -> Parent max_tool_iterations: 3, ..Default::default() }, - skills: Arc::new(Vec::new()), + workflows: Arc::new(Vec::new()), memory_context: Arc::new(Some("parent memory".to_string())), session_id: "round16-parent".to_string(), channel: "round16-channel".to_string(), @@ -741,7 +741,7 @@ async fn round16_agent_builder_turn_uses_public_harness_paths() { .model_name("round16-model".to_string()) .temperature(0.0) .workspace_dir(harness.workspace.clone()) - .skills(Vec::new()) + .workflows(Vec::new()) .auto_save(false) .event_context("round16-session", "round16-channel") .agent_definition_name("round16_builder")