Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 108 additions & 27 deletions client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,56 @@ import {
effortAwareModelOptions,
effortSurvivingModel,
localBackendForProvider,
toolFreeLocalSelectionPolicy,
publicReviewSelectionPolicy,
supportsPublicReviewPosture,
PUBLIC_REVIEW_ACTIONS_POSTURE,
PUBLIC_REVIEW_NO_TOOL_POSTURE,
} from '../../../../utils/providers';
import useLocalModels from '../../../../hooks/useLocalModels';
import ProviderModelSelector from '../../../ProviderModelSelector';
import { pipelineStages } from './scheduleConstants';
import ToggleSwitch from '../../../ToggleSwitch';
import {
pipelineStages,
prReviewerStageRole,
stagePublicReviewPosture,
togglePrReviewerActions,
} from './scheduleConstants';

// Which providers on THIS install can enforce a posture. The server publishes
// `publicReviewPostures` per provider, derived from the vendor rows, so the
// picker never carries its own list of vendor names — an install with only
// grok, only codex, or only a local Claude wrapper each get a correct list.
const eligibleProvidersFor = (providers, posture) =>
(providers || []).filter((provider) => supportsPublicReviewPosture(provider, posture));

export default function PipelineStageConfig({ taskType, config, providers, onUpdate, updating, setUpdating }) {
const stages = pipelineStages(config);
const needsSecurityModelPolicy = taskType === 'pr-reviewer';
const { ollama, lmstudio, capabilitiesByBackend, loading: localModelsLoading } = useLocalModels({
enabled: needsSecurityModelPolicy,
});
// Stage 2 uses the same authoritative no-tool model predicate, but its
// provider is a maintained local Claude wrapper rather than a direct HTTP
// backend. The server-derived marker is the provider-side capability gate;
// the shared policy still requires a local installed model with text
// capability and no `tools` capability.
const publicReviewSelectionPolicy = useMemo(
() => toolFreeLocalSelectionPolicy(capabilitiesByBackend, {
providerPredicate: (provider) => provider?.publicReviewSupported === true,
}),
[capabilitiesByBackend],
);
// One policy per posture. The provider half is server-derived; the model half
// adds the authoritative no-tool capability check only for a local runtime,
// which is the only place PortOS can probe it.
const selectionPolicies = useMemo(() => ({
[PUBLIC_REVIEW_NO_TOOL_POSTURE]: publicReviewSelectionPolicy(PUBLIC_REVIEW_NO_TOOL_POSTURE, capabilitiesByBackend),
[PUBLIC_REVIEW_ACTIONS_POSTURE]: publicReviewSelectionPolicy(PUBLIC_REVIEW_ACTIONS_POSTURE, capabilitiesByBackend),
}), [capabilitiesByBackend]);

const hasActionsStage = stages.some((stage) => prReviewerStageRole(stage) === 'actions');

const handlePrReviewerActionsToggle = async (enabled) => {
setUpdating(true);
const updatedMeta = {
...config.taskMetadata,
pipeline: {
...config.taskMetadata.pipeline,
stages: togglePrReviewerActions(stages, enabled),
},
};
await onUpdate(taskType, { taskMetadata: updatedMeta }).catch(() => {});
setUpdating(false);
};

const handleStageUpdate = async (stageIndex, field, value) => {
setUpdating(true);
Expand Down Expand Up @@ -64,25 +91,58 @@ export default function PipelineStageConfig({ taskType, config, providers, onUpd
return (
<div>
<h4 className="text-sm font-medium text-gray-400 mb-3">Pipeline Stages</h4>
{needsSecurityModelPolicy && (
<div className="rounded-lg border border-port-accent-2/30 bg-port-bg/60 px-3 py-3 mb-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-white">Run final code review and actions</p>
<p className="text-xs text-gray-400 mt-1">
When enabled, a sandbox-capable reviewer applies only the screened patch, runs local tests, and returns a structured review for the deterministic GitHub coordinator. It is nested here, not a separate scheduled task.
</p>
</div>
<ToggleSwitch
enabled={hasActionsStage}
onChange={() => handlePrReviewerActionsToggle(!hasActionsStage)}
disabled={updating}
ariaLabel="Enable final code review and actions"
size="sm"
/>
</div>
{!hasActionsStage && (
<p className="text-xs text-port-warning mt-2">
Disabled runs stop after the tool-free eligibility gate; no PR comments, issue filing, CI triggers, or merge actions occur.
</p>
)}
</div>
)}
<div className="space-y-3">
{stages.map((stage, i) => {
const stageProvider = providers?.find(p => p.id === stage.providerId);
const isSecurityStage = needsSecurityModelPolicy && i === 0;
const isPublicReviewStage = needsSecurityModelPolicy && i === 1;
const role = needsSecurityModelPolicy
? (prReviewerStageRole(stage) || (i === 0 ? 'security' : i === 1 ? 'eligibility' : 'actions'))
: null;
const isSecurityStage = role === 'security';
// The posture is read off the stage's own execution profile, so a
// custom pipeline that reuses one of these profiles gets the same
// gating without being a pr-reviewer stage.
const posture = isSecurityStage ? null : stagePublicReviewPosture(stage);
const isNoToolStage = posture === PUBLIC_REVIEW_NO_TOOL_POSTURE;
const isActionsStage = Boolean(posture) && !isNoToolStage;
const eligibleProviders = posture ? eligibleProvidersFor(providers, posture) : null;
const localBackend = localBackendForProvider(stageProvider);
const localModelIds = localBackend === 'ollama' ? ollama : localBackend === 'lmstudio' ? lmstudio : [];
// Keep the richer capability object on each local option so the shared
// policy does not need a second lookup. The status hook's ids are the
// installed-model source of truth; a provider's stale catalog is never
// enough to make a model eligible for a security scan.
const stageModels = isPublicReviewStage
// A LOCAL provider's installed-model list is the source of truth (its
// stored catalog is stale, and only an installed model has a probeable
// capability report). Every other provider uses its own catalog, so a
// cloud CLI stage can pick any model that provider offers.
const stageModels = isNoToolStage && localBackend
? localModelIds.map(id => ({
id,
name: id,
capabilities: capabilitiesByBackend?.[localBackend]?.[id],
}))
: effortAwareModelOptions(stageProvider, stage.model);
const selectionPolicy = isPublicReviewStage ? publicReviewSelectionPolicy : undefined;
const selectionPolicy = posture ? selectionPolicies[posture] : undefined;
const stageProviderId = stage.providerId || '';
const stageModel = stage.model || '';
const stageEffort = stage.effort || '';
Expand All @@ -95,6 +155,12 @@ export default function PipelineStageConfig({ taskType, config, providers, onUpd
{stage.readOnly && (
<span className="text-[10px] px-1 py-0.5 bg-gray-600/30 text-gray-400 rounded">read-only</span>
)}
{isNoToolStage && (
<span className="text-[10px] px-1 py-0.5 bg-port-accent/15 text-port-accent rounded">tool-free gate</span>
)}
{isActionsStage && (
<span className="text-[10px] px-1 py-0.5 bg-port-accent-2/15 text-port-accent-2 rounded">sandboxed actions</span>
)}
<span className="text-sm text-white font-medium">{stage.name}</span>
{i < stages.length - 1 && (
<span className="text-gray-500 ml-auto text-xs">→ Stage {i + 2}</span>
Expand All @@ -120,18 +186,33 @@ export default function PipelineStageConfig({ taskType, config, providers, onUpd
onModelChange={(model) => updateStage('model', model)}
effort={stageEffort}
onEffortChange={(effort) => updateStage('effort', effort)}
emptyProviderOption={isPublicReviewStage ? 'Select enforced local review provider (required)' : 'Default (task-level)'}
emptyModelOption={isPublicReviewStage ? 'Select installed no-tool model (required)' : 'Default (task-level)'}
emptyProviderOption={posture
? 'First eligible provider on this install'
: 'Default (task-level)'}
emptyModelOption={posture ? 'Use provider default model' : 'Default (task-level)'}
alwaysShowModel
selectionPolicy={selectionPolicy}
disabled={updating}
/>
)}
{isPublicReviewStage && (
{posture && eligibleProviders?.length === 0 && (
<p className="text-xs text-port-warning mt-2">
No enabled AI provider on this install can enforce the{' '}
{isActionsStage ? 'sandboxed-actions' : 'tool-free'} posture, so this stage will not run.
Enable a supported CLI provider in{' '}
<Link to="/ai" className="underline hover:text-port-accent">Settings → Providers</Link>.
</p>
)}
{isNoToolStage && eligibleProviders?.length > 0 && (
<p className="text-xs text-gray-500 mt-2">
{localModelsLoading
? 'Loading installed local model capability reports…'
: 'Stage 2 accepts only the maintained local Claude wrapper and an installed text model whose runtime reports no tool-calling capability. The reviewer is read-only; the deterministic coordinator owns comments, approvals, rebases, and merges.'}
: `Tool-free stage. Eligible on this install: ${eligibleProviders.map((p) => p.name || p.id).join(', ')}. A local model must additionally report no tool-calling capability; a cloud model is held tool-free by the provider's own enforced flags. Leave the provider unset to use the first eligible one. It returns only a binary allowlist; the final stage never receives rejected content.`}
</p>
)}
{isActionsStage && eligibleProviders?.length > 0 && (
<p className="text-xs text-gray-500 mt-2">
{`Sandboxed stage. Eligible on this install: ${eligibleProviders.map((p) => p.name || p.id).join(', ')}. PortOS passes the selected provider, model, and thinking effort through that provider's maintained sandbox recipe, with no forge credential or configuration overlays; the deterministic coordinator owns comments, issue filing, CI triggers, and merges.`}
</p>
)}
</div>
Expand All @@ -140,9 +221,9 @@ export default function PipelineStageConfig({ taskType, config, providers, onUpd
</div>
<p className="text-xs text-gray-500 mt-2">
{needsSecurityModelPolicy
? 'Stage 1 screens complete public content with a managed classifier; only cleared content reaches the read-only Stage 2 reviewer. Stages are nested, not independently scheduled.'
? 'Stage 1 screens complete public content with a managed classifier; only cleared content reaches the tool-free Eligibility Gate, and only eligible PRs reach the optional sandboxed final review. Stages are nested, not independently scheduled.'
: 'Each stage runs as a separate agent inside this pipeline; stages are not scheduled independently.'}
{' Configure different providers per stage (e.g., Codex for review, Claude for implementation).'}
{' Configure a different provider, model, and thinking effort per stage.'}
</p>
</div>
);
Expand Down
186 changes: 186 additions & 0 deletions client/src/components/cos/tabs/schedule/PipelineStageConfig.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router';

vi.mock('../../../../hooks/useLocalModels', () => ({
default: () => ({
ollama: ['safe-model', 'tool-model'],
lmstudio: [],
capabilitiesByBackend: {
ollama: {
'safe-model': ['chat'],
'tool-model': ['chat', 'tools'],
},
},
loading: false,
}),
}));

import PipelineStageConfig from './PipelineStageConfig';

const STAGES = [
{
name: 'Security Scan',
role: 'security',
promptKey: 'pr-reviewer-security',
readOnly: true,
},
{
name: 'Eligibility Gate',
role: 'eligibility',
promptKey: 'pr-reviewer-eligibility',
readOnly: true,
providerId: 'claude-ollama',
model: 'safe-model',
},
{
name: 'Code Review & Actions',
role: 'actions',
promptKey: 'pr-reviewer-review',
readOnly: true,
providerId: 'codex-cli',
model: 'gpt-5.6',
},
];

const providers = [
{
id: 'claude-ollama',
name: 'Local Claude',
type: 'cli',
command: 'claude',
endpoint: 'http://127.0.0.1:11434',
publicReviewSupported: true,
models: ['safe-model'],
},
{
id: 'codex-cli',
name: 'Codex CLI',
type: 'cli',
command: 'codex',
publicReviewActionsSupported: true,
models: ['gpt-5.6'],
},
{
id: 'antigravity-cli',
name: 'Antigravity CLI',
type: 'cli',
command: 'agy',
publicReviewActionsSupported: true,
models: ['gemini-3.6-flash'],
},
{
id: 'other-cli',
name: 'Other CLI',
type: 'cli',
command: 'other-agent',
models: ['other-model'],
},
];

function renderStages(stages = STAGES, onUpdate = vi.fn().mockResolvedValue(undefined)) {
render(
<MemoryRouter>
<PipelineStageConfig
taskType="pr-reviewer"
config={{ taskMetadata: { pipeline: { stages } } }}
providers={providers}
onUpdate={onUpdate}
updating={false}
setUpdating={() => {}}
/>
</MemoryRouter>,
);
return onUpdate;
}

describe('PipelineStageConfig — pr-reviewer', () => {
it('uses shared capability policies for the gate and sandbox-capable action providers', () => {
renderStages();

const providerSelects = screen.getAllByLabelText('Provider');
expect([...providerSelects[0].options].map((option) => option.value)).toEqual(['', 'claude-ollama']);
expect([...providerSelects[1].options].map((option) => option.value)).toEqual(['', 'codex-cli', 'antigravity-cli']);

const modelSelects = screen.getAllByLabelText('Model');
expect([...modelSelects[0].options].map((option) => option.value)).toEqual(['', 'safe-model']);
expect([...modelSelects[1].options].map((option) => option.value)).toEqual(['', 'gpt-5.6']);
expect(screen.getByText(/maintained sandbox/i)).toBeInTheDocument();
});

it('removes the optional actions stage without changing the mandatory gate', async () => {
const onUpdate = renderStages();
fireEvent.click(screen.getByRole('switch', { name: 'Enable final code review and actions' }));

await waitFor(() => expect(onUpdate).toHaveBeenCalledWith('pr-reviewer', {
taskMetadata: {
pipeline: { stages: STAGES.slice(0, 2) },
},
}));
});

it('restores the complete restricted action-stage posture when enabled', async () => {
const onUpdate = renderStages(STAGES.slice(0, 2));
fireEvent.click(screen.getByRole('switch', { name: 'Enable final code review and actions' }));

await waitFor(() => expect(onUpdate).toHaveBeenCalledWith('pr-reviewer', {
taskMetadata: expect.objectContaining({
pipeline: expect.objectContaining({
stages: expect.arrayContaining([
expect.objectContaining({
role: 'actions',
promptKey: 'pr-reviewer-review',
executionProfile: 'public-review-actions',
discardWorktree: true,
noCodeOutput: true,
}),
]),
}),
}),
}));
});
});

// The refactor's core promise: the picker's eligible set comes from the
// server-published `publicReviewPostures` on each provider, so an install with
// none of the vendors the old copy named still configures both stages.
describe('PipelineStageConfig — posture-driven eligibility', () => {
const profiledStages = [
STAGES[0],
{ ...STAGES[1], executionProfile: 'public-review-gate', providerId: '', model: '' },
{ ...STAGES[2], executionProfile: 'public-review-actions', providerId: '', model: '' },
];

const renderWith = (installProviders) => render(
<MemoryRouter>
<PipelineStageConfig
taskType="pr-reviewer"
config={{ taskMetadata: { pipeline: { stages: profiledStages } } }}
providers={installProviders}
onUpdate={vi.fn().mockResolvedValue(undefined)}
updating={false}
setUpdating={() => {}}
/>
</MemoryRouter>,
);

it('offers a grok-only install its own provider for BOTH stages', () => {
renderWith([
{ id: 'grok-cli', name: 'Grok', type: 'cli', command: 'grok', models: ['grok-4'], publicReviewPostures: ['no-tool', 'sandboxed-actions'] },
{ id: 'opencode', name: 'OpenCode', type: 'cli', command: 'opencode', models: ['x'], publicReviewPostures: [] },
]);
const providerSelects = screen.getAllByLabelText('Provider');
expect([...providerSelects[0].options].map((o) => o.value)).toEqual(['', 'grok-cli']);
expect([...providerSelects[1].options].map((o) => o.value)).toEqual(['', 'grok-cli']);
// A non-local provider's own catalog is selectable — the installed-local
// model list only applies where PortOS can probe capabilities.
expect(screen.getAllByText(/Eligible on this install: Grok/).length).toBe(2);
});

it('warns instead of silently offering nothing when a stage has no eligible provider', () => {
renderWith([
{ id: 'claude-ollama', name: 'Local Claude', type: 'cli', command: 'claude', endpoint: 'http://127.0.0.1:11434', models: ['safe-model'], publicReviewPostures: ['no-tool'] },
]);
expect(screen.getByText(/No enabled AI provider on this install can enforce the sandboxed-actions posture/)).toBeInTheDocument();
});
});
Loading