feat(workflows): agent workflows — phase-keyed task lifecycle playbooks (#3008)

This commit is contained in:
Steven Enamakel
2026-05-30 01:00:18 -07:00
committed by GitHub
parent 8c23721de6
commit 20ad76d796
92 changed files with 5309 additions and 3 deletions
@@ -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.
@@ -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]].
@@ -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).
+10
View File
@@ -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
);
}
+47
View File
@@ -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':
'إدارة عواملك واستقلاليتها وما يمكنها الوصول إليه على هذا الجهاز.',
+47
View File
@@ -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':
'আপনার এজেন্ট, তাদের স্বায়ত্তশাসন এবং এই কম্পিউটারে তারা কী অ্যাক্সেস করতে পারে তা পরিচালনা করুন।',
+47
View File
@@ -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.',
+49
View File
@@ -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.',
+47
View File
@@ -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.',
+47
View File
@@ -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.',
+47
View File
@@ -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':
'अपने एजेंट, उनकी स्वायत्तता और इस कंप्यूटर पर वे क्या एक्सेस कर सकते हैं, यह प्रबंधित करें।',
+47
View File
@@ -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.',
+47
View File
@@ -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.',
+47
View File
@@ -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':
'에이전트와 그 자율성, 그리고 이 컴퓨터에서 액세스할 수 있는 항목을 관리하세요.',
+47
View File
@@ -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.',
+47
View File
@@ -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.',
+47
View File
@@ -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':
'Управляйте агентами, их автономностью и доступом к ресурсам компьютера.',
+47
View File
@@ -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': '注册表、自主权与系统访问',
+204
View File
@@ -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();
});
});
});
+278
View File
@@ -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>
);
}
+9 -1
View File
@@ -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 />}
+153
View File
@@ -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();
});
});
});
+194
View File
@@ -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;
},
};
+2
View File
@@ -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({
+213
View File
@@ -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();
});
});
});
+159
View File
@@ -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;
+4
View File
@@ -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());
+12
View File
@@ -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",
+1
View File
@@ -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");
+1
View File
@@ -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"));
+1
View File
@@ -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: &[],
}
}
+15
View File
@@ -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!(
+5
View File
@@ -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],
}
// ─────────────────────────────────────────────────────────────────────────────
+2
View File
@@ -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};
+476
View File
@@ -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()
);
}
+1
View File
@@ -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());
+147
View File
@@ -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<_>>()
);
}
+42
View File
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
#[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 &amp; B"));
assert!(out.contains("&lt;x&gt;"));
assert!(out.contains("&quot;now&quot;"));
}
#[test]
fn available_alias_matches_catalog() {
let list = vec![wf("x", "x", "y")];
assert_eq!(
render_available_workflows(&list),
render_workflow_catalog(&list)
);
}
+35
View File
@@ -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,
};
+147
View File
@@ -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");
}
+69
View File
@@ -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"));
}
+280
View File
@@ -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>(&params, "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>(&params, "name")?;
let description = read_optional_str(&params, "description").unwrap_or_default();
let when_to_use = read_optional_str(&params, "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>(&params, "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>(&params, "id")?;
let phase = read_required::<String>(&params, "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");
}
+94
View File
@@ -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());
}
+241
View File
@@ -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()
}
+84
View File
@@ -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,
}
}
+1
View File
@@ -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"));
+1
View File
@@ -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;
+25
View File
@@ -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-toolinitiated 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,
+6 -1
View File
@@ -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()`
+11
View File
@@ -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 {
+128
View File
@@ -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();
}
+1
View File
@@ -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,