sync: update from private repo (edc775f2)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-07-06 01:04:12 +00:00
parent 747377bef9
commit b1292e34b2
322 changed files with 28001 additions and 4686 deletions
@@ -0,0 +1,77 @@
// @vitest-environment jsdom
/**
* Component tests for PythonPackagesPanel (per-space Python package UI).
*
* api + App.useAuthState are mocked so no real network and canManage=true.
*/
import '../../test/dom-setup';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import i18n from '../../i18n';
const { fetchMock, addMock, removeMock, fetchMembersMock } = vi.hoisted(() => ({
fetchMock: vi.fn(),
addMock: vi.fn(),
removeMock: vi.fn(),
fetchMembersMock: vi.fn(),
}));
vi.mock('../../api', () => ({
fetchSpacePythonPackages: fetchMock,
addSpacePythonPackage: addMock,
removeSpacePythonPackage: removeMock,
fetchSpaceMembers: fetchMembersMock,
}));
vi.mock('../../App', () => ({
useAuthState: () => ({ mode: 'disabled' as const }),
}));
import { PythonPackagesPanel } from './PythonPackagesPanel';
const ENABLED = {
enabled: true,
indexUrl: 'https://pypi.org/simple',
maxPackagesPerSpace: 30,
preflight: { ok: true },
packages: [{ name: 'requests', spec: 'requests==2.32.3', addedAt: '2026-07-02T00:00:00Z' }],
};
beforeEach(() => {
vi.clearAllMocks();
void i18n.changeLanguage('ja');
fetchMembersMock.mockResolvedValue([]);
fetchMock.mockResolvedValue(structuredClone(ENABLED));
addMock.mockResolvedValue({ packages: [] });
removeMock.mockResolvedValue({ packages: [] });
});
describe('PythonPackagesPanel', () => {
it('lists installed packages by their spec', async () => {
renderWithProviders(<PythonPackagesPanel spaceId="s1" />);
await waitFor(() => expect(screen.getByText('requests==2.32.3')).toBeInTheDocument());
});
it('submits a typed spec via addSpacePythonPackage', async () => {
renderWithProviders(<PythonPackagesPanel spaceId="s1" />);
await waitFor(() => expect(screen.getByTestId('python-add-input')).toBeInTheDocument());
await userEvent.type(screen.getByTestId('python-add-input'), 'httpx==0.27.0');
await userEvent.keyboard('{Enter}');
await waitFor(() => expect(addMock).toHaveBeenCalledWith('s1', 'httpx==0.27.0'));
});
it('removes a package via removeSpacePythonPackage', async () => {
renderWithProviders(<PythonPackagesPanel spaceId="s1" />);
await waitFor(() => expect(screen.getByText('requests==2.32.3')).toBeInTheDocument());
await userEvent.click(screen.getByText('削除'));
await waitFor(() => expect(removeMock).toHaveBeenCalledWith('s1', 'requests'));
});
it('shows a disabled notice and blocks the input when the feature is off', async () => {
fetchMock.mockResolvedValue({ ...structuredClone(ENABLED), enabled: false, preflight: { ok: false, reason: 'feature disabled' } });
renderWithProviders(<PythonPackagesPanel spaceId="s1" />);
await waitFor(() => expect(screen.getByTestId('python-add-input')).toBeDisabled());
});
});
@@ -0,0 +1,176 @@
/**
* PythonPackagesPanel.tsx — ワークスペースごとの Python パッケージ管理 UI
*
* admin/オーナーが wheel パッケージ名を直接入力して、そのワークスペース専用の
* オーバーレイに追加する。追加したパッケージは、そのワークスペースのエージェント
* だけが `import` できる(他ワークスペースには波及しない)。
*
* - GET/POST/DELETE /api/local/spaces/:id/python-packagescanManageSpace が編集)
* - インストールはサーバー側で out-of-band に実行(ネットワークは分離 bwrap のみ)。
* wheels のみ許可(sdist の任意コード実行を回避)。
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchSpacePythonPackages,
fetchSpaceMembers,
addSpacePythonPackage,
removeSpacePythonPackage,
} from '../../api';
import { useAuthState } from '../../App';
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
export function PythonPackagesPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
const { t } = useTranslation('spaces');
const auth = useAuthState();
const qc = useQueryClient();
const [input, setInput] = useState('');
const { data: members } = useQuery({
queryKey: ['space-members', spaceId],
queryFn: () => fetchSpaceMembers(spaceId),
staleTime: 30_000,
});
const ownerRow = (members ?? []).find(m => m.isOwner);
const canManage =
auth.mode === 'disabled' ||
(auth.mode === 'authenticated' &&
(auth.user.role === 'admin' || (!!ownerRow && ownerRow.userId === auth.user.id)));
const { data, isLoading, isError, error } = useQuery({
queryKey: ['space-python-packages', spaceId],
queryFn: () => fetchSpacePythonPackages(spaceId),
staleTime: 15_000,
});
const invalidate = () => qc.invalidateQueries({ queryKey: ['space-python-packages', spaceId] });
const addMut = useMutation({
mutationFn: (spec: string) => addSpacePythonPackage(spaceId, spec),
onSuccess: () => {
setInput('');
showToast?.(t('python.added'), 'success');
void invalidate();
},
onError: (e) => showToast?.(t('python.addFailed', { msg: errMsg(e) }), 'error'),
});
const removeMut = useMutation({
mutationFn: (name: string) => removeSpacePythonPackage(spaceId, name),
onSuccess: () => { showToast?.(t('python.removed'), 'success'); void invalidate(); },
onError: (e) => showToast?.(t('python.removeFailed', { msg: errMsg(e) }), 'error'),
});
const busy = addMut.isPending || removeMut.isPending;
const submit = () => {
const spec = input.trim();
if (!spec || busy) return;
addMut.mutate(spec);
};
if (isLoading) {
return (
<div className="h-full overflow-y-auto"><div className="max-w-2xl mx-auto px-6 py-8">
<div className="text-[13px] text-slate-400">{t('common:loading')}</div>
</div></div>
);
}
if (isError || !data) {
return (
<div className="h-full overflow-y-auto"><div className="max-w-2xl mx-auto px-6 py-8">
<div className="text-[13px] text-red-600">{t('python.fetchError', { msg: errMsg(error) })}</div>
</div></div>
);
}
const disabledFeature = !data.enabled;
const preflightBad = data.enabled && !data.preflight.ok;
return (
<div className="h-full overflow-y-auto" data-testid="space-python-packages">
<div className="max-w-2xl mx-auto px-6 py-8 space-y-6">
<div>
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('python.heading')}</h2>
<p className="text-[13px] text-slate-500 leading-relaxed">{t('python.intro')}</p>
</div>
{disabledFeature && (
<div className="rounded-md border border-hairline bg-surface/40 px-4 py-3 text-[13px] text-slate-500">
{t('python.disabled')}
</div>
)}
{preflightBad && (
<div className="rounded-md border border-amber-300 bg-amber-50/60 dark:bg-amber-900/10 px-4 py-3 text-[13px] text-amber-800 dark:text-amber-300">
{data.preflight.reason ?? t('python.preflightBad')}
</div>
)}
{/* 追加フォーム */}
<section>
<label className="block text-[11px] font-semibold uppercase tracking-wide text-slate-500 mb-2">
{t('python.addLabel')}
</label>
<div className="flex gap-2">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submit(); }}
disabled={!canManage || disabledFeature || busy}
placeholder="requests==2.32.3"
data-testid="python-add-input"
className="h-9 flex-1 rounded-md border border-hairline px-3 text-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring disabled:opacity-50"
/>
<button
type="button"
onClick={submit}
disabled={!canManage || disabledFeature || busy || !input.trim()}
className="px-4 py-1.5 rounded-md text-sm font-semibold bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{addMut.isPending ? t('python.installing') : t('python.add')}
</button>
</div>
<p className="text-2xs text-slate-500 leading-relaxed mt-1.5">{t('python.addHint')}</p>
</section>
{/* 一覧 */}
<section>
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-slate-500 mb-2">
{t('python.installedHeading')}
</h3>
{data.packages.length === 0 ? (
<p className="text-[13px] text-slate-400">{t('python.none')}</p>
) : (
<div className="rounded-md border border-hairline bg-surface/40 divide-y divide-hairline overflow-hidden">
{data.packages.map(pkg => (
<div key={pkg.name} className="flex items-center gap-3 px-3 py-2.5">
<code className="min-w-0 flex-1 truncate text-[13px] text-slate-900">{pkg.spec}</code>
<button
type="button"
onClick={() => removeMut.mutate(pkg.name)}
disabled={!canManage || busy}
className="text-xs text-red-600 hover:text-red-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{t('python.remove')}
</button>
</div>
))}
</div>
)}
</section>
{!canManage && (
<p className="text-[13px] text-slate-400">{t('python.readonly')}</p>
)}
</div>
</div>
);
}
+3 -3
View File
@@ -24,7 +24,7 @@ import {
fmtTimeBadge,
layoutWeekBars,
} from '../../lib/calendar';
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isSpreadsheetPreviewable, isPresentationPreviewable } from '../../lib/utils';
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isOfficePreviewable, officePreviewKind } from '../../lib/utils';
import { useIsMobile } from '../../hooks/useIsMobile';
import { FilePreview } from '../files/FilePreview';
import type { OfficePreviewDescriptor } from '../files/FilePreview';
@@ -313,8 +313,8 @@ function DayPanel({
const handlePreview = useCallback(async (filePath: string, name: string) => {
try {
if (isSpreadsheetPreviewable(name) || isPresentationPreviewable(name)) {
const kind = isSpreadsheetPreviewable(name) ? 'spreadsheet' : 'presentation';
if (isOfficePreviewable(name)) {
const kind = officePreviewKind(name);
setPreview({
name,
content: '',
+3 -3
View File
@@ -21,7 +21,7 @@ import { filterAndSortTasks, groupTasksByStatus, statusCounts, totalTaskCount }
import { filterTasksByScope, type TaskScope } from '../../lib/taskScope';
import { workspaceDirRole } from '../../lib/workspaceDirs';
import { FilterBar } from '../list/FilterBar';
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isSpreadsheetPreviewable, isPresentationPreviewable } from '../../lib/utils';
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isOfficePreviewable, officePreviewKind } from '../../lib/utils';
import type { OfficePreviewDescriptor } from '../files/FilePreview';
import { CreateTaskDialog } from '../create/CreateTaskDialog';
import { LocalTaskListItem } from '../list/TaskListItem';
@@ -1015,8 +1015,8 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
const handlePreview = useCallback(async (filePath: string, name: string) => {
try {
if (isSpreadsheetPreviewable(name) || isPresentationPreviewable(name)) {
const kind = isSpreadsheetPreviewable(name) ? 'spreadsheet' : 'presentation';
if (isOfficePreviewable(name)) {
const kind = officePreviewKind(name);
setPreview({
name,
content: '',
+4 -3
View File
@@ -22,6 +22,7 @@ import { SshConnectionsPanel } from '../userfolder/SshConnectionsPanel';
import { SpaceMembersPanel } from './SpaceMembersPanel';
import { SpaceBrowserPanel } from './SpaceBrowserPanel';
import { SpaceToolSettings } from './SpaceToolSettings';
import { PythonPackagesPanel } from './PythonPackagesPanel';
import { PieceEditor } from '../settings/PieceEditor';
import { usePieceList } from '../../hooks/usePieces';
import { splitPieces } from '../../lib/splitPieces';
@@ -30,7 +31,7 @@ import { useAuthState } from '../../App';
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members' | 'tools';
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members' | 'tools' | 'python';
const SECTIONS: { id: SettingsSection; labelKey: string; testid: string }[] = [
{ id: 'agents', labelKey: 'settings.nav.agents', testid: 'space-settings-nav-agents' },
@@ -41,6 +42,7 @@ const SECTIONS: { id: SettingsSection; labelKey: string; testid: string }[] = [
{ id: 'ssh', labelKey: 'settings.nav.ssh', testid: 'space-settings-nav-ssh' },
{ id: 'browser', labelKey: 'settings.nav.browser', testid: 'space-settings-nav-browser' },
{ id: 'tools', labelKey: 'settings.nav.tools', testid: 'space-settings-nav-tools' },
{ id: 'python', labelKey: 'settings.nav.python', testid: 'space-settings-nav-python' },
{ id: 'members', labelKey: 'settings.nav.members', testid: 'space-settings-nav-members' },
];
@@ -85,6 +87,7 @@ export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToa
{section === 'ssh' && <SshConnectionsPanel spaceId={spaceId} showToast={showToast} />}
{section === 'browser' && <SpaceBrowserPanel spaceId={spaceId} showToast={showToast} />}
{section === 'tools' && <SpaceToolSettings spaceId={spaceId} showToast={showToast} />}
{section === 'python' && <PythonPackagesPanel spaceId={spaceId} showToast={showToast} />}
{section === 'members' && <SpaceMembersPanel spaceId={spaceId} showToast={showToast} />}
</div>
</div>
@@ -119,10 +122,8 @@ function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?:
initial_movement: 'execute',
movements: [{
name: 'execute',
edit: true,
persona: 'worker',
instruction: '',
allowed_tools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'],
default_next: 'COMPLETE',
rules: [{ condition: '完了', next: 'COMPLETE' }],
}],