diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index bb9930db..e81a3400 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -185,6 +185,7 @@ "navAnnouncements": "Announcements", "navOrgChart": "Org chart", "navEmployees": "Employees", + "navGovernance": "Governance", "announcementsTitle": "Announcements", "announcementsBody": "Publish notices and track who has read them. This page lives in the host — no sidecar or second port.", "openAnnouncements": "Open announcements", @@ -193,7 +194,10 @@ "openOrgChart": "Open org chart", "employeesTitle": "Employees", "employeesBody": "Directory of people and AI colleagues on the host org tree. Same store as the org chart — no sidecar or second port.", - "openEmployees": "Open employees" + "openEmployees": "Open employees", + "governanceTitle": "Governance", + "governanceBody": "Pending approvals and the host audit log. High-risk tools stay blocked until a human decides — no sidecar or second port.", + "openGovernance": "Open governance" }, "systemSettings": { "subtitle": "Workbench, remote desktop, and host administration in one place." diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 1537a7b8..7b0c26ae 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -185,6 +185,7 @@ "navAnnouncements": "通知公告", "navOrgChart": "组织架构", "navEmployees": "员工目录", + "navGovernance": "治理", "announcementsTitle": "通知公告", "announcementsBody": "发布公告并跟踪已读。此页跑在宿主内,不需要边车或第二端口。", "openAnnouncements": "打开通知公告", @@ -193,7 +194,10 @@ "openOrgChart": "打开组织架构", "employeesTitle": "员工目录", "employeesBody": "宿主组织树上的人机员工名册。与组织架构共用同一份数据,不需要边车或第二端口。", - "openEmployees": "打开员工目录" + "openEmployees": "打开员工目录", + "governanceTitle": "治理", + "governanceBody": "待人工复核与宿主审计日志。高风险工具在有人拍板前保持拦截,不需要边车或第二端口。", + "openGovernance": "打开治理" }, "systemSettings": { "subtitle": "工作台、远程桌面与主机管理集中在这里。" diff --git a/dashboard/src/org-ui/api/createClient.test.ts b/dashboard/src/org-ui/api/createClient.test.ts index ed73bb72..04d14a94 100644 --- a/dashboard/src/org-ui/api/createClient.test.ts +++ b/dashboard/src/org-ui/api/createClient.test.ts @@ -40,6 +40,12 @@ describe("createOrgApiClient", () => { ], }; } + if (path === "/org-module/governance/pauses") { + return { + pauses: [{ pause_id: "p1", status: "pending", tool_name: "delete" }], + enabled: true, + }; + } throw new Error(`unexpected ${path}`); }); const client = createOrgApiClient({ fetchJson }); @@ -53,6 +59,7 @@ describe("createOrgApiClient", () => { expect(await client.announcements.unread()).toEqual({ count: 2 }); expect((await client.org.tree())[0]?.name).toBe("HQ"); expect((await client.employees.list())[0]?.name).toBe("Ada"); + expect((await client.governance.pauses()).pauses[0]?.pause_id).toBe("p1"); expect(fetchJson).toHaveBeenCalled(); }); diff --git a/dashboard/src/org-ui/api/createClient.ts b/dashboard/src/org-ui/api/createClient.ts index ec35f506..b3f71bf6 100644 --- a/dashboard/src/org-ui/api/createClient.ts +++ b/dashboard/src/org-ui/api/createClient.ts @@ -149,10 +149,77 @@ export interface OrgEmployeesClient { remove(id: number): Promise; } +export type GovernancePauseStatus = + | "pending" + | "approved" + | "rejected" + | "expired"; + +export interface GovernancePause { + pause_id: string; + tool_name: string; + category: string; + action: string; + actor_id: string; + tenant_id: string; + args_digest: string; + reason: string; + status: GovernancePauseStatus | string; + created_at: number; + resolved_at: number | null; + ttl_seconds: number; +} + +export interface GovernanceAuditEvent { + audit_id?: string; + event?: string; + tool_name?: string; + category?: string; + action?: string; + actor_id?: string; + tenant_id?: string; + args_digest?: string; + result?: string; + execute?: boolean; + reason?: string; + pause_id?: string; + rule_source?: string; + ts?: number; + [key: string]: unknown; +} + +export interface GovernanceDecision { + status: string; + execute: boolean; + blocked: boolean; + reason: string; + category?: string; + pause_id?: string; + rule_source?: string; + sidecar_reached?: boolean; + audit_id?: string; +} + +export interface GovernancePauseList { + pauses: GovernancePause[]; + enabled?: boolean; +} + +export interface GovernanceAuditList { + events: GovernanceAuditEvent[]; +} + +export interface OrgGovernanceClient { + pauses(status?: string): Promise; + audit(limit?: number): Promise; + resolve(pauseId: string, approve: boolean): Promise; +} + export interface OrgApiClient { announcements: OrgAnnouncementsClient; org: OrgChartClient; employees: OrgEmployeesClient; + governance: OrgGovernanceClient; } async function unwrap(payload: OrgEnvelope): Promise { @@ -170,9 +237,11 @@ export function createOrgApiClient(opts: { fetchJson: OrgFetcher; announcementsPrefix?: string; orgPrefix?: string; + governancePrefix?: string; }): OrgApiClient { const prefix = opts.announcementsPrefix ?? "/org-module/announcements"; const orgPrefix = opts.orgPrefix ?? "/org-module/org"; + const governancePrefix = opts.governancePrefix ?? "/org-module/governance"; const fetchJson = opts.fetchJson; const announcements: OrgAnnouncementsClient = { @@ -375,5 +444,26 @@ export function createOrgApiClient(opts: { }, }; - return { announcements, org, employees }; + const governance: OrgGovernanceClient = { + async pauses(status) { + const query = status ? `?status=${encodeURIComponent(status)}` : ""; + return fetchJson( + `${governancePrefix}/pauses${query}`, + ); + }, + async audit(limit = 50) { + return fetchJson( + `${governancePrefix}/audit?limit=${limit}`, + ); + }, + async resolve(pauseId, approve) { + return fetchJson(`${governancePrefix}/resolve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pause_id: pauseId, approve }), + }); + }, + }; + + return { announcements, org, employees, governance }; } diff --git a/dashboard/src/org-ui/contract.ts b/dashboard/src/org-ui/contract.ts index 9b848c25..8b1364e7 100644 --- a/dashboard/src/org-ui/contract.ts +++ b/dashboard/src/org-ui/contract.ts @@ -3,6 +3,7 @@ export const SHARED_ORG_UI_MODULES = [ "announcements", "organization", "employees", + "governance", ] as const; export type SharedOrgUiModule = (typeof SHARED_ORG_UI_MODULES)[number]; diff --git a/dashboard/src/org-ui/index.ts b/dashboard/src/org-ui/index.ts index eb47367a..e65f7ab5 100644 --- a/dashboard/src/org-ui/index.ts +++ b/dashboard/src/org-ui/index.ts @@ -9,6 +9,12 @@ export type { EmployeeListParams, EmployeeStats, EmployeeWrite, + GovernanceAuditEvent, + GovernanceAuditList, + GovernanceDecision, + GovernancePause, + GovernancePauseList, + GovernancePauseStatus, OrgAnnouncementsClient, OrgApiClient, OrgChartClient, @@ -16,6 +22,7 @@ export type { OrgEmployee, OrgEmployeesClient, OrgEnvelope, + OrgGovernanceClient, } from "./api/createClient"; export { AnnouncementPage } from "./pages/announcements/AnnouncementPage"; export type { AnnouncementPageProps } from "./pages/announcements/AnnouncementPage"; @@ -25,6 +32,8 @@ export { EmployeesPage } from "./pages/employees/EmployeesPage"; export type { EmployeesPageProps } from "./pages/employees/EmployeesPage"; export { EmployeeDetailPage } from "./pages/employees/EmployeeDetailPage"; export type { EmployeeDetailPageProps } from "./pages/employees/EmployeeDetailPage"; +export { GovernancePage } from "./pages/governance/GovernancePage"; +export type { GovernancePageProps } from "./pages/governance/GovernancePage"; export type { IdentityBridge, OrgFetcher, diff --git a/dashboard/src/org-ui/pages/governance/GovernancePage.module.css b/dashboard/src/org-ui/pages/governance/GovernancePage.module.css new file mode 100644 index 00000000..56fac9c9 --- /dev/null +++ b/dashboard/src/org-ui/pages/governance/GovernancePage.module.css @@ -0,0 +1,121 @@ +.page { + max-width: 1040px; + margin: 0 auto; + padding: 8px 4px 32px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.titleRow { + display: flex; + align-items: center; + gap: 10px; +} + +.title { + margin: 0; + font-size: 20px; + font-weight: 650; +} + +.subtitle { + margin: 4px 0 0; + color: var(--fn-text-secondary, #667085); + font-size: 13px; +} + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; +} + +.stat { + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 10px; + padding: 12px 14px; + background: var(--fn-bg-container, #fff); +} + +.statValue { + font-size: 22px; + font-weight: 650; + line-height: 1.2; +} + +.statLabel { + margin-top: 4px; + font-size: 12px; + color: var(--fn-text-secondary, #667085); +} + +.hint { + margin: 0; + color: var(--fn-text-secondary, #667085); + font-size: 13px; + line-height: 1.5; +} + +.empty { + text-align: center; + padding: 48px 12px; + color: var(--fn-text-secondary, #667085); +} + +.tableWrap { + overflow-x: auto; + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 12px; + background: var(--fn-bg-container, #fff); +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + +.reason { + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detailGrid { + display: grid; + grid-template-columns: 1fr; + gap: 8px; +} + +.row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 0; + font-size: 14px; + border-bottom: 1px solid var(--fn-border, #f0f0f0); +} + +.row:last-child { + border-bottom: none; +} + +.muted { + color: var(--fn-text-secondary, #667085); +} + +.body { + margin: 0; + white-space: pre-wrap; + line-height: 1.6; + font-size: 13px; +} diff --git a/dashboard/src/org-ui/pages/governance/GovernancePage.test.tsx b/dashboard/src/org-ui/pages/governance/GovernancePage.test.tsx new file mode 100644 index 00000000..989e9bfa --- /dev/null +++ b/dashboard/src/org-ui/pages/governance/GovernancePage.test.tsx @@ -0,0 +1,104 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { GovernancePage } from "./GovernancePage"; +import type { OrgGovernanceClient } from "../../api/createClient"; + +function mockClient(): OrgGovernanceClient { + return { + pauses: vi.fn(async () => ({ + enabled: true, + pauses: [ + { + pause_id: "abc123", + tool_name: "delete_employee", + category: "delete", + action: "delete", + actor_id: "1", + tenant_id: "default", + args_digest: "deadbeef", + reason: "default deny", + status: "pending", + created_at: 1_700_000_000, + resolved_at: null, + ttl_seconds: 1800, + }, + ], + })), + audit: vi.fn(async () => ({ + events: [ + { + audit_id: "a1", + event: "check", + tool_name: "delete_employee", + result: "pending", + reason: "default deny", + pause_id: "abc123", + ts: 1_700_000_000, + }, + ], + })), + resolve: vi.fn(async () => ({ + status: "allow", + execute: false, + blocked: true, + reason: "approved", + pause_id: "abc123", + })), + }; +} + +const adminSession = { + userId: 1, + displayName: "Ada", + role: "admin", + isAdmin: true, +}; + +const memberSession = { + userId: 2, + displayName: "Mo", + role: "user", + isAdmin: false, +}; + +describe("GovernancePage", () => { + it("renders the shared page without an iframe", async () => { + const client = mockClient(); + render( + , + ); + expect(await screen.findByTestId("org-ui-governance")).toBeInTheDocument(); + expect(screen.getByText("Governance")).toBeInTheDocument(); + expect(screen.getByText("delete_employee")).toBeInTheDocument(); + expect(document.querySelector("iframe")).toBeNull(); + expect(client.pauses).toHaveBeenCalled(); + expect(client.audit).toHaveBeenCalled(); + }); + + it("lets an admin approve through the injected client", async () => { + const client = mockClient(); + render( + , + ); + fireEvent.click(await screen.findByTestId("org-gov-approve-abc123")); + fireEvent.click( + await screen.findByTestId("org-gov-confirm-approve-abc123"), + ); + await waitFor(() => { + expect(client.resolve).toHaveBeenCalledWith("abc123", true); + }); + }); + + it("hides resolve actions for non-admins", async () => { + const client = mockClient(); + render( + , + ); + expect(await screen.findByTestId("org-ui-governance")).toBeInTheDocument(); + expect(screen.queryByTestId("org-gov-approve-abc123")).toBeNull(); + expect(screen.queryByTestId("org-gov-reject-abc123")).toBeNull(); + expect( + screen.getByText("Approve and reject are limited to administrators."), + ).toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/org-ui/pages/governance/GovernancePage.tsx b/dashboard/src/org-ui/pages/governance/GovernancePage.tsx new file mode 100644 index 00000000..2ba35be9 --- /dev/null +++ b/dashboard/src/org-ui/pages/governance/GovernancePage.tsx @@ -0,0 +1,406 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Modal, Space, Table, Tabs, Tag, Typography } from "antd"; +import { History, RefreshCw, Shield } from "lucide-react"; +import type { + GovernanceAuditEvent, + GovernancePause, + OrgGovernanceClient, +} from "../../api/createClient"; +import type { OrgLocale, OrgSession } from "../../shell"; +import { governanceLabels } from "./labels"; +import styles from "./GovernancePage.module.css"; + +export interface GovernancePageProps { + client: OrgGovernanceClient; + session: OrgSession; + locale: OrgLocale; + formatDateTime?: (iso: string) => string; + /** Present so export App.tsx can pass SHARED_ORG_UI_MODULES without unused props. */ + modules?: readonly string[]; +} + +function toIso(ts: number | undefined): string { + if (ts == null || !Number.isFinite(ts)) return ""; + const millis = ts > 1e12 ? ts : ts * 1000; + const date = new Date(millis); + if (Number.isNaN(date.getTime())) return ""; + return date.toISOString(); +} + +function defaultFormat(iso: string): string { + if (!iso) return ""; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toISOString().replace("T", " ").slice(0, 19); +} + +function statusColor(status: string): string { + if (status === "pending") return "gold"; + if (status === "approved" || status === "allow") return "green"; + if (status === "rejected" || status === "deny") return "red"; + if (status === "expired") return "default"; + return "blue"; +} + +export function GovernancePage({ + client, + session, + locale, + formatDateTime, +}: GovernancePageProps) { + const labels = governanceLabels(locale); + const format = formatDateTime ?? defaultFormat; + const [pauses, setPauses] = useState([]); + const [events, setEvents] = useState([]); + const [enabled, setEnabled] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [resolving, setResolving] = useState(""); + const [detail, setDetail] = useState(null); + + const fetchAll = useCallback(async () => { + setLoading(true); + setError(""); + try { + const [pausePayload, auditPayload] = await Promise.all([ + client.pauses(), + client.audit(80), + ]); + setPauses(pausePayload.pauses); + setEnabled( + typeof pausePayload.enabled === "boolean" ? pausePayload.enabled : null, + ); + setEvents(auditPayload.events.slice().reverse()); + } catch { + setPauses([]); + setEvents([]); + setError(labels.loadFailed); + } finally { + setLoading(false); + } + }, [client, labels.loadFailed]); + + useEffect(() => { + void fetchAll(); + }, [fetchAll]); + + const pending = useMemo( + () => pauses.filter((row) => row.status === "pending"), + [pauses], + ); + const allowed = events.filter((row) => row.result === "allow").length; + const denied = events.filter((row) => row.result === "deny").length; + + const resolve = async (pauseId: string, approve: boolean) => { + setResolving(pauseId); + setError(""); + try { + await client.resolve(pauseId, approve); + await fetchAll(); + } catch { + setError(labels.resolveFailed); + } finally { + setResolving(""); + } + }; + + const askResolve = (pause: GovernancePause, approve: boolean) => { + Modal.confirm({ + title: approve ? labels.confirmApprove : labels.confirmReject, + okText: approve ? labels.approve : labels.reject, + okButtonProps: { + danger: !approve, + "data-testid": approve + ? `org-gov-confirm-approve-${pause.pause_id}` + : `org-gov-confirm-reject-${pause.pause_id}`, + }, + onOk: () => resolve(pause.pause_id, approve), + }); + }; + + return ( +
+
+
+ +
+ + {labels.title} + +

{labels.subtitle}

+
+
+ + {enabled === true ? ( + + {labels.enabled} + + ) : enabled === false ? ( + {labels.disabled} + ) : null} + + +
+ + {error ? {error} : null} + +
+
+
{pending.length}
+
{labels.pendingCount}
+
+
+
{allowed}
+
{labels.allowed}
+
+
+
{denied}
+
{labels.denied}
+
+
+
{events.length}
+
{labels.totalEvents}
+
+
+ +

+ {enabled ? labels.enabledHint : labels.disabledHint} +

+ {session.isAdmin ? null : ( +

{labels.adminOnly}

+ )} + + + {loading ? ( +

{labels.loading}

+ ) : ( +

{labels.enabledHint}

+ )} +
+ ), + }, + { + key: "pending", + label: `${labels.pending} (${pending.length})`, + children: loading ? ( +

{labels.loading}

+ ) : pending.length === 0 ? ( +
+

{labels.emptyPending}

+

{labels.emptyPendingHint}

+
+ ) : ( +
+ format(toIso(value)), + }, + { title: labels.tool, dataIndex: "tool_name" }, + { + title: labels.category, + dataIndex: "category", + render: (value: string) => {value || "—"}, + }, + { + title: labels.actor, + dataIndex: "actor_id", + render: (value: string) => value || labels.noActor, + }, + { + title: labels.reason, + dataIndex: "reason", + render: (value: string) => ( + + {value} + + ), + }, + { + title: labels.pauseId, + dataIndex: "pause_id", + render: (value: string) => ( + {value} + ), + }, + { + title: labels.actions, + key: "actions", + render: (_: unknown, row: GovernancePause) => + session.isAdmin ? ( + + + + + ) : null, + }, + ]} + /> + + ), + }, + { + key: "audit", + label: labels.audit, + children: loading ? ( +

{labels.loading}

+ ) : events.length === 0 ? ( +
+ +

{labels.emptyAudit}

+

{labels.emptyAuditHint}

+
+ ) : ( +
+
+ String(row.audit_id || `${row.ts || "evt"}-${index}`) + } + dataSource={events} + pagination={{ pageSize: 20 }} + size="small" + columns={[ + { + title: labels.time, + dataIndex: "ts", + render: (value: number) => format(toIso(value)), + }, + { + title: labels.event, + dataIndex: "event", + render: (value: string) => value || "—", + }, + { + title: labels.tool, + dataIndex: "tool_name", + render: (value: string) => value || "—", + }, + { + title: labels.result, + dataIndex: "result", + render: (value: string) => + value ? ( + {value} + ) : ( + "—" + ), + }, + { + title: labels.reason, + dataIndex: "reason", + render: (value: string) => ( + + {value || "—"} + + ), + }, + { + title: labels.actions, + key: "detail", + render: (_: unknown, row: GovernanceAuditEvent) => ( + + ), + }, + ]} + /> + + ), + }, + ]} + /> + + setDetail(null)} + footer={} + > + {detail ? ( +
+
+ {labels.event} + {String(detail.event || "—")} +
+
+ {labels.tool} + {String(detail.tool_name || "—")} +
+
+ {labels.category} + {String(detail.category || "—")} +
+
+ {labels.actor} + {String(detail.actor_id || labels.noActor)} +
+
+ {labels.result} + {String(detail.result || "—")} +
+
+ {labels.pauseId} + + {String(detail.pause_id || "—")} + +
+
+ {labels.ruleSource} + {String(detail.rule_source || "—")} +
+
+ {labels.digest} + + {String(detail.args_digest || "—")} + +
+

{String(detail.reason || "")}

+
+ ) : null} +
+ + ); +} diff --git a/dashboard/src/org-ui/pages/governance/labels.ts b/dashboard/src/org-ui/pages/governance/labels.ts new file mode 100644 index 00000000..1bc35d57 --- /dev/null +++ b/dashboard/src/org-ui/pages/governance/labels.ts @@ -0,0 +1,105 @@ +import type { OrgLocale } from "../../shell"; + +const EN = { + title: "Governance", + subtitle: + "Pending human approvals and the local host audit. High-risk tools stay blocked until someone decides.", + loading: "Loading governance…", + refresh: "Refresh", + overview: "Overview", + pending: "Pending approvals", + audit: "Audit", + enabled: "Gate on", + disabled: "Gate off", + enabledHint: + "The host PEP/PDP already default-denies outbound, delete, pay, and prod. This page does not rewrite that engine.", + disabledHint: + "The interceptor is off. Pauses and audit still show what the engine recorded on disk.", + totalEvents: "Audit events", + allowed: "Allowed", + denied: "Denied", + pendingCount: "Pending", + emptyPending: "No pauses waiting for a human.", + emptyPendingHint: + "High-risk tool calls create a durable pause here. Approve or reject; the agent must re-check before it can execute.", + emptyAudit: "No governance events yet.", + emptyAuditHint: + "Policy checks write to the host audit JSONL. Sidecar SQL.js is not the source of truth.", + time: "Time", + tool: "Tool", + category: "Category", + actor: "Actor", + reason: "Reason", + status: "Status", + pauseId: "Pause id", + event: "Event", + result: "Result", + actions: "Actions", + approve: "Approve", + reject: "Reject", + confirmReject: "Reject this pause? The tool stays blocked.", + confirmApprove: + "Approve this pause? The agent must re-check with this id before execute is true.", + detail: "Details", + close: "Close", + resolveFailed: "Could not resolve this pause", + loadFailed: "Could not load governance data", + adminOnly: "Approve and reject are limited to administrators.", + ruleSource: "Rule source", + digest: "Args digest", + noActor: "Unknown actor", +}; + +const ZH: typeof EN = { + title: "治理", + subtitle: + "待人工复核的暂停,以及宿主本地审计。高风险工具在有人拍板前保持拦截。", + loading: "加载治理…", + refresh: "刷新", + overview: "概览", + pending: "待审批", + audit: "审计", + enabled: "闸门已开", + disabled: "闸门关闭", + enabledHint: + "宿主 PEP/PDP 已对 outbound / delete / pay / prod 默认拒绝。本页只展示与裁决,不重写引擎。", + disabledHint: "进程内拦截器未开启。磁盘上已有的暂停与审计仍可在此查看。", + totalEvents: "审计事件", + allowed: "通过", + denied: "拒绝", + pendingCount: "待审批", + emptyPending: "没有等待人工处理的暂停。", + emptyPendingHint: + "高风险工具调用会在此留下持久暂停。批准或驳回后,智能体必须带着同一 id 再检查才能执行。", + emptyAudit: "还没有治理事件。", + emptyAuditHint: "策略检查写入宿主 audit JSONL。边车 SQL.js 不是事实来源。", + time: "时间", + tool: "工具", + category: "类别", + actor: "操作者", + reason: "原因", + status: "状态", + pauseId: "暂停 ID", + event: "事件", + result: "结果", + actions: "操作", + approve: "批准", + reject: "驳回", + confirmReject: "驳回该暂停?工具将保持拦截。", + confirmApprove: + "批准该暂停?智能体必须带着此 id 再检查,execute 才会为 true。", + detail: "详情", + close: "关闭", + resolveFailed: "无法处理该暂停", + loadFailed: "无法加载治理数据", + adminOnly: "批准与驳回仅管理员可用。", + ruleSource: "规则来源", + digest: "参数摘要", + noActor: "未知操作者", +}; + +export type GovernanceLabels = typeof EN; + +export function governanceLabels(locale: OrgLocale): GovernanceLabels { + return locale === "zh" ? ZH : EN; +} diff --git a/dashboard/src/pages/Organization/governance/index.tsx b/dashboard/src/pages/Organization/governance/index.tsx new file mode 100644 index 00000000..b82b71c7 --- /dev/null +++ b/dashboard/src/pages/Organization/governance/index.tsx @@ -0,0 +1,50 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { request } from "../../../api/request"; +import { useCurrentUser } from "../../../hooks/useCurrentUser"; +import { useServerTimezone } from "../../../hooks/useServerTimezone"; +import PageShell from "../../../layouts/PageShell"; +import { + GovernancePage, + createOrgApiClient, + type OrgSession, +} from "../../../org-ui"; +import { formatServerIsoDateTime } from "../../../utils/formatMessageTime"; +import { useOrgPathTabs } from "../orgPathTabs"; + +export default function OrganizationGovernancePage() { + const { t, i18n } = useTranslation(); + const user = useCurrentUser(); + const timeZone = useServerTimezone(); + const pathTabs = useOrgPathTabs("governance"); + const client = useMemo( + () => + createOrgApiClient({ + fetchJson: (path, init) => request(path, init), + }), + [], + ); + const locale = i18n.language?.toLowerCase().startsWith("zh") ? "zh" : "en"; + const session: OrgSession = { + userId: user?.id ?? 0, + displayName: user?.display_name || user?.username || "", + role: user?.role || "user", + isAdmin: user?.role === "admin", + }; + + return ( + + formatServerIsoDateTime(iso, timeZone)} + /> + + ); +} diff --git a/dashboard/src/pages/Organization/index.test.tsx b/dashboard/src/pages/Organization/index.test.tsx index 2fca3cd6..15306efc 100644 --- a/dashboard/src/pages/Organization/index.test.tsx +++ b/dashboard/src/pages/Organization/index.test.tsx @@ -24,6 +24,10 @@ function renderOrg() { path="/organization/employees" element={
employees
} /> + governance} + /> experts} @@ -157,6 +161,7 @@ describe("OrganizationPage", () => { expect(screen.getByTestId("org-open-announcements")).toBeInTheDocument(); expect(screen.getByTestId("org-open-chart")).toBeInTheDocument(); expect(screen.getByTestId("org-open-employees")).toBeInTheDocument(); + expect(screen.getByTestId("org-open-governance")).toBeInTheDocument(); expect(screen.queryByTestId("org-mini-browser")).toBeNull(); expect(screen.queryByTestId("org-browser-frame")).toBeNull(); expect(screen.queryByTestId("org-sidecar-gate")).toBeNull(); @@ -226,6 +231,15 @@ describe("OrganizationPage", () => { expect(screen.queryByTestId("org-browser-frame")).toBeNull(); }); + it("opens in-host governance without a sidecar iframe", async () => { + renderOrg(); + fireEvent.click(await screen.findByTestId("org-open-governance")); + expect( + await screen.findByTestId("org-governance-page"), + ).toBeInTheDocument(); + expect(screen.queryByTestId("org-browser-frame")).toBeNull(); + }); + it("imports into FreeOS and opens Experts so colleagues are selectable", async () => { vi.mocked(orgModuleApi.assemble).mockResolvedValue({ sidecar_reachable: false, diff --git a/dashboard/src/pages/Organization/index.tsx b/dashboard/src/pages/Organization/index.tsx index 6994a6a2..c9c5f757 100644 --- a/dashboard/src/pages/Organization/index.tsx +++ b/dashboard/src/pages/Organization/index.tsx @@ -450,6 +450,22 @@ export default function OrganizationPage() { {t("organization.openEmployees")} +
+ +

+ {t("organization.governanceTitle")} +

+

+ {t("organization.governanceBody")} +

+ +
{firstRun && ( diff --git a/dashboard/src/pages/Organization/orgPathTabs.tsx b/dashboard/src/pages/Organization/orgPathTabs.tsx index e8a98cd0..14f8bc05 100644 --- a/dashboard/src/pages/Organization/orgPathTabs.tsx +++ b/dashboard/src/pages/Organization/orgPathTabs.tsx @@ -1,10 +1,10 @@ -import { Building2, Megaphone, Network, Users } from "lucide-react"; +import { Building2, Megaphone, Network, Shield, Users } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import type { PathTabsConfig } from "../../layouts/PageShell"; export function useOrgPathTabs( - active: "workbench" | "announcements" | "org" | "employees", + active: "workbench" | "announcements" | "org" | "employees" | "governance", ): PathTabsConfig { const { t } = useTranslation(); const navigate = useNavigate(); @@ -31,6 +31,11 @@ export function useOrgPathTabs( label: t("organization.navEmployees"), icon: , }, + { + value: "governance", + label: t("organization.navGovernance"), + icon: , + }, ], onChange: (value) => { if (value === "announcements") { @@ -45,6 +50,10 @@ export function useOrgPathTabs( navigate("/organization/employees"); return; } + if (value === "governance") { + navigate("/organization/governance"); + return; + } navigate("/organization"); }, }; diff --git a/dashboard/src/routes/controlAdminPath.test.ts b/dashboard/src/routes/controlAdminPath.test.ts index a1f315b3..8ba4c808 100644 --- a/dashboard/src/routes/controlAdminPath.test.ts +++ b/dashboard/src/routes/controlAdminPath.test.ts @@ -70,6 +70,7 @@ describe("pathPermissionKeys", () => { expect(pathPermissionKeys("/organization/announcements")).toBeNull(); expect(pathPermissionKeys("/organization/org")).toBeNull(); expect(pathPermissionKeys("/organization/employees")).toBeNull(); + expect(pathPermissionKeys("/organization/governance")).toBeNull(); expect(pathPermissionKeys("/projects")).toBeNull(); expect(pathPermissionKeys("/tasks")).toBeNull(); expect(pathPermissionKeys("/token-usage")).toBeNull(); @@ -155,6 +156,7 @@ describe("unknown dashboard paths", () => { expect(resolveSelectedKey("/organization/employees/4")).toBe( "organization", ); + expect(resolveSelectedKey("/organization/governance")).toBe("organization"); }); it("are caught by the not-found route", () => { diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index 2831f20c..b01fe6b2 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -28,6 +28,9 @@ const OrganizationEmployeesPage = lazy( const OrganizationEmployeeDetailPage = lazy( () => import("../pages/Organization/employees/detail"), ); +const OrganizationGovernancePage = lazy( + () => import("../pages/Organization/governance"), +); const ProjectsPage = lazy(() => import("../pages/Projects")); // Misc @@ -84,6 +87,7 @@ export const pathToKey: Record = { "/organization/announcements": "organization", "/organization/org": "organization", "/organization/employees": "organization", + "/organization/governance": "organization", "/projects": "projects", "/tasks": "projects", "/connectors": "personalization", @@ -230,6 +234,10 @@ export const routeConfigs: RouteConfig[] = [ path: "/organization/employees/:id", element: , }, + { + path: "/organization/governance", + element: , + }, { path: "/projects", element: }, { path: "/tasks", element: }, { diff --git a/docs/architecture-integration.md b/docs/architecture-integration.md index 1780e1fb..fe2d7520 100644 --- a/docs/architecture-integration.md +++ b/docs/architecture-integration.md @@ -290,7 +290,7 @@ The Phase A work sits on the existing FreeOS bootstrap: | Self-growth loop | Bridge | `freeos org loop run` · `/api/org-module/loop/run` | | Colleague agents | Host agents table | `org-` · `{home}/org-agents/` · tenant `routing.json` | | Org APIs | Host BFF + optional sidecar | `/api/org-module/*` in-host (announcements + org chart CRUD are in-host); sidecar `/api/org`, `/api/employees`, … until each remaining CRUD slice moves (see [org-merge-plan.md](org-merge-plan.md)) | -| Org UI | Host Dashboard (`org-ui`) | Native `/organization` workbench + `/organization/announcements` + `/organization/org` + `/organization/employees` (shared `dashboard/src/org-ui`; directory employees reuse org-chart SQLite). Standalone site is **exported** from the same source (`freeos org export-standalone`). Sidecar `:3780` iframe is opt-in compat only | +| Org UI | Host Dashboard (`org-ui`) | Native `/organization` workbench + `/organization/announcements` + `/organization/org` + `/organization/employees` + `/organization/governance` (shared `dashboard/src/org-ui`; directory employees reuse org-chart SQLite; governance reads host pauses/audit). Standalone site is **exported** from the same source (`freeos org export-standalone`). Sidecar `:3780` iframe is opt-in compat only | ### Auth and tenant mapping diff --git a/docs/org-merge-plan.md b/docs/org-merge-plan.md index ee1bcd71..0da1501a 100644 --- a/docs/org-merge-plan.md +++ b/docs/org-merge-plan.md @@ -1,6 +1,6 @@ # 组织模块合并计划(openXYOS → FreeOS Dashboard) -**状态:** Phase 3 组织架构与员工目录切片已落地(宿主 org tree / 员工 CRUD + `/organization/org` + `/organization/employees` + `org-ui` OrgChartPage / EmployeesPage + export-standalone 模块列表)。其余 Open-12 仍待切片。 +**状态:** Phase 3 组织架构、员工目录与治理 UI 切片已落地(宿主 org tree / 员工 CRUD + 治理 pauses/audit + `/organization/org` + `/organization/employees` + `/organization/governance` + `org-ui` OrgChartPage / EmployeesPage / GovernancePage + export-standalone 模块列表)。其余 Open-12 仍待切片。 **日期:** 2026-09-19 **依据:** Phase 0 迁移图、[architecture-integration.md](architecture-integration.md)、[asset-loop.md](asset-loop.md)、[ADR 001](adr/001-single-process-model.md)、#55 宿主内 Organization @@ -25,7 +25,7 @@ Phase 1 冻结了合同。Phase 2(通知公告)与 Phase 3 的组织架构 | **0** 宿主内地基 | `org_os` 控制面、`/api/org-module/*` BFF、Dashboard 原生工作台、sidecar 改为可选 | **已完成**(#55) | | **1** 合同冻结 | ADR + 本计划:单源双交付、包布局、API 归属、IdentityBridge、Phase 2 验收 | **本文** | | **2** 垂直切片 | **通知公告 Announcements** 按合同落地(Dashboard 路由 + 可导出同一页面) | **已完成** | -| **3** Open-12 其余页 | 工作台、组织架构、员工、技能、智能体、任务、知识、反思、治理 UI、设置 | **进行中**:组织架构与员工目录已迁;其余未开始 | +| **3** Open-12 其余页 | 工作台、组织架构、员工、技能、智能体、任务、知识、反思、治理 UI、设置 | **进行中**:组织架构、员工目录与治理 UI 已迁;其余未开始 | | **4** 身份与 CRUD | 已迁页面走 IdentityBridge;业务 CRUD 按切片迁入宿主;未迁路由仍可代理到可选 sidecar | 与 2–3 交叉推进 | | **5** 导出与安装器 | `freeos org export-standalone` 产出独立站;默认安装器保持零 Node | 未开始 | @@ -64,7 +64,7 @@ Catalog 十二键(`catalog.py` ↔ `open-module-catalog.ts`): | tasks | OpenApp `/tasks` | sidecar `/api/tasks` | Phase 3 | | knowledge | OpenApp `/knowledge` | sidecar `/api/knowledge` | Phase 3;与宿主知识库边界保持「组织资料 ≠ Octop RAG 库」直到单独立项 | | reflections | OpenApp `/reflections` | sidecar `/api/reflections` | Phase 3 | -| governance UI | OpenApp `/governance` | sidecar `/api/governance` + 宿主 `org_os/governance` | Phase 3:策略/审计 **展示** 迁入;PEP/PDP 与 durable pause **已在宿主,勿重写引擎** | +| governance UI | OpenApp `/governance` | sidecar `/api/governance` + 宿主 `org_os/governance` | **Phase 3 切片已落地**:`/organization/governance` 展示待审批 / 审计 / 裁决;PEP/PDP 与 durable pause **已在宿主,勿重写引擎**。未迁:边车权限矩阵、通信规则、流程模板 | | settings | OpenApp `/settings` | sidecar `/api/settings`、`/api/module-settings` | Phase 3:租户/模块开关;宿主已有 `PATCH /api/org-module` 与 `/modules` | **商业 `App.tsx` 多出来的路由(本波不迁):** workflows、contracts、assets、attendance/leave、expense、daily-report、goals、budgets、performance、efficiency、audit 等。它们不是 Open-12。 @@ -117,7 +117,7 @@ uv run freeos org export-standalone --out dist/openxyos-web | `assemble` · `produce` · `pack` · `loop/run` | 宿主 loop | 保持 | | `employees` · `employees/transition` · `employees/spawn` | 宿主 lifecycle | 保持;与员工 **目录 CRUD** 分开 | | `assets/*` · `blueprints/compile` · `skills/generate\|publish` | 宿主工厂 | 保持 | -| `governance/check\|resolve\|audit` | 宿主 PEP/PDP | 保持;治理 **UI** 另切片 | +| `governance/check\|resolve\|audit` · `pauses` | 宿主 PEP/PDP | **已挂 UI**。`GET /pauses` 补列出待审批;resolve 仍管理员/`plugins` 门控 | | `sidecar/*` · `source/download` | 可选兼容 | 留在 Advanced;默认路径不依赖 | ### 业务 CRUD(当前 sidecar Express → 按切片迁入宿主) @@ -211,7 +211,7 @@ org-ui → IdentityBridge.getSession() | 汇报线、职级、技能绑定、架构版本 | 商业 OrgChart 扩展,不是本垂直切片的最低可用集 | | 导入文件 / 导出 PNG·SVG·PDF | 依赖 sidecar 上传与 html-to-image/jsPDF | | 头像上传与预设图 | 需要独立文件存储 | -| 其余 Open-12(工作台扩展、技能、智能体、任务、知识、反思、治理 UI、设置) | 后续切片 | +| 其余 Open-12(工作台扩展、技能、智能体、任务、知识、反思、设置) | 后续切片 | | `freeos org export-standalone` 完整 Vite/Node 打包 | Phase 5 | --- @@ -244,7 +244,40 @@ openXYOS sidecar `/api/employees`(人才市场、备选、资产离职清算 | lifecycle spawn / transition UI | 已在宿主;工作台同事列表与 Experts 已覆盖 | | 人才市场、备选入职、reserve ↔ internal | sidecar 另表;本切片不做第二套 talent DB | | 资产离职清算、绩效、汇报线、技能绑定、头像上传 | 依赖未迁资源或文件存储 | -| 其余 Open-12(技能、智能体、任务、知识、反思、治理 UI、设置) | 后续切片 | +| 其余 Open-12(技能、智能体、任务、知识、反思、设置) | 后续切片 | +| `freeos org export-standalone` 完整 Vite/Node 打包 | Phase 5 | + +--- + +## Phase 3 进度:治理 UI(本切片) + +按同一缝落地,**只迁治理展示与裁决**。PEP/PDP、durable pause、audit JSONL **已在宿主**,不重写 `GovernanceEngine`。 + +### 数据模型(单一 SoT) + +| 表面 | 存储 | API | 含义 | +|---|---|---|---| +| 待审批 pause | `{FREEOS_HOME}/governance/pauses/.json` | `GET /api/org-module/governance/pauses` · `POST …/resolve` | 高风险工具默认拒绝后的人工复核 | +| 本地审计 | `{FREEOS_HOME}/governance/audit.jsonl` | `GET /api/org-module/governance/audit` | 宿主事实来源;不是 sidecar SQL.js | +| 策略检查 | 同上 | `POST /api/org-module/governance/check` | 引擎已有;UI 不另造检查器 | + +openXYOS sidecar `/api/governance`(权限矩阵、通信规则、流程模板、stats)仍是可选兼容面,默认路径不读它。 + +### 已落地 + +1. 壳无关组件:`dashboard/src/org-ui/pages/governance`(`GovernancePage`),数据经 `createOrgApiClient().governance`。 +2. Dashboard 子路由:`/organization/governance`(工作台 path tabs + 入口卡)。 +3. 宿主 API:已有 check / resolve / audit;本切片补 `GET /governance/pauses` 供列表。裁决走 `require_permission("plugins")`(管理员绕过)。 +4. 导出骨架:`freeos org export-standalone` 模块列表与 `App.tsx` 同时导入 `GovernancePage`。 + +### 本切片明确推迟 + +| 推迟项 | 原因 | +|---|---| +| Chat / 从治理页发起会话 | 对话运行时留在 FreeOS/Octop | +| 边车权限矩阵 / 通信规则 / 流程模板 CRUD | sidecar SQL.js;本波不重写引擎、不迁商业矩阵编辑器 | +| IM `/approve` 接线 | 引擎与 CLI 已能裁决;IM 另切片 | +| 其余 Open-12(技能、智能体、任务、知识、反思、设置) | 后续切片 | | `freeos org export-standalone` 完整 Vite/Node 打包 | Phase 5 | --- diff --git a/scripts/org-export/README.md b/scripts/org-export/README.md index a559f267..fdf0b93c 100644 --- a/scripts/org-export/README.md +++ b/scripts/org-export/README.md @@ -3,7 +3,7 @@ `freeos org export-standalone --out dist/openxyos-web` writes a scaffold that **imports the same org-ui pages** Dashboard mounts under `/organization/...`. -Phase 3 includes **Announcements**, **Org chart**, and **Employees**: +Phase 3 includes **Announcements**, **Org chart**, **Employees**, and **Governance**: | Delivery | Route | Component | |---|---|---| @@ -14,9 +14,12 @@ Phase 3 includes **Announcements**, **Org chart**, and **Employees**: | Dashboard | `/organization/employees` | `dashboard/src/org-ui` → `EmployeesPage` | | Dashboard | `/organization/employees/:id` | `dashboard/src/org-ui` → `EmployeeDetailPage` | | Standalone (this export) | `/employees` | same import | +| Dashboard | `/organization/governance` | `dashboard/src/org-ui` → `GovernancePage` | +| Standalone (this export) | `/governance` | same import | Directory employees share `{FREEOS_HOME}/org/org_chart.sqlite` with the org -chart. Do not copy the pages into a second tree. +chart. Governance pauses/audit live under `{FREEOS_HOME}/governance/`. +Do not copy the pages into a second tree. ## Phase 5 TODO diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py index ca3f5645..19ce57ed 100644 --- a/src/octop/api/routers/org_module.py +++ b/src/octop/api/routers/org_module.py @@ -363,6 +363,25 @@ async def governance_resolve( return decision.to_dict() +@router.get("/governance/pauses", summary="List durable governance pauses") +async def governance_pauses( + status: str = "", + server: OctopServer = Depends(get_server), + _user: Any = Depends(current_user), +) -> dict[str, Any]: + from octop.modules.org_os.governance.engine import GovernanceEngine + + wanted = status.strip() or None + if wanted and wanted not in {"pending", "approved", "rejected", "expired"}: + raise HTTPException(status_code=400, detail="invalid pause status") + service = _service(server) + engine = GovernanceEngine.from_home(service.home, sidecar_url=service.sidecar_url()) + return { + "pauses": [row.to_dict() for row in engine.store.list_pauses(wanted)], + "enabled": service.governance_enabled(), + } + + @router.get("/governance/audit", summary="Tail local governance audit JSONL") async def governance_audit( limit: int = 50, diff --git a/src/octop/cli/commands/org.py b/src/octop/cli/commands/org.py index 559d083f..1899ce0c 100644 --- a/src/octop/cli/commands/org.py +++ b/src/octop/cli/commands/org.py @@ -385,8 +385,8 @@ def export_standalone(out_dir: Path) -> None: """Scaffold a standalone Organization web from shared org-ui (Phase 3). Writes the shared module list and an App.tsx that imports AnnouncementPage, - OrgChartPage, and EmployeesPage. Full Vite + Node packaging is Phase 5 - (TODO in the generated README). + OrgChartPage, EmployeesPage, and GovernancePage. Full Vite + Node packaging + is Phase 5 (TODO in the generated README). """ from octop.modules.org_os.export_standalone import write_standalone_scaffold diff --git a/src/octop/modules/org_os/contract.py b/src/octop/modules/org_os/contract.py index 30a2f2f9..9e823805 100644 --- a/src/octop/modules/org_os/contract.py +++ b/src/octop/modules/org_os/contract.py @@ -9,6 +9,7 @@ "announcements", "organization", "employees", + "governance", ) diff --git a/src/octop/modules/org_os/export_standalone.py b/src/octop/modules/org_os/export_standalone.py index 29933cfa..1c4da9c5 100644 --- a/src/octop/modules/org_os/export_standalone.py +++ b/src/octop/modules/org_os/export_standalone.py @@ -2,8 +2,8 @@ Full Vite + Node server packaging is Phase 5. This writes a snapshot that lists the shared org-ui modules and points at the same AnnouncementPage, -OrgChartPage, and EmployeesPage sources Dashboard mounts under -``/organization/...``. +OrgChartPage, EmployeesPage, and GovernancePage sources Dashboard mounts +under ``/organization/...``. """ from __future__ import annotations @@ -27,18 +27,21 @@ | announcements | `/organization/announcements` | `/announcements` | `dashboard/src/org-ui` → `AnnouncementPage` | | organization | `/organization/org` | `/org` | `dashboard/src/org-ui` → `OrgChartPage` | | employees | `/organization/employees` | `/employees` | `dashboard/src/org-ui` → `EmployeesPage` / `EmployeeDetailPage` | +| governance | `/organization/governance` | `/governance` | `dashboard/src/org-ui` → `GovernancePage` | Do **not** copy those pages into a second tree. Edit `dashboard/src/org-ui`. Directory employees share `{FREEOS_HOME}/org/org_chart.sqlite` with the org chart. Host lifecycle colleagues stay on `GET /api/org-module/employees`. +Governance pauses and audit stay on `{FREEOS_HOME}/governance/`. ## Phase 3 vs Phase 5 Phase 3 (this scaffold): - Shared module list (`src/modules.json`) -- Thin `src/App.tsx` that imports `AnnouncementPage`, `OrgChartPage`, and `EmployeesPage` +- Thin `src/App.tsx` that imports `AnnouncementPage`, `OrgChartPage`, + `EmployeesPage`, and `GovernancePage` - Documents IdentityBridge: embedded mode uses FreeOS JWT; standalone uses local JWT Phase 5 (TODO — not implemented here): @@ -62,6 +65,7 @@ AnnouncementPage, EmployeeDetailPage, EmployeesPage, + GovernancePage, OrgChartPage, SHARED_ORG_UI_MODULES, } from "org-ui"; @@ -69,13 +73,15 @@ OrgAnnouncementsClient, OrgChartClient, OrgEmployeesClient, + OrgGovernanceClient, OrgSession, } from "org-ui"; /** * Standalone shell stub. Phase 5 wires a real IdentityBridge + local JWT. * Page components are the same ones Dashboard mounts at - * /organization/announcements, /organization/org, and /organization/employees. + * /organization/announcements, /organization/org, /organization/employees, + * and /organization/governance. */ const session: OrgSession = { userId: 0, @@ -88,6 +94,7 @@ client: OrgAnnouncementsClient; orgClient: OrgChartClient; employeesClient: OrgEmployeesClient; + governanceClient: OrgGovernanceClient; locale?: "zh" | "en"; }) { return ( @@ -117,6 +124,12 @@ employeeId={0} modules={SHARED_ORG_UI_MODULES} /> + ); } @@ -129,7 +142,7 @@ "description": ( "Standalone Organization web export skeleton. " "Pages come from dashboard/src/org-ui " - "(Phase 3: Announcements + Org chart + Employees). " + "(Phase 3: Announcements + Org chart + Employees + Governance). " "Full Node packaging is Phase 5." ), "type": "module", @@ -175,6 +188,13 @@ def write_standalone_scaffold(out_dir: Path) -> dict[str, Any]: "api": "/api/org-module/org/employees", "store": "{FREEOS_HOME}/org/org_chart.sqlite", }, + "governance": { + "component": "GovernancePage", + "embedded_route": "/organization/governance", + "standalone_route": "/governance", + "api": "/api/org-module/governance", + "store": "{FREEOS_HOME}/governance", + }, }, "todo": "Phase 5: full Vite + server packaging; do not fork org-ui pages", } diff --git a/src/octop/modules/org_os/governance/README.md b/src/octop/modules/org_os/governance/README.md index 7fc48a7d..4463a167 100644 --- a/src/octop/modules/org_os/governance/README.md +++ b/src/octop/modules/org_os/governance/README.md @@ -55,7 +55,14 @@ uv run freeos org governance reject uv run freeos org governance audit ``` -HTTP (host JWT): `POST /api/org-module/governance/check`. +HTTP (host JWT): + +- `POST /api/org-module/governance/check` +- `POST /api/org-module/governance/resolve` (plugins / admin) +- `GET /api/org-module/governance/pauses` +- `GET /api/org-module/governance/audit` + +Dashboard: `/organization/governance` (shared `org-ui` GovernancePage). ## Approval must block diff --git a/src/octop/modules/org_os/governance/store.py b/src/octop/modules/org_os/governance/store.py index f86db3e6..51e3e26b 100644 --- a/src/octop/modules/org_os/governance/store.py +++ b/src/octop/modules/org_os/governance/store.py @@ -140,6 +140,21 @@ def append_audit(self, event: dict[str, Any]) -> str: handle.write(json.dumps(row, ensure_ascii=False, default=str) + "\n") return audit_id + def list_pauses(self, status: str | None = None) -> list[PauseRecord]: + """List durable pauses. ``get()`` applies TTL so expired rows surface.""" + if not self.pauses_dir.is_dir(): + return [] + rows: list[PauseRecord] = [] + for path in self.pauses_dir.glob("*.json"): + record = self.get(path.stem) + if record is None: + continue + if status and record.status != status: + continue + rows.append(record) + rows.sort(key=lambda item: item.created_at, reverse=True) + return rows + def tail_audit(self, limit: int = 50) -> list[dict[str, Any]]: if not self.audit_file.is_file(): return [] diff --git a/tests/unit/cli/test_org_export_standalone.py b/tests/unit/cli/test_org_export_standalone.py index 88e41540..823c5674 100644 --- a/tests/unit/cli/test_org_export_standalone.py +++ b/tests/unit/cli/test_org_export_standalone.py @@ -1,4 +1,4 @@ -"""``freeos org export-standalone`` writes the shared Announcements + Org + Employees seam.""" +"""``freeos org export-standalone`` writes the shared org-ui seam.""" from __future__ import annotations @@ -10,18 +10,24 @@ from octop.cli.main import cli -def test_export_standalone_lists_announcements_org_and_employees(tmp_path: Path) -> None: +def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: out = tmp_path / "openxyos-web" runner = CliRunner() result = runner.invoke(cli, ["org", "export-standalone", "--out", str(out)]) assert result.exit_code == 0, result.output payload = json.loads(result.output) - assert payload["modules"] == ["announcements", "organization", "employees"] + assert payload["modules"] == [ + "announcements", + "organization", + "employees", + "governance", + ] modules = json.loads((out / "src" / "modules.json").read_text(encoding="utf-8")) assert modules["shared_org_ui_modules"] == [ "announcements", "organization", "employees", + "governance", ] assert modules["pages"]["announcements"]["component"] == "AnnouncementPage" assert modules["pages"]["announcements"]["embedded_route"] == "/organization/announcements" @@ -30,14 +36,19 @@ def test_export_standalone_lists_announcements_org_and_employees(tmp_path: Path) assert modules["pages"]["employees"]["component"] == "EmployeesPage" assert modules["pages"]["employees"]["embedded_route"] == "/organization/employees" assert modules["pages"]["employees"]["api"] == "/api/org-module/org/employees" + assert modules["pages"]["governance"]["component"] == "GovernancePage" + assert modules["pages"]["governance"]["embedded_route"] == "/organization/governance" + assert modules["pages"]["governance"]["api"] == "/api/org-module/governance" app = (out / "src" / "App.tsx").read_text(encoding="utf-8") assert "AnnouncementPage" in app assert "OrgChartPage" in app assert "EmployeesPage" in app assert "EmployeeDetailPage" in app + assert "GovernancePage" in app assert 'from "org-ui"' in app readme = (out / "README.md").read_text(encoding="utf-8") assert "Phase 5" in readme assert "AnnouncementPage" in readme assert "OrgChartPage" in readme assert "EmployeesPage" in readme + assert "GovernancePage" in readme diff --git a/tests/unit/test_org_chart.py b/tests/unit/test_org_chart.py index 3dc38b17..62f2f973 100644 --- a/tests/unit/test_org_chart.py +++ b/tests/unit/test_org_chart.py @@ -43,6 +43,7 @@ def test_shared_organization_module_is_in_catalog() -> None: assert "organization" in SHARED_ORG_UI_MODULES assert "announcements" in SHARED_ORG_UI_MODULES assert "employees" in SHARED_ORG_UI_MODULES + assert "governance" in SHARED_ORG_UI_MODULES assert_shared_modules_in_catalog() diff --git a/tests/unit/test_org_governance.py b/tests/unit/test_org_governance.py new file mode 100644 index 00000000..e0576696 --- /dev/null +++ b/tests/unit/test_org_governance.py @@ -0,0 +1,177 @@ +"""In-host governance pauses/audit BFF used by the org-ui Governance page.""" + +from __future__ import annotations + +import time +from pathlib import Path +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient + +from octop.api.deps import current_user, get_server +from octop.api.routers.org_module import router +from octop.infra.errors import OctopError +from octop.infra.users.identity import Role, User +from octop.modules.org_os.contract import ( + SHARED_ORG_UI_MODULES, + assert_shared_modules_in_catalog, +) +from octop.modules.org_os.governance.store import DurableGovernanceStore +from octop.modules.org_os.governance.types import PolicyRequest + + +def _admin() -> User: + return User(id=1, username="admin", role=Role.ADMIN, display_name="Ada") + + +def _member() -> User: + return User(id=2, username="member", role=Role.USER, display_name="Mo") + + +def _client(tmp_path: Path, user: User) -> TestClient: + app = FastAPI() + app.include_router(router, prefix="/api/org-module") + + @app.exception_handler(OctopError) + async def _octop(_request: object, exc: OctopError) -> JSONResponse: + return JSONResponse(status_code=exc.status, content=exc.to_envelope()) + + server = SimpleNamespace( + paths=SimpleNamespace(config=tmp_path / "config.json", root=tmp_path), + services=None, + ) + app.dependency_overrides[current_user] = lambda: user + app.dependency_overrides[get_server] = lambda: server + return TestClient(app) + + +def test_shared_governance_module_is_in_catalog() -> None: + assert "governance" in SHARED_ORG_UI_MODULES + assert "announcements" in SHARED_ORG_UI_MODULES + assert "organization" in SHARED_ORG_UI_MODULES + assert "employees" in SHARED_ORG_UI_MODULES + assert_shared_modules_in_catalog() + + +def test_store_list_pauses_filters_and_expires(tmp_path: Path) -> None: + store = DurableGovernanceStore(tmp_path / "governance") + pending = store.create_pause( + tool_name="delete_employee", + category="delete", + action="delete", + actor_id="1", + tenant_id="default", + args_digest="abc", + reason="default deny", + ) + approved = store.create_pause( + tool_name="pay_invoice", + category="pay", + action="pay", + actor_id="1", + tenant_id="default", + args_digest="def", + reason="needs approval", + ) + store.resolve(approved.pause_id, "approved") + stale = store.create_pause( + tool_name="http_request", + category="outbound", + action="", + actor_id="1", + tenant_id="default", + args_digest="ghi", + reason="stale", + ) + stale.created_at = time.time() - stale.ttl_seconds - 10 + store._write_pause(stale) + + all_rows = store.list_pauses() + assert {row.pause_id for row in all_rows} == { + pending.pause_id, + approved.pause_id, + stale.pause_id, + } + assert [row.pause_id for row in store.list_pauses("pending")] == [pending.pause_id] + assert [row.pause_id for row in store.list_pauses("approved")] == [approved.pause_id] + expired = store.list_pauses("expired") + assert len(expired) == 1 + assert expired[0].pause_id == stale.pause_id + assert expired[0].status == "expired" + + +def test_api_lists_pauses_and_admin_can_resolve(tmp_path: Path) -> None: + admin = _client(tmp_path, _admin()) + checked = admin.post( + "/api/org-module/governance/check", + json={"tool_name": "delete_employee", "category": "delete", "args": {"id": "1"}}, + ) + assert checked.status_code == 200 + pause_id = checked.json()["pause_id"] + assert checked.json()["status"] == "pending" + assert checked.json()["execute"] is False + + listed = admin.get("/api/org-module/governance/pauses") + assert listed.status_code == 200 + body = listed.json() + assert body["enabled"] is False + assert len(body["pauses"]) == 1 + assert body["pauses"][0]["pause_id"] == pause_id + assert body["pauses"][0]["status"] == "pending" + + pending_only = admin.get("/api/org-module/governance/pauses?status=pending") + assert len(pending_only.json()["pauses"]) == 1 + bad = admin.get("/api/org-module/governance/pauses?status=nope") + assert bad.status_code == 400 + + resolved = admin.post( + "/api/org-module/governance/resolve", + json={"pause_id": pause_id, "approve": True}, + ) + assert resolved.status_code == 200 + assert resolved.json()["status"] == "allow" + assert resolved.json()["execute"] is False + + after = admin.get("/api/org-module/governance/pauses?status=pending") + assert after.json()["pauses"] == [] + approved = admin.get("/api/org-module/governance/pauses?status=approved") + assert approved.json()["pauses"][0]["pause_id"] == pause_id + + audit = admin.get("/api/org-module/governance/audit?limit=20") + assert audit.status_code == 200 + events = audit.json()["events"] + assert any(row.get("event") == "check" for row in events) + assert any(row.get("event") == "approve" for row in events) + + +def test_api_member_can_read_but_cannot_resolve(tmp_path: Path) -> None: + admin = _client(tmp_path, _admin()) + pause_id = admin.post( + "/api/org-module/governance/check", + json={"tool_name": "delete_employee", "category": "delete"}, + ).json()["pause_id"] + + member = _client(tmp_path, _member()) + listed = member.get("/api/org-module/governance/pauses") + assert listed.status_code == 200 + assert listed.json()["pauses"][0]["pause_id"] == pause_id + audit = member.get("/api/org-module/governance/audit") + assert audit.status_code == 200 + denied = member.post( + "/api/org-module/governance/resolve", + json={"pause_id": pause_id, "approve": False}, + ) + assert denied.status_code == 403 + + +def test_engine_evaluate_roundtrip_used_by_ui(tmp_path: Path) -> None: + from octop.modules.org_os.governance.engine import GovernanceEngine + + engine = GovernanceEngine(DurableGovernanceStore(tmp_path / "governance")) + decision = engine.evaluate( + PolicyRequest(tool_name="delete_employee", category="delete", args={"id": "9"}) + ) + assert decision.status == "pending" + assert engine.store.list_pauses("pending")[0].pause_id == decision.pause_id