feat(space): 共享工作区增量3a —— Agent 编排按 Space 共享(纯 PG 打样)
引入 Space 中间容器(租户>Space>成员),资源作用域从 owner 改为 space_id,
让"个人私有/项目临时组队/整租户共享"出自同一模型(设计见 SPACE_DESIGN.md)。
先在纯 PG 的 Agent 上打样,零存储风险,验证协作+RBAC+切换 UX。
后端:
- 新表 Space{tenant_id,name,kind,creator,archived} + SpaceMember{space_id,user_id,role}
(如 Tenant 般不 isTenantScoped);User.ActiveSpaceID;Agent 作用域 owner→space_id,
owner 降级为创建人(供 UI 显示 / 删他人鉴权)
- store/space.go:个人空间幂等/活跃空间解析/切换/列表/建/成员CRUD/归档
- 迁移顺序坑:结构体只放非唯一 index,MigrateAgentSpaces 回填 space_id 后再建唯一
索引 idx_agent_sn + DROP 旧 idx_agent_on(否则存量空 space_id 撞车);启动序4步幂等
- 中间件 SpaceContext(注入 space_id) + RequireSpaceRole(照 RequireTenantRole)
- handler/space.go 空间端点 + 路由;agent.go 改空间作用域(删/覆盖他人需 admin)
- 计费零改动(Space 与 ResolveBillingTenantID 正交)
桌面端:
- api.ts space 接口;顶栏 SpaceSwitcher(含新建项目空间);StudioView 随空间切换
重拉编排 + viewer 禁保存;Agent 列表显示创建人 + 按 mine 控删除
验证:中间件6门控单测 + DB迁移(13个人空间/9 Agent全re-key/索引换新) + 后端HTTP全
场景(member见他人编排/删他人403、viewer存403、非成员切空间400+隔离、owner删他人200)
+ 浏览器实机(切换器3空间/Studio空间编排随切换隔离刷新/创建人显示/console无错)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@ import { Placeholder } from "./views/Placeholder";
|
||||
import { CommandPalette, type Command } from "./components/CommandPalette";
|
||||
import { UpdateBanner } from "./components/UpdateBanner";
|
||||
import { Login } from "./views/Login";
|
||||
import { submitTask, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, tenantCurrent, myTenants, switchTenant, type Identity, type AuthUser, type TenantCtx, type MyTenant } from "./lib/api";
|
||||
import { submitTask, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, tenantCurrent, myTenants, switchTenant, spaceCurrent, mySpaces, switchSpace, createSpace, type Identity, type AuthUser, type TenantCtx, type MyTenant, type SpaceCtx, type MySpace } from "./lib/api";
|
||||
import type { TaskDsl } from "./lib/dsl";
|
||||
import { emptyRun, type RunState } from "./lib/run";
|
||||
import { ToastProvider } from "./ui";
|
||||
@@ -44,6 +44,8 @@ export default function App() {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [tenant, setTenant] = useState<TenantCtx | null>(null);
|
||||
const [tenants, setTenants] = useState<MyTenant[]>([]);
|
||||
const [space, setSpace] = useState<SpaceCtx | null>(null);
|
||||
const [spaces, setSpaces] = useState<MySpace[]>([]);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const identity = useMemo<Identity>(() => ({ userId: user?.id ?? "", sessionId: getSessionId() }), [user]);
|
||||
const [run, setRun] = useState<RunState>(emptyRun);
|
||||
@@ -88,7 +90,7 @@ export default function App() {
|
||||
setTenant(null);
|
||||
}, []);
|
||||
|
||||
// 租户上下文 + 积分余额:登录后拉取,并每 20s 轮询保持大致实时(顶栏余额芯片用)。
|
||||
// 租户 + 工作区(Space)上下文 + 积分余额:登录后拉取,并每 20s 轮询保持大致实时。
|
||||
const refreshTenant = useCallback(() => {
|
||||
tenantCurrent()
|
||||
.then(setTenant)
|
||||
@@ -96,11 +98,28 @@ export default function App() {
|
||||
myTenants()
|
||||
.then((r) => setTenants(r.tenants))
|
||||
.catch(() => {});
|
||||
spaceCurrent()
|
||||
.then(setSpace)
|
||||
.catch(() => {});
|
||||
mySpaces()
|
||||
.then((r) => setSpaces(r.spaces))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const onSwitchTenant = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await switchTenant(id);
|
||||
refreshTenant(); // 切租户会重置活跃空间到该租户个人空间,一并刷新
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[refreshTenant],
|
||||
);
|
||||
const onSwitchSpace = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await switchSpace(id);
|
||||
refreshTenant();
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -108,10 +127,24 @@ export default function App() {
|
||||
},
|
||||
[refreshTenant],
|
||||
);
|
||||
const onCreateSpace = useCallback(
|
||||
async (name: string) => {
|
||||
try {
|
||||
const { id } = await createSpace(name, "project");
|
||||
await switchSpace(id); // 建完即切入
|
||||
refreshTenant();
|
||||
} catch (e) {
|
||||
window.alert((e as Error).message);
|
||||
}
|
||||
},
|
||||
[refreshTenant],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setTenant(null);
|
||||
setTenants([]);
|
||||
setSpace(null);
|
||||
setSpaces([]);
|
||||
return;
|
||||
}
|
||||
refreshTenant();
|
||||
@@ -255,7 +288,7 @@ export default function App() {
|
||||
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
|
||||
/>
|
||||
<UpdateBanner />
|
||||
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
|
||||
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} space={space} spaces={spaces} onSwitchSpace={onSwitchSpace} onCreateSpace={onCreateSpace} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
|
||||
<ApprovalBar run={run} />
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<LeftNav active={view} onSelect={setView} />
|
||||
@@ -263,7 +296,7 @@ export default function App() {
|
||||
{view === "home" ? (
|
||||
<Home onSelect={setView} />
|
||||
) : view === "studio" ? (
|
||||
<StudioView onRun={onRun} phase={run.phase} identity={identity} readOnly={tenant?.role === "viewer"} />
|
||||
<StudioView onRun={onRun} phase={run.phase} identity={identity} readOnly={tenant?.role === "viewer"} spaceId={space?.space?.id ?? ""} spaceReadOnly={space?.role === "viewer"} />
|
||||
) : view === "kb" ? (
|
||||
<KbView identity={identity} />
|
||||
) : view === "report" ? (
|
||||
|
||||
Reference in New Issue
Block a user