// api.ts から分割(挙動不変): スペース(CRUD・メンバー・招待・ツールポリシー・Python パッケージ)。 import { BASE } from './client'; import type { SpaceTemplate } from '../../../src/spaces/space-templates'; // --- Spaces (個人/案件) --------------------------------------------------- export interface Space { id: string; kind: 'personal' | 'case'; title: string; description: string; ownerId: string | null; visibility: 'private' | 'org' | 'public'; visibilityScopeOrgId: string | null; status: 'open' | 'archived'; brandColor: string | null; workspaceDir: string | null; createdAt: string; updatedAt: string; favorite?: boolean; hidden?: boolean; /** 閲覧者自身のメンバーロール。一覧(GET /spaces)・詳細(GET /spaces/:id)の両方で付与。 * 非メンバー(根オーナー含む。owner_id はメンバー行ではない)は null。 */ myRole?: SpaceMemberRole | null; } export async function fetchSpaces(): Promise { const res = await fetch(`${BASE}/local/spaces`); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch spaces'); return data as Space[]; } export async function updateSpaceDisplayPrefs( id: string, patch: { favorite?: boolean; hidden?: boolean }, ): Promise<{ favorite: boolean; hidden: boolean }> { const res = await fetch(`${BASE}/local/spaces/${id}/display`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error ?? 'Failed to update workspace display settings'); return data as { favorite: boolean; hidden: boolean }; } export type SpaceMemberRole = 'owner' | 'editor' | 'viewer'; export interface SpaceMember { userId: string; name: string | null; email: string | null; avatarUrl: string | null; role: SpaceMemberRole; isOwner: boolean; } export async function fetchSpaceMembers(spaceId: string): Promise { const res = await fetch(`${BASE}/local/spaces/${spaceId}/members`); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch members'); return data as SpaceMember[]; } export async function addSpaceMember( spaceId: string, input: { userId: string; role: SpaceMemberRole }, ): Promise { const res = await fetch(`${BASE}/local/spaces/${spaceId}/members`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d?.error ?? 'Failed to add member'); } } export async function updateSpaceMemberRole( spaceId: string, userId: string, role: SpaceMemberRole, ): Promise { const res = await fetch(`${BASE}/local/spaces/${spaceId}/members/${encodeURIComponent(userId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role }), }); if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d?.error ?? 'Failed to update member role'); } } export async function removeSpaceMember(spaceId: string, userId: string): Promise { const res = await fetch(`${BASE}/local/spaces/${spaceId}/members/${encodeURIComponent(userId)}`, { method: 'DELETE', }); if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d?.error ?? 'Failed to remove member'); } } // ─── ワークスペース招待リンク(再利用トークン)────────────────────── export type SpaceInviteRole = 'editor' | 'viewer'; export interface SpaceInviteInfo { token: string; /** 相対パス。origin を前置して共有する。 */ url: string; role: SpaceInviteRole; createdAt: string; expiresAt: string | null; revokedAt: string | null; valid: boolean; } /** 現行の招待リンク(canManageSpace)。無ければ null。 */ export async function fetchSpaceInvite(spaceId: string): Promise { const res = await fetch(`${BASE}/local/spaces/${spaceId}/invite`); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch invite'); return (data?.invite ?? null) as SpaceInviteInfo | null; } /** 招待リンクを (再)生成する(canManageSpace)。 */ export async function createSpaceInvite( spaceId: string, input: { role: SpaceInviteRole; expiresInDays?: number | null }, ): Promise { const res = await fetch(`${BASE}/local/spaces/${spaceId}/invite`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error ?? 'Failed to create invite'); return data.invite as SpaceInviteInfo; } /** 招待リンクを無効化する(canManageSpace)。 */ export async function revokeSpaceInvite(spaceId: string): Promise { const res = await fetch(`${BASE}/local/spaces/${spaceId}/invite`, { method: 'DELETE' }); if (!res.ok && res.status !== 204) { const d = await res.json().catch(() => ({})); throw new Error(d?.error ?? 'Failed to revoke invite'); } } export interface InvitePreview { spaceId: string; spaceTitle: string; role: SpaceInviteRole; } /** * 招待トークンのプレビュー。`status` で分岐を呼び出し側に渡す: * 'ok' → preview / 'unauthorized'(要ログイン)/ 'invalid'(無効・期限切れ・不明)。 */ export async function fetchInvitePreview( token: string, ): Promise<{ status: 'ok'; preview: InvitePreview } | { status: 'unauthorized' | 'invalid' }> { const res = await fetch(`${BASE}/local/spaces/invite/${encodeURIComponent(token)}`); if (res.status === 401) return { status: 'unauthorized' }; if (!res.ok) return { status: 'invalid' }; const data = await res.json(); return { status: 'ok', preview: data as InvitePreview }; } /** 招待を受諾して参加する。参加先 spaceId を返す。 */ export async function acceptSpaceInvite(token: string): Promise<{ spaceId: string; alreadyMember: boolean }> { const res = await fetch(`${BASE}/local/spaces/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error ?? 'Failed to accept invite'); return data as { spaceId: string; alreadyMember: boolean }; } // ─── ワークスペース ツールポリシー ──────────────────────────────────────── export interface ToolCategory { name: string; sensitive: boolean; enabled: boolean; } export interface SpaceToolPolicy { disabledSafe: string[]; enabledSensitive: string[]; /** 3 値: undefined = 未設定(全体設定を継承)/ true = 許可 / false = 遮断。 */ allowNetwork?: boolean; } /** サンドボックスから外部ネットワークへ出られるかの、いまの状態(issue #1015)。 */ export interface SandboxNetworkState { /** ワークスペースの設定。null = 未設定(全体設定を継承)。 */ workspace: boolean | null; /** 全体設定(config.yaml の safety.bash_allow_network)。 */ global: boolean; /** 実際に適用される値。 */ effective: boolean; /** effective がどちらの設定から来たか。 */ source: 'workspace' | 'global'; /** サンドボックス機構の選択。'off' のときこの切り替えは効かない。 */ sandboxMode: 'auto' | 'always' | 'off'; /** * 隔離の下地(bwrap)が実際に動いたか(issue #1215)。 * サーバが 1 回だけ走らせた点検の結果。省略されるのは古いサーバの応答だけ。 */ bwrapAvailable?: boolean; /** Bash の制限を外す設定。true のときこの切り替えは効かない。 */ bashUnrestricted: boolean; } export interface SpaceToolPolicyResponse { policy: SpaceToolPolicy; categories: ToolCategory[]; sensitiveTools: { name: string; enabled: boolean }[]; sandboxNetwork?: SandboxNetworkState; } export async function fetchSpaceToolPolicy(spaceId: string): Promise { const res = await fetch(`${BASE}/local/spaces/${spaceId}/tool-policy`); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch tool policy'); return data as SpaceToolPolicyResponse; } export async function updateSpaceToolPolicy( spaceId: string, // allowNetwork: キーを送らなければ現状維持、null で未設定に戻す、boolean で設定。 patch: { disabledSafe?: string[]; enabledSensitive?: string[]; allowNetwork?: boolean | null }, ): Promise { const res = await fetch(`${BASE}/local/spaces/${spaceId}/tool-policy`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }); if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d?.error ?? 'Failed to update tool policy'); } } // ─── Workspace Auto Prompter rules ─────────────────────────────────────── export type AutoPrompterTiming = 'always' | 'tool.before' | 'completion.before_accept'; export type AutoPrompterToolCategory = | 'command_execution' | 'internet_search' | 'file_read' | 'file_write' | 'external_communication' | 'browser' | 'ssh'; export type AutoPrompterAction = | { type: 'allow' } | { type: 'deny'; reason: string } | { type: 'advise'; instruction: string } | { type: 'approval'; message: string }; export interface AutoPrompterRule { id: string; name?: string; condition?: string; version: number; enabled: boolean; timing: AutoPrompterTiming; target?: { toolNames?: string[]; toolCategories?: AutoPrompterToolCategory[]; }; scope?: { pieces?: string[]; movements?: string[]; }; fireLimits?: { perTask?: number; perMovement?: number; }; action: AutoPrompterAction; } export interface SpaceAutoPrompterResponse { enabled: boolean; rules: AutoPrompterRule[]; } const autoPrompterBase = (spaceId: string): string => `${BASE}/local/spaces/${encodeURIComponent(spaceId)}/auto-prompter`; const autoPrompterResponse = async (res: Response): Promise => { const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error ?? 'Failed to update Auto Prompter'); return data as SpaceAutoPrompterResponse; }; export async function fetchSpaceAutoPrompter(spaceId: string): Promise { return autoPrompterResponse(await fetch(autoPrompterBase(spaceId))); } export async function updateSpaceAutoPrompterEnabled( spaceId: string, enabled: boolean, ): Promise { return autoPrompterResponse(await fetch(autoPrompterBase(spaceId), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }), })); } export async function createSpaceAutoPrompterRule( spaceId: string, rule: AutoPrompterRule, ): Promise { const res = await fetch(`${autoPrompterBase(spaceId)}/rules`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(rule), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error ?? 'Failed to create Auto Prompter rule'); return data.rule as AutoPrompterRule; } export async function updateSpaceAutoPrompterRule( spaceId: string, rule: AutoPrompterRule, ): Promise { const res = await fetch(`${autoPrompterBase(spaceId)}/rules/${encodeURIComponent(rule.id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(rule), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error ?? 'Failed to update Auto Prompter rule'); return data.rule as AutoPrompterRule; } export async function deleteSpaceAutoPrompterRule( spaceId: string, ruleId: string, version: number, ): Promise { const res = await fetch(`${autoPrompterBase(spaceId)}/rules/${encodeURIComponent(ruleId)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data?.error ?? 'Failed to delete Auto Prompter rule'); } } // ─── Per-space Python packages ──────────────────────────────────────────── export interface SpacePythonPackage { name: string; spec: string; addedAt: string; } export interface SpacePythonPackagesResponse { enabled: boolean; maxPackagesPerSpace: number; preflight: { ok: boolean; reason?: string }; packages: SpacePythonPackage[]; } export async function fetchSpacePythonPackages(spaceId: string): Promise { const res = await fetch(`${BASE}/local/spaces/${spaceId}/python-packages`); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch python packages'); return data as SpacePythonPackagesResponse; } export async function addSpacePythonPackage( spaceId: string, spec: string, ): Promise<{ packages: SpacePythonPackage[] }> { const res = await fetch(`${BASE}/local/spaces/${spaceId}/python-packages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ spec }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error ?? 'Failed to add package'); return data as { packages: SpacePythonPackage[] }; } export async function removeSpacePythonPackage( spaceId: string, name: string, ): Promise<{ packages: SpacePythonPackage[] }> { const res = await fetch(`${BASE}/local/spaces/${spaceId}/python-packages/${encodeURIComponent(name)}`, { method: 'DELETE', }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error ?? 'Failed to remove package'); return data as { packages: SpacePythonPackage[] }; } // ─── Per-space webhook notifications (issue #797, PR1: Discord only) ───── export type SpaceWebhookProvider = 'discord' | 'slack' | 'teams'; export type SpaceWebhookEvent = 'succeeded' | 'failed' | 'waiting_human'; export interface SpaceWebhook { id: string; provider: SpaceWebhookProvider; label: string; events: SpaceWebhookEvent[]; includeDetails: boolean; enabled: boolean; disabledReason: string | null; lastSuccessAt: string | null; lastFailureAt: string | null; failureCount: number; createdAt: string; updatedAt: string; // NOTE: no `url` / `urlEnc` field — the server never returns the webhook // URL once saved (write-only secret). } export interface SpaceWebhookList { webhooks: SpaceWebhook[]; /** * Server-side master switch (`webhooks.enabled` in config.yaml). When * false the delivery subsystem was never started, so a test send is * refused before any connection is opened. The screen has to say so — * without it the only feedback was a bare "送信失敗" (issue #1125). */ deliveryEnabled: boolean; } export async function fetchSpaceWebhooks(spaceId: string): Promise { const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks`); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch webhooks'); return { webhooks: (data?.webhooks ?? []) as SpaceWebhook[], // Absent field → treated as enabled. Blocking test sends on a server // that is actually delivering would be the worse mistake of the two. deliveryEnabled: data?.deliveryEnabled !== false, }; } export async function createSpaceWebhook( spaceId: string, input: { provider: SpaceWebhookProvider; label: string; url: string; events: SpaceWebhookEvent[]; includeDetails?: boolean }, ): Promise { const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error ?? 'Failed to create webhook'); return data as SpaceWebhook; } export async function updateSpaceWebhook( spaceId: string, webhookId: string, patch: { label?: string; url?: string; events?: SpaceWebhookEvent[]; includeDetails?: boolean }, ): Promise { const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks/${webhookId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error ?? 'Failed to update webhook'); return data as SpaceWebhook; } export async function setSpaceWebhookEnabled( spaceId: string, webhookId: string, enabled: boolean, ): Promise { const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks/${webhookId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error ?? 'Failed to update webhook'); return data as SpaceWebhook; } export async function deleteSpaceWebhook(spaceId: string, webhookId: string): Promise { const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks/${webhookId}`, { method: 'DELETE' }); if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d?.error ?? 'Failed to delete webhook'); } } /** * Why a test send failed, so the screen can word it differently (issue * #1125): delivery is switched off server-side, the destination answered and * refused (`status` carries its HTTP status), or nothing answered at all. */ export type SpaceWebhookTestFailureCode = | 'delivery_disabled' | 'rejected' | 'network' | 'blocked' | 'invalid_url' | 'not_found' | 'not_implemented' | 'internal'; export interface SpaceWebhookTestResult { ok: boolean; error?: string; code?: SpaceWebhookTestFailureCode; status?: number; } export async function testSendSpaceWebhook( spaceId: string, webhookId: string, ): Promise { const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks/${webhookId}/test`, { method: 'POST' }); const data = await res.json().catch(() => ({})); if (!res.ok) { return { ok: false, error: data?.error ?? 'Failed to send test notification', code: data?.code, status: data?.status, }; } return data as SpaceWebhookTestResult; } export async function createSpace(input: { title: string; description?: string; brandColor?: string | null; visibility?: Space['visibility']; /** * 用途テンプレート(issue #993 S2-3)。差し替わるのは AGENTS.md の雛形だけで、 * ワークスペースの属性としては残らない。未指定なら従来どおりの雛形。 */ template?: SpaceTemplate; }): Promise { const res = await fetch(`${BASE}/local/spaces`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to create space'); return data as Space; } export async function updateSpace( id: string, patch: Partial>, ): Promise { const res = await fetch(`${BASE}/local/spaces/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to update space'); return data as Space; } export async function archiveSpace(id: string): Promise { const res = await fetch(`${BASE}/local/spaces/${id}/archive`, { method: 'POST' }); if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d?.error ?? 'Failed to archive space'); } }