mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(workflows): agent workflows — phase-keyed task lifecycle playbooks (#3008)
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# Project memory index
|
||||
|
||||
- [cargo check vs test verification](cargo-check-vs-test-verification.md) — `cargo check` skips test modules; use `cargo test --no-run` to verify tests compile.
|
||||
- [Subagent output can be lost](subagent-output-can-be-lost.md) — commit verified subagent work promptly; scope parallel agents to non-overlapping paths.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: cargo-check-vs-test-verification
|
||||
description: cargo check passing does NOT mean tests compile; always cargo test --no-run to verify test modules
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
`cargo check --manifest-path Cargo.toml` compiles the **lib only** — it does NOT compile `#[cfg(test)]` modules or sibling `*_tests.rs` files. A domain can pass `cargo check` while its own test modules have compile errors (missing imports in `use super::*` test files, private-fn re-export E0364, missing struct fields in test-only `PromptContext` construction sites, etc.).
|
||||
|
||||
**Why:** In this repo, `cargo check` greenlit a new domain whose `select_tests.rs` had unresolved `WorkflowPhase`/`PHASE_*` imports and whose `ops.rs` had a `pub(crate) use slugify` (E0364) — all invisible until `cargo test` compiled the test cfg. Also: adding a field to a widely-constructed struct (e.g. `PromptContext`) requires updating **every** construction site including those in `tests/*.rs` integration tests (e.g. `tests/personality_e2e.rs`), which `cargo check` won't surface.
|
||||
|
||||
**How to apply:** To verify Rust work actually compiles AND tests are valid, run `cargo test --manifest-path Cargo.toml --no-run` (compiles all test targets) and then run the actual tests. Never report "N tests pass" based on a `cargo check` exit code or a `cargo test <filter>` run that shows "0 passed; N filtered out" — that means the filter matched nothing, not success. Confirm the result line shows a non-zero passed count. See [[subagent-output-can-be-lost]].
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: subagent-output-can-be-lost
|
||||
description: Long-running subagents can die/roll back losing uncommitted work; commit verified subagent output promptly
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
During the agent_workflows feature build, a `codecrusher` subagent ran ~20 min, hit an API socket error, and its working-tree output was largely rolled back (only 2 of ~10 files survived) — and a separate harness subagent got stuck in a loop re-running 5-minute background `cargo test` commands without converging, requiring a manual TaskStop + `pkill -f "cargo test"`.
|
||||
|
||||
**How to apply:**
|
||||
- When a subagent completes a self-contained, independently-verified slice (e.g. frontend in `app/` only), **commit it promptly** as a checkpoint rather than letting it sit uncommitted while other agents run — uncommitted work is the only thing at risk of a rollback.
|
||||
- Give parallel subagents **non-overlapping path scopes** (one in `src/`, one in `app/`) and tell them NOT to commit, so the main thread reconciles and commits.
|
||||
- If a subagent goes quiet, check liveness via file mtimes + `TaskOutput(block:false)`; if it's looping on long background commands, `TaskStop` it and `pkill` stray `cargo` processes, then finish the work directly.
|
||||
- Always independently re-verify a subagent's claimed results — see [[cargo-check-vs-test-verification]] (a subagent reported "tests pass" when the test cfg never compiled).
|
||||
@@ -7,6 +7,7 @@ import PublicRoute from './components/PublicRoute';
|
||||
import HumanPage from './features/human/HumanPage';
|
||||
import { getIsMobile } from './lib/platform';
|
||||
import Accounts from './pages/Accounts';
|
||||
import AgentWorkflows from './pages/AgentWorkflows';
|
||||
import Channels from './pages/Channels';
|
||||
import Home from './pages/Home';
|
||||
import Intelligence from './pages/Intelligence';
|
||||
@@ -174,6 +175,15 @@ const AppRoutes = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/workflows"
|
||||
element={
|
||||
<ProtectedRoute requireAuth={true}>
|
||||
<AgentWorkflows />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="/webhooks" element={<Navigate to="/settings/webhooks-triggers" replace />} />
|
||||
|
||||
<Route
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* CreateWorkflowModal
|
||||
* -------------------
|
||||
*
|
||||
* Centered modal that scaffolds a new workflow via `openhuman.workflows_create`.
|
||||
* Mirrors CreateSkillModal — backdrop, Escape-to-close, focus capture, 520px
|
||||
* max-width, clean white background.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { type Workflow, workflowsApi } from '../../services/api/workflowsApi';
|
||||
|
||||
const log = debug('workflows:create-modal');
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onCreated: (workflow: Workflow) => void;
|
||||
}
|
||||
|
||||
export default function CreateWorkflowModal({ onClose, onCreated }: Props) {
|
||||
const { t } = useT();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [whenToUse, setWhenToUse] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const nameInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const isValid = name.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
previousFocusRef.current = document.activeElement as HTMLElement | null;
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
nameInputRef.current?.focus();
|
||||
});
|
||||
log('mount');
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
previousFocusRef.current?.focus?.();
|
||||
log('unmount');
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !submitting) {
|
||||
log('escape-key close');
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [onClose, submitting]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isValid || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
log('submit name=%s', name.trim());
|
||||
try {
|
||||
const workflow = await workflowsApi.createWorkflow({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
when_to_use: whenToUse.trim() || undefined,
|
||||
});
|
||||
log('created name=%s', workflow.name);
|
||||
onCreated(workflow);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('create error %s', msg);
|
||||
setError(msg);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget && !submitting) {
|
||||
log('backdrop-click close');
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-fade-in"
|
||||
onClick={() => {
|
||||
if (!submitting) {
|
||||
log('backdrop-direct close');
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-workflow-title"
|
||||
className="relative w-full max-w-[520px] rounded-2xl bg-white dark:bg-neutral-900 shadow-2xl animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 border-b border-stone-100 dark:border-neutral-800 px-5 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2
|
||||
id="create-workflow-title"
|
||||
className="text-base font-semibold text-stone-900 dark:text-neutral-100 font-sans">
|
||||
{t('workflows.create.title')}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
|
||||
{t('workflows.create.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!submitting) {
|
||||
log('close-button');
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
disabled={submitting}
|
||||
aria-label={t('common.close')}
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-stone-400 dark:text-neutral-500 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 hover:text-stone-600 dark:hover:text-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:opacity-40">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<form id="create-workflow-form" onSubmit={e => void handleSubmit(e)}>
|
||||
<div className="max-h-[65vh] overflow-y-auto px-5 py-4 space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="wf-name"
|
||||
className="block text-xs font-medium text-stone-700 dark:text-neutral-200 mb-1">
|
||||
{t('workflows.create.name')}
|
||||
<span className="ml-1 text-coral-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
id="wf-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
disabled={submitting}
|
||||
placeholder={t('workflows.create.namePlaceholder')}
|
||||
required
|
||||
className="w-full rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="wf-description"
|
||||
className="block text-xs font-medium text-stone-700 dark:text-neutral-200 mb-1">
|
||||
{t('workflows.create.description')}
|
||||
<span className="ml-1 text-stone-400 dark:text-neutral-500 font-normal">
|
||||
{t('workflows.create.optional')}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="wf-description"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
disabled={submitting}
|
||||
placeholder={t('workflows.create.descriptionPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* When to use */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="wf-when-to-use"
|
||||
className="block text-xs font-medium text-stone-700 dark:text-neutral-200 mb-1">
|
||||
{t('workflows.create.whenToUse')}
|
||||
<span className="ml-1 text-stone-400 dark:text-neutral-500 font-normal">
|
||||
{t('workflows.create.optional')}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="wf-when-to-use"
|
||||
value={whenToUse}
|
||||
onChange={e => setWhenToUse(e.target.value)}
|
||||
disabled={submitting}
|
||||
placeholder={t('workflows.create.whenToUsePlaceholder')}
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-coral-200 bg-coral-50 p-3 text-xs text-coral-800">
|
||||
{t('workflows.create.createError')}: {error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-stone-100 dark:border-neutral-800 px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:opacity-40">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="create-workflow-form"
|
||||
disabled={!isValid || submitting}
|
||||
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
{submitting ? t('workflows.create.creating') : t('workflows.create.createBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* PhaseEditor
|
||||
* -----------
|
||||
* Read-only display of a single workflow phase. Shows rules, scripts,
|
||||
* tool allow/deny lists, and context providers. Editing is not supported
|
||||
* in v1 — workflows are defined by the core on disk.
|
||||
*/
|
||||
import { type FC } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { ToolScope, WorkflowPhase } from '../../services/api/workflowsApi';
|
||||
|
||||
interface PhaseEditorProps {
|
||||
phaseName: string;
|
||||
phase: WorkflowPhase;
|
||||
}
|
||||
|
||||
function StringList({ items, emptyKey }: { items: string[]; emptyKey: string }) {
|
||||
const { t } = useT();
|
||||
if (items.length === 0) {
|
||||
return <p className="text-xs italic text-stone-400 dark:text-neutral-500">{t(emptyKey)}</p>;
|
||||
}
|
||||
return (
|
||||
<ul className="mt-1 space-y-1">
|
||||
{items.map((item, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="rounded-md bg-stone-50 dark:bg-neutral-800/60 border border-stone-200 dark:border-neutral-700 px-2 py-1 font-mono text-[11px] text-stone-700 dark:text-neutral-200">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolScopeDisplay({ scope }: { scope: ToolScope | null | undefined }) {
|
||||
const { t } = useT();
|
||||
if (!scope) {
|
||||
return (
|
||||
<p className="text-xs italic text-stone-400 dark:text-neutral-500">
|
||||
{t('workflows.phase.toolScope.inherited')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{scope.allow.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-sage-600 dark:text-sage-400">
|
||||
{t('workflows.phase.toolScope.allow')}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{scope.allow.map(tool => (
|
||||
<span
|
||||
key={tool}
|
||||
className="inline-flex items-center rounded border border-sage-200 dark:border-sage-500/30 bg-sage-50 dark:bg-sage-500/10 px-1.5 py-0.5 font-mono text-[10px] text-sage-700 dark:text-sage-300">
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{scope.deny.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-coral-600 dark:text-coral-400">
|
||||
{t('workflows.phase.toolScope.deny')}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{scope.deny.map(tool => (
|
||||
<span
|
||||
key={tool}
|
||||
className="inline-flex items-center rounded border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-1.5 py-0.5 font-mono text-[10px] text-coral-700 dark:text-coral-300">
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{scope.allow.length === 0 && scope.deny.length === 0 ? (
|
||||
<p className="text-xs italic text-stone-400 dark:text-neutral-500">
|
||||
{t('workflows.phase.toolScope.empty')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PhaseEditor: FC<PhaseEditorProps> = ({ phaseName, phase }) => {
|
||||
const { t } = useT();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 p-4 space-y-4">
|
||||
{/* Phase name */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full border border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10 px-2.5 py-0.5 font-mono text-xs font-medium text-primary-700 dark:text-primary-300">
|
||||
{phaseName}
|
||||
</span>
|
||||
{phase.description ? (
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400">{phase.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Rules */}
|
||||
<div>
|
||||
<h4 className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
{t('workflows.phase.rules')} ({phase.rules.length})
|
||||
</h4>
|
||||
<StringList items={phase.rules} emptyKey="workflows.phase.rules.empty" />
|
||||
</div>
|
||||
|
||||
{/* Scripts */}
|
||||
<div>
|
||||
<h4 className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
{t('workflows.phase.scripts')} ({phase.scripts.length})
|
||||
</h4>
|
||||
<StringList items={phase.scripts} emptyKey="workflows.phase.scripts.empty" />
|
||||
</div>
|
||||
|
||||
{/* Tool scope */}
|
||||
<div>
|
||||
<h4 className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
{t('workflows.phase.toolScope')}
|
||||
</h4>
|
||||
<div className="mt-1">
|
||||
<ToolScopeDisplay scope={phase.tools} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context */}
|
||||
<div>
|
||||
<h4 className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
{t('workflows.phase.context')} ({phase.context.length})
|
||||
</h4>
|
||||
<StringList items={phase.context} emptyKey="workflows.phase.context.empty" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhaseEditor;
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* WorkflowCard
|
||||
* ------------
|
||||
* A list-row card for a single workflow summary. Mirrors the SkillCard pattern.
|
||||
* Displays the workflow name, description, scope pill, phase count, tags, and
|
||||
* optional action buttons.
|
||||
*/
|
||||
import { type FC } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { WorkflowSummary } from '../../services/api/workflowsApi';
|
||||
|
||||
interface WorkflowCardProps {
|
||||
workflow: WorkflowSummary;
|
||||
onView: (workflow: WorkflowSummary) => void;
|
||||
onDelete?: (workflow: WorkflowSummary) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function scopePillCls(scope: WorkflowSummary['scope']): string {
|
||||
switch (scope) {
|
||||
case 'user':
|
||||
return 'bg-sage-50 text-sage-700 border-sage-200';
|
||||
case 'project':
|
||||
return 'bg-amber-50 text-amber-700 border-amber-200';
|
||||
default:
|
||||
return 'bg-stone-100 text-stone-700 border-stone-200 dark:bg-neutral-800 dark:text-neutral-200 dark:border-neutral-700';
|
||||
}
|
||||
}
|
||||
|
||||
const WorkflowCard: FC<WorkflowCardProps> = ({ workflow, onView, onDelete, testId }) => {
|
||||
const { t } = useT();
|
||||
const pillCls = scopePillCls(workflow.scope);
|
||||
const scopeLabel = workflow.scope === 'user' ? t('scope.user') : t('scope.project');
|
||||
const canDelete = workflow.scope === 'user';
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="flex items-start justify-between gap-3 rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 shadow-soft transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800/50">
|
||||
{/* Left: content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{workflow.name}
|
||||
</h3>
|
||||
<span
|
||||
className={`inline-flex flex-shrink-0 items-center rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${pillCls}`}>
|
||||
{scopeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{workflow.description ? (
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-stone-500 dark:text-neutral-400">
|
||||
{workflow.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Phases */}
|
||||
{workflow.phases.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{workflow.phases.map(phase => (
|
||||
<span
|
||||
key={phase}
|
||||
className="inline-flex items-center rounded-md border border-primary-100 bg-primary-50 dark:bg-primary-500/10 dark:border-primary-500/30 px-1.5 py-0.5 font-mono text-[10px] text-primary-700 dark:text-primary-300">
|
||||
{phase}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Tags */}
|
||||
{workflow.tags.length > 0 ? (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{workflow.tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center rounded-md border border-stone-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800/60 px-1.5 py-0.5 text-[10px] text-stone-600 dark:text-neutral-300">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Warnings */}
|
||||
{workflow.warnings.length > 0 ? (
|
||||
<p className="mt-1.5 text-[11px] text-amber-700 dark:text-amber-300">
|
||||
{t('workflows.warnings').replace('{count}', String(workflow.warnings.length))}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Right: actions */}
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onView(workflow)}
|
||||
data-testid={testId ? `${testId}-view` : undefined}
|
||||
className="rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500">
|
||||
{t('common.seeAll')}
|
||||
</button>
|
||||
{canDelete && onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(workflow)}
|
||||
data-testid={testId ? `${testId}-delete` : undefined}
|
||||
aria-label={t('workflows.delete')}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-stone-400 dark:text-neutral-500 transition-colors hover:bg-coral-50 dark:hover:bg-coral-500/10 hover:text-coral-600 dark:hover:text-coral-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-coral-500">
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowCard;
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* WorkflowDetailDrawer
|
||||
* --------------------
|
||||
*
|
||||
* Right-side slide-in drawer surfacing full metadata for a workflow plus its
|
||||
* phases. Mirrors SkillDetailDrawer — rendered via createPortal, Escape/
|
||||
* backdrop click to close, focus capture on open.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { type Workflow, workflowsApi, type WorkflowSummary } from '../../services/api/workflowsApi';
|
||||
import PhaseEditor from './PhaseEditor';
|
||||
|
||||
const log = debug('workflows:drawer');
|
||||
|
||||
interface Props {
|
||||
workflow: WorkflowSummary;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function scopePillCls(scope: WorkflowSummary['scope']): string {
|
||||
switch (scope) {
|
||||
case 'user':
|
||||
return 'bg-sage-50 text-sage-700 border-sage-200';
|
||||
case 'project':
|
||||
return 'bg-amber-50 text-amber-700 border-amber-200';
|
||||
default:
|
||||
return 'bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 border-stone-200 dark:border-neutral-700';
|
||||
}
|
||||
}
|
||||
|
||||
export default function WorkflowDetailDrawer({ workflow, onClose }: Props) {
|
||||
const { t } = useT();
|
||||
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
const [fullWorkflow, setFullWorkflow] = useState<Workflow | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// Capture focus on mount, restore on unmount.
|
||||
useEffect(() => {
|
||||
previousFocusRef.current = document.activeElement as HTMLElement | null;
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
closeBtnRef.current?.focus();
|
||||
});
|
||||
log('mount workflowId=%s', workflow.id);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
previousFocusRef.current?.focus?.();
|
||||
log('unmount workflowId=%s', workflow.id);
|
||||
};
|
||||
}, [workflow.id]);
|
||||
|
||||
// Close on Escape key.
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
log('escape-key close workflowId=%s', workflow.id);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [onClose, workflow.id]);
|
||||
|
||||
// Load full workflow on mount.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setFullWorkflow(null);
|
||||
setLoadError(null);
|
||||
void workflowsApi
|
||||
.readWorkflow(workflow.id)
|
||||
.then(wf => {
|
||||
if (!cancelled) {
|
||||
log('loaded full workflow id=%s phases=%d', wf.dir_name, Object.keys(wf.phases).length);
|
||||
setFullWorkflow(wf);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (!cancelled) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('load error id=%s %s', workflow.id, msg);
|
||||
setLoadError(msg);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workflow.id]);
|
||||
|
||||
const handleBackdropClick = useCallback(() => {
|
||||
log('backdrop close workflowId=%s', workflow.id);
|
||||
onClose();
|
||||
}, [onClose, workflow.id]);
|
||||
|
||||
const pillCls = scopePillCls(workflow.scope);
|
||||
const scopeLabel = workflow.scope === 'user' ? t('scope.user') : t('scope.project');
|
||||
|
||||
const phases = fullWorkflow ? Object.entries(fullWorkflow.phases) : [];
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex"
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) handleBackdropClick();
|
||||
}}>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-fade-in"
|
||||
onClick={handleBackdropClick}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="workflow-drawer-title"
|
||||
className="relative ml-auto flex h-full w-full max-w-[540px] flex-col bg-white dark:bg-neutral-900 shadow-2xl animate-slide-in-right">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 border-b border-stone-100 dark:border-neutral-800 px-5 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2
|
||||
id="workflow-drawer-title"
|
||||
className="truncate text-base font-semibold text-stone-900 dark:text-neutral-100 font-sans">
|
||||
{workflow.name}
|
||||
</h2>
|
||||
<span
|
||||
className={`inline-flex flex-shrink-0 items-center rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${pillCls}`}>
|
||||
{scopeLabel}
|
||||
</span>
|
||||
</div>
|
||||
{workflow.when_to_use ? (
|
||||
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400 italic">
|
||||
{workflow.when_to_use}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
ref={closeBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
log('close-button workflowId=%s', workflow.id);
|
||||
onClose();
|
||||
}}
|
||||
aria-label={t('common.close')}
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-stone-400 dark:text-neutral-500 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 hover:text-stone-600 dark:hover:text-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
{/* Description */}
|
||||
{workflow.description ? (
|
||||
<p className="text-sm leading-relaxed text-stone-700 dark:text-neutral-200 font-sans">
|
||||
{workflow.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Metadata */}
|
||||
<dl className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-2 text-xs">
|
||||
{workflow.tags.length > 0 ? (
|
||||
<>
|
||||
<dt className="font-medium text-stone-500 dark:text-neutral-400">
|
||||
{t('workflows.detail.tags')}
|
||||
</dt>
|
||||
<dd className="flex flex-wrap gap-1">
|
||||
{workflow.tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center rounded-md border border-stone-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800/60 px-1.5 py-0.5 text-[10px] text-stone-700 dark:text-neutral-200">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
{fullWorkflow?.location ? (
|
||||
<>
|
||||
<dt className="font-medium text-stone-500 dark:text-neutral-400">
|
||||
{t('workflows.detail.location')}
|
||||
</dt>
|
||||
<dd
|
||||
className="truncate font-mono text-[11px] text-stone-600 dark:text-neutral-300"
|
||||
title={fullWorkflow.location}>
|
||||
{fullWorkflow.location}
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
|
||||
{/* Warnings */}
|
||||
{workflow.warnings.length > 0 ? (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-amber-900">
|
||||
{t('workflows.detail.warnings')}
|
||||
</p>
|
||||
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-xs text-amber-800">
|
||||
{workflow.warnings.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Phases */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
{t('workflows.detail.phases')} ({workflow.phases.length})
|
||||
</h3>
|
||||
{loadError ? (
|
||||
<div className="rounded-xl border border-coral-200 bg-coral-50 p-3 text-xs text-coral-800">
|
||||
{t('workflows.detail.loadError')}: {loadError}
|
||||
</div>
|
||||
) : fullWorkflow === null ? (
|
||||
<p className="text-xs text-stone-400 dark:text-neutral-500 animate-pulse">
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
) : phases.length === 0 ? (
|
||||
<p className="text-xs italic text-stone-400 dark:text-neutral-500">
|
||||
{t('workflows.detail.noPhases')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{phases.map(([phaseName, phase]) => (
|
||||
<PhaseEditor key={phaseName} phaseName={phaseName} phase={phase} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -268,6 +268,9 @@ const messages: TranslationMap = {
|
||||
'memory.tab.tasksDescription':
|
||||
'أنشئ المهام وتتبعها — مهامك الخاصة بالإضافة إلى اللوحات التي يبنيها وكلاؤك عبر المحادثات.',
|
||||
'memory.tab.subconscious': 'اللاوعي',
|
||||
'memory.tab.workflows': 'Workflows',
|
||||
'memory.tab.workflowsDescription':
|
||||
'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'memory.tab.dreams': 'الأحلام',
|
||||
'memory.tab.calls': 'المكالمات',
|
||||
'memory.tab.diagram': 'Diagram',
|
||||
@@ -3980,6 +3983,50 @@ const messages: TranslationMap = {
|
||||
'settings.agents.editor.save': 'أنقذ',
|
||||
'settings.agents.editor.create': 'وكيل خلق',
|
||||
'settings.agents.editor.saving': 'إنقاذ...',
|
||||
'nav.workflows': 'Workflows',
|
||||
'workflows.title': 'Agent Workflows',
|
||||
'workflows.subtitle': 'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'workflows.createNew': 'New Workflow',
|
||||
'workflows.listHeading': 'Workflows',
|
||||
'workflows.delete': 'Delete Workflow',
|
||||
'workflows.deleteError': 'Failed to delete workflow',
|
||||
'workflows.warnings': '{count} warning(s)',
|
||||
'workflows.empty.title': 'No workflows yet',
|
||||
'workflows.empty.body':
|
||||
'Create a workflow to define rules and scripts for task lifecycle phases.',
|
||||
'workflows.create.title': 'New Workflow',
|
||||
'workflows.create.subtitle': 'Define a lifecycle-bound rule set for the agent.',
|
||||
'workflows.create.name': 'Name',
|
||||
'workflows.create.namePlaceholder': 'e.g. Python project workflow',
|
||||
'workflows.create.description': 'Description',
|
||||
'workflows.create.descriptionPlaceholder': 'What does this workflow do?',
|
||||
'workflows.create.whenToUse': 'When to use',
|
||||
'workflows.create.whenToUsePlaceholder': 'When should the agent pick up this workflow?',
|
||||
'workflows.create.optional': '(optional)',
|
||||
'workflows.create.createBtn': 'Create workflow',
|
||||
'workflows.create.creating': 'Creating…',
|
||||
'workflows.create.createError': 'Could not create workflow',
|
||||
'workflows.create.successTitle': 'Workflow created',
|
||||
'workflows.create.successMessage': 'was created successfully.',
|
||||
'workflows.deleteConfirm.title': 'Delete workflow',
|
||||
'workflows.deleteConfirm.body': 'Remove "{name}"? This cannot be undone.',
|
||||
'workflows.detail.tags': 'Tags',
|
||||
'workflows.detail.location': 'Location',
|
||||
'workflows.detail.warnings': 'Warnings',
|
||||
'workflows.detail.phases': 'Phases',
|
||||
'workflows.detail.noPhases': 'No phases defined.',
|
||||
'workflows.detail.loadError': 'Failed to load full workflow',
|
||||
'workflows.phase.rules': 'Rules',
|
||||
'workflows.phase.rules.empty': 'No rules.',
|
||||
'workflows.phase.scripts': 'Scripts',
|
||||
'workflows.phase.scripts.empty': 'No scripts.',
|
||||
'workflows.phase.toolScope': 'Tool scope',
|
||||
'workflows.phase.toolScope.allow': 'Allow',
|
||||
'workflows.phase.toolScope.deny': 'Deny',
|
||||
'workflows.phase.toolScope.inherited': 'Inherited from workflow default.',
|
||||
'workflows.phase.toolScope.empty': 'No tool restrictions.',
|
||||
'workflows.phase.context': 'Context',
|
||||
'workflows.phase.context.empty': 'No context providers.',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'إدارة عواملك واستقلاليتها وما يمكنها الوصول إليه على هذا الجهاز.',
|
||||
|
||||
@@ -271,6 +271,9 @@ const messages: TranslationMap = {
|
||||
'memory.tab.tasksDescription':
|
||||
'টাস্ক তৈরি করুন এবং ট্র্যাক করুন — আপনার নিজের কাজের তালিকা এবং এজেন্টরা কথোপকথন জুড়ে যে বোর্ডগুলি তৈরি করে।',
|
||||
'memory.tab.subconscious': 'সাবকনশাস',
|
||||
'memory.tab.workflows': 'Workflows',
|
||||
'memory.tab.workflowsDescription':
|
||||
'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'memory.tab.dreams': 'স্বপ্ন',
|
||||
'memory.tab.calls': 'কল',
|
||||
'memory.tab.diagram': 'Diagram',
|
||||
@@ -4052,6 +4055,50 @@ const messages: TranslationMap = {
|
||||
'settings.agents.editor.save': 'সংরক্ষণ',
|
||||
'settings.agents.editor.create': 'নির্মাণ করুন',
|
||||
'settings.agents.editor.saving': 'ইনস্টল করা হয়েছে...',
|
||||
'nav.workflows': 'Workflows',
|
||||
'workflows.title': 'Agent Workflows',
|
||||
'workflows.subtitle': 'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'workflows.createNew': 'New Workflow',
|
||||
'workflows.listHeading': 'Workflows',
|
||||
'workflows.delete': 'Delete Workflow',
|
||||
'workflows.deleteError': 'Failed to delete workflow',
|
||||
'workflows.warnings': '{count} warning(s)',
|
||||
'workflows.empty.title': 'No workflows yet',
|
||||
'workflows.empty.body':
|
||||
'Create a workflow to define rules and scripts for task lifecycle phases.',
|
||||
'workflows.create.title': 'New Workflow',
|
||||
'workflows.create.subtitle': 'Define a lifecycle-bound rule set for the agent.',
|
||||
'workflows.create.name': 'Name',
|
||||
'workflows.create.namePlaceholder': 'e.g. Python project workflow',
|
||||
'workflows.create.description': 'Description',
|
||||
'workflows.create.descriptionPlaceholder': 'What does this workflow do?',
|
||||
'workflows.create.whenToUse': 'When to use',
|
||||
'workflows.create.whenToUsePlaceholder': 'When should the agent pick up this workflow?',
|
||||
'workflows.create.optional': '(optional)',
|
||||
'workflows.create.createBtn': 'Create workflow',
|
||||
'workflows.create.creating': 'Creating…',
|
||||
'workflows.create.createError': 'Could not create workflow',
|
||||
'workflows.create.successTitle': 'Workflow created',
|
||||
'workflows.create.successMessage': 'was created successfully.',
|
||||
'workflows.deleteConfirm.title': 'Delete workflow',
|
||||
'workflows.deleteConfirm.body': 'Remove "{name}"? This cannot be undone.',
|
||||
'workflows.detail.tags': 'Tags',
|
||||
'workflows.detail.location': 'Location',
|
||||
'workflows.detail.warnings': 'Warnings',
|
||||
'workflows.detail.phases': 'Phases',
|
||||
'workflows.detail.noPhases': 'No phases defined.',
|
||||
'workflows.detail.loadError': 'Failed to load full workflow',
|
||||
'workflows.phase.rules': 'Rules',
|
||||
'workflows.phase.rules.empty': 'No rules.',
|
||||
'workflows.phase.scripts': 'Scripts',
|
||||
'workflows.phase.scripts.empty': 'No scripts.',
|
||||
'workflows.phase.toolScope': 'Tool scope',
|
||||
'workflows.phase.toolScope.allow': 'Allow',
|
||||
'workflows.phase.toolScope.deny': 'Deny',
|
||||
'workflows.phase.toolScope.inherited': 'Inherited from workflow default.',
|
||||
'workflows.phase.toolScope.empty': 'No tool restrictions.',
|
||||
'workflows.phase.context': 'Context',
|
||||
'workflows.phase.context.empty': 'No context providers.',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'আপনার এজেন্ট, তাদের স্বায়ত্তশাসন এবং এই কম্পিউটারে তারা কী অ্যাক্সেস করতে পারে তা পরিচালনা করুন।',
|
||||
|
||||
@@ -280,6 +280,9 @@ const messages: TranslationMap = {
|
||||
'memory.tab.tasksDescription':
|
||||
'Aufgaben erstellen und verfolgen – eigene To-dos sowie die Boards, die Agenten in Gesprächen anlegen.',
|
||||
'memory.tab.subconscious': 'Unterbewusstsein',
|
||||
'memory.tab.workflows': 'Workflows',
|
||||
'memory.tab.workflowsDescription':
|
||||
'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'memory.tab.dreams': 'Träume',
|
||||
'memory.tab.calls': 'Anrufe',
|
||||
'memory.tab.diagram': 'Diagram',
|
||||
@@ -4163,6 +4166,50 @@ const messages: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Speichern',
|
||||
'settings.agents.editor.create': 'Vermittler erstellen',
|
||||
'settings.agents.editor.saving': 'Sichern…',
|
||||
'nav.workflows': 'Workflows',
|
||||
'workflows.title': 'Agent Workflows',
|
||||
'workflows.subtitle': 'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'workflows.createNew': 'New Workflow',
|
||||
'workflows.listHeading': 'Workflows',
|
||||
'workflows.delete': 'Delete Workflow',
|
||||
'workflows.deleteError': 'Failed to delete workflow',
|
||||
'workflows.warnings': '{count} warning(s)',
|
||||
'workflows.empty.title': 'No workflows yet',
|
||||
'workflows.empty.body':
|
||||
'Create a workflow to define rules and scripts for task lifecycle phases.',
|
||||
'workflows.create.title': 'New Workflow',
|
||||
'workflows.create.subtitle': 'Define a lifecycle-bound rule set for the agent.',
|
||||
'workflows.create.name': 'Name',
|
||||
'workflows.create.namePlaceholder': 'e.g. Python project workflow',
|
||||
'workflows.create.description': 'Description',
|
||||
'workflows.create.descriptionPlaceholder': 'What does this workflow do?',
|
||||
'workflows.create.whenToUse': 'When to use',
|
||||
'workflows.create.whenToUsePlaceholder': 'When should the agent pick up this workflow?',
|
||||
'workflows.create.optional': '(optional)',
|
||||
'workflows.create.createBtn': 'Create workflow',
|
||||
'workflows.create.creating': 'Creating…',
|
||||
'workflows.create.createError': 'Could not create workflow',
|
||||
'workflows.create.successTitle': 'Workflow created',
|
||||
'workflows.create.successMessage': 'was created successfully.',
|
||||
'workflows.deleteConfirm.title': 'Delete workflow',
|
||||
'workflows.deleteConfirm.body': 'Remove "{name}"? This cannot be undone.',
|
||||
'workflows.detail.tags': 'Tags',
|
||||
'workflows.detail.location': 'Location',
|
||||
'workflows.detail.warnings': 'Warnings',
|
||||
'workflows.detail.phases': 'Phases',
|
||||
'workflows.detail.noPhases': 'No phases defined.',
|
||||
'workflows.detail.loadError': 'Failed to load full workflow',
|
||||
'workflows.phase.rules': 'Rules',
|
||||
'workflows.phase.rules.empty': 'No rules.',
|
||||
'workflows.phase.scripts': 'Scripts',
|
||||
'workflows.phase.scripts.empty': 'No scripts.',
|
||||
'workflows.phase.toolScope': 'Tool scope',
|
||||
'workflows.phase.toolScope.allow': 'Allow',
|
||||
'workflows.phase.toolScope.deny': 'Deny',
|
||||
'workflows.phase.toolScope.inherited': 'Inherited from workflow default.',
|
||||
'workflows.phase.toolScope.empty': 'No tool restrictions.',
|
||||
'workflows.phase.context': 'Context',
|
||||
'workflows.phase.context.empty': 'No context providers.',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Verwalten Sie Ihre Agenten, deren Autonomie und worauf sie auf diesem Computer zugreifen dürfen.',
|
||||
|
||||
@@ -296,6 +296,9 @@ const en: TranslationMap = {
|
||||
'memory.tab.tasksDescription':
|
||||
'Create and track tasks — your own to-dos plus the boards your agents build across conversations.',
|
||||
'memory.tab.subconscious': 'Subconscious',
|
||||
'memory.tab.workflows': 'Workflows',
|
||||
'memory.tab.workflowsDescription':
|
||||
'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'memory.tab.dreams': 'Dreams',
|
||||
'memory.tab.calls': 'Calls',
|
||||
'memory.tab.diagram': 'Diagram',
|
||||
@@ -4275,6 +4278,52 @@ const en: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Save',
|
||||
'settings.agents.editor.create': 'Create agent',
|
||||
'settings.agents.editor.saving': 'Saving…',
|
||||
|
||||
// ── Agent Workflows ──────────────────────────────────────────────────────
|
||||
'nav.workflows': 'Workflows',
|
||||
'workflows.title': 'Agent Workflows',
|
||||
'workflows.subtitle': 'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'workflows.createNew': 'New Workflow',
|
||||
'workflows.listHeading': 'Workflows',
|
||||
'workflows.delete': 'Delete Workflow',
|
||||
'workflows.deleteError': 'Failed to delete workflow',
|
||||
'workflows.warnings': '{count} warning(s)',
|
||||
'workflows.empty.title': 'No workflows yet',
|
||||
'workflows.empty.body':
|
||||
'Create a workflow to define rules and scripts for task lifecycle phases.',
|
||||
'workflows.create.title': 'New Workflow',
|
||||
'workflows.create.subtitle': 'Define a lifecycle-bound rule set for the agent.',
|
||||
'workflows.create.name': 'Name',
|
||||
'workflows.create.namePlaceholder': 'e.g. Python project workflow',
|
||||
'workflows.create.description': 'Description',
|
||||
'workflows.create.descriptionPlaceholder': 'What does this workflow do?',
|
||||
'workflows.create.whenToUse': 'When to use',
|
||||
'workflows.create.whenToUsePlaceholder': 'When should the agent pick up this workflow?',
|
||||
'workflows.create.optional': '(optional)',
|
||||
'workflows.create.createBtn': 'Create workflow',
|
||||
'workflows.create.creating': 'Creating…',
|
||||
'workflows.create.createError': 'Could not create workflow',
|
||||
'workflows.create.successTitle': 'Workflow created',
|
||||
'workflows.create.successMessage': 'was created successfully.',
|
||||
'workflows.deleteConfirm.title': 'Delete workflow',
|
||||
'workflows.deleteConfirm.body': 'Remove "{name}"? This cannot be undone.',
|
||||
'workflows.detail.tags': 'Tags',
|
||||
'workflows.detail.location': 'Location',
|
||||
'workflows.detail.warnings': 'Warnings',
|
||||
'workflows.detail.phases': 'Phases',
|
||||
'workflows.detail.noPhases': 'No phases defined.',
|
||||
'workflows.detail.loadError': 'Failed to load full workflow',
|
||||
'workflows.phase.rules': 'Rules',
|
||||
'workflows.phase.rules.empty': 'No rules.',
|
||||
'workflows.phase.scripts': 'Scripts',
|
||||
'workflows.phase.scripts.empty': 'No scripts.',
|
||||
'workflows.phase.toolScope': 'Tool scope',
|
||||
'workflows.phase.toolScope.allow': 'Allow',
|
||||
'workflows.phase.toolScope.deny': 'Deny',
|
||||
'workflows.phase.toolScope.inherited': 'Inherited from workflow default.',
|
||||
'workflows.phase.toolScope.empty': 'No tool restrictions.',
|
||||
'workflows.phase.context': 'Context',
|
||||
'workflows.phase.context.empty': 'No context providers.',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Manage your agents, their autonomy, and what they can access on this computer.',
|
||||
|
||||
@@ -280,6 +280,9 @@ const messages: TranslationMap = {
|
||||
'memory.tab.tasksDescription':
|
||||
'Crea y realiza un seguimiento de tareas: tus propios pendientes más los tableros que tus agentes crean a lo largo de las conversaciones.',
|
||||
'memory.tab.subconscious': 'Subconsciente',
|
||||
'memory.tab.workflows': 'Workflows',
|
||||
'memory.tab.workflowsDescription':
|
||||
'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'memory.tab.dreams': 'Sueños',
|
||||
'memory.tab.calls': 'Llamadas',
|
||||
'memory.tab.diagram': 'Diagram',
|
||||
@@ -4128,6 +4131,50 @@ const messages: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Guardar',
|
||||
'settings.agents.editor.create': 'Crear agente',
|
||||
'settings.agents.editor.saving': 'Guardando…',
|
||||
'nav.workflows': 'Workflows',
|
||||
'workflows.title': 'Agent Workflows',
|
||||
'workflows.subtitle': 'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'workflows.createNew': 'New Workflow',
|
||||
'workflows.listHeading': 'Workflows',
|
||||
'workflows.delete': 'Delete Workflow',
|
||||
'workflows.deleteError': 'Failed to delete workflow',
|
||||
'workflows.warnings': '{count} warning(s)',
|
||||
'workflows.empty.title': 'No workflows yet',
|
||||
'workflows.empty.body':
|
||||
'Create a workflow to define rules and scripts for task lifecycle phases.',
|
||||
'workflows.create.title': 'New Workflow',
|
||||
'workflows.create.subtitle': 'Define a lifecycle-bound rule set for the agent.',
|
||||
'workflows.create.name': 'Name',
|
||||
'workflows.create.namePlaceholder': 'e.g. Python project workflow',
|
||||
'workflows.create.description': 'Description',
|
||||
'workflows.create.descriptionPlaceholder': 'What does this workflow do?',
|
||||
'workflows.create.whenToUse': 'When to use',
|
||||
'workflows.create.whenToUsePlaceholder': 'When should the agent pick up this workflow?',
|
||||
'workflows.create.optional': '(optional)',
|
||||
'workflows.create.createBtn': 'Create workflow',
|
||||
'workflows.create.creating': 'Creating…',
|
||||
'workflows.create.createError': 'Could not create workflow',
|
||||
'workflows.create.successTitle': 'Workflow created',
|
||||
'workflows.create.successMessage': 'was created successfully.',
|
||||
'workflows.deleteConfirm.title': 'Delete workflow',
|
||||
'workflows.deleteConfirm.body': 'Remove "{name}"? This cannot be undone.',
|
||||
'workflows.detail.tags': 'Tags',
|
||||
'workflows.detail.location': 'Location',
|
||||
'workflows.detail.warnings': 'Warnings',
|
||||
'workflows.detail.phases': 'Phases',
|
||||
'workflows.detail.noPhases': 'No phases defined.',
|
||||
'workflows.detail.loadError': 'Failed to load full workflow',
|
||||
'workflows.phase.rules': 'Rules',
|
||||
'workflows.phase.rules.empty': 'No rules.',
|
||||
'workflows.phase.scripts': 'Scripts',
|
||||
'workflows.phase.scripts.empty': 'No scripts.',
|
||||
'workflows.phase.toolScope': 'Tool scope',
|
||||
'workflows.phase.toolScope.allow': 'Allow',
|
||||
'workflows.phase.toolScope.deny': 'Deny',
|
||||
'workflows.phase.toolScope.inherited': 'Inherited from workflow default.',
|
||||
'workflows.phase.toolScope.empty': 'No tool restrictions.',
|
||||
'workflows.phase.context': 'Context',
|
||||
'workflows.phase.context.empty': 'No context providers.',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Administra tus agentes, su autonomía y a qué pueden acceder en este equipo.',
|
||||
|
||||
@@ -279,6 +279,9 @@ const messages: TranslationMap = {
|
||||
'memory.tab.tasksDescription':
|
||||
'Créez et suivez des tâches — vos propres listes de choses à faire ainsi que les tableaux que vos agents construisent au fil des conversations.',
|
||||
'memory.tab.subconscious': 'Subconscient',
|
||||
'memory.tab.workflows': 'Workflows',
|
||||
'memory.tab.workflowsDescription':
|
||||
'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'memory.tab.dreams': 'Rêves',
|
||||
'memory.tab.calls': 'Appels',
|
||||
'memory.tab.diagram': 'Diagram',
|
||||
@@ -4145,6 +4148,50 @@ const messages: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Enregistrer',
|
||||
'settings.agents.editor.create': 'Créer un agent',
|
||||
'settings.agents.editor.saving': 'Enregistrement…',
|
||||
'nav.workflows': 'Workflows',
|
||||
'workflows.title': 'Agent Workflows',
|
||||
'workflows.subtitle': 'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'workflows.createNew': 'New Workflow',
|
||||
'workflows.listHeading': 'Workflows',
|
||||
'workflows.delete': 'Delete Workflow',
|
||||
'workflows.deleteError': 'Failed to delete workflow',
|
||||
'workflows.warnings': '{count} warning(s)',
|
||||
'workflows.empty.title': 'No workflows yet',
|
||||
'workflows.empty.body':
|
||||
'Create a workflow to define rules and scripts for task lifecycle phases.',
|
||||
'workflows.create.title': 'New Workflow',
|
||||
'workflows.create.subtitle': 'Define a lifecycle-bound rule set for the agent.',
|
||||
'workflows.create.name': 'Name',
|
||||
'workflows.create.namePlaceholder': 'e.g. Python project workflow',
|
||||
'workflows.create.description': 'Description',
|
||||
'workflows.create.descriptionPlaceholder': 'What does this workflow do?',
|
||||
'workflows.create.whenToUse': 'When to use',
|
||||
'workflows.create.whenToUsePlaceholder': 'When should the agent pick up this workflow?',
|
||||
'workflows.create.optional': '(optional)',
|
||||
'workflows.create.createBtn': 'Create workflow',
|
||||
'workflows.create.creating': 'Creating…',
|
||||
'workflows.create.createError': 'Could not create workflow',
|
||||
'workflows.create.successTitle': 'Workflow created',
|
||||
'workflows.create.successMessage': 'was created successfully.',
|
||||
'workflows.deleteConfirm.title': 'Delete workflow',
|
||||
'workflows.deleteConfirm.body': 'Remove "{name}"? This cannot be undone.',
|
||||
'workflows.detail.tags': 'Tags',
|
||||
'workflows.detail.location': 'Location',
|
||||
'workflows.detail.warnings': 'Warnings',
|
||||
'workflows.detail.phases': 'Phases',
|
||||
'workflows.detail.noPhases': 'No phases defined.',
|
||||
'workflows.detail.loadError': 'Failed to load full workflow',
|
||||
'workflows.phase.rules': 'Rules',
|
||||
'workflows.phase.rules.empty': 'No rules.',
|
||||
'workflows.phase.scripts': 'Scripts',
|
||||
'workflows.phase.scripts.empty': 'No scripts.',
|
||||
'workflows.phase.toolScope': 'Tool scope',
|
||||
'workflows.phase.toolScope.allow': 'Allow',
|
||||
'workflows.phase.toolScope.deny': 'Deny',
|
||||
'workflows.phase.toolScope.inherited': 'Inherited from workflow default.',
|
||||
'workflows.phase.toolScope.empty': 'No tool restrictions.',
|
||||
'workflows.phase.context': 'Context',
|
||||
'workflows.phase.context.empty': 'No context providers.',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Gérez vos agents, leur autonomie et ce à quoi ils peuvent accéder sur cet ordinateur.',
|
||||
|
||||
@@ -270,6 +270,9 @@ const messages: TranslationMap = {
|
||||
'memory.tab.tasksDescription':
|
||||
'कार्य बनाएं और ट्रैक करें — आपके अपने टू-डू और साथ ही वे बोर्ड जो आपके एजेंट वार्तालापों में बनाते हैं।',
|
||||
'memory.tab.subconscious': 'सबकॉन्शस',
|
||||
'memory.tab.workflows': 'Workflows',
|
||||
'memory.tab.workflowsDescription':
|
||||
'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'memory.tab.dreams': 'ड्रीम्स',
|
||||
'memory.tab.calls': 'कॉल्स',
|
||||
'memory.tab.diagram': 'Diagram',
|
||||
@@ -4059,6 +4062,50 @@ const messages: TranslationMap = {
|
||||
'settings.agents.editor.save': 'सहेजें',
|
||||
'settings.agents.editor.create': 'एजेंट बनाना',
|
||||
'settings.agents.editor.saving': 'बचत',
|
||||
'nav.workflows': 'Workflows',
|
||||
'workflows.title': 'Agent Workflows',
|
||||
'workflows.subtitle': 'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'workflows.createNew': 'New Workflow',
|
||||
'workflows.listHeading': 'Workflows',
|
||||
'workflows.delete': 'Delete Workflow',
|
||||
'workflows.deleteError': 'Failed to delete workflow',
|
||||
'workflows.warnings': '{count} warning(s)',
|
||||
'workflows.empty.title': 'No workflows yet',
|
||||
'workflows.empty.body':
|
||||
'Create a workflow to define rules and scripts for task lifecycle phases.',
|
||||
'workflows.create.title': 'New Workflow',
|
||||
'workflows.create.subtitle': 'Define a lifecycle-bound rule set for the agent.',
|
||||
'workflows.create.name': 'Name',
|
||||
'workflows.create.namePlaceholder': 'e.g. Python project workflow',
|
||||
'workflows.create.description': 'Description',
|
||||
'workflows.create.descriptionPlaceholder': 'What does this workflow do?',
|
||||
'workflows.create.whenToUse': 'When to use',
|
||||
'workflows.create.whenToUsePlaceholder': 'When should the agent pick up this workflow?',
|
||||
'workflows.create.optional': '(optional)',
|
||||
'workflows.create.createBtn': 'Create workflow',
|
||||
'workflows.create.creating': 'Creating…',
|
||||
'workflows.create.createError': 'Could not create workflow',
|
||||
'workflows.create.successTitle': 'Workflow created',
|
||||
'workflows.create.successMessage': 'was created successfully.',
|
||||
'workflows.deleteConfirm.title': 'Delete workflow',
|
||||
'workflows.deleteConfirm.body': 'Remove "{name}"? This cannot be undone.',
|
||||
'workflows.detail.tags': 'Tags',
|
||||
'workflows.detail.location': 'Location',
|
||||
'workflows.detail.warnings': 'Warnings',
|
||||
'workflows.detail.phases': 'Phases',
|
||||
'workflows.detail.noPhases': 'No phases defined.',
|
||||
'workflows.detail.loadError': 'Failed to load full workflow',
|
||||
'workflows.phase.rules': 'Rules',
|
||||
'workflows.phase.rules.empty': 'No rules.',
|
||||
'workflows.phase.scripts': 'Scripts',
|
||||
'workflows.phase.scripts.empty': 'No scripts.',
|
||||
'workflows.phase.toolScope': 'Tool scope',
|
||||
'workflows.phase.toolScope.allow': 'Allow',
|
||||
'workflows.phase.toolScope.deny': 'Deny',
|
||||
'workflows.phase.toolScope.inherited': 'Inherited from workflow default.',
|
||||
'workflows.phase.toolScope.empty': 'No tool restrictions.',
|
||||
'workflows.phase.context': 'Context',
|
||||
'workflows.phase.context.empty': 'No context providers.',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'अपने एजेंट, उनकी स्वायत्तता और इस कंप्यूटर पर वे क्या एक्सेस कर सकते हैं, यह प्रबंधित करें।',
|
||||
|
||||
@@ -272,6 +272,9 @@ const messages: TranslationMap = {
|
||||
'memory.tab.tasksDescription':
|
||||
'Buat dan lacak tugas — to-do Anda sendiri beserta papan yang dibangun agen Anda di berbagai percakapan.',
|
||||
'memory.tab.subconscious': 'Bawah sadar',
|
||||
'memory.tab.workflows': 'Workflows',
|
||||
'memory.tab.workflowsDescription':
|
||||
'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'memory.tab.dreams': 'Mimpi',
|
||||
'memory.tab.calls': 'Panggilan',
|
||||
'memory.tab.diagram': 'Diagram',
|
||||
@@ -4068,6 +4071,50 @@ const messages: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Simpan',
|
||||
'settings.agents.editor.create': 'Buat agen',
|
||||
'settings.agents.editor.saving': 'Menyimpan...',
|
||||
'nav.workflows': 'Workflows',
|
||||
'workflows.title': 'Agent Workflows',
|
||||
'workflows.subtitle': 'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'workflows.createNew': 'New Workflow',
|
||||
'workflows.listHeading': 'Workflows',
|
||||
'workflows.delete': 'Delete Workflow',
|
||||
'workflows.deleteError': 'Failed to delete workflow',
|
||||
'workflows.warnings': '{count} warning(s)',
|
||||
'workflows.empty.title': 'No workflows yet',
|
||||
'workflows.empty.body':
|
||||
'Create a workflow to define rules and scripts for task lifecycle phases.',
|
||||
'workflows.create.title': 'New Workflow',
|
||||
'workflows.create.subtitle': 'Define a lifecycle-bound rule set for the agent.',
|
||||
'workflows.create.name': 'Name',
|
||||
'workflows.create.namePlaceholder': 'e.g. Python project workflow',
|
||||
'workflows.create.description': 'Description',
|
||||
'workflows.create.descriptionPlaceholder': 'What does this workflow do?',
|
||||
'workflows.create.whenToUse': 'When to use',
|
||||
'workflows.create.whenToUsePlaceholder': 'When should the agent pick up this workflow?',
|
||||
'workflows.create.optional': '(optional)',
|
||||
'workflows.create.createBtn': 'Create workflow',
|
||||
'workflows.create.creating': 'Creating…',
|
||||
'workflows.create.createError': 'Could not create workflow',
|
||||
'workflows.create.successTitle': 'Workflow created',
|
||||
'workflows.create.successMessage': 'was created successfully.',
|
||||
'workflows.deleteConfirm.title': 'Delete workflow',
|
||||
'workflows.deleteConfirm.body': 'Remove "{name}"? This cannot be undone.',
|
||||
'workflows.detail.tags': 'Tags',
|
||||
'workflows.detail.location': 'Location',
|
||||
'workflows.detail.warnings': 'Warnings',
|
||||
'workflows.detail.phases': 'Phases',
|
||||
'workflows.detail.noPhases': 'No phases defined.',
|
||||
'workflows.detail.loadError': 'Failed to load full workflow',
|
||||
'workflows.phase.rules': 'Rules',
|
||||
'workflows.phase.rules.empty': 'No rules.',
|
||||
'workflows.phase.scripts': 'Scripts',
|
||||
'workflows.phase.scripts.empty': 'No scripts.',
|
||||
'workflows.phase.toolScope': 'Tool scope',
|
||||
'workflows.phase.toolScope.allow': 'Allow',
|
||||
'workflows.phase.toolScope.deny': 'Deny',
|
||||
'workflows.phase.toolScope.inherited': 'Inherited from workflow default.',
|
||||
'workflows.phase.toolScope.empty': 'No tool restrictions.',
|
||||
'workflows.phase.context': 'Context',
|
||||
'workflows.phase.context.empty': 'No context providers.',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Kelola agen Anda, otonomi mereka, dan apa yang dapat mereka akses di komputer ini.',
|
||||
|
||||
@@ -277,6 +277,9 @@ const messages: TranslationMap = {
|
||||
'memory.tab.tasksDescription':
|
||||
'Crea e monitora le attività — i tuoi to-do personali e le board create dagli agenti nelle conversazioni.',
|
||||
'memory.tab.subconscious': 'Subconscio',
|
||||
'memory.tab.workflows': 'Workflows',
|
||||
'memory.tab.workflowsDescription':
|
||||
'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'memory.tab.dreams': 'Sogni',
|
||||
'memory.tab.calls': 'Chiamate',
|
||||
'memory.tab.diagram': 'Diagram',
|
||||
@@ -4123,6 +4126,50 @@ const messages: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Salva',
|
||||
'settings.agents.editor.create': 'Crea agente',
|
||||
'settings.agents.editor.saving': 'Salvataggio…',
|
||||
'nav.workflows': 'Workflows',
|
||||
'workflows.title': 'Agent Workflows',
|
||||
'workflows.subtitle': 'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'workflows.createNew': 'New Workflow',
|
||||
'workflows.listHeading': 'Workflows',
|
||||
'workflows.delete': 'Delete Workflow',
|
||||
'workflows.deleteError': 'Failed to delete workflow',
|
||||
'workflows.warnings': '{count} warning(s)',
|
||||
'workflows.empty.title': 'No workflows yet',
|
||||
'workflows.empty.body':
|
||||
'Create a workflow to define rules and scripts for task lifecycle phases.',
|
||||
'workflows.create.title': 'New Workflow',
|
||||
'workflows.create.subtitle': 'Define a lifecycle-bound rule set for the agent.',
|
||||
'workflows.create.name': 'Name',
|
||||
'workflows.create.namePlaceholder': 'e.g. Python project workflow',
|
||||
'workflows.create.description': 'Description',
|
||||
'workflows.create.descriptionPlaceholder': 'What does this workflow do?',
|
||||
'workflows.create.whenToUse': 'When to use',
|
||||
'workflows.create.whenToUsePlaceholder': 'When should the agent pick up this workflow?',
|
||||
'workflows.create.optional': '(optional)',
|
||||
'workflows.create.createBtn': 'Create workflow',
|
||||
'workflows.create.creating': 'Creating…',
|
||||
'workflows.create.createError': 'Could not create workflow',
|
||||
'workflows.create.successTitle': 'Workflow created',
|
||||
'workflows.create.successMessage': 'was created successfully.',
|
||||
'workflows.deleteConfirm.title': 'Delete workflow',
|
||||
'workflows.deleteConfirm.body': 'Remove "{name}"? This cannot be undone.',
|
||||
'workflows.detail.tags': 'Tags',
|
||||
'workflows.detail.location': 'Location',
|
||||
'workflows.detail.warnings': 'Warnings',
|
||||
'workflows.detail.phases': 'Phases',
|
||||
'workflows.detail.noPhases': 'No phases defined.',
|
||||
'workflows.detail.loadError': 'Failed to load full workflow',
|
||||
'workflows.phase.rules': 'Rules',
|
||||
'workflows.phase.rules.empty': 'No rules.',
|
||||
'workflows.phase.scripts': 'Scripts',
|
||||
'workflows.phase.scripts.empty': 'No scripts.',
|
||||
'workflows.phase.toolScope': 'Tool scope',
|
||||
'workflows.phase.toolScope.allow': 'Allow',
|
||||
'workflows.phase.toolScope.deny': 'Deny',
|
||||
'workflows.phase.toolScope.inherited': 'Inherited from workflow default.',
|
||||
'workflows.phase.toolScope.empty': 'No tool restrictions.',
|
||||
'workflows.phase.context': 'Context',
|
||||
'workflows.phase.context.empty': 'No context providers.',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Gestisci i tuoi agenti, la loro autonomia e a cosa possono accedere su questo computer.',
|
||||
|
||||
@@ -270,6 +270,9 @@ const messages: TranslationMap = {
|
||||
'memory.tab.tasksDescription':
|
||||
'작업을 만들고 추적하세요 — 본인의 할 일 목록과 에이전트가 대화를 통해 구성한 보드가 모두 포함됩니다.',
|
||||
'memory.tab.subconscious': '잠재의식',
|
||||
'memory.tab.workflows': 'Workflows',
|
||||
'memory.tab.workflowsDescription':
|
||||
'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'memory.tab.dreams': '꿈',
|
||||
'memory.tab.calls': '통화',
|
||||
'memory.tab.diagram': 'Diagram',
|
||||
@@ -4021,6 +4024,50 @@ const messages: TranslationMap = {
|
||||
'settings.agents.editor.save': '저장',
|
||||
'settings.agents.editor.create': '에이전트 만들기',
|
||||
'settings.agents.editor.saving': '저장 중…',
|
||||
'nav.workflows': 'Workflows',
|
||||
'workflows.title': 'Agent Workflows',
|
||||
'workflows.subtitle': 'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'workflows.createNew': 'New Workflow',
|
||||
'workflows.listHeading': 'Workflows',
|
||||
'workflows.delete': 'Delete Workflow',
|
||||
'workflows.deleteError': 'Failed to delete workflow',
|
||||
'workflows.warnings': '{count} warning(s)',
|
||||
'workflows.empty.title': 'No workflows yet',
|
||||
'workflows.empty.body':
|
||||
'Create a workflow to define rules and scripts for task lifecycle phases.',
|
||||
'workflows.create.title': 'New Workflow',
|
||||
'workflows.create.subtitle': 'Define a lifecycle-bound rule set for the agent.',
|
||||
'workflows.create.name': 'Name',
|
||||
'workflows.create.namePlaceholder': 'e.g. Python project workflow',
|
||||
'workflows.create.description': 'Description',
|
||||
'workflows.create.descriptionPlaceholder': 'What does this workflow do?',
|
||||
'workflows.create.whenToUse': 'When to use',
|
||||
'workflows.create.whenToUsePlaceholder': 'When should the agent pick up this workflow?',
|
||||
'workflows.create.optional': '(optional)',
|
||||
'workflows.create.createBtn': 'Create workflow',
|
||||
'workflows.create.creating': 'Creating…',
|
||||
'workflows.create.createError': 'Could not create workflow',
|
||||
'workflows.create.successTitle': 'Workflow created',
|
||||
'workflows.create.successMessage': 'was created successfully.',
|
||||
'workflows.deleteConfirm.title': 'Delete workflow',
|
||||
'workflows.deleteConfirm.body': 'Remove "{name}"? This cannot be undone.',
|
||||
'workflows.detail.tags': 'Tags',
|
||||
'workflows.detail.location': 'Location',
|
||||
'workflows.detail.warnings': 'Warnings',
|
||||
'workflows.detail.phases': 'Phases',
|
||||
'workflows.detail.noPhases': 'No phases defined.',
|
||||
'workflows.detail.loadError': 'Failed to load full workflow',
|
||||
'workflows.phase.rules': 'Rules',
|
||||
'workflows.phase.rules.empty': 'No rules.',
|
||||
'workflows.phase.scripts': 'Scripts',
|
||||
'workflows.phase.scripts.empty': 'No scripts.',
|
||||
'workflows.phase.toolScope': 'Tool scope',
|
||||
'workflows.phase.toolScope.allow': 'Allow',
|
||||
'workflows.phase.toolScope.deny': 'Deny',
|
||||
'workflows.phase.toolScope.inherited': 'Inherited from workflow default.',
|
||||
'workflows.phase.toolScope.empty': 'No tool restrictions.',
|
||||
'workflows.phase.context': 'Context',
|
||||
'workflows.phase.context.empty': 'No context providers.',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'에이전트와 그 자율성, 그리고 이 컴퓨터에서 액세스할 수 있는 항목을 관리하세요.',
|
||||
|
||||
@@ -275,6 +275,9 @@ const messages: TranslationMap = {
|
||||
'memory.tab.tasksDescription':
|
||||
'Twórz i śledź zadania — własne listy do zrobienia oraz tablice, które Twoi agenci budują w rozmowach.',
|
||||
'memory.tab.subconscious': 'Podświadomość',
|
||||
'memory.tab.workflows': 'Workflows',
|
||||
'memory.tab.workflowsDescription':
|
||||
'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'memory.tab.dreams': 'Marzenia senne',
|
||||
'memory.tab.calls': 'Połączenia',
|
||||
'memory.tab.diagram': 'Diagram',
|
||||
@@ -4124,6 +4127,50 @@ const messages: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Zapisz',
|
||||
'settings.agents.editor.create': 'Utwórz agenta',
|
||||
'settings.agents.editor.saving': 'Zapisywanie…',
|
||||
'nav.workflows': 'Workflows',
|
||||
'workflows.title': 'Agent Workflows',
|
||||
'workflows.subtitle': 'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'workflows.createNew': 'New Workflow',
|
||||
'workflows.listHeading': 'Workflows',
|
||||
'workflows.delete': 'Delete Workflow',
|
||||
'workflows.deleteError': 'Failed to delete workflow',
|
||||
'workflows.warnings': '{count} warning(s)',
|
||||
'workflows.empty.title': 'No workflows yet',
|
||||
'workflows.empty.body':
|
||||
'Create a workflow to define rules and scripts for task lifecycle phases.',
|
||||
'workflows.create.title': 'New Workflow',
|
||||
'workflows.create.subtitle': 'Define a lifecycle-bound rule set for the agent.',
|
||||
'workflows.create.name': 'Name',
|
||||
'workflows.create.namePlaceholder': 'e.g. Python project workflow',
|
||||
'workflows.create.description': 'Description',
|
||||
'workflows.create.descriptionPlaceholder': 'What does this workflow do?',
|
||||
'workflows.create.whenToUse': 'When to use',
|
||||
'workflows.create.whenToUsePlaceholder': 'When should the agent pick up this workflow?',
|
||||
'workflows.create.optional': '(optional)',
|
||||
'workflows.create.createBtn': 'Create workflow',
|
||||
'workflows.create.creating': 'Creating…',
|
||||
'workflows.create.createError': 'Could not create workflow',
|
||||
'workflows.create.successTitle': 'Workflow created',
|
||||
'workflows.create.successMessage': 'was created successfully.',
|
||||
'workflows.deleteConfirm.title': 'Delete workflow',
|
||||
'workflows.deleteConfirm.body': 'Remove "{name}"? This cannot be undone.',
|
||||
'workflows.detail.tags': 'Tags',
|
||||
'workflows.detail.location': 'Location',
|
||||
'workflows.detail.warnings': 'Warnings',
|
||||
'workflows.detail.phases': 'Phases',
|
||||
'workflows.detail.noPhases': 'No phases defined.',
|
||||
'workflows.detail.loadError': 'Failed to load full workflow',
|
||||
'workflows.phase.rules': 'Rules',
|
||||
'workflows.phase.rules.empty': 'No rules.',
|
||||
'workflows.phase.scripts': 'Scripts',
|
||||
'workflows.phase.scripts.empty': 'No scripts.',
|
||||
'workflows.phase.toolScope': 'Tool scope',
|
||||
'workflows.phase.toolScope.allow': 'Allow',
|
||||
'workflows.phase.toolScope.deny': 'Deny',
|
||||
'workflows.phase.toolScope.inherited': 'Inherited from workflow default.',
|
||||
'workflows.phase.toolScope.empty': 'No tool restrictions.',
|
||||
'workflows.phase.context': 'Context',
|
||||
'workflows.phase.context.empty': 'No context providers.',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Zarządzaj agentami, ich autonomią i tym, do czego mogą uzyskać dostęp na tym komputerze.',
|
||||
|
||||
@@ -279,6 +279,9 @@ const messages: TranslationMap = {
|
||||
'memory.tab.tasksDescription':
|
||||
'Crie e acompanhe tarefas — seus próprios afazeres e os painéis que seus agentes constroem ao longo das conversas.',
|
||||
'memory.tab.subconscious': 'Subconsciente',
|
||||
'memory.tab.workflows': 'Workflows',
|
||||
'memory.tab.workflowsDescription':
|
||||
'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'memory.tab.dreams': 'Sonhos',
|
||||
'memory.tab.calls': 'Chamadas',
|
||||
'memory.tab.diagram': 'Diagram',
|
||||
@@ -4123,6 +4126,50 @@ const messages: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Salvar',
|
||||
'settings.agents.editor.create': 'Criar agente',
|
||||
'settings.agents.editor.saving': 'Salvando…',
|
||||
'nav.workflows': 'Workflows',
|
||||
'workflows.title': 'Agent Workflows',
|
||||
'workflows.subtitle': 'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'workflows.createNew': 'New Workflow',
|
||||
'workflows.listHeading': 'Workflows',
|
||||
'workflows.delete': 'Delete Workflow',
|
||||
'workflows.deleteError': 'Failed to delete workflow',
|
||||
'workflows.warnings': '{count} warning(s)',
|
||||
'workflows.empty.title': 'No workflows yet',
|
||||
'workflows.empty.body':
|
||||
'Create a workflow to define rules and scripts for task lifecycle phases.',
|
||||
'workflows.create.title': 'New Workflow',
|
||||
'workflows.create.subtitle': 'Define a lifecycle-bound rule set for the agent.',
|
||||
'workflows.create.name': 'Name',
|
||||
'workflows.create.namePlaceholder': 'e.g. Python project workflow',
|
||||
'workflows.create.description': 'Description',
|
||||
'workflows.create.descriptionPlaceholder': 'What does this workflow do?',
|
||||
'workflows.create.whenToUse': 'When to use',
|
||||
'workflows.create.whenToUsePlaceholder': 'When should the agent pick up this workflow?',
|
||||
'workflows.create.optional': '(optional)',
|
||||
'workflows.create.createBtn': 'Create workflow',
|
||||
'workflows.create.creating': 'Creating…',
|
||||
'workflows.create.createError': 'Could not create workflow',
|
||||
'workflows.create.successTitle': 'Workflow created',
|
||||
'workflows.create.successMessage': 'was created successfully.',
|
||||
'workflows.deleteConfirm.title': 'Delete workflow',
|
||||
'workflows.deleteConfirm.body': 'Remove "{name}"? This cannot be undone.',
|
||||
'workflows.detail.tags': 'Tags',
|
||||
'workflows.detail.location': 'Location',
|
||||
'workflows.detail.warnings': 'Warnings',
|
||||
'workflows.detail.phases': 'Phases',
|
||||
'workflows.detail.noPhases': 'No phases defined.',
|
||||
'workflows.detail.loadError': 'Failed to load full workflow',
|
||||
'workflows.phase.rules': 'Rules',
|
||||
'workflows.phase.rules.empty': 'No rules.',
|
||||
'workflows.phase.scripts': 'Scripts',
|
||||
'workflows.phase.scripts.empty': 'No scripts.',
|
||||
'workflows.phase.toolScope': 'Tool scope',
|
||||
'workflows.phase.toolScope.allow': 'Allow',
|
||||
'workflows.phase.toolScope.deny': 'Deny',
|
||||
'workflows.phase.toolScope.inherited': 'Inherited from workflow default.',
|
||||
'workflows.phase.toolScope.empty': 'No tool restrictions.',
|
||||
'workflows.phase.context': 'Context',
|
||||
'workflows.phase.context.empty': 'No context providers.',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Gerencie seus agentes, sua autonomia e o que eles podem acessar neste computador.',
|
||||
|
||||
@@ -272,6 +272,9 @@ const messages: TranslationMap = {
|
||||
'memory.tab.tasksDescription':
|
||||
'Создавайте и отслеживайте задачи — ваши личные дела и доски, которые агенты формируют в ходе разговоров.',
|
||||
'memory.tab.subconscious': 'Подсознание',
|
||||
'memory.tab.workflows': 'Workflows',
|
||||
'memory.tab.workflowsDescription':
|
||||
'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'memory.tab.dreams': 'Сны',
|
||||
'memory.tab.calls': 'Звонки',
|
||||
'memory.tab.diagram': 'Diagram',
|
||||
@@ -4092,6 +4095,50 @@ const messages: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Сохранять',
|
||||
'settings.agents.editor.create': 'Создать агента',
|
||||
'settings.agents.editor.saving': 'Сохранение…',
|
||||
'nav.workflows': 'Workflows',
|
||||
'workflows.title': 'Agent Workflows',
|
||||
'workflows.subtitle': 'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'workflows.createNew': 'New Workflow',
|
||||
'workflows.listHeading': 'Workflows',
|
||||
'workflows.delete': 'Delete Workflow',
|
||||
'workflows.deleteError': 'Failed to delete workflow',
|
||||
'workflows.warnings': '{count} warning(s)',
|
||||
'workflows.empty.title': 'No workflows yet',
|
||||
'workflows.empty.body':
|
||||
'Create a workflow to define rules and scripts for task lifecycle phases.',
|
||||
'workflows.create.title': 'New Workflow',
|
||||
'workflows.create.subtitle': 'Define a lifecycle-bound rule set for the agent.',
|
||||
'workflows.create.name': 'Name',
|
||||
'workflows.create.namePlaceholder': 'e.g. Python project workflow',
|
||||
'workflows.create.description': 'Description',
|
||||
'workflows.create.descriptionPlaceholder': 'What does this workflow do?',
|
||||
'workflows.create.whenToUse': 'When to use',
|
||||
'workflows.create.whenToUsePlaceholder': 'When should the agent pick up this workflow?',
|
||||
'workflows.create.optional': '(optional)',
|
||||
'workflows.create.createBtn': 'Create workflow',
|
||||
'workflows.create.creating': 'Creating…',
|
||||
'workflows.create.createError': 'Could not create workflow',
|
||||
'workflows.create.successTitle': 'Workflow created',
|
||||
'workflows.create.successMessage': 'was created successfully.',
|
||||
'workflows.deleteConfirm.title': 'Delete workflow',
|
||||
'workflows.deleteConfirm.body': 'Remove "{name}"? This cannot be undone.',
|
||||
'workflows.detail.tags': 'Tags',
|
||||
'workflows.detail.location': 'Location',
|
||||
'workflows.detail.warnings': 'Warnings',
|
||||
'workflows.detail.phases': 'Phases',
|
||||
'workflows.detail.noPhases': 'No phases defined.',
|
||||
'workflows.detail.loadError': 'Failed to load full workflow',
|
||||
'workflows.phase.rules': 'Rules',
|
||||
'workflows.phase.rules.empty': 'No rules.',
|
||||
'workflows.phase.scripts': 'Scripts',
|
||||
'workflows.phase.scripts.empty': 'No scripts.',
|
||||
'workflows.phase.toolScope': 'Tool scope',
|
||||
'workflows.phase.toolScope.allow': 'Allow',
|
||||
'workflows.phase.toolScope.deny': 'Deny',
|
||||
'workflows.phase.toolScope.inherited': 'Inherited from workflow default.',
|
||||
'workflows.phase.toolScope.empty': 'No tool restrictions.',
|
||||
'workflows.phase.context': 'Context',
|
||||
'workflows.phase.context.empty': 'No context providers.',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Управляйте агентами, их автономностью и доступом к ресурсам компьютера.',
|
||||
|
||||
@@ -260,6 +260,9 @@ const messages: TranslationMap = {
|
||||
'memory.tab.tasksDescription':
|
||||
'创建并跟踪任务——包括您自己的待办事项以及智能体在对话中创建的看板。',
|
||||
'memory.tab.subconscious': '潜意识',
|
||||
'memory.tab.workflows': 'Workflows',
|
||||
'memory.tab.workflowsDescription':
|
||||
'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'memory.tab.dreams': '梦境',
|
||||
'memory.tab.calls': '调用记录',
|
||||
'memory.tab.diagram': 'Diagram',
|
||||
@@ -3856,6 +3859,50 @@ const messages: TranslationMap = {
|
||||
'settings.agents.editor.save': '保存',
|
||||
'settings.agents.editor.create': '创建智能体',
|
||||
'settings.agents.editor.saving': '保存中…',
|
||||
'nav.workflows': 'Workflows',
|
||||
'workflows.title': 'Agent Workflows',
|
||||
'workflows.subtitle': 'Lifecycle-bound rule sets that guide how the agent behaves during tasks.',
|
||||
'workflows.createNew': 'New Workflow',
|
||||
'workflows.listHeading': 'Workflows',
|
||||
'workflows.delete': 'Delete Workflow',
|
||||
'workflows.deleteError': 'Failed to delete workflow',
|
||||
'workflows.warnings': '{count} warning(s)',
|
||||
'workflows.empty.title': 'No workflows yet',
|
||||
'workflows.empty.body':
|
||||
'Create a workflow to define rules and scripts for task lifecycle phases.',
|
||||
'workflows.create.title': 'New Workflow',
|
||||
'workflows.create.subtitle': 'Define a lifecycle-bound rule set for the agent.',
|
||||
'workflows.create.name': 'Name',
|
||||
'workflows.create.namePlaceholder': 'e.g. Python project workflow',
|
||||
'workflows.create.description': 'Description',
|
||||
'workflows.create.descriptionPlaceholder': 'What does this workflow do?',
|
||||
'workflows.create.whenToUse': 'When to use',
|
||||
'workflows.create.whenToUsePlaceholder': 'When should the agent pick up this workflow?',
|
||||
'workflows.create.optional': '(optional)',
|
||||
'workflows.create.createBtn': 'Create workflow',
|
||||
'workflows.create.creating': 'Creating…',
|
||||
'workflows.create.createError': 'Could not create workflow',
|
||||
'workflows.create.successTitle': 'Workflow created',
|
||||
'workflows.create.successMessage': 'was created successfully.',
|
||||
'workflows.deleteConfirm.title': 'Delete workflow',
|
||||
'workflows.deleteConfirm.body': 'Remove "{name}"? This cannot be undone.',
|
||||
'workflows.detail.tags': 'Tags',
|
||||
'workflows.detail.location': 'Location',
|
||||
'workflows.detail.warnings': 'Warnings',
|
||||
'workflows.detail.phases': 'Phases',
|
||||
'workflows.detail.noPhases': 'No phases defined.',
|
||||
'workflows.detail.loadError': 'Failed to load full workflow',
|
||||
'workflows.phase.rules': 'Rules',
|
||||
'workflows.phase.rules.empty': 'No rules.',
|
||||
'workflows.phase.scripts': 'Scripts',
|
||||
'workflows.phase.scripts.empty': 'No scripts.',
|
||||
'workflows.phase.toolScope': 'Tool scope',
|
||||
'workflows.phase.toolScope.allow': 'Allow',
|
||||
'workflows.phase.toolScope.deny': 'Deny',
|
||||
'workflows.phase.toolScope.inherited': 'Inherited from workflow default.',
|
||||
'workflows.phase.toolScope.empty': 'No tool restrictions.',
|
||||
'workflows.phase.context': 'Context',
|
||||
'workflows.phase.context.empty': 'No context providers.',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description': '管理您的智能体、其自主权及其在本机上的访问权限。',
|
||||
'settings.agentsSection.menuDesc': '注册表、自主权与系统访问',
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* AgentWorkflows page — Vitest coverage
|
||||
*
|
||||
* Verifies:
|
||||
* - Loading state while workflows are being fetched.
|
||||
* - Renders workflow cards once loaded.
|
||||
* - Empty state shown when there are no workflows.
|
||||
* - Create modal opens on button click.
|
||||
* - Create workflow success path: modal closes, toast appears, list refreshes.
|
||||
* - Delete workflow success path: confirmation dialog, success toast.
|
||||
* - Delete error path: error toast shown.
|
||||
*/
|
||||
import { combineReducers, configureStore } from '@reduxjs/toolkit';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getCoreStateSnapshot } from '../lib/coreState/store';
|
||||
import { CoreStateContext } from '../providers/coreStateContext';
|
||||
import { workflowsApi } from '../services/api/workflowsApi';
|
||||
import localeReducer from '../store/localeSlice';
|
||||
import workflowsReducer from '../store/workflowsSlice';
|
||||
import AgentWorkflows from './AgentWorkflows';
|
||||
|
||||
// Mock the workflowsApi
|
||||
vi.mock('../services/api/workflowsApi', () => ({
|
||||
workflowsApi: {
|
||||
listWorkflows: vi.fn(),
|
||||
readWorkflow: vi.fn(),
|
||||
createWorkflow: vi.fn(),
|
||||
uninstallWorkflow: vi.fn(),
|
||||
getWorkflowPhase: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockSummary = {
|
||||
id: 'test-workflow',
|
||||
name: 'Test Workflow',
|
||||
description: 'A test workflow',
|
||||
when_to_use: 'When testing',
|
||||
tags: ['test'],
|
||||
scope: 'user' as const,
|
||||
phases: ['on_pick_up_task'],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const mockWorkflow = {
|
||||
name: 'Test Workflow',
|
||||
dir_name: 'test-workflow',
|
||||
description: 'A test workflow',
|
||||
when_to_use: 'When testing',
|
||||
tags: ['test'],
|
||||
tools: null,
|
||||
phases: {
|
||||
on_pick_up_task: { rules: ['Write tests first'], scripts: [], tools: null, context: [] },
|
||||
},
|
||||
location: null,
|
||||
scope: 'user' as const,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
function makeStore() {
|
||||
return configureStore({
|
||||
reducer: combineReducers({ locale: localeReducer, workflows: workflowsReducer }),
|
||||
});
|
||||
}
|
||||
|
||||
function renderPage(store = makeStore()) {
|
||||
const coreStateStub = {
|
||||
...getCoreStateSnapshot(),
|
||||
refresh: async () => {},
|
||||
refreshTeams: async () => {},
|
||||
refreshTeamMembers: async () => {},
|
||||
refreshTeamInvites: async () => {},
|
||||
setAnalyticsEnabled: async () => {},
|
||||
setMeetAutoOrchestratorHandoff: async () => {},
|
||||
setOnboardingCompletedFlag: async () => {},
|
||||
setEncryptionKey: async () => {},
|
||||
patchSnapshot: () => {},
|
||||
setOnboardingTasks: async () => {},
|
||||
storeSessionToken: async () => {},
|
||||
clearSession: async () => {},
|
||||
};
|
||||
|
||||
return {
|
||||
store,
|
||||
...render(
|
||||
<Provider store={store}>
|
||||
<CoreStateContext.Provider value={coreStateStub}>
|
||||
<MemoryRouter>
|
||||
<AgentWorkflows />
|
||||
</MemoryRouter>
|
||||
</CoreStateContext.Provider>
|
||||
</Provider>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentWorkflows', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders page title', async () => {
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
renderPage();
|
||||
expect(screen.getByText('Agent Workflows')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no workflows', async () => {
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No workflows yet')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders workflow cards after loading', async () => {
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([mockSummary]);
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Workflow')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('workflow-card-test-workflow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens create modal when New Workflow button is clicked', async () => {
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('workflows-create-btn')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('workflows-create-btn'));
|
||||
expect(screen.getByRole('dialog', { name: /New Workflow/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows success toast and closes modal after creating workflow', async () => {
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(workflowsApi.createWorkflow).mockResolvedValue(mockWorkflow);
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('workflows-create-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open create modal
|
||||
fireEvent.click(screen.getByTestId('workflows-create-btn'));
|
||||
expect(screen.getByRole('dialog', { name: /New Workflow/i })).toBeInTheDocument();
|
||||
|
||||
// Fill name
|
||||
const nameInput = screen.getByLabelText(/Name/);
|
||||
fireEvent.change(nameInput, { target: { value: 'Test Workflow' } });
|
||||
|
||||
// Submit
|
||||
const submitBtn = screen.getByRole('button', { name: /Create workflow/i });
|
||||
// Re-fetch after create
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([mockSummary]);
|
||||
await act(async () => {
|
||||
fireEvent.click(submitBtn);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Workflow created')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens delete confirmation when delete button is clicked', async () => {
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([mockSummary]);
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('workflow-card-test-workflow-delete')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('workflow-card-test-workflow-delete'));
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Delete workflow/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes workflow and shows success toast on confirmed delete', async () => {
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([mockSummary]);
|
||||
vi.mocked(workflowsApi.uninstallWorkflow).mockResolvedValue({
|
||||
id: 'test-workflow',
|
||||
removed: true,
|
||||
});
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('workflow-card-test-workflow-delete')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('workflow-card-test-workflow-delete'));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('wf-delete-confirm-btn'));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete Workflow')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows retry button when load fails', async () => {
|
||||
vi.mocked(workflowsApi.listWorkflows).mockRejectedValue(new Error('network error'));
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Try again/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* AgentWorkflows page
|
||||
* -------------------
|
||||
*
|
||||
* Lists, creates, views, and deletes agent workflows. Mirrors the Skills page
|
||||
* structure: cards for each workflow, a detail drawer on click, a create
|
||||
* modal, and delete confirmation. Workflow data is loaded via the Redux
|
||||
* `workflows` slice backed by `workflowsApi`.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { ToastContainer } from '../components/intelligence/Toast';
|
||||
import CreateWorkflowModal from '../components/workflows/CreateWorkflowModal';
|
||||
import WorkflowCard from '../components/workflows/WorkflowCard';
|
||||
import WorkflowDetailDrawer from '../components/workflows/WorkflowDetailDrawer';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import type { Workflow, WorkflowSummary } from '../services/api/workflowsApi';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import {
|
||||
loadWorkflows,
|
||||
removeWorkflow,
|
||||
selectWorkflows,
|
||||
selectWorkflowsError,
|
||||
selectWorkflowsStatus,
|
||||
} from '../store/workflowsSlice';
|
||||
import type { ToastNotification } from '../types/intelligence';
|
||||
|
||||
const log = debug('agentWorkflows');
|
||||
|
||||
export default function AgentWorkflows() {
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
const workflows = useAppSelector(selectWorkflows);
|
||||
const status = useAppSelector(selectWorkflowsStatus);
|
||||
const storeError = useAppSelector(selectWorkflowsError);
|
||||
|
||||
const [selectedWorkflow, setSelectedWorkflow] = useState<WorkflowSummary | null>(null);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [deleteCandidate, setDeleteCandidate] = useState<WorkflowSummary | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [toasts, setToasts] = useState<ToastNotification[]>([]);
|
||||
|
||||
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
|
||||
const newToast: ToastNotification = { ...toast, id: `toast-${Date.now()}-${Math.random()}` };
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, []);
|
||||
|
||||
// Initial load of workflows.
|
||||
useEffect(() => {
|
||||
log('mount — dispatch loadWorkflows');
|
||||
void dispatch(loadWorkflows());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleCreated = useCallback(
|
||||
(workflow: Workflow) => {
|
||||
log('created workflow name=%s', workflow.name);
|
||||
setCreateModalOpen(false);
|
||||
// Refresh list to pick up newly created workflow with full server data.
|
||||
void dispatch(loadWorkflows());
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: t('workflows.create.successTitle'),
|
||||
message: `"${workflow.name}" ${t('workflows.create.successMessage')}`,
|
||||
});
|
||||
},
|
||||
[dispatch, addToast, t]
|
||||
);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteCandidate || deleting) return;
|
||||
setDeleting(true);
|
||||
log('delete workflow id=%s', deleteCandidate.id);
|
||||
try {
|
||||
await dispatch(removeWorkflow(deleteCandidate.id)).unwrap();
|
||||
log('deleted workflow id=%s', deleteCandidate.id);
|
||||
// Close the drawer if it was showing the deleted workflow.
|
||||
setSelectedWorkflow(prev => (prev && prev.id === deleteCandidate.id ? null : prev));
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: t('workflows.delete'),
|
||||
message: `"${deleteCandidate.name}" ${t('common.success')}`,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('delete error id=%s %s', deleteCandidate.id, msg);
|
||||
addToast({ type: 'error', title: t('workflows.deleteError'), message: msg });
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteCandidate(null);
|
||||
}
|
||||
}, [deleteCandidate, deleting, dispatch, addToast, t]);
|
||||
|
||||
const isLoading = status === 'loading';
|
||||
const isEmpty = workflows.length === 0 && !isLoading;
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<div className="min-h-full flex flex-col">
|
||||
<div className="flex-1 flex items-start justify-center p-4 pt-6">
|
||||
<div className="w-full max-w-3xl space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{t('workflows.title')}
|
||||
</h1>
|
||||
<p className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
|
||||
{t('workflows.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workflows-create-btn"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
className="flex-shrink-0 rounded-lg bg-primary-500 px-3 py-2 text-xs font-semibold text-white shadow-soft transition-colors hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1">
|
||||
{t('workflows.createNew')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{storeError ? (
|
||||
<div className="rounded-2xl border border-coral-200 bg-coral-50 p-3 shadow-soft">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-xs text-coral-800">{storeError}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(loadWorkflows())}
|
||||
className="flex-shrink-0 rounded-lg border border-coral-200 bg-white px-3 py-1.5 text-[11px] font-medium text-coral-700 hover:bg-coral-50">
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{isLoading && workflows.length === 0 ? (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-20 rounded-2xl bg-stone-100 dark:bg-neutral-800" />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Empty state */}
|
||||
{isEmpty ? (
|
||||
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-10 text-center shadow-soft animate-fade-up">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-primary-50 dark:bg-primary-500/10">
|
||||
<svg
|
||||
className="h-6 w-6 text-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{t('workflows.empty.title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
|
||||
{t('workflows.empty.body')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
className="mt-4 rounded-lg bg-primary-500 px-4 py-2 text-xs font-semibold text-white shadow-soft hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500">
|
||||
{t('workflows.createNew')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Workflow list */}
|
||||
{workflows.length > 0 ? (
|
||||
<div
|
||||
className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 shadow-soft animate-fade-up"
|
||||
data-testid="workflows-list">
|
||||
<div className="px-1 pb-3 pt-1">
|
||||
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{t('workflows.listHeading')} ({workflows.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{workflows.map(wf => (
|
||||
<WorkflowCard
|
||||
key={wf.id}
|
||||
workflow={wf}
|
||||
testId={`workflow-card-${wf.id}`}
|
||||
onView={w => {
|
||||
log('open drawer workflowId=%s', w.id);
|
||||
setSelectedWorkflow(w);
|
||||
}}
|
||||
onDelete={w => {
|
||||
log('open delete candidate workflowId=%s', w.id);
|
||||
setDeleteCandidate(w);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail drawer */}
|
||||
{selectedWorkflow ? (
|
||||
<WorkflowDetailDrawer
|
||||
workflow={selectedWorkflow}
|
||||
onClose={() => setSelectedWorkflow(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Create modal */}
|
||||
{createModalOpen ? (
|
||||
<CreateWorkflowModal onClose={() => setCreateModalOpen(false)} onCreated={handleCreated} />
|
||||
) : null}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{deleteCandidate ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget && !deleting) setDeleteCandidate(null);
|
||||
}}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-fade-in"
|
||||
onClick={() => {
|
||||
if (!deleting) setDeleteCandidate(null);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="wf-delete-title"
|
||||
className="relative w-full max-w-sm rounded-2xl bg-white dark:bg-neutral-900 shadow-2xl animate-fade-in p-6 space-y-4">
|
||||
<h2
|
||||
id="wf-delete-title"
|
||||
className="text-base font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{t('workflows.deleteConfirm.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-stone-600 dark:text-neutral-300">
|
||||
{t('workflows.deleteConfirm.body').replace('{name}', deleteCandidate.name)}
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteCandidate(null)}
|
||||
disabled={deleting}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:opacity-40">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="wf-delete-confirm-btn"
|
||||
onClick={() => void handleDeleteConfirm()}
|
||||
disabled={deleting}
|
||||
className="rounded-lg bg-coral-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-coral-700 focus:outline-none focus:ring-2 focus:ring-coral-500 focus:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{deleting ? t('common.loading') : t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ToastContainer notifications={toasts} onRemove={removeToast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,8 +18,9 @@ import type {
|
||||
ConfirmationModal as ConfirmationModalType,
|
||||
ToastNotification,
|
||||
} from '../types/intelligence';
|
||||
import AgentWorkflows from './AgentWorkflows';
|
||||
|
||||
type IntelligenceTab = 'memory' | 'subconscious' | 'tasks' | 'diagram' | 'centrality';
|
||||
type IntelligenceTab = 'memory' | 'subconscious' | 'tasks' | 'workflows' | 'diagram' | 'centrality';
|
||||
|
||||
export default function Intelligence() {
|
||||
const { t } = useT();
|
||||
@@ -92,6 +93,11 @@ export default function Intelligence() {
|
||||
{ id: 'tasks', label: t('memory.tab.tasks'), description: t('memory.tab.tasksDescription') },
|
||||
{ id: 'memory', label: t('memory.tab.memory') },
|
||||
{ id: 'subconscious', label: t('memory.tab.subconscious') },
|
||||
{
|
||||
id: 'workflows',
|
||||
label: t('memory.tab.workflows'),
|
||||
description: t('memory.tab.workflowsDescription'),
|
||||
},
|
||||
{ id: 'diagram', label: t('memory.tab.diagram') },
|
||||
{ id: 'centrality', label: t('memory.tab.centrality') },
|
||||
];
|
||||
@@ -179,6 +185,8 @@ export default function Intelligence() {
|
||||
|
||||
{activeTab === 'tasks' && <IntelligenceTasksTab />}
|
||||
|
||||
{activeTab === 'workflows' && <AgentWorkflows />}
|
||||
|
||||
{activeTab === 'diagram' && <DiagramViewerTab />}
|
||||
|
||||
{activeTab === 'centrality' && <GraphCentralityTab />}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { workflowsApi } from './workflowsApi';
|
||||
|
||||
const mockCallCoreRpc = vi.fn();
|
||||
vi.mock('../coreRpcClient', () => ({ callCoreRpc: (...a: unknown[]) => mockCallCoreRpc(...a) }));
|
||||
|
||||
const mockWorkflow = {
|
||||
name: 'Test Workflow',
|
||||
dir_name: 'test-workflow',
|
||||
description: 'A test workflow',
|
||||
when_to_use: 'When testing',
|
||||
tags: ['test'],
|
||||
tools: null,
|
||||
phases: { on_pick_up_task: { rules: ['Follow TDD'], scripts: [], tools: null, context: [] } },
|
||||
location: '/home/user/.openhuman/workflows/test-workflow',
|
||||
scope: 'user' as const,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const mockSummary = {
|
||||
id: 'test-workflow',
|
||||
name: 'Test Workflow',
|
||||
description: 'A test workflow',
|
||||
when_to_use: 'When testing',
|
||||
tags: ['test'],
|
||||
scope: 'user' as const,
|
||||
phases: ['on_pick_up_task'],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
describe('workflowsApi', () => {
|
||||
beforeEach(() => {
|
||||
mockCallCoreRpc.mockReset();
|
||||
});
|
||||
|
||||
describe('listWorkflows', () => {
|
||||
it('calls openhuman.workflows_list and returns workflows array', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ workflows: [mockSummary] });
|
||||
const result = await workflowsApi.listWorkflows();
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'openhuman.workflows_list' })
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('test-workflow');
|
||||
});
|
||||
|
||||
it('returns empty array when workflows is missing', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({});
|
||||
const result = await workflowsApi.listWorkflows();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('unwraps data-envelope shape', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ data: { workflows: [mockSummary] } });
|
||||
const result = await workflowsApi.listWorkflows();
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readWorkflow', () => {
|
||||
it('calls openhuman.workflows_read with id param', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ workflow: mockWorkflow });
|
||||
const result = await workflowsApi.readWorkflow('test-workflow');
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'openhuman.workflows_read',
|
||||
params: { id: 'test-workflow' },
|
||||
})
|
||||
);
|
||||
expect(result.name).toBe('Test Workflow');
|
||||
});
|
||||
|
||||
it('unwraps data-envelope shape', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ data: { workflow: mockWorkflow } });
|
||||
const result = await workflowsApi.readWorkflow('test-workflow');
|
||||
expect(result.dir_name).toBe('test-workflow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWorkflow', () => {
|
||||
it('calls openhuman.workflows_create with name param', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ workflow: mockWorkflow });
|
||||
const result = await workflowsApi.createWorkflow({ name: 'Test Workflow' });
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'openhuman.workflows_create',
|
||||
params: expect.objectContaining({ name: 'Test Workflow' }),
|
||||
})
|
||||
);
|
||||
expect(result.name).toBe('Test Workflow');
|
||||
});
|
||||
|
||||
it('includes optional description and when_to_use when provided', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ workflow: mockWorkflow });
|
||||
await workflowsApi.createWorkflow({ name: 'Test', description: 'desc', when_to_use: 'when' });
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ description: 'desc', when_to_use: 'when' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('omits undefined optional fields', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ workflow: mockWorkflow });
|
||||
await workflowsApi.createWorkflow({ name: 'Test' });
|
||||
const call = mockCallCoreRpc.mock.calls[0][0] as { params: Record<string, unknown> };
|
||||
expect(call.params).not.toHaveProperty('description');
|
||||
expect(call.params).not.toHaveProperty('when_to_use');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstallWorkflow', () => {
|
||||
it('calls openhuman.workflows_uninstall with id param', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ id: 'test-workflow', removed: true });
|
||||
const result = await workflowsApi.uninstallWorkflow('test-workflow');
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'openhuman.workflows_uninstall',
|
||||
params: { id: 'test-workflow' },
|
||||
})
|
||||
);
|
||||
expect(result.removed).toBe(true);
|
||||
});
|
||||
|
||||
it('unwraps data-envelope shape', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ data: { id: 'test-workflow', removed: true } });
|
||||
const result = await workflowsApi.uninstallWorkflow('test-workflow');
|
||||
expect(result.id).toBe('test-workflow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkflowPhase', () => {
|
||||
it('calls openhuman.workflows_phase with id and phase params', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ guidance: 'Follow TDD', tool_scope: null });
|
||||
const result = await workflowsApi.getWorkflowPhase('test-workflow', 'on_pick_up_task');
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'openhuman.workflows_phase',
|
||||
params: { id: 'test-workflow', phase: 'on_pick_up_task' },
|
||||
})
|
||||
);
|
||||
expect(result.guidance).toBe('Follow TDD');
|
||||
expect(result.tool_scope).toBeNull();
|
||||
});
|
||||
|
||||
it('handles null guidance', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ guidance: null, tool_scope: null });
|
||||
const result = await workflowsApi.getWorkflowPhase('test-workflow', 'on_close_task');
|
||||
expect(result.guidance).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import debug from 'debug';
|
||||
|
||||
import { callCoreRpc } from '../coreRpcClient';
|
||||
|
||||
const log = debug('workflowsApi');
|
||||
|
||||
/**
|
||||
* Tool allow/deny scope for a workflow or phase.
|
||||
* Mirrors `openhuman::workflows::types::ToolScope`.
|
||||
*/
|
||||
export interface ToolScope {
|
||||
allow: string[];
|
||||
deny: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A single workflow phase definition.
|
||||
* Mirrors `openhuman::workflows::types::WorkflowPhase`.
|
||||
*/
|
||||
export interface WorkflowPhase {
|
||||
description?: string | null;
|
||||
rules: string[];
|
||||
scripts: string[];
|
||||
tools?: ToolScope | null;
|
||||
context: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary of a workflow returned by `openhuman.workflows_list`.
|
||||
* Mirrors `openhuman::workflows::types::WorkflowSummary`.
|
||||
*/
|
||||
export interface WorkflowSummary {
|
||||
/** Stable identifier — usually the slugified directory name. */
|
||||
id: string;
|
||||
/** Display name from frontmatter. */
|
||||
name: string;
|
||||
/** Short description from frontmatter. */
|
||||
description: string;
|
||||
/** When the agent should pick up this workflow. */
|
||||
when_to_use: string;
|
||||
/** Tags declared in frontmatter. */
|
||||
tags: string[];
|
||||
/** Where the workflow came from: user-scope or project-scope. */
|
||||
scope: 'user' | 'project';
|
||||
/** Phase names declared in this workflow. */
|
||||
phases: string[];
|
||||
/** Non-fatal parse warnings to surface in the UI. */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Full workflow definition returned by `openhuman.workflows_read`.
|
||||
* Mirrors `openhuman::workflows::types::Workflow`.
|
||||
*/
|
||||
export interface Workflow {
|
||||
name: string;
|
||||
dir_name: string;
|
||||
description: string;
|
||||
when_to_use: string;
|
||||
tags: string[];
|
||||
tools?: ToolScope | null;
|
||||
phases: Record<string, WorkflowPhase>;
|
||||
location?: string | null;
|
||||
scope: 'user' | 'project';
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
interface WorkflowsListResult {
|
||||
workflows: WorkflowSummary[];
|
||||
}
|
||||
|
||||
interface WorkflowsReadResult {
|
||||
workflow: Workflow;
|
||||
}
|
||||
|
||||
interface WorkflowsCreateResult {
|
||||
workflow: Workflow;
|
||||
}
|
||||
|
||||
interface WorkflowsUninstallResult {
|
||||
id: string;
|
||||
removed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase guidance returned by `openhuman.workflows_phase`.
|
||||
*/
|
||||
export interface WorkflowPhaseResult {
|
||||
guidance: string | null;
|
||||
tool_scope: ToolScope | null;
|
||||
}
|
||||
|
||||
interface Envelope<T> {
|
||||
data?: T;
|
||||
}
|
||||
|
||||
function unwrapEnvelope<T>(response: Envelope<T> | T): T {
|
||||
if (response && typeof response === 'object' && 'data' in response) {
|
||||
const envelope = response as Envelope<T>;
|
||||
if (envelope.data !== undefined) {
|
||||
return envelope.data as T;
|
||||
}
|
||||
}
|
||||
return response as T;
|
||||
}
|
||||
|
||||
export const workflowsApi = {
|
||||
/**
|
||||
* Enumerate workflows visible in the active workspace via
|
||||
* `openhuman.workflows_list`.
|
||||
*/
|
||||
listWorkflows: async (): Promise<WorkflowSummary[]> => {
|
||||
log('listWorkflows: request');
|
||||
const response = await callCoreRpc<Envelope<WorkflowsListResult> | WorkflowsListResult>({
|
||||
method: 'openhuman.workflows_list',
|
||||
});
|
||||
const result = unwrapEnvelope(response);
|
||||
const workflows = result?.workflows ?? [];
|
||||
log('listWorkflows: response count=%d', workflows.length);
|
||||
return workflows;
|
||||
},
|
||||
|
||||
/**
|
||||
* Read a single workflow by id via `openhuman.workflows_read`.
|
||||
*/
|
||||
readWorkflow: async (id: string): Promise<Workflow> => {
|
||||
log('readWorkflow: request id=%s', id);
|
||||
const response = await callCoreRpc<Envelope<WorkflowsReadResult> | WorkflowsReadResult>({
|
||||
method: 'openhuman.workflows_read',
|
||||
params: { id },
|
||||
});
|
||||
const result = unwrapEnvelope(response);
|
||||
log('readWorkflow: response name=%s', result.workflow.name);
|
||||
return result.workflow;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new workflow via `openhuman.workflows_create`.
|
||||
*
|
||||
* The Rust side slugifies the name, writes the workflow directory with the
|
||||
* supplied metadata, and returns the freshly-created Workflow so the caller
|
||||
* can update local state without a full refetch.
|
||||
*/
|
||||
createWorkflow: async (params: {
|
||||
name: string;
|
||||
description?: string;
|
||||
when_to_use?: string;
|
||||
}): Promise<Workflow> => {
|
||||
log('createWorkflow: request name=%s', params.name);
|
||||
const response = await callCoreRpc<Envelope<WorkflowsCreateResult> | WorkflowsCreateResult>({
|
||||
method: 'openhuman.workflows_create',
|
||||
params: {
|
||||
name: params.name,
|
||||
...(params.description !== undefined ? { description: params.description } : {}),
|
||||
...(params.when_to_use !== undefined ? { when_to_use: params.when_to_use } : {}),
|
||||
},
|
||||
});
|
||||
const result = unwrapEnvelope(response);
|
||||
log('createWorkflow: response name=%s', result.workflow.name);
|
||||
return result.workflow;
|
||||
},
|
||||
|
||||
/**
|
||||
* Uninstall a workflow by id via `openhuman.workflows_uninstall`.
|
||||
*
|
||||
* Only user-scope workflows can be uninstalled. Project-scope workflows are
|
||||
* read-only — the backend returns an error which surfaces as a rejected
|
||||
* promise.
|
||||
*/
|
||||
uninstallWorkflow: async (id: string): Promise<WorkflowsUninstallResult> => {
|
||||
log('uninstallWorkflow: request id=%s', id);
|
||||
const response = await callCoreRpc<
|
||||
Envelope<WorkflowsUninstallResult> | WorkflowsUninstallResult
|
||||
>({ method: 'openhuman.workflows_uninstall', params: { id } });
|
||||
const result = unwrapEnvelope(response);
|
||||
log('uninstallWorkflow: response id=%s removed=%s', result.id, result.removed);
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch guidance and tool scope for a specific phase of a workflow via
|
||||
* `openhuman.workflows_phase`.
|
||||
*/
|
||||
getWorkflowPhase: async (id: string, phase: string): Promise<WorkflowPhaseResult> => {
|
||||
log('getWorkflowPhase: request id=%s phase=%s', id, phase);
|
||||
const response = await callCoreRpc<Envelope<WorkflowPhaseResult> | WorkflowPhaseResult>({
|
||||
method: 'openhuman.workflows_phase',
|
||||
params: { id, phase },
|
||||
});
|
||||
const result = unwrapEnvelope(response);
|
||||
log('getWorkflowPhase: response guidance=%s', result.guidance != null ? 'yes' : 'null');
|
||||
return result;
|
||||
},
|
||||
};
|
||||
@@ -28,6 +28,7 @@ import socketReducer from './socketSlice';
|
||||
import themeReducer from './themeSlice';
|
||||
import threadReducer from './threadSlice';
|
||||
import { userScopedStorage } from './userScopedStorage';
|
||||
import workflowsReducer from './workflowsSlice';
|
||||
|
||||
// Persisted slices write through `userScopedStorage` so each user's blob
|
||||
// lives at `${userId}:persist:<key>` instead of a single per-device blob
|
||||
@@ -166,6 +167,7 @@ export const store = configureStore({
|
||||
mascot: persistedMascotReducer,
|
||||
persona: persistedPersonaReducer,
|
||||
theme: persistedThemeReducer,
|
||||
workflows: workflowsReducer,
|
||||
},
|
||||
middleware: getDefaultMiddleware => {
|
||||
const middleware = getDefaultMiddleware({
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { workflowsApi } from '../services/api/workflowsApi';
|
||||
import workflowsReducer, {
|
||||
clearSelectedWorkflow,
|
||||
createWorkflow,
|
||||
loadWorkflows,
|
||||
readWorkflow,
|
||||
removeWorkflow,
|
||||
selectSelectedWorkflow,
|
||||
selectWorkflows,
|
||||
selectWorkflowsError,
|
||||
selectWorkflowsStatus,
|
||||
type WorkflowsState,
|
||||
} from './workflowsSlice';
|
||||
|
||||
// Mock the workflowsApi
|
||||
vi.mock('../services/api/workflowsApi', () => ({
|
||||
workflowsApi: {
|
||||
listWorkflows: vi.fn(),
|
||||
readWorkflow: vi.fn(),
|
||||
createWorkflow: vi.fn(),
|
||||
uninstallWorkflow: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockWorkflow = {
|
||||
name: 'Test Workflow',
|
||||
dir_name: 'test-workflow',
|
||||
description: 'A test',
|
||||
when_to_use: 'When testing',
|
||||
tags: [],
|
||||
tools: null,
|
||||
phases: {
|
||||
on_pick_up_task: { rules: ['Write tests first'], scripts: [], tools: null, context: [] },
|
||||
},
|
||||
location: null,
|
||||
scope: 'user' as const,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const mockSummary = {
|
||||
id: 'test-workflow',
|
||||
name: 'Test Workflow',
|
||||
description: 'A test',
|
||||
when_to_use: 'When testing',
|
||||
tags: [],
|
||||
scope: 'user' as const,
|
||||
phases: ['on_pick_up_task'],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
function makeStore() {
|
||||
return configureStore({ reducer: { workflows: workflowsReducer } });
|
||||
}
|
||||
|
||||
describe('workflowsSlice', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('has correct initial state', () => {
|
||||
const store = makeStore();
|
||||
const state = store.getState().workflows as WorkflowsState;
|
||||
expect(state.workflows).toEqual([]);
|
||||
expect(state.selectedWorkflow).toBeNull();
|
||||
expect(state.status).toBe('idle');
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
|
||||
describe('loadWorkflows', () => {
|
||||
it('sets status to loading on pending', () => {
|
||||
const store = makeStore();
|
||||
// dispatch without resolving
|
||||
vi.mocked(workflowsApi.listWorkflows).mockReturnValue(new Promise(() => {}));
|
||||
void store.dispatch(loadWorkflows());
|
||||
const state = store.getState().workflows as WorkflowsState;
|
||||
expect(state.status).toBe('loading');
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
|
||||
it('populates workflows on fulfilled', async () => {
|
||||
const store = makeStore();
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([mockSummary]);
|
||||
await store.dispatch(loadWorkflows());
|
||||
const state = store.getState().workflows as WorkflowsState;
|
||||
expect(state.workflows).toHaveLength(1);
|
||||
expect(state.workflows[0].id).toBe('test-workflow');
|
||||
expect(state.status).toBe('idle');
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
|
||||
it('sets error on rejected', async () => {
|
||||
const store = makeStore();
|
||||
vi.mocked(workflowsApi.listWorkflows).mockRejectedValue(new Error('RPC failed'));
|
||||
await store.dispatch(loadWorkflows());
|
||||
const state = store.getState().workflows as WorkflowsState;
|
||||
expect(state.status).toBe('error');
|
||||
expect(state.error).toContain('RPC failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readWorkflow', () => {
|
||||
it('sets selectedWorkflow on fulfilled', async () => {
|
||||
const store = makeStore();
|
||||
vi.mocked(workflowsApi.readWorkflow).mockResolvedValue(mockWorkflow);
|
||||
await store.dispatch(readWorkflow('test-workflow'));
|
||||
const state = store.getState().workflows as WorkflowsState;
|
||||
expect(state.selectedWorkflow).toEqual(mockWorkflow);
|
||||
expect(state.status).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWorkflow', () => {
|
||||
it('adds workflow to list and sets selectedWorkflow on fulfilled', async () => {
|
||||
const store = makeStore();
|
||||
vi.mocked(workflowsApi.createWorkflow).mockResolvedValue(mockWorkflow);
|
||||
await store.dispatch(createWorkflow({ name: 'Test Workflow' }));
|
||||
const state = store.getState().workflows as WorkflowsState;
|
||||
expect(state.workflows).toHaveLength(1);
|
||||
expect(state.workflows[0].id).toBe('test-workflow');
|
||||
expect(state.selectedWorkflow).toEqual(mockWorkflow);
|
||||
expect(state.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('does not duplicate if workflow id already exists', async () => {
|
||||
const store = makeStore();
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([mockSummary]);
|
||||
await store.dispatch(loadWorkflows());
|
||||
vi.mocked(workflowsApi.createWorkflow).mockResolvedValue(mockWorkflow);
|
||||
await store.dispatch(createWorkflow({ name: 'Test Workflow' }));
|
||||
const state = store.getState().workflows as WorkflowsState;
|
||||
expect(state.workflows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sets status to saving on pending', () => {
|
||||
const store = makeStore();
|
||||
vi.mocked(workflowsApi.createWorkflow).mockReturnValue(new Promise(() => {}));
|
||||
void store.dispatch(createWorkflow({ name: 'Test' }));
|
||||
const state = store.getState().workflows as WorkflowsState;
|
||||
expect(state.status).toBe('saving');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeWorkflow', () => {
|
||||
it('removes workflow from list on fulfilled', async () => {
|
||||
const store = makeStore();
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([mockSummary]);
|
||||
await store.dispatch(loadWorkflows());
|
||||
vi.mocked(workflowsApi.uninstallWorkflow).mockResolvedValue({
|
||||
id: 'test-workflow',
|
||||
removed: true,
|
||||
});
|
||||
await store.dispatch(removeWorkflow('test-workflow'));
|
||||
const state = store.getState().workflows as WorkflowsState;
|
||||
expect(state.workflows).toHaveLength(0);
|
||||
expect(state.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('clears selectedWorkflow if it was the removed workflow', async () => {
|
||||
const store = makeStore();
|
||||
vi.mocked(workflowsApi.readWorkflow).mockResolvedValue(mockWorkflow);
|
||||
await store.dispatch(readWorkflow('test-workflow'));
|
||||
vi.mocked(workflowsApi.uninstallWorkflow).mockResolvedValue({
|
||||
id: 'test-workflow',
|
||||
removed: true,
|
||||
});
|
||||
await store.dispatch(removeWorkflow('test-workflow'));
|
||||
const state = store.getState().workflows as WorkflowsState;
|
||||
expect(state.selectedWorkflow).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSelectedWorkflow', () => {
|
||||
it('sets selectedWorkflow to null', async () => {
|
||||
const store = makeStore();
|
||||
vi.mocked(workflowsApi.readWorkflow).mockResolvedValue(mockWorkflow);
|
||||
await store.dispatch(readWorkflow('test-workflow'));
|
||||
store.dispatch(clearSelectedWorkflow());
|
||||
const state = store.getState().workflows as WorkflowsState;
|
||||
expect(state.selectedWorkflow).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectors', () => {
|
||||
it('selectWorkflows returns the workflows array', async () => {
|
||||
const store = makeStore();
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([mockSummary]);
|
||||
await store.dispatch(loadWorkflows());
|
||||
const workflows = selectWorkflows(store.getState() as { workflows: WorkflowsState });
|
||||
expect(workflows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('selectSelectedWorkflow returns null initially', () => {
|
||||
const store = makeStore();
|
||||
const selected = selectSelectedWorkflow(store.getState() as { workflows: WorkflowsState });
|
||||
expect(selected).toBeNull();
|
||||
});
|
||||
|
||||
it('selectWorkflowsStatus returns idle initially', () => {
|
||||
const store = makeStore();
|
||||
const s = selectWorkflowsStatus(store.getState() as { workflows: WorkflowsState });
|
||||
expect(s).toBe('idle');
|
||||
});
|
||||
|
||||
it('selectWorkflowsError returns null initially', () => {
|
||||
const store = makeStore();
|
||||
const err = selectWorkflowsError(store.getState() as { workflows: WorkflowsState });
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||
import debug from 'debug';
|
||||
|
||||
import { type Workflow, workflowsApi, type WorkflowSummary } from '../services/api/workflowsApi';
|
||||
import { resetUserScopedState } from './resetActions';
|
||||
|
||||
const log = debug('workflows');
|
||||
|
||||
export type WorkflowsStatus = 'idle' | 'loading' | 'saving' | 'error';
|
||||
|
||||
export interface WorkflowsState {
|
||||
workflows: WorkflowSummary[];
|
||||
selectedWorkflow: Workflow | null;
|
||||
status: WorkflowsStatus;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: WorkflowsState = {
|
||||
workflows: [],
|
||||
selectedWorkflow: null,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export const loadWorkflows = createAsyncThunk('workflows/load', async () =>
|
||||
workflowsApi.listWorkflows()
|
||||
);
|
||||
|
||||
export const readWorkflow = createAsyncThunk('workflows/read', async (id: string) =>
|
||||
workflowsApi.readWorkflow(id)
|
||||
);
|
||||
|
||||
export const createWorkflow = createAsyncThunk(
|
||||
'workflows/create',
|
||||
async (params: { name: string; description?: string; when_to_use?: string }) =>
|
||||
workflowsApi.createWorkflow(params)
|
||||
);
|
||||
|
||||
export const removeWorkflow = createAsyncThunk('workflows/remove', async (id: string) =>
|
||||
workflowsApi.uninstallWorkflow(id)
|
||||
);
|
||||
|
||||
const workflowsSlice = createSlice({
|
||||
name: 'workflows',
|
||||
initialState,
|
||||
reducers: {
|
||||
clearSelectedWorkflow(state) {
|
||||
state.selectedWorkflow = null;
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
// loadWorkflows
|
||||
.addCase(loadWorkflows.pending, state => {
|
||||
state.status = 'loading';
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(loadWorkflows.fulfilled, (state, action) => {
|
||||
state.workflows = action.payload;
|
||||
state.status = 'idle';
|
||||
state.error = null;
|
||||
log('loaded %d workflow(s)', state.workflows.length);
|
||||
})
|
||||
.addCase(loadWorkflows.rejected, (state, action) => {
|
||||
state.status = 'error';
|
||||
state.error = errorMessage(action.error.message ?? action.error);
|
||||
log('load failed: %s', state.error);
|
||||
})
|
||||
|
||||
// readWorkflow
|
||||
.addCase(readWorkflow.pending, state => {
|
||||
state.status = 'loading';
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(readWorkflow.fulfilled, (state, action) => {
|
||||
state.selectedWorkflow = action.payload;
|
||||
state.status = 'idle';
|
||||
state.error = null;
|
||||
log('read workflow name=%s', action.payload.name);
|
||||
})
|
||||
.addCase(readWorkflow.rejected, (state, action) => {
|
||||
state.status = 'error';
|
||||
state.error = errorMessage(action.error.message ?? action.error);
|
||||
})
|
||||
|
||||
// createWorkflow
|
||||
.addCase(createWorkflow.pending, state => {
|
||||
state.status = 'saving';
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(createWorkflow.fulfilled, (state, action) => {
|
||||
const wf = action.payload;
|
||||
// Optimistically add to the list as a summary entry.
|
||||
const summary: WorkflowSummary = {
|
||||
id: wf.dir_name,
|
||||
name: wf.name,
|
||||
description: wf.description,
|
||||
when_to_use: wf.when_to_use,
|
||||
tags: wf.tags,
|
||||
scope: wf.scope,
|
||||
phases: Object.keys(wf.phases),
|
||||
warnings: wf.warnings,
|
||||
};
|
||||
const exists = state.workflows.some(w => w.id === summary.id);
|
||||
if (!exists) {
|
||||
state.workflows = [...state.workflows, summary];
|
||||
}
|
||||
state.selectedWorkflow = wf;
|
||||
state.status = 'idle';
|
||||
state.error = null;
|
||||
log('created workflow name=%s', wf.name);
|
||||
})
|
||||
.addCase(createWorkflow.rejected, (state, action) => {
|
||||
state.status = 'error';
|
||||
state.error = errorMessage(action.error.message ?? action.error);
|
||||
})
|
||||
|
||||
// removeWorkflow
|
||||
.addCase(removeWorkflow.pending, state => {
|
||||
state.status = 'saving';
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(removeWorkflow.fulfilled, (state, action) => {
|
||||
const removedId = action.payload.id;
|
||||
state.workflows = state.workflows.filter(w => w.id !== removedId);
|
||||
if (state.selectedWorkflow && state.selectedWorkflow.dir_name === removedId) {
|
||||
state.selectedWorkflow = null;
|
||||
}
|
||||
state.status = 'idle';
|
||||
state.error = null;
|
||||
log('removed workflow id=%s', removedId);
|
||||
})
|
||||
.addCase(removeWorkflow.rejected, (state, action) => {
|
||||
state.status = 'error';
|
||||
state.error = errorMessage(action.error.message ?? action.error);
|
||||
})
|
||||
|
||||
.addCase(resetUserScopedState, () => initialState);
|
||||
},
|
||||
});
|
||||
|
||||
export const { clearSelectedWorkflow } = workflowsSlice.actions;
|
||||
|
||||
export const selectWorkflows = (state: { workflows: WorkflowsState }) => state.workflows.workflows;
|
||||
|
||||
export const selectSelectedWorkflow = (state: { workflows: WorkflowsState }) =>
|
||||
state.workflows.selectedWorkflow;
|
||||
|
||||
export const selectWorkflowsStatus = (state: { workflows: WorkflowsState }) =>
|
||||
state.workflows.status;
|
||||
|
||||
export const selectWorkflowsError = (state: { workflows: WorkflowsState }) => state.workflows.error;
|
||||
|
||||
export default workflowsSlice.reducer;
|
||||
@@ -130,6 +130,9 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
|
||||
// Local procedural operating experience for agent self-learning
|
||||
controllers
|
||||
.extend(crate::openhuman::agent_experience::all_agent_experience_registered_controllers());
|
||||
// Agent workflows — phase-keyed guidance bound to task lifecycle
|
||||
controllers
|
||||
.extend(crate::openhuman::agent_workflows::all_agent_workflows_registered_controllers());
|
||||
// System and process health monitoring
|
||||
controllers.extend(crate::openhuman::health::all_health_registered_controllers());
|
||||
// Diagnostic tools
|
||||
@@ -305,6 +308,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas.extend(crate::openhuman::agent::all_agent_controller_schemas());
|
||||
schemas.extend(crate::openhuman::agent_registry::all_agent_registry_controller_schemas());
|
||||
schemas.extend(crate::openhuman::agent_experience::all_agent_experience_controller_schemas());
|
||||
schemas.extend(crate::openhuman::agent_workflows::all_agent_workflows_controller_schemas());
|
||||
schemas.extend(crate::openhuman::health::all_health_controller_schemas());
|
||||
schemas.extend(crate::openhuman::doctor::all_doctor_controller_schemas());
|
||||
schemas.extend(crate::openhuman::encryption::all_encryption_controller_schemas());
|
||||
|
||||
@@ -1218,6 +1218,18 @@ pub(super) const CAPABILITIES: &[Capability] = &[
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: DERIVED_TO_BACKEND,
|
||||
},
|
||||
Capability {
|
||||
id: "automation.agent_workflows",
|
||||
name: "Agent Workflows",
|
||||
domain: "automation",
|
||||
category: CapabilityCategory::Automation,
|
||||
description: "Define phase-keyed workflows (WORKFLOW.md) that inject rules, run \
|
||||
gated scripts, scope visible tools, and surface working-directory \
|
||||
context across a task's lifecycle (pick-up, close, directory entry).",
|
||||
how_to: "Workflows",
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: None,
|
||||
},
|
||||
Capability {
|
||||
id: "automation.view_cron_jobs",
|
||||
name: "View Cron Jobs",
|
||||
|
||||
@@ -432,6 +432,7 @@ async fn render_integrations_agent(config: &Config, toolkit: &str) -> Result<Dum
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
|
||||
let mut text = build(&ctx)
|
||||
|
||||
@@ -537,6 +537,7 @@ fn datetime_section_output_matches_iso8601_date_and_utc_offset_pattern() {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
|
||||
let rendered = DateTimeSection.build(&ctx).unwrap();
|
||||
|
||||
@@ -94,6 +94,7 @@ impl AgentBuilder {
|
||||
temperature: None,
|
||||
workspace_dir: None,
|
||||
skills: None,
|
||||
workflows: None,
|
||||
auto_save: None,
|
||||
post_turn_hooks: Vec::new(),
|
||||
learning_enabled: false,
|
||||
@@ -205,6 +206,20 @@ impl AgentBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the agent workflows available to the agent.
|
||||
///
|
||||
/// Populated at session start via
|
||||
/// [`crate::openhuman::agent_workflows::load_workflows`]; defaults to empty
|
||||
/// when not set so callers that do not participate in the workflow system
|
||||
/// do not need to change.
|
||||
pub fn workflows(
|
||||
mut self,
|
||||
workflows: Vec<crate::openhuman::agent_workflows::Workflow>,
|
||||
) -> Self {
|
||||
self.workflows = Some(workflows);
|
||||
self
|
||||
}
|
||||
|
||||
/// Enables or disables automatic saving of conversation history to memory.
|
||||
pub fn auto_save(mut self, auto_save: bool) -> Self {
|
||||
self.auto_save = Some(auto_save);
|
||||
@@ -544,6 +559,7 @@ impl AgentBuilder {
|
||||
.workspace_dir
|
||||
.unwrap_or_else(|| std::path::PathBuf::from(".")),
|
||||
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(),
|
||||
@@ -1546,6 +1562,15 @@ impl Agent {
|
||||
.temperature(effective_temperature)
|
||||
.workspace_dir(config.workspace_dir.clone())
|
||||
.skills(crate::openhuman::skills::load_skills(&config.workspace_dir))
|
||||
.workflows({
|
||||
let wf = crate::openhuman::agent_workflows::load_workflows(&config.workspace_dir);
|
||||
log::debug!(
|
||||
"[workflows][phase] loaded {} workflow(s) from workspace={}",
|
||||
wf.len(),
|
||||
config.workspace_dir.display()
|
||||
);
|
||||
wf
|
||||
})
|
||||
.auto_save(config.memory.auto_save)
|
||||
.post_turn_hooks(post_turn_hooks)
|
||||
.learning_enabled(config.learning.enabled)
|
||||
|
||||
@@ -2120,6 +2120,7 @@ impl Agent {
|
||||
personality_soul_md: None, // TODO: personality_ctx.soul_md_override
|
||||
personality_memory_md: None, // TODO: personality_ctx.memory_md_override
|
||||
personality_roster: vec![], // TODO: build_personality_roster(&workspace_dir)
|
||||
workflows: &self.workflows,
|
||||
};
|
||||
// Route through the global context manager so every
|
||||
// prompt-building call-site — main agent, sub-agent runner,
|
||||
|
||||
@@ -52,6 +52,8 @@ pub struct Agent {
|
||||
pub(super) temperature: f64,
|
||||
pub(super) workspace_dir: std::path::PathBuf,
|
||||
pub(super) skills: Vec<crate::openhuman::skills::Skill>,
|
||||
/// Agent workflows discovered at session start.
|
||||
pub(super) workflows: Vec<crate::openhuman::agent_workflows::Workflow>,
|
||||
pub(super) auto_save: bool,
|
||||
/// Last memory context loaded for the current turn. Stored so it can
|
||||
/// be forwarded to subagents via `ParentExecutionContext`.
|
||||
@@ -226,6 +228,9 @@ pub struct AgentBuilder {
|
||||
pub(super) temperature: Option<f64>,
|
||||
pub(super) workspace_dir: Option<std::path::PathBuf>,
|
||||
pub(super) skills: Option<Vec<crate::openhuman::skills::Skill>>,
|
||||
/// Agent workflows to surface in the prompt. Populated from `load_workflows`
|
||||
/// at session start; defaults to empty when not explicitly set.
|
||||
pub(super) workflows: Option<Vec<crate::openhuman::agent_workflows::Workflow>>,
|
||||
pub(super) auto_save: Option<bool>,
|
||||
pub(super) post_turn_hooks: Vec<Arc<dyn PostTurnHook>>,
|
||||
pub(super) learning_enabled: bool,
|
||||
|
||||
@@ -1092,6 +1092,7 @@ async fn run_typed_mode(
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
|
||||
let system_prompt = match &definition.system_prompt {
|
||||
|
||||
@@ -1592,6 +1592,7 @@ async fn orchestrator_prompt_drives_composio_call_via_delegation_chain() {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
}
|
||||
};
|
||||
let system_prompt = orch_prompt::build(&ctx).expect("build orchestrator prompt");
|
||||
|
||||
@@ -865,6 +865,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let rendered = section.build(&ctx).expect("render profile section");
|
||||
assert!(rendered.starts_with("## Agent profile"));
|
||||
|
||||
@@ -1021,6 +1021,7 @@ fn empty_prompt_context_for_static_sections() -> PromptContext<'static> {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ fn prompt_builder_assembles_sections() {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let rendered = SystemPromptBuilder::with_defaults().build(&ctx).unwrap();
|
||||
assert!(rendered.contains("## Tools"));
|
||||
@@ -105,6 +106,7 @@ fn identity_section_creates_missing_workspace_files() {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
|
||||
let section = IdentitySection;
|
||||
@@ -148,6 +150,7 @@ fn datetime_section_includes_timestamp_and_timezone() {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
|
||||
let rendered = DateTimeSection.build(&ctx).unwrap();
|
||||
@@ -193,6 +196,7 @@ fn ctx_with_identity(identity: Option<UserIdentity>) -> PromptContext<'static> {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,6 +339,7 @@ fn tools_section_pformat_renders_signature_not_schema() {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
|
||||
let rendered = ToolsSection.build(&ctx).unwrap();
|
||||
@@ -378,6 +383,7 @@ fn tools_section_uses_pformat_signature_for_text_dispatchers() {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let rendered = ToolsSection.build(&ctx).unwrap();
|
||||
assert!(
|
||||
@@ -428,6 +434,7 @@ fn user_memory_section_renders_namespaces_with_headings() {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let rendered = UserMemorySection.build(&ctx).unwrap();
|
||||
assert!(rendered.starts_with("## User Memory\n\n"));
|
||||
@@ -507,6 +514,7 @@ fn user_memory_section_returns_empty_when_no_summaries() {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let rendered = UserMemorySection.build(&ctx).unwrap();
|
||||
assert!(rendered.is_empty());
|
||||
@@ -1155,6 +1163,7 @@ fn for_subagent_builder_injects_user_files_even_when_identity_omitted() {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
|
||||
// Test a narrow-agent runtime path:
|
||||
@@ -1201,6 +1210,7 @@ fn for_subagent_builder_injects_user_files_even_when_identity_omitted() {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let narrow = builder.build(&ctx_narrow).unwrap();
|
||||
assert!(
|
||||
@@ -1281,6 +1291,7 @@ fn prompt_tool_constructors_and_user_memory_skip_empty_bodies() {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let rendered = UserMemorySection.build(&ctx).unwrap();
|
||||
assert!(rendered.contains("### user"));
|
||||
@@ -1309,6 +1320,7 @@ fn ctx_with_learned(learned: LearnedContextData) -> PromptContext<'static> {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1448,6 +1460,7 @@ fn tools_section_empty_for_native() {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let out = ToolsSection.build(&ctx).unwrap();
|
||||
assert!(
|
||||
@@ -1481,6 +1494,7 @@ fn tools_section_nonempty_for_pformat() {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let out = ToolsSection.build(&ctx).unwrap();
|
||||
assert!(
|
||||
@@ -1516,6 +1530,7 @@ fn tools_section_native_with_dispatcher_instructions_returns_instructions() {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let out = ToolsSection.build(&ctx).unwrap();
|
||||
assert!(
|
||||
|
||||
@@ -357,6 +357,11 @@ pub struct PromptContext<'a> {
|
||||
/// Non-self personality roster entries for the master agent's prompt.
|
||||
/// Empty for non-master agents.
|
||||
pub personality_roster: Vec<PersonalityRosterEntry>,
|
||||
/// Agent workflows available in this session. Injected into the prompt
|
||||
/// so agents know which workflows they can invoke via `workflow_phase`.
|
||||
/// Empty when no workflows are installed or the harness has not yet loaded
|
||||
/// them (sub-agent paths, tests, etc.).
|
||||
pub workflows: &'a [crate::openhuman::agent_workflows::Workflow],
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod remember_preference;
|
||||
mod run_skill;
|
||||
pub mod save_preference;
|
||||
mod todo;
|
||||
pub mod workflow_tools;
|
||||
|
||||
pub use ask_clarification::AskClarificationTool;
|
||||
pub use delegate::DelegateTool;
|
||||
@@ -15,3 +16,4 @@ pub use remember_preference::RememberPreferenceTool;
|
||||
pub use run_skill::{RunSkillTool, RUN_SKILL_TOOL_NAME};
|
||||
pub use save_preference::SavePreferenceTool;
|
||||
pub use todo::TodoTool;
|
||||
pub use workflow_tools::{WorkflowLoadTool, WorkflowPhaseTool};
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
//! Agent tools for the `agent_workflows` domain.
|
||||
//!
|
||||
//! Two tools are provided:
|
||||
//!
|
||||
//! * **`workflow_load`** — read-only; returns the full workflow definition
|
||||
//! (name, description, phases with their rules/scripts/tools/context) as a
|
||||
//! human-readable text block. The model uses this to inspect what a workflow
|
||||
//! does before deciding whether to activate it.
|
||||
//!
|
||||
//! * **`workflow_phase`** — execute-class; activates a specific phase of a
|
||||
//! workflow. It (a) reads the workflow from disk, (b) runs each phase script
|
||||
//! through the security-gated [`ShellTool`] path (same rate-limits,
|
||||
//! path-guards, and `ApprovalGate` routing as a raw `shell` call), (c)
|
||||
//! builds working-directory context via
|
||||
//! [`crate::openhuman::agent_workflows::working_dir_context`], and (d)
|
||||
//! returns a combined result containing the rendered phase guidance, the
|
||||
//! effective tool scope, the working-dir context block, and each script's
|
||||
//! gated output.
|
||||
//!
|
||||
//! Because `workflow_phase` runs shell scripts it is marked `external_effect`
|
||||
//! / `PermissionLevel::Execute` so the harness routes it through the
|
||||
//! `ApprovalGate` exactly like a raw `shell` call.
|
||||
|
||||
use crate::openhuman::agent::host_runtime::RuntimeAdapter;
|
||||
use crate::openhuman::agent_workflows::{
|
||||
effective_tool_scope, phase_guidance, read_workflow, working_dir_context,
|
||||
};
|
||||
use crate::openhuman::security::{AuditLogger, SecurityPolicy};
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
|
||||
use crate::openhuman::tools::ShellTool;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Maximum number of characters to retain from a single script's output.
|
||||
/// Long output is truncated and an ellipsis marker appended so the context
|
||||
/// window is not dominated by one verbose script.
|
||||
const MAX_SCRIPT_OUTPUT_CHARS: usize = 4_096;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// WorkflowLoadTool
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Read-only tool that returns the full definition of a workflow.
|
||||
///
|
||||
/// The model calls this when it wants to inspect a workflow's phases, rules,
|
||||
/// and scripts before deciding to activate one.
|
||||
pub struct WorkflowLoadTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WorkflowLoadTool {
|
||||
fn name(&self) -> &str {
|
||||
"workflow_load"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Load and inspect a workflow definition by its id (directory slug). \
|
||||
Returns the workflow name, description, and for each phase: its \
|
||||
description, rules, scripts, tool-scope, and working-dir context \
|
||||
providers. Use this before calling workflow_phase to understand what \
|
||||
a workflow does."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Workflow id — the directory slug (e.g. \
|
||||
\"github-issue-crusher\"). Use \
|
||||
agent_workflows_list to discover available ids."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn permission_level(&self) -> PermissionLevel {
|
||||
PermissionLevel::ReadOnly
|
||||
}
|
||||
|
||||
fn external_effect(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let id = match args.get("id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Ok(ToolResult::error(
|
||||
"missing required argument: id".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
log::debug!("[workflows][phase] workflow_load invoked id={:?}", id);
|
||||
|
||||
match read_workflow(id) {
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[workflows][phase] workflow_load failed id={:?} err={}",
|
||||
id,
|
||||
e
|
||||
);
|
||||
Ok(ToolResult::error(format!(
|
||||
"workflow not found or could not be read: {e}"
|
||||
)))
|
||||
}
|
||||
Ok(workflow) => {
|
||||
let mut out = String::with_capacity(2048);
|
||||
out.push_str(&format!(
|
||||
"# Workflow: {}\n\nId: {}\nDescription: {}\n",
|
||||
workflow.name, workflow.dir_name, workflow.description
|
||||
));
|
||||
if !workflow.when_to_use.is_empty() {
|
||||
out.push_str(&format!("When to use: {}\n", workflow.when_to_use));
|
||||
}
|
||||
if !workflow.tags.is_empty() {
|
||||
out.push_str(&format!("Tags: {}\n", workflow.tags.join(", ")));
|
||||
}
|
||||
if !workflow.phases.is_empty() {
|
||||
out.push_str("\n## Phases\n");
|
||||
let mut phase_names: Vec<&String> = workflow.phases.keys().collect();
|
||||
phase_names.sort();
|
||||
for phase_name in phase_names {
|
||||
let phase = &workflow.phases[phase_name];
|
||||
out.push_str(&format!("\n### {}\n", phase_name));
|
||||
if let Some(desc) = &phase.description {
|
||||
out.push_str(&format!("Description: {}\n", desc));
|
||||
}
|
||||
if !phase.rules.is_empty() {
|
||||
out.push_str("Rules:\n");
|
||||
for rule in &phase.rules {
|
||||
out.push_str(&format!(" - {}\n", rule));
|
||||
}
|
||||
}
|
||||
if !phase.scripts.is_empty() {
|
||||
out.push_str("Scripts:\n");
|
||||
for script in &phase.scripts {
|
||||
out.push_str(&format!(" $ {}\n", script));
|
||||
}
|
||||
}
|
||||
if let Some(tools) = &phase.tools {
|
||||
if !tools.allow.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"Tool scope (allow): {}\n",
|
||||
tools.allow.join(", ")
|
||||
));
|
||||
}
|
||||
if !tools.deny.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"Tool scope (deny): {}\n",
|
||||
tools.deny.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
if !phase.context.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"Context providers: {}\n",
|
||||
phase.context.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !workflow.warnings.is_empty() {
|
||||
out.push_str("\n## Warnings\n");
|
||||
for w in &workflow.warnings {
|
||||
out.push_str(&format!(" ! {}\n", w));
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"[workflows][phase] workflow_load success id={:?} phases={} output_chars={}",
|
||||
id,
|
||||
workflow.phases.len(),
|
||||
out.len()
|
||||
);
|
||||
Ok(ToolResult::success(out))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// WorkflowPhaseTool
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Execute-class tool that activates a named phase of an installed workflow.
|
||||
///
|
||||
/// On activation the tool:
|
||||
/// 1. Reads the workflow definition from disk.
|
||||
/// 2. Runs each `phase.scripts` entry through the security-gated
|
||||
/// [`ShellTool::run_with_security`] path — same rate-limits, path-guards,
|
||||
/// and `ApprovalGate` routing as a raw `shell` call.
|
||||
/// 3. Builds working-directory context via
|
||||
/// [`crate::openhuman::agent_workflows::working_dir_context`].
|
||||
/// 4. Returns a combined block containing: rendered phase guidance, the
|
||||
/// effective tool scope, the working-dir context, and each script's
|
||||
/// gated output (truncated to [`MAX_SCRIPT_OUTPUT_CHARS`]).
|
||||
///
|
||||
/// Because this tool runs shell scripts it is marked `external_effect` and
|
||||
/// `PermissionLevel::Execute` so the harness routes it through the
|
||||
/// `ApprovalGate` before `execute()` is called.
|
||||
pub struct WorkflowPhaseTool {
|
||||
workspace_dir: PathBuf,
|
||||
security: Arc<SecurityPolicy>,
|
||||
runtime: Arc<dyn RuntimeAdapter>,
|
||||
audit: Arc<AuditLogger>,
|
||||
}
|
||||
|
||||
impl WorkflowPhaseTool {
|
||||
/// Create a new `WorkflowPhaseTool`.
|
||||
///
|
||||
/// The `security`, `runtime`, and `audit` arcs should come from the same
|
||||
/// session-scoped instances used to construct the session's `ShellTool`,
|
||||
/// ensuring rate-limit counters and audit trails are shared.
|
||||
pub fn new(
|
||||
workspace_dir: PathBuf,
|
||||
security: Arc<SecurityPolicy>,
|
||||
runtime: Arc<dyn RuntimeAdapter>,
|
||||
audit: Arc<AuditLogger>,
|
||||
) -> Self {
|
||||
Self {
|
||||
workspace_dir,
|
||||
security,
|
||||
runtime,
|
||||
audit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WorkflowPhaseTool {
|
||||
fn name(&self) -> &str {
|
||||
"workflow_phase"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Activate a named phase of an installed workflow. The tool runs the \
|
||||
phase's gated scripts (same security policy as the shell tool), \
|
||||
surfaces working-directory context (e.g. git status), renders the \
|
||||
phase's guidance rules, and returns the effective tool scope so you \
|
||||
know which tools are allowed for the rest of the task. Call \
|
||||
workflow_load first to inspect what a workflow does."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["id", "phase"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Workflow id — the directory slug (e.g. \
|
||||
\"github-issue-crusher\")."
|
||||
},
|
||||
"phase": {
|
||||
"type": "string",
|
||||
"description": "Phase name to activate, e.g. \
|
||||
\"on_pick_up_task\", \"on_close_task\", \
|
||||
or \"on_enter_directory\"."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn permission_level(&self) -> PermissionLevel {
|
||||
// Phase scripts run through the shell — treat as Execute so the
|
||||
// harness applies the appropriate gate.
|
||||
PermissionLevel::Execute
|
||||
}
|
||||
|
||||
fn external_effect(&self) -> bool {
|
||||
// Phase scripts may have external effects; always route through
|
||||
// the ApprovalGate.
|
||||
true
|
||||
}
|
||||
|
||||
fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let id = match args.get("id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Ok(ToolResult::error(
|
||||
"missing required argument: id".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let phase_name = match args.get("phase").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Ok(ToolResult::error(
|
||||
"missing required argument: phase".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"[workflows][phase] workflow_phase invoked id={:?} phase={:?}",
|
||||
id,
|
||||
phase_name
|
||||
);
|
||||
|
||||
// (a) Read the workflow.
|
||||
let workflow = match read_workflow(id) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[workflows][phase] workflow_phase: workflow not found id={:?} err={}",
|
||||
id,
|
||||
e
|
||||
);
|
||||
return Ok(ToolResult::error(format!(
|
||||
"workflow not found or could not be read: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// Locate the requested phase.
|
||||
let phase = match workflow.phases.get(phase_name) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
log::warn!(
|
||||
"[workflows][phase] workflow_phase: phase not found id={:?} phase={:?} \
|
||||
available={:?}",
|
||||
id,
|
||||
phase_name,
|
||||
workflow.phase_names()
|
||||
);
|
||||
return Ok(ToolResult::error(format!(
|
||||
"phase {:?} not found in workflow {:?}; available phases: {}",
|
||||
phase_name,
|
||||
id,
|
||||
workflow.phase_names().join(", ")
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let mut out = String::with_capacity(4096);
|
||||
|
||||
// (b) Run phase scripts through the gated shell path.
|
||||
if !phase.scripts.is_empty() {
|
||||
log::debug!(
|
||||
"[workflows][phase] running {} script(s) for id={:?} phase={:?}",
|
||||
phase.scripts.len(),
|
||||
id,
|
||||
phase_name
|
||||
);
|
||||
let shell = ShellTool::new(
|
||||
Arc::clone(&self.security),
|
||||
Arc::clone(&self.runtime),
|
||||
Arc::clone(&self.audit),
|
||||
);
|
||||
out.push_str("## Script Results\n\n");
|
||||
for (i, script) in phase.scripts.iter().enumerate() {
|
||||
log::debug!(
|
||||
"[workflows][phase] executing script #{} id={:?} phase={:?} cmd={:?}",
|
||||
i + 1,
|
||||
id,
|
||||
phase_name,
|
||||
script
|
||||
);
|
||||
let (allowed, result) = shell.run_with_security(script).await;
|
||||
let raw_output = result.output();
|
||||
let truncated = raw_output.len() > MAX_SCRIPT_OUTPUT_CHARS;
|
||||
let display_output = if truncated {
|
||||
format!(
|
||||
"{}…[output truncated at {} chars]",
|
||||
&raw_output[..MAX_SCRIPT_OUTPUT_CHARS],
|
||||
raw_output.len()
|
||||
)
|
||||
} else {
|
||||
raw_output.to_string()
|
||||
};
|
||||
|
||||
log::debug!(
|
||||
"[workflows][phase] script #{} completed id={:?} phase={:?} \
|
||||
allowed={} is_error={} output_chars={}",
|
||||
i + 1,
|
||||
id,
|
||||
phase_name,
|
||||
allowed,
|
||||
result.is_error,
|
||||
raw_output.len()
|
||||
);
|
||||
|
||||
out.push_str(&format!("### Script: `{}`\n", script));
|
||||
if !allowed {
|
||||
out.push_str("Status: BLOCKED by security policy\n");
|
||||
} else if result.is_error {
|
||||
out.push_str("Status: ERROR\n");
|
||||
} else {
|
||||
out.push_str("Status: OK\n");
|
||||
}
|
||||
if !display_output.trim().is_empty() {
|
||||
out.push_str("Output:\n```\n");
|
||||
out.push_str(display_output.trim_end());
|
||||
out.push_str("\n```\n");
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// (c) Build working-directory context.
|
||||
if !phase.context.is_empty() {
|
||||
log::debug!(
|
||||
"[workflows][phase] building working-dir context id={:?} phase={:?} providers={:?}",
|
||||
id,
|
||||
phase_name,
|
||||
phase.context
|
||||
);
|
||||
let ctx = working_dir_context(&self.workspace_dir, &phase.context);
|
||||
if !ctx.trim().is_empty() {
|
||||
out.push_str("## Working Directory Context\n\n");
|
||||
out.push_str(ctx.trim_end());
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
// (d) Phase guidance (rules).
|
||||
if let Some(guidance) = phase_guidance(&workflow, phase_name) {
|
||||
if !guidance.trim().is_empty() {
|
||||
out.push_str("## Phase Guidance\n\n");
|
||||
out.push_str(guidance.trim_end());
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Effective tool scope.
|
||||
if let Some(scope) = effective_tool_scope(&workflow, phase_name) {
|
||||
let mut scope_lines = Vec::new();
|
||||
if !scope.allow.is_empty() {
|
||||
scope_lines.push(format!("Allow: {}", scope.allow.join(", ")));
|
||||
}
|
||||
if !scope.deny.is_empty() {
|
||||
scope_lines.push(format!("Deny: {}", scope.deny.join(", ")));
|
||||
}
|
||||
if !scope_lines.is_empty() {
|
||||
out.push_str("## Effective Tool Scope\n\n");
|
||||
for line in scope_lines {
|
||||
out.push_str(&format!("{}\n", line));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if out.trim().is_empty() {
|
||||
out.push_str(&format!(
|
||||
"Phase {:?} of workflow {:?} activated (no scripts, context, or guidance configured).\n",
|
||||
phase_name, id
|
||||
));
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[workflows][phase] workflow_phase complete id={:?} phase={:?} output_chars={}",
|
||||
id,
|
||||
phase_name,
|
||||
out.len()
|
||||
);
|
||||
|
||||
Ok(ToolResult::success(out))
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Unit tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "workflow_tools_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Unit tests for `WorkflowLoadTool` and `WorkflowPhaseTool`.
|
||||
//!
|
||||
//! Focus on:
|
||||
//! * Tool metadata (name, description, permission_level, external_effect).
|
||||
//! * `parameters_schema` shape.
|
||||
//! * Argument validation (missing/invalid args return an error ToolResult).
|
||||
//! * Error path when a workflow id does not resolve.
|
||||
//!
|
||||
//! Running the actual shell in a unit test is optional and deliberately
|
||||
//! omitted — the approval-gate and security-policy paths are tested in the
|
||||
//! integration suite.
|
||||
|
||||
use super::*;
|
||||
use crate::openhuman::agent::host_runtime::NativeRuntime;
|
||||
use crate::openhuman::security::SecurityPolicy;
|
||||
|
||||
fn make_phase_tool() -> WorkflowPhaseTool {
|
||||
WorkflowPhaseTool::new(
|
||||
std::path::PathBuf::from("."),
|
||||
Arc::new(SecurityPolicy::default()),
|
||||
Arc::new(NativeRuntime::new()),
|
||||
crate::openhuman::security::AuditLogger::disabled(),
|
||||
)
|
||||
}
|
||||
|
||||
// ── WorkflowLoadTool metadata ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn load_tool_name() {
|
||||
assert_eq!(WorkflowLoadTool.name(), "workflow_load");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_tool_is_readonly() {
|
||||
assert_eq!(
|
||||
WorkflowLoadTool.permission_level(),
|
||||
PermissionLevel::ReadOnly
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_tool_no_external_effect() {
|
||||
assert!(!WorkflowLoadTool.external_effect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_tool_schema_has_required_id() {
|
||||
let schema = WorkflowLoadTool.parameters_schema();
|
||||
assert_eq!(schema["type"], "object");
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
assert!(required.iter().any(|v| v.as_str() == Some("id")));
|
||||
assert!(schema["properties"]["id"].is_object());
|
||||
}
|
||||
|
||||
// ── WorkflowPhaseTool metadata ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn phase_tool_name() {
|
||||
let tool = make_phase_tool();
|
||||
assert_eq!(tool.name(), "workflow_phase");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_tool_is_execute() {
|
||||
let tool = make_phase_tool();
|
||||
assert_eq!(tool.permission_level(), PermissionLevel::Execute);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_tool_has_external_effect() {
|
||||
let tool = make_phase_tool();
|
||||
assert!(tool.external_effect());
|
||||
assert!(tool.external_effect_with_args(&serde_json::json!({})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_tool_schema_has_required_id_and_phase() {
|
||||
let tool = make_phase_tool();
|
||||
let schema = tool.parameters_schema();
|
||||
assert_eq!(schema["type"], "object");
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
let names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
|
||||
assert!(names.contains(&"id"), "schema must require 'id'");
|
||||
assert!(names.contains(&"phase"), "schema must require 'phase'");
|
||||
assert!(schema["properties"]["id"].is_object());
|
||||
assert!(schema["properties"]["phase"].is_object());
|
||||
}
|
||||
|
||||
// ── WorkflowLoadTool argument validation ─────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_missing_id_returns_error() {
|
||||
let result = WorkflowLoadTool
|
||||
.execute(serde_json::json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_error, "expected error for missing 'id'");
|
||||
assert!(result.output().contains("id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_unknown_workflow_returns_error() {
|
||||
let result = WorkflowLoadTool
|
||||
.execute(serde_json::json!({"id": "__nonexistent_workflow_xyz__"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.is_error,
|
||||
"expected error for unknown workflow: {}",
|
||||
result.output()
|
||||
);
|
||||
}
|
||||
|
||||
// ── WorkflowPhaseTool argument validation ────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn phase_missing_id_returns_error() {
|
||||
let tool = make_phase_tool();
|
||||
let result = tool
|
||||
.execute(serde_json::json!({"phase": "on_pick_up_task"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_error, "expected error for missing 'id'");
|
||||
assert!(result.output().contains("id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn phase_missing_phase_returns_error() {
|
||||
let tool = make_phase_tool();
|
||||
let result = tool
|
||||
.execute(serde_json::json!({"id": "some-workflow"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_error, "expected error for missing 'phase'");
|
||||
assert!(result.output().contains("phase"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn phase_unknown_workflow_returns_error() {
|
||||
let tool = make_phase_tool();
|
||||
let result = tool
|
||||
.execute(serde_json::json!({
|
||||
"id": "__nonexistent_workflow_xyz__",
|
||||
"phase": "on_pick_up_task"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.is_error,
|
||||
"expected error for unknown workflow: {}",
|
||||
result.output()
|
||||
);
|
||||
}
|
||||
@@ -723,6 +723,7 @@ fn extract_inline_prompt(def: &AgentDefinition) -> Option<String> {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
match build(&ctx) {
|
||||
Ok(body) if !body.is_empty() => Some(body),
|
||||
|
||||
@@ -66,6 +66,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let body = build(&ctx).unwrap();
|
||||
assert!(!body.is_empty());
|
||||
|
||||
@@ -70,6 +70,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let body = build(&ctx).unwrap();
|
||||
assert!(!body.is_empty());
|
||||
|
||||
@@ -65,6 +65,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let body = build(&ctx).unwrap();
|
||||
assert!(!body.is_empty());
|
||||
|
||||
@@ -91,6 +91,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let body = build(&ctx).unwrap();
|
||||
assert!(!body.is_empty());
|
||||
|
||||
@@ -13,4 +13,13 @@ omit_skills_catalog = true
|
||||
hint = "agentic"
|
||||
|
||||
[tools]
|
||||
named = ["composio_list_tools", "file_read", "composio_execute"]
|
||||
named = [
|
||||
"composio_list_tools",
|
||||
"file_read",
|
||||
"composio_execute",
|
||||
# Workflow tools — surface installed WORKFLOW.md bundles so the
|
||||
# integrations_agent can inspect and activate phases when operating
|
||||
# inside a task lifecycle (e.g. on_pick_up_task).
|
||||
"workflow_load",
|
||||
"workflow_phase",
|
||||
]
|
||||
|
||||
@@ -48,6 +48,16 @@ pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
let workflows = crate::openhuman::agent_workflows::render_available_workflows(ctx.workflows);
|
||||
if !workflows.trim().is_empty() {
|
||||
log::debug!(
|
||||
"[workflows][phase] injecting {} workflow(s) into integrations_agent prompt",
|
||||
ctx.workflows.len()
|
||||
);
|
||||
out.push_str(workflows.trim_end());
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
let integrations = render_connected_integrations(ctx.connected_integrations);
|
||||
if !integrations.trim().is_empty() {
|
||||
out.push_str(integrations.trim_end());
|
||||
@@ -230,6 +240,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -371,6 +371,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let body = build(&ctx)
|
||||
.unwrap_or_else(|e| panic!("{} prompt build failed: {e}", def.id));
|
||||
|
||||
@@ -93,6 +93,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,4 +146,10 @@ named = [
|
||||
# `src/openhuman/update/` end-to-end — no parallel release logic.
|
||||
"update_check",
|
||||
"update_apply",
|
||||
# Workflow tools — let the orchestrator inspect and activate installed
|
||||
# WORKFLOW.md bundles. `workflow_load` is read-only (inspect a workflow);
|
||||
# `workflow_phase` activates a phase (runs gated scripts, surfaces guidance).
|
||||
# Both are routed through the same security/approval gate as `shell`.
|
||||
"workflow_load",
|
||||
"workflow_phase",
|
||||
]
|
||||
|
||||
@@ -194,6 +194,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let body = build(&ctx).unwrap();
|
||||
assert!(!body.is_empty());
|
||||
|
||||
@@ -66,6 +66,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let body = build(&ctx).unwrap();
|
||||
assert!(!body.is_empty());
|
||||
|
||||
@@ -115,6 +115,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let body = build(&ctx).unwrap();
|
||||
assert!(!body.is_empty());
|
||||
|
||||
@@ -66,6 +66,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let body = build(&ctx).unwrap();
|
||||
assert!(!body.is_empty());
|
||||
|
||||
@@ -70,6 +70,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let body = build(&ctx).unwrap();
|
||||
assert!(!body.is_empty());
|
||||
|
||||
@@ -67,6 +67,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let body = build(&ctx).unwrap();
|
||||
assert!(!body.is_empty());
|
||||
|
||||
@@ -66,6 +66,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let body = build(&ctx).unwrap();
|
||||
assert!(!body.is_empty());
|
||||
|
||||
@@ -66,6 +66,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let body = build(&ctx).unwrap();
|
||||
assert!(!body.is_empty());
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Workflow discovery: scanning user + project roots, scope resolution, and
|
||||
//! collision handling (project shadows user).
|
||||
//!
|
||||
//! Mirrors `skills::ops_discover`. User-scope workflows live under
|
||||
//! `~/.openhuman/workflows/<slug>/`; project-scope under
|
||||
//! `<workspace>/.openhuman/workflows/<slug>/` and are only loaded when the
|
||||
//! `<workspace>/.openhuman/trust` marker is present.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::parse::parse_workflow_md;
|
||||
use super::types::{is_workspace_trusted, Workflow, WorkflowScope, WORKFLOW_MD};
|
||||
|
||||
/// Convenience shim: discover workflows for a workspace using the current
|
||||
/// user's home directory, honoring the trust marker.
|
||||
pub fn load_workflows(workspace_dir: &Path) -> Vec<Workflow> {
|
||||
let trusted = is_workspace_trusted(workspace_dir);
|
||||
discover_workflows(dirs::home_dir().as_deref(), Some(workspace_dir), trusted)
|
||||
}
|
||||
|
||||
/// Discover workflows from user and (optionally trusted) project scopes.
|
||||
///
|
||||
/// On name collision, project scope wins over user scope. Results are sorted
|
||||
/// by display name.
|
||||
pub fn discover_workflows(
|
||||
home_dir: Option<&Path>,
|
||||
workspace_dir: Option<&Path>,
|
||||
trusted: bool,
|
||||
) -> Vec<Workflow> {
|
||||
// Scan user first, then project, so project registrations overwrite user
|
||||
// ones on name collision.
|
||||
let mut by_name: HashMap<String, Workflow> = HashMap::new();
|
||||
|
||||
if let Some(home) = home_dir {
|
||||
absorb(
|
||||
&mut by_name,
|
||||
scan_root(
|
||||
&home.join(".openhuman").join("workflows"),
|
||||
WorkflowScope::User,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ws) = workspace_dir {
|
||||
if trusted {
|
||||
absorb(
|
||||
&mut by_name,
|
||||
scan_root(
|
||||
&ws.join(".openhuman").join("workflows"),
|
||||
WorkflowScope::Project,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
log::debug!(
|
||||
"[workflows] project scope skipped (untrusted workspace) at {}",
|
||||
ws.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut out: Vec<Workflow> = by_name.into_values().collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
log::debug!("[workflows] discovered {} workflow(s)", out.len());
|
||||
out
|
||||
}
|
||||
|
||||
fn absorb(by_name: &mut HashMap<String, Workflow>, incoming: Vec<Workflow>) {
|
||||
for workflow in incoming {
|
||||
by_name.insert(workflow.name.clone(), workflow);
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_root(root: &Path, scope: WorkflowScope) -> Vec<Workflow> {
|
||||
let entries = match std::fs::read_dir(root) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
// `read_dir` order is unspecified; sort by directory name for a stable,
|
||||
// reproducible scan order.
|
||||
let mut entries: Vec<_> = entries.flatten().collect();
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
|
||||
let mut out = Vec::new();
|
||||
for entry in entries {
|
||||
// Use `file_type()` (not `is_dir()`) so a symlinked child cannot be
|
||||
// loaded as a workflow.
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if file_type.is_symlink() || !file_type.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||||
if dir_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if let Some(workflow) = load_workflow_dir(&entry.path(), &dir_name, scope) {
|
||||
out.push(workflow);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn load_workflow_dir(dir: &Path, dir_name: &str, scope: WorkflowScope) -> Option<Workflow> {
|
||||
let path: PathBuf = dir.join(WORKFLOW_MD);
|
||||
// Use `symlink_metadata` (not `exists()`, which dereferences) so a symlinked
|
||||
// WORKFLOW.md inside an otherwise-trusted directory is not followed and read.
|
||||
// This keeps the marker-file check consistent with the non-symlink parent-dir
|
||||
// guard in `scan_root`.
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
Ok(meta) if meta.file_type().is_file() => {}
|
||||
Ok(_) => {
|
||||
log::debug!(
|
||||
"[workflows] skipping non-regular WORKFLOW.md at {}",
|
||||
path.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(_) => return None,
|
||||
}
|
||||
let (frontmatter, _body, warnings) = match parse_workflow_md(&path) {
|
||||
Some(parts) => parts,
|
||||
None => {
|
||||
log::warn!("[workflows] could not parse {}", path.display());
|
||||
return Some(Workflow::from_parts(
|
||||
dir_name,
|
||||
Default::default(),
|
||||
Some(path.clone()),
|
||||
scope,
|
||||
vec![format!("could not parse {}", path.display())],
|
||||
));
|
||||
}
|
||||
};
|
||||
Some(Workflow::from_parts(
|
||||
dir_name,
|
||||
frontmatter,
|
||||
Some(path),
|
||||
scope,
|
||||
warnings,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "discover_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Tests for workflow discovery (scope resolution, project-shadows-user).
|
||||
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_workflow(root: &Path, slug: &str, name: &str, when_to_use: &str) {
|
||||
let dir = root.join(slug);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let body = format!(
|
||||
"---\nname: {name}\ndescription: d\nwhen_to_use: {when_to_use}\nphases:\n on_pick_up_task:\n rules:\n - go\n---\n# {name}\n"
|
||||
);
|
||||
fs::write(dir.join(WORKFLOW_MD), body).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_user_scope_workflows() {
|
||||
let home = TempDir::new().unwrap();
|
||||
let root = home.path().join(".openhuman").join("workflows");
|
||||
write_workflow(&root, "alpha", "alpha", "do alpha");
|
||||
|
||||
let found = discover_workflows(Some(home.path()), None, false);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].name, "alpha");
|
||||
assert_eq!(found[0].scope, WorkflowScope::User);
|
||||
assert!(found[0].phases.contains_key("on_pick_up_task"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_scope_skipped_when_untrusted() {
|
||||
let home = TempDir::new().unwrap();
|
||||
let ws = TempDir::new().unwrap();
|
||||
write_workflow(
|
||||
&ws.path().join(".openhuman").join("workflows"),
|
||||
"proj",
|
||||
"proj",
|
||||
"p",
|
||||
);
|
||||
|
||||
let untrusted = discover_workflows(Some(home.path()), Some(ws.path()), false);
|
||||
assert!(untrusted.is_empty());
|
||||
|
||||
let trusted = discover_workflows(Some(home.path()), Some(ws.path()), true);
|
||||
assert_eq!(trusted.len(), 1);
|
||||
assert_eq!(trusted[0].scope, WorkflowScope::Project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_shadows_user_on_name_collision() {
|
||||
let home = TempDir::new().unwrap();
|
||||
let ws = TempDir::new().unwrap();
|
||||
write_workflow(
|
||||
&home.path().join(".openhuman").join("workflows"),
|
||||
"shared",
|
||||
"shared",
|
||||
"user version",
|
||||
);
|
||||
write_workflow(
|
||||
&ws.path().join(".openhuman").join("workflows"),
|
||||
"shared",
|
||||
"shared",
|
||||
"project version",
|
||||
);
|
||||
|
||||
let found = discover_workflows(Some(home.path()), Some(ws.path()), true);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].scope, WorkflowScope::Project);
|
||||
assert_eq!(found[0].when_to_use, "project version");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_sorted_by_name() {
|
||||
let home = TempDir::new().unwrap();
|
||||
let root = home.path().join(".openhuman").join("workflows");
|
||||
write_workflow(&root, "zeta", "zeta", "z");
|
||||
write_workflow(&root, "alpha", "alpha", "a");
|
||||
|
||||
let found = discover_workflows(Some(home.path()), None, false);
|
||||
let names: Vec<_> = found.iter().map(|w| w.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["alpha", "zeta"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_dirs_and_missing_marker_file_ignored() {
|
||||
let home = TempDir::new().unwrap();
|
||||
let root = home.path().join(".openhuman").join("workflows");
|
||||
// A hidden dir and a dir without WORKFLOW.md should both be skipped.
|
||||
fs::create_dir_all(root.join(".hidden")).unwrap();
|
||||
fs::create_dir_all(root.join("nomarker")).unwrap();
|
||||
write_workflow(&root, "real", "real", "r");
|
||||
|
||||
let found = discover_workflows(Some(home.path()), None, false);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].name, "real");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinked_workflow_md_is_not_followed() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let home = TempDir::new().unwrap();
|
||||
let root = home.path().join(".openhuman").join("workflows");
|
||||
|
||||
// A real workflow elsewhere whose WORKFLOW.md we try to alias into a
|
||||
// discovery directory via symlink — discovery must refuse to follow it.
|
||||
let real = TempDir::new().unwrap();
|
||||
let real_md = real.path().join("WORKFLOW.md");
|
||||
fs::write(
|
||||
&real_md,
|
||||
"---\nname: sneaky\ndescription: d\n---\n# sneaky\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let attacker_dir = root.join("attacker");
|
||||
fs::create_dir_all(&attacker_dir).unwrap();
|
||||
symlink(&real_md, attacker_dir.join(WORKFLOW_MD)).unwrap();
|
||||
|
||||
let found = discover_workflows(Some(home.path()), None, false);
|
||||
assert!(
|
||||
found.iter().all(|w| w.name != "sneaky"),
|
||||
"symlinked WORKFLOW.md must not be followed: {:?}",
|
||||
found.iter().map(|w| &w.name).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Prompt-injection helpers: render the available-workflows catalog.
|
||||
//!
|
||||
//! Analogous to `render_available_skills()` — produces an XML-ish catalog
|
||||
//! (id + name + when_to_use) the agent reads to decide which workflow to load
|
||||
//! for a task.
|
||||
|
||||
use super::types::Workflow;
|
||||
|
||||
/// Render the available-workflows catalog. Returns an empty string when there
|
||||
/// are no workflows (so callers can unconditionally concatenate the result).
|
||||
pub fn render_workflow_catalog(workflows: &[Workflow]) -> String {
|
||||
if workflows.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut out = String::from("<available_workflows>\n");
|
||||
for wf in workflows {
|
||||
out.push_str(&format!(
|
||||
" <workflow id=\"{}\" name=\"{}\">\n {}\n </workflow>\n",
|
||||
xml_escape(&wf.dir_name),
|
||||
xml_escape(&wf.name),
|
||||
xml_escape(&wf.when_to_use),
|
||||
));
|
||||
}
|
||||
out.push_str("</available_workflows>\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// Alias used by the prompt builder; identical to [`render_workflow_catalog`].
|
||||
pub fn render_available_workflows(workflows: &[Workflow]) -> String {
|
||||
render_workflow_catalog(workflows)
|
||||
}
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "inject_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Tests for the workflow prompt catalog renderer.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn wf(name: &str, dir: &str, when: &str) -> Workflow {
|
||||
Workflow {
|
||||
name: name.to_string(),
|
||||
dir_name: dir.to_string(),
|
||||
when_to_use: when.to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_list_renders_empty_string() {
|
||||
assert_eq!(render_workflow_catalog(&[]), "");
|
||||
assert_eq!(render_available_workflows(&[]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_id_name_and_when_to_use() {
|
||||
let list = vec![wf("Bug Triage", "bug-triage", "a bug is reported")];
|
||||
let out = render_workflow_catalog(&list);
|
||||
assert!(out.contains("<available_workflows>"));
|
||||
assert!(out.contains("id=\"bug-triage\""));
|
||||
assert!(out.contains("name=\"Bug Triage\""));
|
||||
assert!(out.contains("a bug is reported"));
|
||||
assert!(out.contains("</available_workflows>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_xml_special_characters() {
|
||||
let list = vec![wf("A & B", "a-b", "use when <x> happens \"now\"")];
|
||||
let out = render_workflow_catalog(&list);
|
||||
assert!(out.contains("A & B"));
|
||||
assert!(out.contains("<x>"));
|
||||
assert!(out.contains(""now""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_alias_matches_catalog() {
|
||||
let list = vec![wf("x", "x", "y")];
|
||||
assert_eq!(
|
||||
render_available_workflows(&list),
|
||||
render_workflow_catalog(&list)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Agent Workflows domain — phase-keyed guidance bound to task lifecycle.
|
||||
//!
|
||||
//! Mirrors the `skills` domain layout. A *workflow* is a `WORKFLOW.md` file
|
||||
//! with YAML frontmatter describing **phases** (lifecycle hooks such as
|
||||
//! `on_pick_up_task`, `on_close_task`, `on_enter_directory`). Each phase can
|
||||
//! inject rules, run gated scripts, scope visible tools, and surface
|
||||
//! working-directory context. Workflows are bound to *tasks*: the harness fires
|
||||
//! phases off task-board status transitions (pick-up / close) and directory
|
||||
//! changes, then runs the phase's effects before the agent's next turn.
|
||||
|
||||
pub mod discover;
|
||||
pub mod inject;
|
||||
pub mod ops;
|
||||
pub mod parse;
|
||||
pub mod schemas;
|
||||
pub mod select;
|
||||
pub mod types;
|
||||
pub mod workdir;
|
||||
|
||||
pub use types::{
|
||||
is_workspace_trusted, ToolScope, Workflow, WorkflowFrontmatter, WorkflowPhase, WorkflowScope,
|
||||
WorkflowSummary, KNOWN_PHASES, PHASE_CLOSE_TASK, PHASE_ENTER_DIRECTORY, PHASE_PICK_UP_TASK,
|
||||
WORKFLOW_MD,
|
||||
};
|
||||
|
||||
pub use discover::{discover_workflows, load_workflows};
|
||||
pub use inject::{render_available_workflows, render_workflow_catalog};
|
||||
pub use ops::{create_workflow, read_workflow, uninstall_workflow};
|
||||
pub use select::{best_match, effective_tool_scope, phase_guidance};
|
||||
pub use workdir::working_dir_context;
|
||||
|
||||
pub use schemas::{
|
||||
all_controller_schemas as all_agent_workflows_controller_schemas,
|
||||
all_registered_controllers as all_agent_workflows_registered_controllers,
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Workflow lifecycle operations: create, read, uninstall.
|
||||
//!
|
||||
//! Public entry points operate against the user-scope root
|
||||
//! (`~/.openhuman/workflows/`). The `*_in_root` inner variants take an explicit
|
||||
//! root so tests can exercise the full create/read/uninstall cycle against a
|
||||
//! temp directory without touching the real home (mirrors `skills::create_skill_inner`).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::parse::parse_workflow_md;
|
||||
use super::types::WORKFLOW_MD;
|
||||
use super::{Workflow, WorkflowScope};
|
||||
|
||||
/// Resolve the user-scope workflows root: `~/.openhuman/workflows/`.
|
||||
fn user_workflows_root() -> Result<PathBuf, String> {
|
||||
let home = dirs::home_dir().ok_or("could not resolve home directory")?;
|
||||
Ok(home.join(".openhuman").join("workflows"))
|
||||
}
|
||||
|
||||
/// Create a new user-scope workflow by scaffolding a `WORKFLOW.md`.
|
||||
pub fn create_workflow(
|
||||
name: &str,
|
||||
description: &str,
|
||||
when_to_use: &str,
|
||||
) -> Result<Workflow, String> {
|
||||
let root = user_workflows_root()?;
|
||||
create_workflow_in_root(&root, name, description, when_to_use)
|
||||
}
|
||||
|
||||
pub(crate) fn create_workflow_in_root(
|
||||
root: &Path,
|
||||
name: &str,
|
||||
description: &str,
|
||||
when_to_use: &str,
|
||||
) -> Result<Workflow, String> {
|
||||
let slug = slugify(name);
|
||||
if slug.is_empty() {
|
||||
return Err("workflow name must contain alphanumeric characters".to_string());
|
||||
}
|
||||
let dir = root.join(&slug);
|
||||
let path = dir.join(WORKFLOW_MD);
|
||||
if path.exists() {
|
||||
return Err(format!("workflow '{slug}' already exists"));
|
||||
}
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("failed to create workflow dir {}: {e}", dir.display()))?;
|
||||
let body = scaffold_body(name, description, when_to_use);
|
||||
std::fs::write(&path, body).map_err(|e| format!("failed to write {}: {e}", path.display()))?;
|
||||
log::info!(
|
||||
"[workflows] created workflow slug={slug} at {}",
|
||||
path.display()
|
||||
);
|
||||
let (frontmatter, _body, warnings) = parse_workflow_md(&path)
|
||||
.ok_or_else(|| "failed to re-parse scaffolded workflow".to_string())?;
|
||||
Ok(Workflow::from_parts(
|
||||
slug,
|
||||
frontmatter,
|
||||
Some(path),
|
||||
WorkflowScope::User,
|
||||
warnings,
|
||||
))
|
||||
}
|
||||
|
||||
fn scaffold_body(name: &str, description: &str, when_to_use: &str) -> String {
|
||||
format!(
|
||||
"---\nname: {name}\ndescription: {description}\nwhen_to_use: {when_to_use}\nphases:\n on_pick_up_task:\n rules:\n - Describe what to do when this task starts.\n---\n# {name}\n\n{description}\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Read a workflow by id (dir name) returning the full parsed workflow.
|
||||
pub fn read_workflow(id: &str) -> Result<Workflow, String> {
|
||||
let root = user_workflows_root()?;
|
||||
read_workflow_in_root(&root, id)
|
||||
}
|
||||
|
||||
pub(crate) fn read_workflow_in_root(root: &Path, id: &str) -> Result<Workflow, String> {
|
||||
if id.trim().is_empty() {
|
||||
return Err("workflow id must not be empty".to_string());
|
||||
}
|
||||
if id.contains('/') || id.contains('\\') || id.contains("..") {
|
||||
return Err("invalid workflow id".to_string());
|
||||
}
|
||||
let path = root.join(id).join(WORKFLOW_MD);
|
||||
if !path.exists() {
|
||||
return Err(format!("workflow '{id}' not found"));
|
||||
}
|
||||
let (frontmatter, _body, warnings) =
|
||||
parse_workflow_md(&path).ok_or_else(|| format!("failed to parse workflow '{id}'"))?;
|
||||
Ok(Workflow::from_parts(
|
||||
id.to_string(),
|
||||
frontmatter,
|
||||
Some(path),
|
||||
WorkflowScope::User,
|
||||
warnings,
|
||||
))
|
||||
}
|
||||
|
||||
/// Uninstall a user-scope workflow by id, hardened against path traversal.
|
||||
pub fn uninstall_workflow(id: &str) -> Result<bool, String> {
|
||||
let root = user_workflows_root()?;
|
||||
uninstall_workflow_in_root(&root, id)
|
||||
}
|
||||
|
||||
pub(crate) fn uninstall_workflow_in_root(root: &Path, id: &str) -> Result<bool, String> {
|
||||
if id.trim().is_empty() {
|
||||
return Err("workflow id must not be empty".to_string());
|
||||
}
|
||||
// Reject ids with separators or traversal before touching the filesystem.
|
||||
if id.contains('/') || id.contains('\\') || id.contains("..") {
|
||||
return Err("invalid workflow id".to_string());
|
||||
}
|
||||
let canonical_root = std::fs::canonicalize(root)
|
||||
.map_err(|e| format!("failed to canonicalize workflows root: {e}"))?;
|
||||
let dir = canonical_root.join(id);
|
||||
let canonical_dir =
|
||||
std::fs::canonicalize(&dir).map_err(|e| format!("workflow '{id}' not found: {e}"))?;
|
||||
if !canonical_dir.starts_with(&canonical_root) {
|
||||
return Err("workflow path escapes root".to_string());
|
||||
}
|
||||
std::fs::remove_dir_all(&canonical_dir)
|
||||
.map_err(|e| format!("failed to remove workflow '{id}': {e}"))?;
|
||||
log::info!("[workflows] uninstalled workflow id={id}");
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Slugify a workflow name into a filesystem-safe directory id.
|
||||
fn slugify(name: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut prev_dash = false;
|
||||
for ch in name.trim().to_lowercase().chars() {
|
||||
if ch.is_alphanumeric() {
|
||||
slug.push(ch);
|
||||
prev_dash = false;
|
||||
} else if !prev_dash {
|
||||
slug.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
}
|
||||
slug.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
use slugify as slugify_for_test;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ops_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Tests for workflow create/read/uninstall against a temp root.
|
||||
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn create_then_read_round_trip() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let created = create_workflow_in_root(
|
||||
root.path(),
|
||||
"Bug Triage",
|
||||
"handle bugs",
|
||||
"a bug is reported",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(created.dir_name, "bug-triage");
|
||||
assert_eq!(created.name, "Bug Triage");
|
||||
assert!(created.phases.contains_key("on_pick_up_task"));
|
||||
|
||||
let read = read_workflow_in_root(root.path(), "bug-triage").unwrap();
|
||||
assert_eq!(read.name, "Bug Triage");
|
||||
assert_eq!(read.when_to_use, "a bug is reported");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_rejects_duplicate() {
|
||||
let root = TempDir::new().unwrap();
|
||||
create_workflow_in_root(root.path(), "dup", "d", "w").unwrap();
|
||||
let err = create_workflow_in_root(root.path(), "dup", "d", "w").unwrap_err();
|
||||
assert!(err.contains("already exists"), "err: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_rejects_empty_slug() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let err = create_workflow_in_root(root.path(), "!!!", "d", "w").unwrap_err();
|
||||
assert!(err.contains("alphanumeric"), "err: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_missing_workflow_errors() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let err = read_workflow_in_root(root.path(), "ghost").unwrap_err();
|
||||
assert!(err.contains("not found"), "err: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_removes_workflow() {
|
||||
let root = TempDir::new().unwrap();
|
||||
create_workflow_in_root(root.path(), "temp", "d", "w").unwrap();
|
||||
assert!(uninstall_workflow_in_root(root.path(), "temp").unwrap());
|
||||
assert!(read_workflow_in_root(root.path(), "temp").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_rejects_traversal_ids() {
|
||||
let root = TempDir::new().unwrap();
|
||||
for bad in ["..", "../escape", "a/b", "a\\b", ""] {
|
||||
let err = uninstall_workflow_in_root(root.path(), bad).unwrap_err();
|
||||
assert!(
|
||||
err.contains("invalid workflow id") || err.contains("must not be empty"),
|
||||
"id={bad:?} err={err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_normalizes_names() {
|
||||
assert_eq!(slugify_for_test("Bug Triage!"), "bug-triage");
|
||||
assert_eq!(slugify_for_test(" multiple spaces "), "multiple-spaces");
|
||||
assert_eq!(slugify_for_test("ALL_CAPS"), "all-caps");
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! WORKFLOW.md frontmatter parsing.
|
||||
//!
|
||||
//! Mirrors `skills::ops_parse`: split a leading `---`-delimited YAML block from
|
||||
//! the markdown body, deserialize the frontmatter, and collect non-fatal
|
||||
//! warnings (rather than failing) when the YAML is malformed.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::types::WorkflowFrontmatter;
|
||||
|
||||
/// Parse a `WORKFLOW.md` file into `(frontmatter, body, warnings)`.
|
||||
///
|
||||
/// Returns `None` when the file cannot be read or the frontmatter block is
|
||||
/// opened with `---` but never terminated.
|
||||
pub fn parse_workflow_md(path: &Path) -> Option<(WorkflowFrontmatter, String, Vec<String>)> {
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
parse_workflow_md_str(&content)
|
||||
}
|
||||
|
||||
/// Content-only variant of [`parse_workflow_md`].
|
||||
pub fn parse_workflow_md_str(content: &str) -> Option<(WorkflowFrontmatter, String, Vec<String>)> {
|
||||
let mut lines = content.lines();
|
||||
let first = lines.next()?;
|
||||
if first.trim() != "---" {
|
||||
// No frontmatter — treat the whole file as body.
|
||||
return Some((
|
||||
WorkflowFrontmatter::default(),
|
||||
content.to_string(),
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut yaml = String::new();
|
||||
let mut terminated = false;
|
||||
let mut body = String::new();
|
||||
for line in lines {
|
||||
if !terminated && line.trim() == "---" {
|
||||
terminated = true;
|
||||
continue;
|
||||
}
|
||||
if !terminated {
|
||||
yaml.push_str(line);
|
||||
yaml.push('\n');
|
||||
} else {
|
||||
body.push_str(line);
|
||||
body.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if !terminated {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let frontmatter = match serde_yaml::from_str::<WorkflowFrontmatter>(&yaml) {
|
||||
Ok(fm) => fm,
|
||||
Err(err) => {
|
||||
log::warn!("[workflows] failed to parse frontmatter: {err}");
|
||||
warnings.push(format!("frontmatter parse error: {err}"));
|
||||
WorkflowFrontmatter::default()
|
||||
}
|
||||
};
|
||||
|
||||
Some((frontmatter, body, warnings))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "parse_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Tests for WORKFLOW.md frontmatter parsing.
|
||||
|
||||
use super::*;
|
||||
|
||||
const FULL: &str = r#"---
|
||||
name: bug-triage
|
||||
description: How to handle an incoming bug report
|
||||
when_to_use: a user reports a bug or something is broken
|
||||
tags: [support, debugging]
|
||||
tools:
|
||||
allow: [shell, git_operations]
|
||||
phases:
|
||||
on_enter_directory:
|
||||
rules:
|
||||
- Read the local README before editing.
|
||||
context: [git]
|
||||
on_pick_up_task:
|
||||
rules:
|
||||
- Always reproduce before proposing a fix.
|
||||
scripts:
|
||||
- git fetch
|
||||
tools:
|
||||
allow: [shell, file_read]
|
||||
deny: [curl]
|
||||
---
|
||||
# Bug triage workflow
|
||||
|
||||
Body prose here.
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_full_frontmatter_and_body() {
|
||||
let (fm, body, warnings) = parse_workflow_md_str(FULL).expect("should parse");
|
||||
assert!(warnings.is_empty(), "warnings: {warnings:?}");
|
||||
assert_eq!(fm.name, "bug-triage");
|
||||
assert_eq!(
|
||||
fm.when_to_use,
|
||||
"a user reports a bug or something is broken"
|
||||
);
|
||||
assert_eq!(fm.tags, vec!["support", "debugging"]);
|
||||
assert_eq!(
|
||||
fm.tools.as_ref().unwrap().allow,
|
||||
vec!["shell", "git_operations"]
|
||||
);
|
||||
assert!(body.contains("Body prose here."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_phase_scripts_tools_and_context() {
|
||||
let (fm, _body, _w) = parse_workflow_md_str(FULL).unwrap();
|
||||
let pickup = fm.phases.get("on_pick_up_task").expect("phase present");
|
||||
assert_eq!(
|
||||
pickup.rules,
|
||||
vec!["Always reproduce before proposing a fix."]
|
||||
);
|
||||
assert_eq!(pickup.scripts, vec!["git fetch"]);
|
||||
let scope = pickup.tools.as_ref().unwrap();
|
||||
assert_eq!(scope.allow, vec!["shell", "file_read"]);
|
||||
assert_eq!(scope.deny, vec!["curl"]);
|
||||
|
||||
let enter = fm.phases.get("on_enter_directory").unwrap();
|
||||
assert_eq!(enter.context, vec!["git"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_frontmatter_treats_whole_file_as_body() {
|
||||
let (fm, body, warnings) = parse_workflow_md_str("# Just a body\nno yaml").unwrap();
|
||||
assert_eq!(fm.name, "");
|
||||
assert!(body.contains("Just a body"));
|
||||
assert!(warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unterminated_frontmatter_returns_none() {
|
||||
let content = "---\nname: x\nstill in frontmatter\n";
|
||||
assert!(parse_workflow_md_str(content).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_yaml_yields_warning_and_default_frontmatter() {
|
||||
// `phases` declared as a scalar instead of a map → deserialize error.
|
||||
let content = "---\nname: x\nphases: not-a-map\n---\nbody\n";
|
||||
let (fm, _body, warnings) = parse_workflow_md_str(content).unwrap();
|
||||
assert_eq!(fm.name, ""); // fell back to default on error
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("frontmatter parse error")),
|
||||
"warnings: {warnings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_land_in_extra() {
|
||||
let content = "---\nname: x\nfuture_key: 42\n---\nbody\n";
|
||||
let (fm, _body, _w) = parse_workflow_md_str(content).unwrap();
|
||||
assert!(fm.extra.contains_key("future_key"));
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Controller schemas for the `workflows` RPC namespace.
|
||||
//!
|
||||
//! Methods: `openhuman.workflows_list`, `workflows_read`, `workflows_create`,
|
||||
//! `workflows_uninstall`, `workflows_phase`. Mirrors the cron/skills controller
|
||||
//! shape (`schemas()` / `all_controller_schemas()` / `all_registered_controllers()`
|
||||
//! / `handle_*`) using `RpcOutcome` + `into_cli_compatible_json()`.
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::WorkflowSummary;
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![
|
||||
schemas("list"),
|
||||
schemas("read"),
|
||||
schemas("create"),
|
||||
schemas("uninstall"),
|
||||
schemas("phase"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
vec![
|
||||
RegisteredController {
|
||||
schema: schemas("list"),
|
||||
handler: handle_list,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("read"),
|
||||
handler: handle_read,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("create"),
|
||||
handler: handle_create,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("uninstall"),
|
||||
handler: handle_uninstall,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("phase"),
|
||||
handler: handle_phase,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn id_input(comment: &'static str) -> FieldSchema {
|
||||
FieldSchema {
|
||||
name: "id",
|
||||
ty: TypeSchema::String,
|
||||
comment,
|
||||
required: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schemas(function: &str) -> ControllerSchema {
|
||||
match function {
|
||||
"list" => ControllerSchema {
|
||||
namespace: "workflows",
|
||||
function: "list",
|
||||
description: "List all discovered workflows (user scope).",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "workflows",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("WorkflowSummary"))),
|
||||
comment: "Discovered workflows.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"read" => ControllerSchema {
|
||||
namespace: "workflows",
|
||||
function: "read",
|
||||
description: "Read a single workflow (frontmatter + body) by id.",
|
||||
inputs: vec![id_input("Workflow id (directory name).")],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "workflow",
|
||||
ty: TypeSchema::Ref("Workflow"),
|
||||
comment: "The resolved workflow.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"create" => ControllerSchema {
|
||||
namespace: "workflows",
|
||||
function: "create",
|
||||
description: "Scaffold a new user-scope workflow (WORKFLOW.md).",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "name",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Human-readable workflow name.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "description",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Short description.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "when_to_use",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Free-text trigger used for auto-match.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "workflow",
|
||||
ty: TypeSchema::Ref("Workflow"),
|
||||
comment: "Newly created workflow.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"uninstall" => ControllerSchema {
|
||||
namespace: "workflows",
|
||||
function: "uninstall",
|
||||
description: "Remove a user-scope workflow by id.",
|
||||
inputs: vec![id_input("Workflow id (directory name) to remove.")],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Object {
|
||||
fields: vec![
|
||||
FieldSchema {
|
||||
name: "id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Requested id.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "removed",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when removed.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
comment: "Removal result.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"phase" => ControllerSchema {
|
||||
namespace: "workflows",
|
||||
function: "phase",
|
||||
description: "Resolve a phase's guidance + effective tool scope for a workflow.",
|
||||
inputs: vec![
|
||||
id_input("Workflow id (directory name)."),
|
||||
FieldSchema {
|
||||
name: "phase",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Phase name (e.g. on_pick_up_task).",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Object {
|
||||
fields: vec![
|
||||
FieldSchema {
|
||||
name: "guidance",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Rendered rules block, if any.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "tool_scope",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Ref("ToolScope"))),
|
||||
comment: "Effective tool scope, if any.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
comment: "Resolved phase payload.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
_other => ControllerSchema {
|
||||
namespace: "workflows",
|
||||
function: "unknown",
|
||||
description: "Unknown workflows controller function.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "function",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Unknown function requested for schema lookup.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "error",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Lookup error details.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_list(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
log::debug!("[workflows][rpc] list");
|
||||
let workflows = super::discover_workflows(dirs::home_dir().as_deref(), None, false);
|
||||
let summaries: Vec<WorkflowSummary> = workflows.iter().map(WorkflowSummary::from).collect();
|
||||
log::debug!("[workflows][rpc] list -> {} workflow(s)", summaries.len());
|
||||
to_json(RpcOutcome::new(
|
||||
json!({ "workflows": summaries }),
|
||||
Vec::new(),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_read(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let id = read_required::<String>(¶ms, "id")?;
|
||||
log::debug!("[workflows][rpc] read id={id}");
|
||||
let workflow = super::read_workflow(id.trim())?;
|
||||
to_json(RpcOutcome::new(json!({ "workflow": workflow }), Vec::new()))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_create(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let name = read_required::<String>(¶ms, "name")?;
|
||||
let description = read_optional_str(¶ms, "description").unwrap_or_default();
|
||||
let when_to_use = read_optional_str(¶ms, "when_to_use").unwrap_or_default();
|
||||
log::info!("[workflows][rpc] create name={name}");
|
||||
let workflow = super::create_workflow(name.trim(), description.trim(), when_to_use.trim())?;
|
||||
to_json(RpcOutcome::new(json!({ "workflow": workflow }), Vec::new()))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_uninstall(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let id = read_required::<String>(¶ms, "id")?;
|
||||
log::info!("[workflows][rpc] uninstall id={id}");
|
||||
let removed = super::uninstall_workflow(id.trim())?;
|
||||
to_json(RpcOutcome::new(
|
||||
json!({ "id": id.trim(), "removed": removed }),
|
||||
Vec::new(),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_phase(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let id = read_required::<String>(¶ms, "id")?;
|
||||
let phase = read_required::<String>(¶ms, "phase")?;
|
||||
log::debug!("[workflows][rpc] phase id={id} phase={phase}");
|
||||
let workflow = super::read_workflow(id.trim())?;
|
||||
let guidance = super::phase_guidance(&workflow, phase.trim());
|
||||
let tool_scope = super::effective_tool_scope(&workflow, phase.trim());
|
||||
to_json(RpcOutcome::new(
|
||||
json!({ "guidance": guidance, "tool_scope": tool_scope }),
|
||||
Vec::new(),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn read_required<T: DeserializeOwned>(params: &Map<String, Value>, key: &str) -> Result<T, String> {
|
||||
let value = params
|
||||
.get(key)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("missing required param '{key}'"))?;
|
||||
serde_json::from_value(value).map_err(|e| format!("invalid '{key}': {e}"))
|
||||
}
|
||||
|
||||
fn read_optional_str(params: &Map<String, Value>, key: &str) -> Option<String> {
|
||||
params
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "schemas_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Branch coverage for the workflows controller schemas.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_controller_schemas_covers_every_function() {
|
||||
let names: Vec<_> = all_controller_schemas()
|
||||
.into_iter()
|
||||
.map(|s| s.function)
|
||||
.collect();
|
||||
assert_eq!(names, vec!["list", "read", "create", "uninstall", "phase"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_registered_controllers_has_handler_per_schema() {
|
||||
let controllers = all_registered_controllers();
|
||||
assert_eq!(controllers.len(), 5);
|
||||
let names: Vec<_> = controllers.iter().map(|c| c.schema.function).collect();
|
||||
assert_eq!(names, vec!["list", "read", "create", "uninstall", "phase"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_schema_uses_workflows_namespace() {
|
||||
for f in ["list", "read", "create", "uninstall", "phase"] {
|
||||
assert_eq!(schemas(f).namespace, "workflows", "fn={f}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_outputs_workflow_summary_array() {
|
||||
let s = schemas("list");
|
||||
assert!(s.inputs.is_empty());
|
||||
assert_eq!(s.outputs[0].name, "workflows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_and_phase_require_id() {
|
||||
let read = schemas("read");
|
||||
assert!(read.inputs.iter().any(|f| f.name == "id" && f.required));
|
||||
|
||||
let phase = schemas("phase");
|
||||
let names: Vec<_> = phase.inputs.iter().map(|f| f.name).collect();
|
||||
assert!(names.contains(&"id"));
|
||||
assert!(names.contains(&"phase"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_requires_only_name() {
|
||||
let s = schemas("create");
|
||||
let required: Vec<_> = s
|
||||
.inputs
|
||||
.iter()
|
||||
.filter(|f| f.required)
|
||||
.map(|f| f.name)
|
||||
.collect();
|
||||
assert_eq!(required, vec!["name"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_function_is_placeholder() {
|
||||
let s = schemas("does-not-exist");
|
||||
assert_eq!(s.function, "unknown");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Phase guidance rendering, effective tool-scope resolution, and workflow
|
||||
//! selection (auto-match over `when_to_use`).
|
||||
|
||||
use super::types::{ToolScope, Workflow};
|
||||
|
||||
/// Render one phase's rules as a compact guidance block, or `None` when the
|
||||
/// phase is absent or carries no rules.
|
||||
pub fn phase_guidance(workflow: &Workflow, phase: &str) -> Option<String> {
|
||||
let p = workflow.phases.get(phase)?;
|
||||
if p.rules.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut out = format!("### {} — phase {}\n", workflow.name, phase);
|
||||
if let Some(desc) = p.description.as_deref() {
|
||||
if !desc.trim().is_empty() {
|
||||
out.push_str(desc.trim());
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
for rule in &p.rules {
|
||||
out.push_str(&format!("- {rule}\n"));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Resolve the effective tool scope for a phase: the phase override unioned
|
||||
/// over the workflow-level default. `allow` is deduplicated; `deny` is the
|
||||
/// union of both. Returns `None` when neither the phase nor the workflow
|
||||
/// declares a scope.
|
||||
pub fn effective_tool_scope(workflow: &Workflow, phase: &str) -> Option<ToolScope> {
|
||||
let phase_scope = workflow.phases.get(phase).and_then(|p| p.tools.as_ref());
|
||||
let wf_scope = workflow.tools.as_ref();
|
||||
|
||||
if phase_scope.is_none() && wf_scope.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut allow: Vec<String> = Vec::new();
|
||||
let mut deny: Vec<String> = Vec::new();
|
||||
for scope in [wf_scope, phase_scope].into_iter().flatten() {
|
||||
for a in &scope.allow {
|
||||
if !allow.contains(a) {
|
||||
allow.push(a.clone());
|
||||
}
|
||||
}
|
||||
for d in &scope.deny {
|
||||
if !deny.contains(d) {
|
||||
deny.push(d.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ToolScope { allow, deny })
|
||||
}
|
||||
|
||||
/// Best-match a workflow against a free-text query by scoring word overlap
|
||||
/// against each workflow's `when_to_use`. Returns `None` when nothing overlaps.
|
||||
pub fn best_match<'a>(workflows: &'a [Workflow], query: &str) -> Option<&'a Workflow> {
|
||||
let query_words = tokenize(query);
|
||||
if query_words.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut best: Option<(&Workflow, usize)> = None;
|
||||
for wf in workflows {
|
||||
if wf.when_to_use.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let hint_words = tokenize(&wf.when_to_use);
|
||||
let score = hint_words
|
||||
.iter()
|
||||
.filter(|w| query_words.contains(*w))
|
||||
.count();
|
||||
if score == 0 {
|
||||
continue;
|
||||
}
|
||||
match best {
|
||||
Some((_, best_score)) if best_score >= score => {}
|
||||
_ => best = Some((wf, score)),
|
||||
}
|
||||
}
|
||||
best.map(|(wf, _)| wf)
|
||||
}
|
||||
|
||||
/// Lowercase word tokens of length ≥ 3 (drops stop-word-ish short tokens).
|
||||
fn tokenize(text: &str) -> Vec<String> {
|
||||
text.to_lowercase()
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| w.len() >= 3)
|
||||
.map(|w| w.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "select_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Tests for phase guidance rendering, tool-scope resolution, and selection.
|
||||
|
||||
use super::*;
|
||||
use crate::openhuman::agent_workflows::types::{
|
||||
WorkflowFrontmatter, WorkflowPhase, PHASE_CLOSE_TASK, PHASE_PICK_UP_TASK,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn wf_with(name: &str, when_to_use: &str, phases: HashMap<String, WorkflowPhase>) -> Workflow {
|
||||
Workflow {
|
||||
name: name.to_string(),
|
||||
dir_name: name.to_string(),
|
||||
when_to_use: when_to_use.to_string(),
|
||||
phases: phases.clone(),
|
||||
frontmatter: WorkflowFrontmatter {
|
||||
name: name.to_string(),
|
||||
when_to_use: when_to_use.to_string(),
|
||||
phases,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_guidance_renders_rules() {
|
||||
let mut phases = HashMap::new();
|
||||
phases.insert(
|
||||
PHASE_PICK_UP_TASK.to_string(),
|
||||
WorkflowPhase {
|
||||
rules: vec!["Reproduce first.".into(), "Write a test.".into()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let wf = wf_with("bug-triage", "bug", phases);
|
||||
let out = phase_guidance(&wf, PHASE_PICK_UP_TASK).unwrap();
|
||||
assert!(out.contains("bug-triage"));
|
||||
assert!(out.contains("Reproduce first."));
|
||||
assert!(out.contains("Write a test."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_guidance_none_for_missing_or_empty_phase() {
|
||||
let wf = wf_with("x", "y", HashMap::new());
|
||||
assert!(phase_guidance(&wf, PHASE_CLOSE_TASK).is_none());
|
||||
|
||||
let mut phases = HashMap::new();
|
||||
phases.insert(PHASE_CLOSE_TASK.to_string(), WorkflowPhase::default());
|
||||
let wf2 = wf_with("x", "y", phases);
|
||||
assert!(phase_guidance(&wf2, PHASE_CLOSE_TASK).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_tool_scope_unions_phase_over_workflow_default() {
|
||||
let mut phases = HashMap::new();
|
||||
phases.insert(
|
||||
PHASE_PICK_UP_TASK.to_string(),
|
||||
WorkflowPhase {
|
||||
tools: Some(ToolScope {
|
||||
allow: vec!["file_read".into()],
|
||||
deny: vec!["curl".into()],
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let mut wf = wf_with("x", "y", phases);
|
||||
wf.tools = Some(ToolScope {
|
||||
allow: vec!["shell".into(), "file_read".into()],
|
||||
deny: vec!["git_operations".into()],
|
||||
});
|
||||
|
||||
let scope = effective_tool_scope(&wf, PHASE_PICK_UP_TASK).unwrap();
|
||||
// allow is the deduped union; deny is the union.
|
||||
assert!(scope.allow.contains(&"shell".to_string()));
|
||||
assert!(scope.allow.contains(&"file_read".to_string()));
|
||||
assert_eq!(
|
||||
scope.allow.iter().filter(|a| *a == "file_read").count(),
|
||||
1,
|
||||
"allow should be deduped"
|
||||
);
|
||||
assert!(scope.deny.contains(&"curl".to_string()));
|
||||
assert!(scope.deny.contains(&"git_operations".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_tool_scope_falls_back_to_workflow_or_phase_only() {
|
||||
// Workflow default only.
|
||||
let mut wf = wf_with("x", "y", HashMap::new());
|
||||
wf.tools = Some(ToolScope {
|
||||
allow: vec!["shell".into()],
|
||||
deny: vec![],
|
||||
});
|
||||
let scope = effective_tool_scope(&wf, PHASE_PICK_UP_TASK).unwrap();
|
||||
assert_eq!(scope.allow, vec!["shell"]);
|
||||
|
||||
// Neither → None.
|
||||
let bare = wf_with("x", "y", HashMap::new());
|
||||
assert!(effective_tool_scope(&bare, PHASE_PICK_UP_TASK).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_match_scores_when_to_use() {
|
||||
let a = wf_with(
|
||||
"a",
|
||||
"a user reports a bug or something is broken",
|
||||
HashMap::new(),
|
||||
);
|
||||
let b = wf_with("b", "deploy the release to production", HashMap::new());
|
||||
let list = vec![a, b];
|
||||
let m = best_match(&list, "I think there is a bug, something broken").unwrap();
|
||||
assert_eq!(m.name, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_match_none_when_nothing_overlaps() {
|
||||
let a = wf_with("a", "deploy release", HashMap::new());
|
||||
let list = vec![a];
|
||||
assert!(best_match(&list, "completely unrelated topic xyz").is_none());
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Core types, constants, and phase helpers for the agent_workflows domain.
|
||||
//!
|
||||
//! A *workflow* is a `WORKFLOW.md` file with YAML frontmatter describing
|
||||
//! **phases** — lifecycle hooks bound to a task's life: when a task is picked
|
||||
//! up, when it is closed, or when the agent enters a working directory. Each
|
||||
//! phase can inject rules, run gated scripts, scope visible tools, and surface
|
||||
//! working-directory context.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// On-disk marker file (`WORKFLOW.md`) at the root of a workflow directory.
|
||||
pub const WORKFLOW_MD: &str = "WORKFLOW.md";
|
||||
/// Workspace trust marker (shared with the skills domain) gating project scope.
|
||||
pub(crate) const TRUST_MARKER: &str = "trust";
|
||||
/// Recommended upper bound on a workflow `name`, surfaced as a non-fatal warning.
|
||||
pub(crate) const MAX_NAME_LEN: usize = 64;
|
||||
/// Recommended upper bound on a workflow `description`.
|
||||
pub(crate) const MAX_DESCRIPTION_LEN: usize = 1024;
|
||||
|
||||
/// Phase-name constant: a task is picked up / started.
|
||||
pub const PHASE_PICK_UP_TASK: &str = "on_pick_up_task";
|
||||
/// Phase-name constant: a task is closed / completed.
|
||||
pub const PHASE_CLOSE_TASK: &str = "on_close_task";
|
||||
/// Phase-name constant: the agent enters a new working directory.
|
||||
pub const PHASE_ENTER_DIRECTORY: &str = "on_enter_directory";
|
||||
|
||||
/// The three well-known v1 phases, in lifecycle order. Custom phase names are
|
||||
/// tolerated; these are the ones the harness auto-detects from task events.
|
||||
pub const KNOWN_PHASES: &[&str] = &[PHASE_ENTER_DIRECTORY, PHASE_PICK_UP_TASK, PHASE_CLOSE_TASK];
|
||||
|
||||
/// Tool-scoping directive for a workflow or a single phase.
|
||||
///
|
||||
/// Scoping only ever **narrows** the agent's existing allowlist — `allow` is
|
||||
/// intersected with what the agent could already see, and `deny` removes from
|
||||
/// that set. It never grants a tool the agent was not already permitted.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ToolScope {
|
||||
#[serde(default)]
|
||||
pub allow: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub deny: Vec<String>,
|
||||
}
|
||||
|
||||
/// A single lifecycle phase within a workflow.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkflowPhase {
|
||||
/// Optional human description of what this phase does.
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// Guidance text injected into the agent's turn when the phase fires.
|
||||
#[serde(default)]
|
||||
pub rules: Vec<String>,
|
||||
/// Shell commands auto-run (through the gated `ShellTool`) at phase entry.
|
||||
#[serde(default)]
|
||||
pub scripts: Vec<String>,
|
||||
/// Optional per-phase tool-scope override (unioned over the workflow default).
|
||||
#[serde(default)]
|
||||
pub tools: Option<ToolScope>,
|
||||
/// Working-dir context providers to surface (e.g. `["git"]`).
|
||||
#[serde(default)]
|
||||
pub context: Vec<String>,
|
||||
}
|
||||
|
||||
/// Discovery scope for a workflow. Determines precedence on name collision.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum WorkflowScope {
|
||||
/// `~/.openhuman/workflows/<slug>/` — the user's global workflows.
|
||||
User,
|
||||
/// `<workspace>/.openhuman/workflows/<slug>/` — requires the trust marker.
|
||||
Project,
|
||||
}
|
||||
|
||||
impl Default for WorkflowScope {
|
||||
fn default() -> Self {
|
||||
Self::User
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed YAML frontmatter of a `WORKFLOW.md` file.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct WorkflowFrontmatter {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Free-text trigger used for auto-match selection.
|
||||
#[serde(default)]
|
||||
pub when_to_use: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// Workflow-level default tool scope; phases may override.
|
||||
#[serde(default)]
|
||||
pub tools: Option<ToolScope>,
|
||||
/// Lifecycle phases keyed by phase name.
|
||||
#[serde(default)]
|
||||
pub phases: HashMap<String, WorkflowPhase>,
|
||||
/// Forward-compat hatch for spec additions.
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, serde_yaml::Value>,
|
||||
}
|
||||
|
||||
/// A discovered workflow.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Workflow {
|
||||
/// Display name (frontmatter, falls back to directory name).
|
||||
pub name: String,
|
||||
/// On-disk slug — the directory name; the id RPCs resolve against.
|
||||
#[serde(default)]
|
||||
pub dir_name: String,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub when_to_use: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tools: Option<ToolScope>,
|
||||
#[serde(default)]
|
||||
pub phases: HashMap<String, WorkflowPhase>,
|
||||
/// Path to the `WORKFLOW.md` file.
|
||||
#[serde(default)]
|
||||
pub location: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub scope: WorkflowScope,
|
||||
/// Full parsed frontmatter (includes the forward-compat blob).
|
||||
#[serde(default)]
|
||||
pub frontmatter: WorkflowFrontmatter,
|
||||
/// Non-fatal parse warnings, surfaced for debugging.
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl Workflow {
|
||||
/// Build a [`Workflow`] from parsed frontmatter, applying name/description
|
||||
/// fallbacks and collecting non-fatal warnings (mirrors
|
||||
/// `skills::load_from_skill_md`).
|
||||
pub fn from_parts(
|
||||
dir_name: impl Into<String>,
|
||||
frontmatter: WorkflowFrontmatter,
|
||||
location: Option<PathBuf>,
|
||||
scope: WorkflowScope,
|
||||
mut warnings: Vec<String>,
|
||||
) -> Self {
|
||||
let dir_name = dir_name.into();
|
||||
|
||||
let name = if frontmatter.name.trim().is_empty() {
|
||||
warnings.push("frontmatter missing 'name'; using directory name".to_string());
|
||||
dir_name.clone()
|
||||
} else {
|
||||
if frontmatter.name.len() > MAX_NAME_LEN {
|
||||
warnings.push(format!(
|
||||
"frontmatter name is {} chars (max recommended: {MAX_NAME_LEN})",
|
||||
frontmatter.name.len()
|
||||
));
|
||||
}
|
||||
frontmatter.name.clone()
|
||||
};
|
||||
|
||||
let description = if frontmatter.description.trim().is_empty() {
|
||||
"No description provided".to_string()
|
||||
} else {
|
||||
if frontmatter.description.len() > MAX_DESCRIPTION_LEN {
|
||||
warnings.push(format!(
|
||||
"description is {} chars (max recommended: {MAX_DESCRIPTION_LEN})",
|
||||
frontmatter.description.len()
|
||||
));
|
||||
}
|
||||
frontmatter.description.clone()
|
||||
};
|
||||
|
||||
Workflow {
|
||||
name,
|
||||
dir_name,
|
||||
description,
|
||||
when_to_use: frontmatter.when_to_use.clone(),
|
||||
tags: frontmatter.tags.clone(),
|
||||
tools: frontmatter.tools.clone(),
|
||||
phases: frontmatter.phases.clone(),
|
||||
location,
|
||||
scope,
|
||||
frontmatter,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorted phase names declared on this workflow.
|
||||
pub fn phase_names(&self) -> Vec<String> {
|
||||
let mut names: Vec<String> = self.phases.keys().cloned().collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// Re-read the `WORKFLOW.md` body (everything after the frontmatter) from
|
||||
/// disk. Returns `None` when the workflow has no on-disk location or the
|
||||
/// file cannot be parsed.
|
||||
pub fn read_body(&self) -> Option<String> {
|
||||
let path = self.location.as_ref()?;
|
||||
super::parse::parse_workflow_md(path).map(|(_, body, _)| body)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire-facing summary of a workflow for list views. Deliberately omits the
|
||||
/// flattened forward-compat blob (`frontmatter.extra`) and the full per-phase
|
||||
/// payload — mirrors the `SkillSummary` pattern.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowSummary {
|
||||
/// Stable id (directory name) used by read/uninstall/phase RPCs.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub when_to_use: String,
|
||||
pub tags: Vec<String>,
|
||||
pub scope: WorkflowScope,
|
||||
/// Sorted phase names declared on the workflow.
|
||||
pub phases: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<&Workflow> for WorkflowSummary {
|
||||
fn from(w: &Workflow) -> Self {
|
||||
WorkflowSummary {
|
||||
id: w.dir_name.clone(),
|
||||
name: w.name.clone(),
|
||||
description: w.description.clone(),
|
||||
when_to_use: w.when_to_use.clone(),
|
||||
tags: w.tags.clone(),
|
||||
scope: w.scope,
|
||||
phases: w.phase_names(),
|
||||
warnings: w.warnings.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the workspace has opted into loading project-scope workflows.
|
||||
/// Looks for `<workspace>/.openhuman/trust` (shared with skills).
|
||||
pub fn is_workspace_trusted(workspace_dir: &Path) -> bool {
|
||||
workspace_dir.join(".openhuman").join(TRUST_MARKER).exists()
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Working-directory context providers.
|
||||
//!
|
||||
//! Builds a compact, agent-readable block describing properties of the current
|
||||
//! working directory (git branch / status / recent log). Extensible: future
|
||||
//! providers (project type, language, etc.) plug into [`working_dir_context`].
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Build the working-dir context block for the requested `providers`. Returns
|
||||
/// an empty string when no providers are requested.
|
||||
pub fn working_dir_context(dir: &Path, providers: &[String]) -> String {
|
||||
if providers.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut out = String::from("### Working directory context\n");
|
||||
out.push_str(&format!("- path: {}\n", dir.display()));
|
||||
for provider in providers {
|
||||
match provider.as_str() {
|
||||
"git" => out.push_str(&git_block(dir)),
|
||||
other => out.push_str(&format!("- unknown context provider: {other}\n")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Run a git command synchronously in `dir`. Returns the trimmed stdout on
|
||||
/// success, or an error if the command fails or git is not available.
|
||||
fn run_git_sync(dir: &Path, args: &[&str]) -> Result<String, String> {
|
||||
match std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
Ok(output) => Err(String::from_utf8_lossy(&output.stderr).to_string()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the git context: branch, dirty flag, porcelain status, recent log.
|
||||
fn git_block(dir: &Path) -> String {
|
||||
let mut b = String::new();
|
||||
|
||||
// Detect repo membership independently of HEAD so a freshly-`init`ed repo
|
||||
// with an unborn branch (no commits yet) is still recognised and reported
|
||||
// as dirty when it has uncommitted/untracked files.
|
||||
if run_git_sync(dir, &["rev-parse", "--is-inside-work-tree"]).is_err() {
|
||||
b.push_str("- git: not a git repository\n");
|
||||
return b;
|
||||
}
|
||||
|
||||
// `--abbrev-ref HEAD` fails on an unborn branch; fall back gracefully.
|
||||
let branch = match run_git_sync(dir, &["rev-parse", "--abbrev-ref", "HEAD"]) {
|
||||
Ok(b) if b.trim() != "HEAD" && !b.trim().is_empty() => b.trim().to_string(),
|
||||
_ => "(unborn)".to_string(),
|
||||
};
|
||||
b.push_str(&format!("- git branch: {branch}\n"));
|
||||
|
||||
let status = run_git_sync(dir, &["status", "--porcelain"]).unwrap_or_default();
|
||||
let dirty = !status.trim().is_empty();
|
||||
b.push_str(&format!("- git dirty: {dirty}\n"));
|
||||
if dirty {
|
||||
b.push_str("- git status:\n");
|
||||
for line in status.lines().take(10) {
|
||||
b.push_str(&format!(" {line}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(log) = run_git_sync(dir, &["log", "--oneline", "-5"]) {
|
||||
if !log.trim().is_empty() {
|
||||
b.push_str("- recent commits:\n");
|
||||
for line in log.lines().take(5) {
|
||||
b.push_str(&format!(" {line}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "workdir_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Tests for working-directory context rendering.
|
||||
|
||||
use super::*;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn git(dir: &std::path::Path, args: &[&str]) {
|
||||
let ok = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
assert!(ok, "git {args:?} failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_providers_render_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
assert_eq!(working_dir_context(dir.path(), &[]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_provider_is_flagged() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let out = working_dir_context(dir.path(), &["nope".to_string()]);
|
||||
assert!(out.contains("unknown context provider: nope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_provider_reports_branch_and_clean_state() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
git(dir.path(), &["init", "-q"]);
|
||||
git(dir.path(), &["config", "user.email", "t@t.test"]);
|
||||
git(dir.path(), &["config", "user.name", "Tester"]);
|
||||
git(dir.path(), &["commit", "--allow-empty", "-q", "-m", "init"]);
|
||||
|
||||
let out = working_dir_context(dir.path(), &["git".to_string()]);
|
||||
assert!(out.contains("Working directory context"));
|
||||
assert!(out.contains("git branch:"));
|
||||
assert!(out.contains("git dirty: false"));
|
||||
assert!(out.contains("recent commits:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_provider_detects_dirty_tree() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
git(dir.path(), &["init", "-q"]);
|
||||
git(dir.path(), &["config", "user.email", "t@t.test"]);
|
||||
git(dir.path(), &["config", "user.name", "Tester"]);
|
||||
std::fs::write(dir.path().join("new.txt"), "hi").unwrap();
|
||||
|
||||
let out = working_dir_context(dir.path(), &["git".to_string()]);
|
||||
assert!(out.contains("git dirty: true"));
|
||||
assert!(out.contains("git status:"));
|
||||
}
|
||||
@@ -309,6 +309,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
curated_snapshot: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +246,7 @@ mod tests {
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
workflows: &[],
|
||||
};
|
||||
let built = section.build(&ctx).unwrap();
|
||||
assert!(built.contains("never email Sarah"));
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod agent_experience;
|
||||
pub mod agent_orchestration;
|
||||
pub mod agent_registry;
|
||||
pub mod agent_tool_policy;
|
||||
pub mod agent_workflows;
|
||||
pub mod app_state;
|
||||
pub mod approval;
|
||||
pub mod artifacts;
|
||||
|
||||
@@ -347,6 +347,31 @@ pub fn update_status(
|
||||
id: &str,
|
||||
status: TaskCardStatus,
|
||||
) -> Result<TodosSnapshot, String> {
|
||||
// [workflows][phase] auto-detection hook — emit a debug marker when a card
|
||||
// transitions to a lifecycle status that a workflow phase could react to.
|
||||
//
|
||||
// TODO(v2): wire a full `workflow_phase` tool call here once the session
|
||||
// handle is injectable at this layer. For now the log acts as the trigger
|
||||
// anchor so operators can grep `[workflows][phase]` and see status events
|
||||
// in context alongside any agent-tool–initiated phase runs.
|
||||
match status {
|
||||
TaskCardStatus::InProgress => {
|
||||
log::debug!(
|
||||
"[workflows][phase] auto-detected on_pick_up_task: \
|
||||
task id={id} transitioned to InProgress — \
|
||||
agent should call workflow_phase(id, \"on_pick_up_task\") if a workflow is active"
|
||||
);
|
||||
}
|
||||
TaskCardStatus::Done => {
|
||||
log::debug!(
|
||||
"[workflows][phase] auto-detected on_close_task: \
|
||||
task id={id} transitioned to Done — \
|
||||
agent should call workflow_phase(id, \"on_close_task\") if a workflow is active"
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
edit(
|
||||
location,
|
||||
id,
|
||||
|
||||
@@ -201,7 +201,12 @@ impl ShellTool {
|
||||
/// Run the command through the security policy and runtime. Returns
|
||||
/// `(allowed, result)` where `allowed=false` means the policy or rate
|
||||
/// limiter blocked execution before the command was launched.
|
||||
async fn run_with_security(&self, command: &str) -> (bool, ToolResult) {
|
||||
///
|
||||
/// Exposed as `pub(crate)` so workflow phase scripts can reuse the
|
||||
/// same gated execution path as the `shell` tool — all security
|
||||
/// checks (rate limits, path guards, approval gate routing) apply
|
||||
/// identically to workflow-triggered commands.
|
||||
pub(crate) async fn run_with_security(&self, command: &str) -> (bool, ToolResult) {
|
||||
// Read-only `Block` + the Option-2 structural guard. Approval for
|
||||
// Write / Network / Destructive already happened at the harness
|
||||
// `ApprovalGate` (see `external_effect_with_args`) before `execute()`
|
||||
|
||||
@@ -238,6 +238,17 @@ pub fn all_tools_with_runtime(
|
||||
security.clone(),
|
||||
)),
|
||||
Box::new(GmailUnsubscribeTool),
|
||||
// Workflow tools — let the agent load and activate installed agent
|
||||
// workflows (WORKFLOW.md bundles). `workflow_load` is read-only;
|
||||
// `workflow_phase` runs gated scripts and is Execute-class so the
|
||||
// harness routes it through the ApprovalGate identically to `shell`.
|
||||
Box::new(WorkflowLoadTool),
|
||||
Box::new(WorkflowPhaseTool::new(
|
||||
workspace_dir.to_path_buf(),
|
||||
security.clone(),
|
||||
Arc::clone(&runtime),
|
||||
Arc::clone(&audit),
|
||||
)),
|
||||
];
|
||||
|
||||
if browser_config.enabled {
|
||||
|
||||
@@ -9215,3 +9215,131 @@ async fn json_rpc_task_sources_fetch_pipeline_e2e() {
|
||||
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
/// Full lifecycle over JSON-RPC for the `workflows` namespace:
|
||||
/// create → list → read → phase → uninstall. Workflows are scaffolded under
|
||||
/// the user-scope root (`$HOME/.openhuman/workflows/<slug>/`), which the temp
|
||||
/// `HOME` isolates per-test.
|
||||
#[tokio::test]
|
||||
async fn json_rpc_workflows_lifecycle_round_trip() {
|
||||
let _env_lock = json_rpc_e2e_env_lock();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let home = tmp.path();
|
||||
let openhuman_home = home.join(".openhuman");
|
||||
|
||||
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
|
||||
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
|
||||
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
|
||||
let _vite_backend_url_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
|
||||
let _api_url_guard = EnvVarGuard::unset("OPENHUMAN_API_URL");
|
||||
|
||||
let (api_addr, api_join) = serve_on_ephemeral(mock_upstream_router()).await;
|
||||
let api_origin = format!("http://{api_addr}");
|
||||
write_min_config(openhuman_home.as_path(), &api_origin);
|
||||
|
||||
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
|
||||
let rpc_base = format!("http://{rpc_addr}");
|
||||
|
||||
// 1. Create a user-scope workflow.
|
||||
let create = post_json_rpc(
|
||||
&rpc_base,
|
||||
9201,
|
||||
"openhuman.workflows_create",
|
||||
json!({
|
||||
"name": "Bug Triage",
|
||||
"description": "How to handle an incoming bug report",
|
||||
"when_to_use": "a user reports a bug or something is broken",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let create_result = assert_no_jsonrpc_error(&create, "workflows_create");
|
||||
let wf = create_result.get("workflow").expect("workflow in create");
|
||||
assert_eq!(
|
||||
wf.get("dir_name").and_then(Value::as_str),
|
||||
Some("bug-triage")
|
||||
);
|
||||
assert_eq!(wf.get("name").and_then(Value::as_str), Some("Bug Triage"));
|
||||
assert!(
|
||||
wf.pointer("/phases/on_pick_up_task").is_some(),
|
||||
"scaffold seeds an on_pick_up_task phase"
|
||||
);
|
||||
|
||||
// 2. List reflects the new workflow.
|
||||
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("workflows")
|
||||
.and_then(Value::as_array)
|
||||
.expect("workflows array");
|
||||
assert_eq!(workflows.len(), 1, "exactly one workflow after create");
|
||||
assert_eq!(
|
||||
workflows[0].get("id").and_then(Value::as_str),
|
||||
Some("bug-triage")
|
||||
);
|
||||
assert_eq!(
|
||||
workflows[0].get("when_to_use").and_then(Value::as_str),
|
||||
Some("a user reports a bug or something is broken")
|
||||
);
|
||||
|
||||
// 3. Read returns the full workflow.
|
||||
let read = post_json_rpc(
|
||||
&rpc_base,
|
||||
9203,
|
||||
"openhuman.workflows_read",
|
||||
json!({ "id": "bug-triage" }),
|
||||
)
|
||||
.await;
|
||||
let read_result = assert_no_jsonrpc_error(&read, "workflows_read");
|
||||
assert_eq!(
|
||||
read_result
|
||||
.pointer("/workflow/name")
|
||||
.and_then(Value::as_str),
|
||||
Some("Bug Triage")
|
||||
);
|
||||
|
||||
// 4. Phase resolution renders the seeded rule's guidance.
|
||||
let phase = post_json_rpc(
|
||||
&rpc_base,
|
||||
9204,
|
||||
"openhuman.workflows_phase",
|
||||
json!({ "id": "bug-triage", "phase": "on_pick_up_task" }),
|
||||
)
|
||||
.await;
|
||||
let phase_result = assert_no_jsonrpc_error(&phase, "workflows_phase");
|
||||
let guidance = phase_result
|
||||
.get("guidance")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
guidance.contains("on_pick_up_task"),
|
||||
"guidance names the phase, got: {guidance}"
|
||||
);
|
||||
|
||||
// 5. Uninstall removes it; the list is empty again.
|
||||
let uninstall = post_json_rpc(
|
||||
&rpc_base,
|
||||
9205,
|
||||
"openhuman.workflows_uninstall",
|
||||
json!({ "id": "bug-triage" }),
|
||||
)
|
||||
.await;
|
||||
let uninstall_result = assert_no_jsonrpc_error(&uninstall, "workflows_uninstall");
|
||||
assert_eq!(
|
||||
uninstall_result.get("removed").and_then(Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
|
||||
let after = post_json_rpc(&rpc_base, 9206, "openhuman.workflows_list", json!({})).await;
|
||||
let after_result = assert_no_jsonrpc_error(&after, "workflows_list");
|
||||
assert!(
|
||||
after_result
|
||||
.get("workflows")
|
||||
.and_then(Value::as_array)
|
||||
.expect("workflows array")
|
||||
.is_empty(),
|
||||
"no workflows after uninstall"
|
||||
);
|
||||
|
||||
api_join.abort();
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ fn empty_prompt_context<'a>(workspace_dir: &'a std::path::Path) -> PromptContext
|
||||
agent_id: "orchestrator",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names,
|
||||
|
||||
Reference in New Issue
Block a user