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

This commit is contained in:
oss-sync
2026-06-29 01:32:25 +00:00
parent b857c33ef6
commit a6d1879d63
75 changed files with 3874 additions and 334 deletions
+7
View File
@@ -1727,6 +1727,13 @@ export interface WorkerStatusRow {
online?: boolean;
/** Per-backend rows for proxy workers (Phase 3c + dashboard tree). */
backends?: WorkerStatusBackendRow[];
/**
* Occupants of this worker — users whose running jobs use it, each with
* the job kind (`task_kind`: 'agent' = normal, 'reflection' = learning).
* Admin-only — the server only populates this for admins, so non-admin
* clients always receive undefined.
*/
occupants?: Array<{ user: string; kind: string }>;
}
export async function fetchWorkerStatuses(): Promise<WorkerStatusRow[]> {
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { WorkerStatusRow } from '../../api';
// react-i18next: pass keys through (we only assert on the dynamic data).
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Pet hooks are irrelevant here — stub to the "no pet" path so the dot renders.
vi.mock('../../hooks/useActivePet', () => ({ useActivePet: () => ({ data: null }) }));
vi.mock('../../hooks/usePetFrameAnalysis', () => ({ usePetFrameAnalysis: () => 0 }));
const workersMock = vi.fn();
vi.mock('../../hooks/useWorkerStatus', () => ({
useWorkerStatus: () => workersMock(),
}));
import { WorkerStatusWidget } from './WorkerStatusWidget';
function row(over: Partial<WorkerStatusRow>): WorkerStatusRow {
return { id: 'w1', name: 'w1', roles: ['task'], state: 'running', proxy: false, ...over };
}
describe('WorkerStatusWidget — occupants (admin)', () => {
beforeEach(() => workersMock.mockReset());
it('renders occupant names, with a kind badge only for non-agent jobs', () => {
workersMock.mockReturnValue({
workers: [row({ occupants: [{ user: 'Alice', kind: 'agent' }, { user: 'Bob', kind: 'reflection' }] })],
isLoading: false,
isError: false,
});
render(<WorkerStatusWidget />);
const el = screen.getByTestId('worker-users-w1');
expect(el.textContent).toContain('Alice');
expect(el.textContent).toContain('Bob');
// The reflection job surfaces a kind badge; the normal 'agent' job does not.
const badge = screen.getByTestId('worker-kind-w1');
expect(badge.textContent).toBe('reflection');
expect(el.textContent).not.toContain('agent');
});
it('renders no occupant line when occupants is absent (non-admin payload)', () => {
workersMock.mockReturnValue({
workers: [row({ occupants: undefined })],
isLoading: false,
isError: false,
});
render(<WorkerStatusWidget />);
expect(screen.queryByTestId('worker-users-w1')).toBeNull();
});
});
Binary file not shown.
@@ -10,6 +10,7 @@
import '../../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../../test/render-helpers';
import '../../../i18n';
import type { LocalTask, LatestReflectionForTask } from '../../../api';
@@ -22,10 +23,12 @@ vi.mock('../../../api', async (importOriginal) => {
...actual,
getLatestReflectionForTask: vi.fn().mockResolvedValue(null),
fetchDelegateRuns: vi.fn().mockResolvedValue([]),
putFeedback: vi.fn(),
};
});
const mockedReflection = vi.mocked(api.getLatestReflectionForTask);
const mockedPutFeedback = vi.mocked(api.putFeedback);
function makeTask(overrides: Partial<LocalTask> = {}): LocalTask {
return {
@@ -93,4 +96,46 @@ describe('OverviewTab', () => {
renderWithProviders(<OverviewTab task={makeTask()} />);
expect(await screen.findByText(/Learned 3 things/)).toBeInTheDocument();
});
describe('feedback tags (i18n)', () => {
it('localizes a previously-saved tag for display (EN), hiding the stored JA value', async () => {
mockedReflection.mockResolvedValue(null);
renderWithProviders(<OverviewTab task={makeTask({
latestJob: { id: 'j', status: 'succeeded' },
feedbackRating: 'good',
feedbackTags: ['出力の精度が高い'],
})} />);
// The stored canonical JA string is shown localized; the raw JA is gone.
expect(screen.getByText('Accurate output')).toBeInTheDocument();
expect(screen.queryByText('出力の精度が高い')).not.toBeInTheDocument();
});
it('falls back to the raw value for an unknown/legacy stored tag', async () => {
mockedReflection.mockResolvedValue(null);
renderWithProviders(<OverviewTab task={makeTask({
latestJob: { id: 'j', status: 'succeeded' },
feedbackRating: 'good',
feedbackTags: ['レガシータグ'],
})} />);
expect(screen.getByText('レガシータグ')).toBeInTheDocument();
});
it('persists the canonical JA value (not the localized label) when a tag is picked', async () => {
mockedReflection.mockResolvedValue(null);
mockedPutFeedback.mockResolvedValue(makeTask());
const user = userEvent.setup();
renderWithProviders(<OverviewTab task={makeTask({ latestJob: { id: 'j', status: 'succeeded' } })} />);
await user.click(screen.getByRole('button', { name: /Good/ }));
// The tag picker shows the localized (EN) label...
await user.click(screen.getByRole('button', { name: 'Accurate output' }));
await user.click(screen.getByRole('button', { name: 'Submit' }));
await waitFor(() => expect(mockedPutFeedback).toHaveBeenCalledTimes(1));
// ...but the stored value stays the canonical JA string (backend/reflection contract).
const [, body] = mockedPutFeedback.mock.calls[0];
expect(body.tags).toEqual(['出力の精度が高い']);
expect(body.rating).toBe('good');
});
});
});
+32 -8
View File
@@ -9,11 +9,35 @@ import { ContextUsageGauge } from '../ContextUsageGauge';
import { ReflectionBadge } from '../ReflectionBadge';
import { showTitleEdit, showFeedback, showMissionEdit } from '../detail-readonly';
const GOOD_TAGS = ['出力の精度が高い', 'フォーマットが適切', '指示をよく理解していた', '速度が適切だった'];
const BAD_TAGS = ['出力の精度が低い', 'フォーマットが不適切', '指示と違う結果になった', '不要な作業をしていた', '途中で止まった / ASKが多すぎた'];
// Feedback tags. `value` is the canonical string that gets persisted (and fed
// to the reflection LLM prompt) — it stays in Japanese so existing stored rows
// and the backend are untouched. `key` is the i18n key used purely for display,
// so the picker and the read-back chips localize with the UI language.
const GOOD_TAGS = [
{ value: '出力の精度が高い', key: 'feedback.tags.goodAccuracy' },
{ value: 'フォーマットが適切', key: 'feedback.tags.goodFormat' },
{ value: '指示をよく理解していた', key: 'feedback.tags.goodUnderstanding' },
{ value: '速度が適切だった', key: 'feedback.tags.goodSpeed' },
];
const BAD_TAGS = [
{ value: '出力の精度が低い', key: 'feedback.tags.badAccuracy' },
{ value: 'フォーマットが不適切', key: 'feedback.tags.badFormat' },
{ value: '指示と違う結果になった', key: 'feedback.tags.badMismatch' },
{ value: '不要な作業をしていた', key: 'feedback.tags.badUnnecessary' },
{ value: '途中で止まった / ASKが多すぎた', key: 'feedback.tags.badStalled' },
];
// value -> i18n key, for localizing previously-saved tags on read-back. Unknown
// values (e.g. older/reworded tags) fall back to the stored string verbatim.
const TAG_KEY = new Map<string, string>([...GOOD_TAGS, ...BAD_TAGS].map(t => [t.value, t.key]));
function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?: boolean }) {
const { t } = useTranslation('detail');
// Localize a stored tag value for display; fall back to the raw value if it's
// not one of the known tags (keeps old/custom strings visible).
const tagLabel = (value: string) => {
const key = TAG_KEY.get(value);
return key ? t(key) : value;
};
const qc = useQueryClient();
const isComplete = task.latestJob?.status === 'succeeded' || task.latestJob?.status === 'failed';
const hasFeedback = !!task.feedbackRating;
@@ -49,7 +73,7 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
{task.feedbackTags && task.feedbackTags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{task.feedbackTags.map(tag => (
<span key={tag} className="px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{tag}</span>
<span key={tag} className="px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{tagLabel(tag)}</span>
))}
</div>
)}
@@ -92,7 +116,7 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
{task.feedbackTags && task.feedbackTags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{task.feedbackTags.map(tag => (
<span key={tag} className="px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{tag}</span>
<span key={tag} className="px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{tagLabel(tag)}</span>
))}
</div>
)}
@@ -130,15 +154,15 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
<div className="flex flex-wrap gap-1.5 mb-3">
{tags.map(tag => (
<button
key={tag}
onClick={() => toggleTag(tag)}
key={tag.value}
onClick={() => toggleTag(tag.value)}
className={`px-2 py-0.5 rounded-full text-2xs border transition-colors ${
selectedTags.includes(tag)
selectedTags.includes(tag.value)
? 'bg-accent-soft border-accent text-accent'
: 'border-slate-200 text-slate-500 hover:border-slate-300'
}`}
>
{tag}
{t(tag.key)}
</button>
))}
</div>
@@ -0,0 +1,349 @@
import { useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useSpaces } from '../../hooks/useSpaces';
// ── API types ─────────────────────────────────────────────────────────────
interface Candidate {
type: 'mcp' | 'ssh';
id: string;
name: string;
detail: string;
willSkip: boolean;
skipReason?: string;
}
interface CandidatesResponse {
mcp: Candidate[];
ssh: Candidate[];
}
interface TransferResult {
copied: { type: string; id: string; name: string }[];
skipped: { type: string; name: string; reason: string }[];
}
// ── API helpers ───────────────────────────────────────────────────────────
async function fetchCandidates(
targetSpaceId: string,
sourceId: string,
): Promise<CandidatesResponse> {
const res = await fetch(
`/api/local/spaces/${encodeURIComponent(targetSpaceId)}/transfer/candidates?sourceId=${encodeURIComponent(sourceId)}`,
{ credentials: 'include' },
);
if (res.status === 403) {
const err = new Error('403');
err.name = 'ForbiddenError';
throw err;
}
if (!res.ok) throw new Error(`Failed to fetch candidates: ${res.status}`);
return (await res.json()) as CandidatesResponse;
}
async function postTransfer(
targetSpaceId: string,
body: { sourceId: string; mcpServerIds: string[]; sshConnectionIds: string[] },
): Promise<TransferResult> {
const res = await fetch(
`/api/local/spaces/${encodeURIComponent(targetSpaceId)}/transfer`,
{
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
if (!res.ok) {
const text = await res.text().catch(() => '');
let msg = `HTTP ${res.status}`;
try { const j = JSON.parse(text); if (j?.error) msg = j.error; } catch { /* ignore */ }
throw new Error(msg);
}
return (await res.json()) as TransferResult;
}
// ── Props ─────────────────────────────────────────────────────────────────
interface ImportFromWorkspaceDialogProps {
targetSpaceId: string;
kind: 'mcp' | 'ssh';
open: boolean;
onClose(): void;
onImported(result: TransferResult): void;
}
// ── Component ─────────────────────────────────────────────────────────────
export function ImportFromWorkspaceDialog({
targetSpaceId,
kind,
open,
onClose,
onImported,
}: ImportFromWorkspaceDialogProps) {
const { data: allSpaces, isLoading: spacesLoading } = useSpaces();
const [sourceId, setSourceId] = useState<string>('');
const [checked, setChecked] = useState<Set<string>>(new Set());
const [transferError, setTransferError] = useState<string | null>(null);
// Exclude the target space from the source options.
const sourceOptions = (allSpaces ?? []).filter(s => s.id !== targetSpaceId && s.status !== 'archived');
// Fetch candidates whenever a source is selected.
const {
data: candidates,
isLoading: candidatesLoading,
error: candidatesError,
} = useQuery({
queryKey: ['transfer-candidates', targetSpaceId, sourceId],
queryFn: () => fetchCandidates(targetSpaceId, sourceId),
enabled: !!sourceId,
staleTime: 15_000,
});
const isForbidden =
candidatesError instanceof Error && candidatesError.name === 'ForbiddenError';
// The list to display based on kind.
const items: Candidate[] = candidates ? candidates[kind] : [];
const transferMut = useMutation({
mutationFn: (ids: string[]) => {
const body =
kind === 'mcp'
? { sourceId, mcpServerIds: ids, sshConnectionIds: [] }
: { sourceId, mcpServerIds: [], sshConnectionIds: ids };
return postTransfer(targetSpaceId, body);
},
onSuccess: (result) => {
onImported(result);
onClose();
},
onError: (err) => {
setTransferError(err instanceof Error ? err.message : String(err));
},
});
const handleToggle = (id: string) => {
setChecked(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSourceChange = (id: string) => {
setSourceId(id);
setChecked(new Set());
setTransferError(null);
};
const handleImport = () => {
setTransferError(null);
transferMut.mutate(Array.from(checked));
};
// Reset state when dialog opens/closes.
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
setSourceId('');
setChecked(new Set());
setTransferError(null);
onClose();
}
};
const checkedCount = checked.size;
const canImport = checkedCount > 0 && !transferMut.isPending;
const kindLabel = kind === 'mcp' ? 'MCP サーバー' : 'SSH 接続';
return (
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-slate-900/50 z-30" />
<Dialog.Content
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-surface rounded-2xl shadow-2xl w-full overflow-auto z-40 focus:outline-none"
style={{ maxWidth: 'min(520px, 94vw)', maxHeight: '88dvh' }}
>
<div className="p-5">
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-5">
<div>
<Dialog.Title className="text-xl font-extrabold text-slate-900 m-0">
</Dialog.Title>
<Dialog.Description className="mt-1 text-[13px] text-slate-500">
{kindLabel}
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
aria-label="閉じる"
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
>
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</Dialog.Close>
</div>
{/* Source workspace selector */}
<div className="flex flex-col gap-1 mb-4">
<label className="text-xs font-semibold text-slate-600">
</label>
{spacesLoading ? (
<div className="text-xs text-slate-400"></div>
) : sourceOptions.length === 0 ? (
<div className="text-xs text-slate-400">
</div>
) : (
<select
value={sourceId}
onChange={e => handleSourceChange(e.target.value)}
className="rounded-md border border-hairline bg-canvas px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-accent-ring"
>
<option value="">-- --</option>
{sourceOptions.map(s => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
)}
</div>
{/* Candidates list */}
{sourceId && (
<div className="mb-4">
{candidatesLoading && (
<div className="text-xs text-slate-400 py-3"></div>
)}
{isForbidden && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
</div>
)}
{candidatesError && !isForbidden && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
: {(candidatesError as Error).message}
</div>
)}
{!candidatesLoading && !candidatesError && items.length === 0 && (
<div className="text-xs text-slate-400 py-3 text-center">
{kindLabel}
</div>
)}
{!candidatesLoading && !candidatesError && items.length > 0 && (
<div>
<div className="text-xs font-semibold text-slate-600 mb-2">{kindLabel}</div>
<ul className="divide-y divide-hairline border border-hairline rounded-md overflow-hidden">
{items.map(item => (
<li
key={item.id}
className={`flex items-start gap-3 px-3 py-2.5 ${
item.willSkip ? 'opacity-60 bg-slate-50' : 'bg-canvas'
}`}
>
<input
type="checkbox"
id={`candidate-${item.id}`}
checked={checked.has(item.id)}
onChange={() => handleToggle(item.id)}
disabled={item.willSkip}
className="mt-0.5 h-4 w-4 flex-shrink-0 rounded border-hairline text-accent focus:ring-accent-ring disabled:cursor-not-allowed"
/>
<label
htmlFor={`candidate-${item.id}`}
className={`flex-1 min-w-0 ${item.willSkip ? 'cursor-not-allowed' : 'cursor-pointer'}`}
>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-medium text-slate-800">
{item.name}
</span>
{item.willSkip && (
<span
title={item.skipReason ?? 'すでに存在します'}
className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-700 leading-none cursor-help"
>
</span>
)}
</div>
<div className="text-2xs text-slate-500 mt-0.5 font-mono truncate">
{item.detail}
</div>
</label>
</li>
))}
</ul>
{/* Select all / deselect */}
{items.some(i => !i.willSkip) && (
<div className="mt-1.5 flex gap-3">
<button
type="button"
onClick={() => setChecked(new Set(items.filter(i => !i.willSkip).map(i => i.id)))}
className="text-2xs text-accent hover:underline"
>
</button>
<button
type="button"
onClick={() => setChecked(new Set())}
className="text-2xs text-slate-500 hover:underline"
>
</button>
</div>
)}
</div>
)}
</div>
)}
{/* Transfer error */}
{transferError && (
<div className="mb-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
: {transferError}
</div>
)}
{/* Footer buttons */}
<div className="flex justify-end gap-2 pt-1">
<Dialog.Close asChild>
<button
type="button"
className="rounded-md border border-hairline px-4 py-2 text-sm font-semibold text-slate-700 transition-colors hover:bg-surface-2"
>
</button>
</Dialog.Close>
<button
type="button"
onClick={handleImport}
disabled={!canImport}
className="rounded-md bg-accent px-4 py-2 text-sm font-bold text-accent-fg transition-colors hover:opacity-90 disabled:opacity-50"
>
{transferMut.isPending
? '取り込み中…'
: `取り込む${checkedCount > 0 ? ` (${checkedCount})` : ''}`}
</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
+26 -8
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSpaces, useArchiveSpace } from '../../hooks/useSpaces';
@@ -425,21 +425,39 @@ function SpaceChat({
const qc = useQueryClient();
const auth = useAuthState();
const { data: allTasks } = useLocalTaskList();
const { data: spaces } = useSpaces();
const [showCreate, setShowCreate] = useState(false);
const { search: searchQuery, status: selectedStatus, sort: sortMode, scope } = filter;
const setScope = (val: TaskScope) => onFilterChange({ scope: val });
const setSearchQuery = (val: string) => onFilterChange({ search: val });
const setSelectedStatus = (val: 'all' | StatusColumn) => onFilterChange({ status: val });
const setSortMode = (val: SortMode) => onFilterChange({ sort: val });
const spaceTasks = filterTasksForSpace(allTasks ?? [], spaceId, isPersonalSpace);
// 自分/他メンバーの切替。共有ワークスペースで「他の人のタスク」が存在するときだけ
// 出す(個人ワークスペースや単独利用では意味がないので隠す)。スコープ分割は Tasks
// ページと同じ filterTasksByScope を再利用する(owner_id null は others 側、という
// 既存規約に合わせる)。フィルタ状態は URL に永続化されるため、スペース切替時は App の
// onSelectSpace が search/status/sort/scope を明示リセットする(remount 依存ではない)。
const userId = auth.mode === 'authenticated' ? auth.user.id : null;
const hasOthersTasks = userId != null && spaceTasks.some(t => t.ownerId !== userId);
// 認証無効 (no-auth 単独利用) は admin 同等に扱う(App.tsx の isAdmin と同じ規約)。
const isAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true;
// case スペースの id 集合。個人バケツ判定(null か case でない id = 誰かの個人
// スペース)に使う。admin の listSpaces は全 case スペースを返すので集合は完全。
const caseSpaceIds = useMemo(
() => new Set((spaces ?? []).filter(s => s.kind === 'case').map(s => s.id)),
[spaces],
);
let spaceTasks = filterTasksForSpace(allTasks ?? [], spaceId, isPersonalSpace, caseSpaceIds);
// 可視性ルール: 個人ワークスペースは本人専用。admin だけが他ユーザーの個人コンテンツを
// 「他のメンバー」タブで監視できる。非 admin の個人スペースは自分の所有分に絞り、他人を
// 一切出さない(リスト API が public/org 等を返しても個人 WS には混ぜない)。
if (isPersonalSpace && !isAdmin && userId != null) {
spaceTasks = spaceTasks.filter(t => t.ownerId === userId);
}
// 自分/他メンバーの切替。共有ワークスペースのメンバー間、または個人スペースを admin が
// 監視するときに「他の人のタスク」が存在する場合だけ出す。スコープ分割は Tasks ページと
// 同じ filterTasksByScope を再利用する(owner_id null は others 側、という既存規約に合わ
// せる)。フィルタ状態は URL に永続化されるため、スペース切替時は App の onSelectSpace が
// search/status/sort/scope を明示リセットする(remount 依存ではない)。
const othersAllowed = !isPersonalSpace || isAdmin;
const hasOthersTasks = othersAllowed && userId != null && spaceTasks.some(t => t.ownerId !== userId);
const tasks = userId != null && hasOthersTasks
? filterTasksByScope(spaceTasks, scope, userId)
: spaceTasks;
+1 -1
View File
@@ -95,7 +95,7 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
space={s}
active={s.id === selectedId}
onSelect={onSelect}
runningCount={countRunningTasksForSpace(tasks ?? [], s)}
runningCount={countRunningTasksForSpace(tasks ?? [], s, myUserId)}
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
/>
))}
@@ -28,6 +28,7 @@ const SENSITIVE_NOTE_KEYS: Record<string, string> = {
ssh: 'tools.sensitiveNote.ssh',
browser: 'tools.sensitiveNote.browser',
Bash: 'tools.sensitiveNote.Bash',
SpawnSubTask: 'tools.sensitiveNote.SpawnSubTask',
};
function errMsg(e: unknown): string {
+33 -5
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuthState } from '../../App';
import { ImportFromWorkspaceDialog } from '../spaces/ImportFromWorkspaceDialog';
interface ServerPublic {
id: string;
@@ -296,6 +297,7 @@ export function McpPanel({ spaceId, showToast }: { spaceId?: string; showToast?:
const [editingId, setEditingId] = useState<string | null>(null);
const [addingSection, setAddingSection] = useState<'global' | 'personal' | null>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const invalidateAll = () => {
qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] });
@@ -423,11 +425,22 @@ export function McpPanel({ spaceId, showToast }: { spaceId?: string; showToast?:
return (
<div className="h-full overflow-y-auto" data-testid={inSpace ? 'space-mcp-panel' : undefined}>
<div className="max-w-3xl mx-auto px-6 py-8 space-y-8">
<div>
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('mcp.title')}</h2>
<p className="text-[13px] text-slate-500 leading-relaxed">
{t('mcp.intro')}
</p>
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('mcp.title')}</h2>
<p className="text-[13px] text-slate-500 leading-relaxed">
{t('mcp.intro')}
</p>
</div>
{inSpace && (
<button
type="button"
onClick={() => setImportDialogOpen(true)}
className="flex-shrink-0 px-3 py-1.5 rounded-md text-xs font-semibold text-slate-600 border border-hairline hover:bg-surface transition-colors"
>
WSから取り込む
</button>
)}
</div>
{isLoading && <div className="text-[13px] text-slate-400">{t('mcp.loading')}</div>}
@@ -493,6 +506,21 @@ export function McpPanel({ spaceId, showToast }: { spaceId?: string; showToast?:
)}
</div>
</div>
{inSpace && importDialogOpen && (
<ImportFromWorkspaceDialog
targetSpaceId={spaceId!}
kind="mcp"
open={importDialogOpen}
onClose={() => setImportDialogOpen(false)}
onImported={(result) => {
qc.invalidateQueries({ queryKey: ['mcp-user-servers', spaceId ?? null] });
const n = result.copied.length;
const m = result.skipped.length;
showToast?.(`${n}件コピー、${m}件スキップ`, 'success');
}}
/>
)}
</div>
);
}
@@ -5,6 +5,7 @@ import type { SshConnection, TestResponse } from '../../lib/ssh-types';
import { SshConnectionForm } from './SshConnectionForm';
import { SshHostKeyDialog } from './SshHostKeyDialog';
import { SshPublicKeyDialog } from './SshPublicKeyDialog';
import { ImportFromWorkspaceDialog } from '../spaces/ImportFromWorkspaceDialog';
interface ConnectionsResponse {
connections: SshConnection[];
@@ -143,6 +144,7 @@ export function SshConnectionsPanel({ spaceId, showToast }: SshConnectionsPanelP
});
const [creating, setCreating] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [testResult, setTestResult] = useState<{ id: string; test: TestResponse; replaceMode: boolean } | null>(null);
const [pubKeyDialog, setPubKeyDialog] = useState<{
@@ -256,14 +258,25 @@ export function SshConnectionsPanel({ spaceId, showToast }: SshConnectionsPanelP
{t('ssh.intro')}
</p>
</div>
<button
type="button"
onClick={() => { setCreating(true); setEditingId(null); }}
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep"
disabled={creating}
>
{t('ssh.create')}
</button>
<div className="flex items-center gap-2">
{spaceId && (
<button
type="button"
onClick={() => setImportDialogOpen(true)}
className="px-3 h-7 text-xs font-semibold text-slate-600 border border-hairline rounded-md hover:bg-surface transition-colors"
>
WSから取り込む
</button>
)}
<button
type="button"
onClick={() => { setCreating(true); setEditingId(null); }}
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep"
disabled={creating}
>
{t('ssh.create')}
</button>
</div>
</div>
{isLoading && <div className="text-xs text-slate-400">{t('common.loading')}</div>}
@@ -358,6 +371,21 @@ export function SshConnectionsPanel({ spaceId, showToast }: SshConnectionsPanelP
onClose={() => setPubKeyDialog(null)}
/>
)}
{spaceId && importDialogOpen && (
<ImportFromWorkspaceDialog
targetSpaceId={spaceId}
kind="ssh"
open={importDialogOpen}
onClose={() => setImportDialogOpen(false)}
onImported={(result) => {
qc.invalidateQueries({ queryKey: ['ssh', 'connections', spaceId ?? null] });
const n = result.copied.length;
const m = result.skipped.length;
showToast?.(`${n}件コピー、${m}件スキップ`, 'success');
}}
/>
)}
</div>
);
}
+52
View File
@@ -12,6 +12,58 @@ MAESTRO に入った、ユーザーに関係する主な変更を新しい順に
> 機能に変更があるたび、このページを更新していきます。日付は変更が本番に入ったおおよその時期です。
## 2026-06-29 — ワーカー / GPU の「使用中」に種別バッジを追加(管理者のみ)
ワーカー一覧の「使用中: …」表示に、いま動いているジョブの**種別バッジ**が付きました。学習(reflection)など通常タスク以外で GPU が埋まっているときに、ユーザー名のとなりに `reflection` のようなタグが出ます。通常タスク(agent)にはバッジを付けないので、「reflection で埋まっているだけ」を一目で見分けられます。これも管理者にだけ見えます(→[管理者向け](./19-admin.md))。
## 2026-06-29 — 個人ワークスペースの公開範囲を整理(管理者は全ユーザーを監視可能に)
個人ワークスペースは作成した本人だけのものになりました。一般ユーザーには、共有されていない他人のチャットが個人ワークスペースに混ざって見えることはありません。一方で**管理者(admin)は、個人ワークスペースの「他のメンバー」タブから他ユーザーの個人ワークスペースの内容も確認できる**ようになりました(全ユーザーの監視のため)。これまでは一部のチャットだけが見えたり、逆に見えるべきものが見えなかったりと不整合がありました(→[ワークスペース](./21-workspaces.md))。
## 2026-06-29 — ワーカー / GPU に「使用中のユーザー」を表示(管理者のみ)
サイドパネルの **ワーカー** 一覧で、実行中のワーカー(GPU)に、いまそのワーカーを占有しているユーザー名が表示されるようになりました。「誰のタスクで GPU が埋まっているのか」を管理者がひと目で把握できます。**この表示は管理者にだけ見えます**。一般ユーザーには従来どおり、稼働状況(実行中/待機)と空きスロットだけが見え、ユーザー名は出ません(→[管理者向け](./19-admin.md))。
## 2026-06-29 — ワークスペース間で MCP 接続・SSH 接続をコピーできるように
ワークスペースの **設定 → MCP**(または **設定 → SSH**)に「他のワークスペースから取り込む」ボタンが追加されました。自分が管理している別のワークスペースから、登録済みの MCP サーバーや SSH 接続をまとめてコピーできます。同名・同じ接続先はスキップされるので、既存の設定が上書きされる心配はありません。なお MCP の OAuth 認証(per-user トークン)は移せないため、取り込み後に移し先で再連携が必要です。SSH の秘密鍵は移し先の鍵で再暗号化されてそのまま使えます(→[MCP サーバー連携](./13-mcp.md)・[SSH リモート操作](./14-ssh.md))。
## 2026-06-27 — SSH がワークスペース設定で使えるように
ワークスペースの **設定 → ツール**`ssh` カテゴリをオンにし、**設定 → SSH** に接続を登録するだけで、そのワークスペースのタスクから SSH ツール(SshExec / SshUpload / SshDownload / SshConsole 系)が使えるようになりました。以前は piece の YAML に `allowed_ssh_connections` を個別に書く必要があり、汎用ピースでは SSH が実質使えませんでした。スコープはワークスペース単位で厳密に分離されており、別ワークスペースに登録した接続には届きません。
## 2026-06-26 — リサーチ等のサブタスクでも保存済みブラウザセッションを使うように
ログイン状態を保存したブラウザセッションをチャットに紐づけても、エージェントが調査をサブタスク(delegate)に委譲したとき、その中の BrowseWeb が保存セッションを引き継がず未ログインのままアクセスしてしまう不具合を修正しました。委譲先のサブタスクや、質問への回答後に再開したジョブでも、親に紐づけたログインセッションがそのまま使われます。リサーチが委譲経由で動くようになって以降、ログインが必要なサイトの取得に影響していた問題です。
## 2026-06-26 — フィードバックの評価タグも英語表示に対応
タスクの良かった/改善点フィードバックで選ぶ評価タグ(「出力の精度が高い」など)が、これまで日本語固定でした。表示言語が English のときは英語で表示されるようにしました。過去に登録済みのフィードバックも、保存内容はそのままに表示だけ言語に追従します。
## 2026-06-26 — 調査タスクの分割を直列 delegate に切り替え
調査・分析・ブレインストーミング系のタスクで、サブ調査を **1 件ずつ順番に(直列 delegate)** 進めるようになりました。並列で同時に走らせる方式から既定を変更しています。
- **品質が揃いやすく、軽い**: 各サブ調査が綺麗なコンテキストで動くので結果のばらつきが減り、GPU・実行枠の消費も抑えられます。並列に比べると壁時計時間は少し長くなります。
- **並列サブタスク(SpawnSubTask)は任意有効化に**: 別ジョブで同時並列に走らせる SpawnSubTask は、**デフォルト無効**になりました。本当に並列が必要なワークスペースだけ、**設定 → ツール** タブで有効にしてください(Bash などと同じ「センシティブ/任意有効化」の扱い)。ふだんの分割は delegate で完結するため、有効化は不要です。
詳しくは「[サブ実行・並列実行](./10-subtasks.md)」を参照してください。
## 2026-06-26 — 並列サブタスクの待機と安全策を強化
大きな仕事を複数のサブタスクに分けて並列実行するとき、結果の取りこぼしや裏で動き続ける処理が起きにくくなりました。
- **完了をきちんと待つ**: サブタスクを起動したあと、エージェントが全部の完了を待ってから次に進むようになりました。待っている間はタスクがいったん停止して実行枠を手放すので、他の仕事を詰まらせません。
- **待ち忘れの防止**: 子がまだ走っている最中に「完了」しようとしても、結果を待たずに終わってしまうことはなくなりました(自動で待ってから続けます)。
- **止めたら子も止まる**: タスクを失敗・キャンセル・リトライしたとき、まだ走っているサブタスクも一緒に止まります。親が消えたのに子だけが裏で GPU を使い続ける、ということがなくなりました(ユーザーの回答待ちで止まっている間は、あとで再開するため子は残します)。
- **起動を少しずつ**: 一度にたくさんのサブタスクを起動しても、実行枠を一気に奪わないよう少しずつ順番待ちに並べます。
詳しくは「[サブ実行・並列実行](./10-subtasks.md)」を参照してください。
## 2026-06-26 — Web 検索の不正な呼び出しでタスクが落ちる不具合修正
エージェントが検索文字列を付けずに Web 検索ツールを呼んでしまったとき、これまではタスク全体がエラーで停止していました。今後はその検索だけをエラーとして扱い、エージェントが続行できるようにしました。出力が不安定なモデルを使っているときに起きやすかった不具合です。
## 2026-06-25 — 最近追加した画面が英語表示に対応
表示言語を English に切り替えたとき、新しめの画面が日本語のまま残っていました。ワークスペース(一覧・詳細・メンバー・カレンダー・ツール設定・アプリ)、ファイル操作(ツールバー・並べ替え・移動ダイアログ・プレビュー)、新規作成時のワークスペース選択などを英語化し、言語設定に合わせて切り替わるようにしました。言語は **設定 → 環境設定** で選べます。
+8
View File
@@ -68,6 +68,14 @@ MAESTRO に頼める代表的な仕事です。
「ユーザー」「CAPTCHA」タブは管理者にだけ表示されます。
### コマンドパレット(⌘K / Ctrl+K)
`⌘K`Windows / Linux は `Ctrl+K`)で **コマンドパレット** が開きます。画面を横断して、ワークスペースやタスクへのジャンプ・主要アクションの実行を、キーボードだけで検索して呼び出せます。入力で候補を絞り込み、上下キーで選んで Enter で実行します。マウスでタブを辿らずに目的の場所へ飛びたいときに便利です。
### 外観(ライト / ダーク)
画面右上のトグルで、外観を **ライト / ダーク / システム追従** に切り替えられます。選択はこの端末に記憶されます。
## はじめての一歩
1. **タスクを作る** — 依頼内容を入力して実行します。書き方のコツは [タスクを作って実行する](./02-tasks.md) を参照
+4
View File
@@ -27,6 +27,10 @@ keywords: [タスク作成, piece選択, 添付, 詳細設定, 可視性, ask po
依頼が曖昧だと、エージェントが確認を返して `waiting_human` で一時停止することがあります。
### プロンプトコーチ(依頼文の採点・改善)
作成ダイアログには、書いた依頼文をその場で評価してくれる **プロンプトコーチ** があります。実行前に依頼文を採点し、何が足りないか(出力形式・観点・制約の不足など)を指摘したうえで、改善した文案を提示します。提案をそのまま入力欄に反映できるので、曖昧な依頼で `waiting_human` に止まる前に、依頼の質を上げられます。入力欄の近くに出る一般的な書き方のヒント(Tips)とは別の、あなたの依頼文に対する個別フィードバックです。
### 添付ファイル
ダイアログのドロップゾーンにファイルをドラッグ&ドロップ、またはクリックで選択して添付できます。添付したファイルはワークスペースの `input/` に保存され、エージェントが読み込めます。依頼文で「input のファイルを読んで」と明示すると確実です。
+6
View File
@@ -24,6 +24,10 @@ keywords: [チャット, ストリーミング, ツールコール, 割り込み
チャットが「会話の見え方」だとすれば、進捗タブは「作業ログの見え方」です。
### コンテキスト残量ゲージ
概要タブと入力欄の上に、いまの会話が使っているコンテキスト量を示す **残量ゲージ** が出ます。上限に近づくと色が変わり、限界が近いことが分かります。ゲージが詰まってくると、エージェントは古いやり取りを自動で要約して空きを作りながら作業を続けます(→[結果を受け取る](./04-results.md)・[トラブルシューティング](./08-troubleshooting.md))。
## 実行中に指示を追加する(割り込み / interjection
エージェントが動いている最中でも、メッセージを送って指示を追加できます。これを **割り込み(interjection** と呼びます。
@@ -50,6 +54,8 @@ keywords: [チャット, ストリーミング, ツールコール, 割り込み
このため、関連する作業は新しいタスクを作り直すより、同じチャットで続けて頼むほうがスムーズです。やり取りの全文は各ワークスペースの `logs/transcript.jsonl` に記録されます。
**別のタスクタイプ(piece)で続ける**こともできます。タスク詳細の「別の piece で続ける」ボタンから piece を選び直すと、これまでの会話・コメントを引き継いだまま、別のやり方(例: チャットで調べた内容をスライド生成 piece で資料化)で続行できます。
## 止める
赤い **「停止」ボタン** を押すと、次のツール呼び出しのチェックポイントでエージェントが停止します。
+11
View File
@@ -70,6 +70,17 @@ Markdown ファイルのプレビューを開くと、右上に **「PDF / 印
ヒント: ボタンが反応しないときは、ブラウザのポップアップブロックを解除してください(新規タブを開いて印刷するため)。
## チャットに出るリッチカード(地図・X・YouTube・商品)
エージェントが地図・SNS・動画・商品などを調べると、結果が**カード**としてチャットに表示されます。素の URL の羅列ではなく、見やすい形にまとまります。
- **地図 / 場所**: 検索した場所がカードで並ぶ
- **X (Twitter) 投稿**: 投稿の内容がカードで表示される
- **YouTube 動画**: 動画のサムネイル付きで一覧される
- **Amazon 商品**: 商品情報がカードで並ぶ
カードはクリックすると拡大表示(モーダル)で詳しく見られます。裏で動いているツール(SearchPlaces / XSearch / SearchYouTube / SearchAmazon など)は [ツールリファレンス](./16-tools.md) を参照してください。
## ダウンロードする
ファイルタブの各エントリからは、ファイルの実体をダウンロードできます。output に書き出された成果物(レポート・CSV・画像など)はここから手元に保存します。
+3 -1
View File
@@ -46,7 +46,9 @@ Piece は「タスクの種類ごとの実行手順」を定義したもので
| `sns-deep-sweep` | タイムラインや複数の投稿を選別し、1件ずつ個別の担当が深掘りして統合レポートにまとめます。投稿が多くても品質が均一になりやすいのが特長です。単一トピックの SNS の声を手早く知りたいときは「SNS 調査(sns-research)」を使ってください |
| `ssh-ops` | SSH 経由でリモートホストに単発オペレーションを実行(health check / config push / log fetch |
| `ssh-console` | 人間と AI が 1 つの PTY セッションを共有する対話的 SSH コンソール。長時間作業や TUIvim / top / tmux / tail 等)向け |
| `piece-builder` | Piece の設計・作成・編集を行う専用エージェント。「Piece を作って」「ワークフローを自動化したい」に対応 |
| `workspace-app` | ワークスペースのファイルを読み書きする自己完結型の HTML GUI(ミニアプリ)をゼロから作る。「○○のアプリ/画面を作って」向け。詳細は [ワークスペース・アプリ](20-workspace-apps.md) |
| `x-ai-digest` | X (Twitter) から AI・技術系の話題を収集し、深掘りしてダイジェスト記事(Markdown)にまとめる。毎朝など定期実行と相性が良い |
| `piece-builder` | **エージェントの定義(Piece の YAML)そのもの**を設計・作成・編集する専用エージェント。「○○用の piece/エージェントを作って」向け。動く HTML アプリを作りたいときは `workspace-app`、定期実行の仕込みは [スケジュール](06-schedules.md) を使う |
`ssh-ops` / `ssh-console` は admin が `config.yaml` で SSH を有効化し、接続登録・grant が済んでいる場合のみ使えます。詳細は [SSH 連携](14-ssh.md) を参照。
+33 -10
View File
@@ -3,20 +3,43 @@ id: subtasks
title: サブ実行・並列実行
category: advanced
order: 100
keywords: [サブタスク, SpawnSubTask, 列, waiting_subtasks, research-sub, 分解, delegate, サブエージェント]
keywords: [サブタスク, SpawnSubTask, WaitSubTask, 並列, 直列, waiting_subtasks, 分解, delegate, サブエージェント, 完了待ち]
---
# サブ実行・並列実行
大きな仕事は、エージェントが複数のサブタスクに分解して並列実行できます。「5 社を比較調査して」のように独立した調査項目が並ぶタスクで効果的です。
大きな仕事は、エージェントが複数のサブ調査に分解して進めます。「5 社を比較調査して」のように独立した調査項目が並ぶタスクで効果的です。
## どう動くか
分解には 2 つのやり方があります。
エージェントは `SpawnSubTask` ツールを呼んでサブタスクをキューに追加します。複数回呼べば複数のサブタスクが並列にスケジュールされます。各サブタスクには独立した専用ワークスペースが割り当てられ、別ジョブとして実行されます。
- **delegate(直列・既定)**: サブ調査を **1 件ずつ順番に** こなします。各サブは綺麗なコンテキストで動くので品質が揃いやすく、GPU・実行枠も軽くて済みます。調査・分析・ブレインストーミング系のタスクは既定でこちらを使います。並列に比べると壁時計時間は少し長くなります。
- **SpawnSubTask(並列・任意)**: 独立したサブタスクを **別ジョブとして同時に** 走らせます。壁時計時間は縮みますが、その分の実行枠を消費します。**デフォルトでは無効**で、必要なワークスペースだけがツール設定で有効化します(後述)。
サブタスクを 1 つ以上生成すると、親タスクは **waiting_subtasks** 状態に入ります。すべての子が完了すると親が再開し、子の成果をまとめて最終出力を作ります
ふだんは delegate で分解が完結するため、ほとんどのタスクで SpawnSubTask を有効にする必要はありません
ジョブの状態遷移については「[タスクの実行と監視](03-running.md)」を参照してください。
## delegate(直列のサブ調査)
エージェントが `delegate` ツールを呼ぶと、その場でサブエージェントがインライン実行されます。サブの中間ターンは親のコンテキストに入らず、最終結果だけが親に戻ります。複数の delegate は順番に(直列で)実行されます。
delegate は標準で有効なので、特別な設定は不要です。実行中の様子はチャット欄に専用のコンソール枠でリアルタイム表示され、完了後の記録は「概要 > サブ実行」に残ります。
## SpawnSubTask(並列の別ジョブ・要有効化)
`SpawnSubTask` はデフォルト無効のツールです。使うには、ワークスペースの **設定 → ツール** タブで明示的にオンにします(Bash などと同じ「センシティブ/任意有効化」の扱い)。有効にしていないワークスペースでは、エージェントに SpawnSubTask が提示されず、分解は delegate(直列)で行われます。
有効にしたうえでエージェントが `SpawnSubTask` を呼ぶと、サブタスクがキューに追加されます。複数回呼べば複数のサブタスクが並列にスケジュールされます。各サブタスクには独立した専用ワークスペースが割り当てられ、別ジョブとして実行されます。
サブタスクを起動したあと、エージェントは `WaitSubTask` を呼んで全部の完了を待ちます。このとき親タスクは **waiting_subtasks** 状態に入り、いったん停止して実行枠(worker)を手放します(待っている間に他の仕事が詰まりません)。すべての子が完了すると親が再開し、子の成果をまとめて最終出力を作ります。
ツール設定の詳しい場所は [ワークスペースとメンバー](./21-workspaces.md) の「ツールポリシー」を参照してください。ジョブの状態遷移については「[タスクの実行と監視](03-running.md)」を参照してください。
## サブタスクまわりの安全策
サブタスクを取りこぼしたり、止めたはずの処理が裏で動き続けたりしないよう、いくつかの安全策が働きます。
- **待ち忘れの防止**: 子がまだ走っている最中にエージェントが「完了」しようとしても、結果を待たずに終わってしまうことはありません。自動でいったん停止して、全部の完了を待ってから続けます。
- **止めたら子も止まる**: 親タスクが失敗・キャンセル・リトライしたとき、まだ走っているサブタスクも一緒に止まります。親が消えたのに子だけが裏で動き続けて GPU を使う、ということがなくなりました。ただし、ユーザーの回答待ち(割り込み待ち)で止まっている間は、あとで再開して結果を使うため子は止めません。
- **起動を少しずつ**: 一度に大量のサブタスクを起動しても、順番待ちの列に少しずつ(既定で 1 秒間隔)並べます。一団が実行枠を一気に奪って他のタスクを後回しにしてしまうのを防ぎます。間隔は `config.yaml``subtasks.spawn_stagger_ms` で調整できます(`0` で無効)。
## SpawnSubTask の引数
@@ -26,9 +49,7 @@ keywords: [サブタスク, SpawnSubTask, 並列, waiting_subtasks, research-sub
| instruction | 何を調査・実行し、どんな形式で `output/` に書くかの具体的な指示 |
| piece | 使用するピース(省略時は `general` |
`piece` には `general` / `research` / `brainstorming` / `orchestrated` / `data-process` / `office-process` などを指定できます。指定したピースが見つからない場合はエラーになります。
調査系のサブタスクには、専用ピース **research-sub**dig → analyze → verify の 3 ステップ)が使われます。research-sub はそれ以上のサブタスク分解を行わず、調査をその場で完結させます。
`piece` には `general` / `research` / `brainstorming` / `data-process` / `office-process` などを指定できます。指定したピースが見つからない場合はエラーになります。
## サブ実行の確認
@@ -67,6 +88,8 @@ subtasks:
## TIP
> サブタスクは「独立して並列実行できる」項目に向きます。前の結果に依存する逐次処理は、1 つのタスク内の movement 遷移で扱う方が確実です(→「[ピースの仕組み](05-pieces.md)」)
> ふだんの分解は delegate(直列)で十分です。SpawnSubTask の並列化は、独立したテーマが複数あって壁時計時間を縮めたいときだけ、ツール設定で有効にして使います
> 前の結果に依存する逐次処理は、1 つのタスク内の movement 遷移で扱う方が確実です(→「[ピースの仕組み](05-pieces.md)」)。
> サブタスクへの instruction には、成果物を `output/` にどう書くかまで具体的に指定すると、親がまとめやすくなります。
+9
View File
@@ -97,6 +97,15 @@ MCP ツール呼び出しの生レスポンスは、各ジョブのワークス
デバッグや監査の際にこれらを確認できます。
## 他のワークスペースから取り込む
同じ MAESTRO 内の別ワークスペースに登録済みの MCP サーバーを、まとめてコピーできます。**設定 → MCP** の「他のワークスペースから取り込む」ボタンを押すと、自分が管理しているワークスペースの一覧が出るので、コピー元を選んで実行してください。
- **操作できる条件**: 取り込み元と移し先の両方でオーナー・管理者であること
- **スキップ**: 同じ id または同じ URL のサーバーがすでにある場合は自動でスキップされ、既存の設定は上書きされません
- **再認証が必要**: OAuth 方式のサーバーは per-user トークンを移せません。取り込み後に「連携」ボタンでブラウザ認証をやり直してください。API key 方式はそのまま使えます
- **同一サーバー内のみ**: 別環境(別インスタンス)へのエクスポートには対応していません
## トラブルシューティング
- ツールが見えない: そのワークスペースに MCP サーバーが登録されていない、OAuth サーバーが未連携、ワークスペースのツール設定で MCP が無効、`MCP_ENCRYPTION_KEY` 未設定、またはタスク作成時に「MCP ツールを無効化」を ON にした、のいずれか
+20 -5
View File
@@ -15,7 +15,12 @@ MAESTRO は、エージェントが SSH 経由でリモートホストを操作
| ssh-ops | 単発コマンド実行・ファイル転送 (health check / config push / log fetch) | `ssh-ops` |
| ssh-console | 対話的な PTY シェル (vim / tmux / tail など長時間作業) | `ssh-console` |
いずれもデフォルトでは無効です。admin が `config.yaml` で有効化し (`ssh.enabled: true`、コンソールは加えて `ssh.console.enabled: true`)、SSH 接続を登録したうえでジョブの所有者に grant を付与する必要があります。詳しくは [管理者ガイド](./19-admin.md) を参照してください
いずれもデフォルトでは無効です。使い始めるには次の 2 つの条件を満たす必要があります
1. **admin が SSH を有効化する**`config.yaml``ssh.enabled: true`(コンソールは加えて `ssh.console.enabled: true`)を設定します。詳しくは [管理者ガイド](./19-admin.md) を参照してください。
2. **ワークスペースポリシーで SSH を有効にし、接続を登録する** — ワークスペースの **設定 → ツール**`ssh` カテゴリをオンにし、**設定 → SSH** で接続を登録します。エージェントはそのワークスペースに登録された接続のみ使えます(他のワークスペースの接続には触れません)。
> **スコープ設計**: 接続の登録がオプトインです。ワークスペースに登録された接続だけがそのワークスペースのタスクから見え、別ワークスペースへの水平移動はできません。MCP サーバーのスコープと同じ考え方です。
## ssh-ops: 単発実行とファイル転送
@@ -65,13 +70,23 @@ MAESTRO は、エージェントが SSH 経由でリモートホストを操作
## どう動かすか
1. admin が SSH を有効化し、接続と grant を整え
2. ユーザーが該当接続を登録 (個人接続の場合) して Test でホストキーを検証
3. `ssh-ops` または `ssh-console` を使うタスクを作成して実行
4. ssh-console の場合はタスク詳細の SSH タブで画面を確認・操作
1. admin が `config.yaml` で SSH を有効化す
2. ワークスペースの **設定 → ツール**`ssh` カテゴリをオンにする
3. **設定 → SSH** で接続を登録し、Test でホストキーを検証する
4. `ssh-ops` または `ssh-console` を使うタスクを作成して実行する
5. ssh-console の場合はタスク詳細の SSH タブで画面を確認・操作する
Piece の選び方や `allowed_tools` の考え方は [piece を使う・作る](./05-pieces.md) を参照してください。
## 他のワークスペースから取り込む
同じ MAESTRO 内の別ワークスペースに登録済みの SSH 接続を、まとめてコピーできます。**設定 → SSH** の「他のワークスペースから取り込む」ボタンを押すと、自分が管理しているワークスペースの一覧が出るので、コピー元を選んで実行してください。
- **操作できる条件**: 取り込み元と移し先の両方でオーナー・管理者であること
- **スキップ**: 同じ label・同じ host/user の組み合わせがすでにある場合は自動でスキップされ、既存の接続は上書きされません
- **秘密鍵ごとコピー**: 秘密鍵は移し先ワークスペースの鍵で再暗号化されて保存されます。取り込み後すぐに使えますが、ホストキー検証(TOFU)が未完了の接続は Test を押して検証してください
- **同一サーバー内のみ**: 別環境(別インスタンス)へのエクスポートには対応していません
## よくあるエラー
| エラーコード | 意味 | 対処 |
+1
View File
@@ -76,6 +76,7 @@ Gateway / Worker いずれも Prometheus 互換の `/metrics` を公開できま
上部の **使用量** タブでは、LLM のトークン使用量を日次で確認できます。Gateway 経由と Direct(各 worker が直接接続)の両方を合算し、お使いの環境のローカル日付で集計します。
- **内訳の切り替え**: 経路・モデル・バックエンド・ユーザー・組織で分解して見られます
- **時間帯別プロファイル**: 0〜23 時の時間帯ごとの使用傾向も確認でき、いつ負荷が集中するかが分かります
- **集計の対象**: 入力 / 出力トークンとリクエスト数。ユーザー別の一覧も出ます
- Virtual Keys のキー別課金パネル(admin の Gateway 設定内)とは **別集計** です。Usage タブは「実際に消費したトークン量の可視化」、課金パネルは「キーごとの予算管理」と捉えてください
+5 -1
View File
@@ -22,6 +22,7 @@ movement の開始時には、その movement で使えるツールの一覧と
|---|---|---|
| ファイル / シェル | ワークスペースのファイル操作とコマンド実行 | Read / Write / Edit / Bash / Glob / Grep |
| Web / 検索 | Web 検索・取得・ダウンロード | WebSearch / WebFetch / DownloadFile |
| 技術ドキュメント | Microsoft Learn の公式ドキュメントを検索・取得 | SearchMicrosoftLearn / FetchMicrosoftLearn |
| ブラウザ | 実ブラウザでのページ操作 | BrowseWeb |
| Office / ドキュメント | Excel / Word / PDF / PPTX の解析 | ReadExcel / ReadPdf / ReadDocx |
| データ | SQLite データベース操作 | SQLite |
@@ -32,7 +33,8 @@ movement の開始時には、その movement で使えるツールの一覧と
| SSH | リモート実行・転送・対話コンソール | SshExec / SshUpload / SshConsoleSend |
| オーケストレーション | サブタスクの生成 | SpawnSubTask |
| 地図 | 場所検索・経路・逆ジオコーディング | SearchPlaces / GetDirections |
| メディア | 文字起こし・動画字幕 | TranscribeAudio / GetYouTubeTranscript |
| メディア | 文字起こし・動画検索・字幕取得 | TranscribeAudio / SearchYouTube / GetYouTubeTranscript |
| カレンダー | ワークスペースのカレンダーへ予定を登録・参照 | AddCalendarEvent / ListCalendarEvents |
| その他 | X(旧Twitter)検索・Amazon 検索など | XSearch / SearchAmazon |
SSH 系の詳しい使い方は [SSH リモート操作](./14-ssh.md) を参照してください。
@@ -46,7 +48,9 @@ SSH 系の詳しい使い方は [SSH リモート操作](./14-ssh.md) を参照
- `RequestTool` — タスクに足りないツールの利用を申請する。チャット上でオーナーが承認すると、その場で使えるようになる
- `ReadUserMemory` / `UpdateUserMemory` — ワークスペースのメモリを読み書きする(→[メモリ](./12-memory.md)
- `CreateChecklist` / `CheckItem` / `GetChecklist` — タスク内の進捗チェックリスト
- `MissionUpdate` — 長時間タスクで、目標と現在地(進捗)をユーザーに途中報告するためのピン留めメモを更新する
- `GetMyOrchestratorState` — 自分が今どのワークスペース・タスクで動いているかを把握する
- `ReadAppDoc` / `ListAppDocs` — アプリ内のヘルプ・ドキュメントをエージェント自身が読む(`#help` のヘルプ応答などで使われる)
各ツールの function definition は 1 文に圧縮されているため、詳しい使い方はこの `ReadToolDoc` で取得する設計です。
+10
View File
@@ -97,6 +97,16 @@ Gateway の運用は [LLM Gateway 連携](#llm-gateway) を参照。
|-----------|------|
| HTTPS / TLS | `server.tls.*` — アプリ内 TLS 終端・証明書・HTTP→HTTPS リダイレクト・HSTS。詳細は下の「[HTTPS とリダイレクト](#https-とリダイレクト)」 |
## ブランディング(見た目のカスタマイズ)
System グループの **Branding** で、アプリ全体の見た目を組織向けに変えられます。
- **アプリ名**: タイトルや画面に出る名称
- **ロゴ / favicon**: 画像をアップロードして差し替え(ブラウザのタブに出るアイコンも変わる)
- **アクセント色**: ボタンやリンクなどの基調色
ここはアプリ**全体**の設定です。個々の案件ワークスペースに付ける「ブランド色」(一覧の色帯など)は別物で、各ワークスペースの設定で変えます(→[ワークスペースとメンバー](./21-workspaces.md))。
## Save & Apply の流れ
フォームの値を変更しても、すぐには反映されません。
+18 -2
View File
@@ -3,12 +3,16 @@ id: admin
title: ユーザー管理 / 安全性
category: admin
order: 190
keywords: [ユーザー管理, 権限, admin, 安全性, bash, sandbox, 監査]
keywords: [ユーザー管理, 権限, admin, 安全性, bash, sandbox, 監査, セットアップ, 初回設定, ウィザード, CAPTCHA, noVNC]
---
# ユーザー管理 / 安全性 (admin)
このページは admin が行う運用作業をまとめます。ユーザーの承認・権限付与、公開範囲モデル、そしてエージェントの安全制御 (Bash 実行・監査ログ) です。
このページは admin が行う運用作業をまとめます。初回セットアップ、ユーザーの承認・権限付与、公開範囲モデル、CAPTCHA の手動解決、そしてエージェントの安全制御 (Bash 実行・監査ログ) です。
## 初回セットアップウィザード
インストール直後(例: `docker compose up` の後)に初めて開くと、全画面の **セットアップウィザード** が表示されます。LLM の接続先・公開ポート・認証方式といった最低限の設定を順番に入力し、最後にまとめて適用します。ここを通すと、後から **設定** タブの各セクションで同じ項目を個別に調整できます。一度設定が済めばウィザードは出ません。
## ユーザー管理
@@ -69,6 +73,10 @@ Gitea を使わない場合は、**ローカル組織** を自分で作ってメ
- 権限チェックは `buildVisibilityWhere(user, alias)` を一覧・取得クエリに差し込む形で一元化されている
- 親 → 子へ公開範囲がコピーされる (タスク → その spawn job、スケジュール → spawn job、親 job → 子 subtask)
## CAPTCHA の手動解決(CAPTCHA タブ)
エージェントがブラウザ操作中に CAPTCHA や追加認証に突き当たって止まることがあります。admin だけに見える **CAPTCHA** タブには、解決待ちのブラウザセッションが集まり、**noVNC のライブ画面**で人が手動で突破できます。画面は別ウィンドウ(ピクチャー・イン・ピクチャーで前面固定)にして、別作業をしながら対応することもできます。解決して取得したログイン状態(cookie / storage)は暗号化保存され、以降の自動ブラウズで再利用されます(→[ブラウザ操作](./09-userfolder.md) のセッション)。
## 安全性: Bash 実行モード
エージェントの `Bash` ツールの隔離は、`config.yaml` の **2 つの独立したキー**で制御します。どちらも設定 UI には出ない **config ファイル専用キー**です(危険なセキュリティ姿勢キーは UI トグルにせず、config 直編集 + 再起動を要求する方針)。
@@ -110,6 +118,14 @@ Gitea を使わない場合は、**ローカル組織** を自分で作ってメ
> 本番は `bash_sandbox: always` + パッケージのプリベイクを推奨。bwrap が使えないホストでは `auto`(hardened フォールバック)になるが、テナント間分離は弱くなる点に注意。
## ワーカー / GPU の占有ユーザー
サイドパネルの **ワーカー** 一覧では、実行中の各ワーカー(GPU)に、いまそのワーカーを占有しているユーザー名(`使用中: ...`)が表示される。複数ユーザーが同じワーカーで同時に実行している場合は連名で出る。「誰のタスクで GPU が埋まっているか」をこの画面だけで把握できる。
- **この表示は管理者にだけ見える**。一般ユーザーには稼働状況(実行中/待機)と空きスロットのみが見え、ユーザー名・ジョブ内容は出ない(マルチテナントのプライバシー保護)
- 表示名は登録ユーザーの名前。名前が取れない場合(旧データ等)はユーザー ID で代替表示する
- ユーザー名のとなりに、ジョブの**種別バッジ**(`task_kind`)が付く。通常タスク(`agent`)にはバッジを付けず、`reflection`(学習)など通常以外のときだけタグを出すので、「学習で埋まっているだけ」を区別できる
## 監査ログ
- ジョブ実行に関する操作は `audit_log` テーブル (`action` / `actor` / `detail` / `created_at`) に記録される
+2
View File
@@ -35,6 +35,8 @@ keywords: [ワークスペース, 個人ワークスペース, 案件ワーク
ワークスペースの「チャット」一覧には、検索・絞り込みバーがあります。キーワード(タイトル・本文・Piece・作成者名)での検索、状態(実行中・待機・失敗など)のタブ絞り込み、並び替え(更新順・状態・タイトル)が使えます。共有ワークスペースで「自分 / 他のメンバー」を切り替えている場合は、その範囲の中で絞り込みます。
**「自分 / 他のメンバー」の切り替えについて**: 共有ワークスペースでは、メンバーが作成したチャットを「他のメンバー」で見られます。個人ワークスペースは作成者本人だけのものなので、通常この切り替えは出ません。例外として**管理者(admin)**は、個人ワークスペースの「他のメンバー」から他ユーザーの個人ワークスペースの内容を確認できます(全ユーザーの監視のため)。一般ユーザーには、共有されていない他人のチャットは見えません。
## ファイルを置く(エージェントの入力にする)
ワークスペースの **Files** にファイルを置くと、そのワークスペースのチャットで **エージェントの入力**になります。チャットを始めると、エージェントへ「ワークスペースの既存ファイル一覧」が自動で渡るので、`input/` フォルダに入れなくても置いたファイルは認識されます。新しく作ったワークスペースには `input/``output/` フォルダが最初から用意されています。ファイルがまだ 1 つもないときは、Files にその旨の案内が控えめに出ます。
+2 -1
View File
@@ -14,7 +14,8 @@
"fetchError": "Failed to fetch",
"empty": "No workers configured",
"expand": "Expand",
"collapse": "Collapse"
"collapse": "Collapse",
"inUse": "In use:"
},
"tabBar": {
"expand": "Expand",
+1 -1
View File
@@ -24,7 +24,7 @@
"cancel": "Cancel", "starting": "Starting..."
},
"focus": { "toStandard": "Back to standard view", "toFocused": "Focused mode (TASK column to a thin rail / split chat and workspace)", "toFocusedShort": "Switch to focused mode" },
"feedback": { "title": "Feedback", "change": "Change", "good": "Good", "bad": "Needs improvement", "commentPlaceholder": "Comment (optional)", "cancel": "Cancel", "submitting": "Submitting...", "submit": "Submit" },
"feedback": { "title": "Feedback", "change": "Change", "good": "Good", "bad": "Needs improvement", "commentPlaceholder": "Comment (optional)", "cancel": "Cancel", "submitting": "Submitting...", "submit": "Submit", "tags": { "goodAccuracy": "Accurate output", "goodFormat": "Well-formatted", "goodUnderstanding": "Understood the instructions", "goodSpeed": "Good speed", "badAccuracy": "Inaccurate output", "badFormat": "Poorly formatted", "badMismatch": "Result differed from the instructions", "badUnnecessary": "Did unnecessary work", "badStalled": "Stalled / too many ASKs" } },
"title": { "edit": "Edit title", "regenerate": "Regenerate title with AI", "save": "Save", "saving": "Saving...", "cancel": "Cancel" },
"mission": {
"pinnedMemo": "pinned memo", "edit": "Edit", "cancel": "Cancel", "saving": "Saving...", "save": "Save",
+2 -1
View File
@@ -373,7 +373,8 @@
"sensitiveNote": {
"ssh": "Allows connecting to remote shells. Commands can be executed on the target servers.",
"browser": "Allows real browser actions. Visiting websites and operating forms will be performed.",
"Bash": "Allows arbitrary command execution. Any shell command can run on the host system."
"Bash": "Allows arbitrary command execution. Any shell command can run on the host system.",
"SpawnSubTask": "Allows decomposition into parallel subtasks (separate jobs). Each child occupies a worker and a GPU slot. The serial 'delegate' tool is usually enough; enable this only when parallel execution is genuinely required."
}
}
}
+2 -1
View File
@@ -14,7 +14,8 @@
"fetchError": "取得に失敗しました",
"empty": "Worker が設定されていません",
"expand": "展開する",
"collapse": "折りたたむ"
"collapse": "折りたたむ",
"inUse": "使用中:"
},
"tabBar": {
"expand": "展開",
+1 -1
View File
@@ -24,7 +24,7 @@
"cancel": "キャンセル", "starting": "起動中..."
},
"focus": { "toStandard": "標準表示に戻る", "toFocused": "集中モード (TASK 列を細い rail に / Chat と Workspace を可変分割)", "toFocusedShort": "集中モードに切替" },
"feedback": { "title": "フィードバック", "change": "変更", "good": "良かった", "bad": "改善が必要", "commentPlaceholder": "コメント(任意)", "cancel": "キャンセル", "submitting": "送信中...", "submit": "送信" },
"feedback": { "title": "フィードバック", "change": "変更", "good": "良かった", "bad": "改善が必要", "commentPlaceholder": "コメント(任意)", "cancel": "キャンセル", "submitting": "送信中...", "submit": "送信", "tags": { "goodAccuracy": "出力の精度が高い", "goodFormat": "フォーマットが適切", "goodUnderstanding": "指示をよく理解していた", "goodSpeed": "速度が適切だった", "badAccuracy": "出力の精度が低い", "badFormat": "フォーマットが不適切", "badMismatch": "指示と違う結果になった", "badUnnecessary": "不要な作業をしていた", "badStalled": "途中で止まった / ASKが多すぎた" } },
"title": { "edit": "タイトルを編集", "regenerate": "AIでタイトルを再生成", "save": "保存", "saving": "保存中...", "cancel": "キャンセル" },
"mission": {
"pinnedMemo": "固定メモ", "edit": "編集", "cancel": "キャンセル", "saving": "保存中...", "save": "保存",
+2 -1
View File
@@ -373,7 +373,8 @@
"sensitiveNote": {
"ssh": "遠隔シェルへの接続を許可します。接続先サーバーでのコマンド実行が可能になります。",
"browser": "実ブラウザ操作を許可します。ウェブサイトへのアクセスやフォーム操作が実行されます。",
"Bash": "任意コマンド実行を許可します。ホストシステムで任意のシェルコマンドが実行できます。"
"Bash": "任意コマンド実行を許可します。ホストシステムで任意のシェルコマンドが実行できます。",
"SpawnSubTask": "並列サブタスク(別ジョブ)への分解を許可します。子ごとにワーカーと GPU スロットを占有します。通常は直列の delegate で十分です。本当に並列実行が必要なときだけ有効化してください。"
}
}
}
+44 -1
View File
@@ -2,7 +2,13 @@ import { describe, it, expect } from 'vitest';
import { filterTasksForSpace, countRunningTasksForSpace } from './spaceTasks';
import type { LocalTask } from '../api';
function task(id: number, spaceId: string | null, updatedAt: string, status?: string): LocalTask {
function task(
id: number,
spaceId: string | null,
updatedAt: string,
status?: string,
ownerId: string | null = 'u1',
): LocalTask {
return {
id,
title: `task ${id}`,
@@ -15,6 +21,7 @@ function task(id: number, spaceId: string | null, updatedAt: string, status?: st
state: 'queued',
workspacePath: null,
spaceId,
ownerId,
createdAt: updatedAt,
updatedAt,
...(status ? { latestJob: { id: `j${id}`, status } } : {}),
@@ -41,6 +48,29 @@ describe('filterTasksForSpace', () => {
expect(result.map(t => t.id)).toEqual([1, 2]);
});
it('includes other users\' personal-space tasks when caseSpaceIds is supplied (admin monitoring)', () => {
// 個人バケツ = null か case でない space_id。caseSpaceIds={sp-a} を渡すと
// 他ユーザーの個人スペース(personal-u2) も含まれ、case スペース(sp-a) は除外される。
const tasks = [
task(1, null, '2026-01-05', undefined, 'u1'), // 自分の legacy null
task(2, 'personal-u1', '2026-01-04', undefined, 'u1'), // 自分の個人スペース
task(3, 'personal-u2', '2026-01-03', undefined, 'u2'), // 他ユーザーの個人スペース
task(4, 'sp-a', '2026-01-02', undefined, 'u2'), // case スペース → 除外
];
const caseSpaceIds = new Set(['sp-a']);
const result = filterTasksForSpace(tasks, 'personal-u1', true, caseSpaceIds);
expect(result.map(t => t.id)).toEqual([1, 2, 3]);
});
it('without caseSpaceIds, personal space keeps only this-space-or-null (back-compat)', () => {
const tasks = [
task(1, null, '2026-01-03', undefined, 'u1'),
task(2, 'personal-u1', '2026-01-02', undefined, 'u1'),
task(3, 'personal-u2', '2026-01-01', undefined, 'u2'), // 他ユーザー個人 → 集合無しでは除外
];
expect(filterTasksForSpace(tasks, 'personal-u1', true).map(t => t.id)).toEqual([1, 2]);
});
it('returns empty when no task matches', () => {
expect(filterTasksForSpace([task(1, 'sp-x', '2026-01-01')], 'sp-y')).toEqual([]);
});
@@ -80,6 +110,19 @@ describe('countRunningTasksForSpace', () => {
expect(countRunningTasksForSpace(tasks, personalSpace)).toBe(2);
});
it('scopes the personal badge to the viewer when viewerId is given (admin sees all rows)', () => {
// admin のリスト API は全件 (1=1) を返すため、owner 絞りが無いと他ユーザーの
// null タスクが個人スペースのバッジに混ざる。viewerId 指定で本人のみ数える。
const tasks = [
task(1, null, '2026-01-01', 'running', 'u1'), // 自分
task(2, null, '2026-01-02', 'running', 'u2'), // 他ユーザー → 除外
task(3, 'personal', '2026-01-03', 'running', 'u1'), // 自分の個人スペース id
];
expect(countRunningTasksForSpace(tasks, personalSpace, 'u1')).toBe(2);
// viewerId 未指定なら従来どおり全件 (no-auth 単独利用)。
expect(countRunningTasksForSpace(tasks, personalSpace)).toBe(3);
});
it('ignores tasks with no latestJob', () => {
const tasks = [task(1, 'sp-a', '2026-01-01'), task(2, 'sp-a', '2026-01-02', 'running')];
expect(countRunningTasksForSpace(tasks, caseSpace)).toBe(1);
+29 -7
View File
@@ -6,15 +6,29 @@ import type { LocalTask } from '../api';
* `spaceId` 一致だけを残す。新しい更新が上に来るよう updatedAt 降順で並べる。
*
* spec §7: `space_id` が null のタスクは個人スペースに属するものとして扱う。
* 個人スペースを表示しているとき (`isPersonalSpace`) は null タスクも含める。
*
* 個人スペース (`isPersonalSpace`) の判定は「個人バケツ」= legacy の null タスク、
* または **case スペースでない `space_id`**(= 誰かの個人スペース)に属するタスク。
* `caseSpaceIds`= 閲覧可能な case スペースの id 集合)を渡すと、他ユーザーの
* 個人スペース(具体 id 持ち)も個人バケツに含められる。これは admin が他ユーザーの
* 個人ワークスペースを監視するために必要(呼び出し側で owner 絞りを併用する)。
*
* `caseSpaceIds` 未指定時は後方互換で「この個人スペース or null」のみを残す。
* 個人スペースかどうかに関わらず、所有者での絞り込みは行わない(呼び出し側の責務)。
*/
export function filterTasksForSpace(
tasks: LocalTask[],
spaceId: string,
isPersonalSpace = false,
caseSpaceIds?: ReadonlySet<string>,
): LocalTask[] {
return tasks
.filter(t => t.spaceId === spaceId || (isPersonalSpace && t.spaceId == null))
.filter(t => {
if (!isPersonalSpace) return t.spaceId === spaceId;
if (t.spaceId == null) return true;
if (caseSpaceIds) return !caseSpaceIds.has(t.spaceId);
return t.spaceId === spaceId;
})
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''));
}
@@ -22,15 +36,23 @@ export function filterTasksForSpace(
* 指定スペースに属し、いま実行中 (latestJob.status === 'running') のタスク件数。
* null の space_id は個人スペースに属するものとして数える (filterTasksForSpace と同じ規則)。
* リスト API はスペースで絞らないため、クライアント保持の全件から数える。
*
* 個人スペースのバッジは**閲覧者本人の実行中のみ**数える。admin はリスト API が全件
* (1=1) を返すため、owner 絞りをしないと他ユーザーの null タスクがレールのバッジに
* 混ざってしまう。`viewerId` を渡すと個人スペースで owner 一致のみに絞る。未指定
* (no-auth 単独利用) は全件が本人のものなので従来どおり絞らない。
*/
export function countRunningTasksForSpace(
tasks: LocalTask[],
space: { id: string; kind: 'personal' | 'case' },
viewerId?: string | null,
): number {
const isPersonal = space.kind === 'personal';
return tasks.filter(
t =>
t.latestJob?.status === 'running' &&
(t.spaceId === space.id || (isPersonal && t.spaceId == null)),
).length;
return tasks.filter((t) => {
if (t.latestJob?.status !== 'running') return false;
const inSpace = t.spaceId === space.id || (isPersonal && t.spaceId == null);
if (!inSpace) return false;
if (isPersonal && viewerId != null && t.ownerId !== viewerId) return false;
return true;
}).length;
}