diff --git a/CHANGELOG.md b/CHANGELOG.md index e84b6fec..677e7b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### 修复 +- 组织页「把 FreeOS 资产推到 openXYOS」不再把行写进 tenant 1 却让嵌入式登录(`demo@demo.com` → tenant 2)看不见。ingest 以当前登录 / 最近登录 / 本地演示租户为准,并把员工/人才写成列表页会显示的状态;打包回执带落地数量,预览跳到员工页。 +- 组织页「运行自增长循环」在员工已是 `active`(或生命周期不允许再跳到 `market`)时不再 500。循环只沿合法边晋升,已到达或非法的步骤记为 skip,接口返回成功的循环证明而不是 `INTERNAL_ERROR`。 - Windows 覆盖安装时,openXYOS 预配不再因为旧 Node 锁住 `%LOCALAPPDATA%\FreeOS\openxyos` 里的文件、`tar.exe` 返回非 0 就直接退出码 3。安装前只停止 FreeOS 自己的 openXYOS(与启动脚本相同的 `Stop-OpenXYOSNode`:`start.pid` / live 与 `$INSTDIR\openxyos` 路径),先解到 LocalAppData 临时目录再合入工作目录,并把 tar 的 stdout/stderr 写入 `provision.log`。tar 非 0 但布局可修复时记警告并继续;只有 extract+heal 之后布局仍不完整才退出 3。幂等短路要求完整布局、`.install-ready`,以及 3780 上是我们的 FreeOS openXYOS,而不是任意 livez。 - 组织页嵌入本机 openXYOS 不再因为边车「已连接」却空白:`CORS_ORIGIN` 即使漏了 `http://127.0.0.1:3780` / `localhost` / `[::1]`,服务端也会合并监听自源,模块脚本和登录不再 500。FreeOS 启动会先停掉 live 目录里旧的 Node,再用当前 `start-sidecar.ps1` 拉起;嵌套的 `openxyos\dist` 会在启动前提升到 live 根。若 livez 正常但自源请求失败,状态栏和预览区会明确提示,而不是一片白。 - Windows 安装成功后删除 `$INSTDIR\openxyos-runtime` 目录和 `openxyos-runtime.zip`(以及 `%LOCALAPPDATA%\FreeOS` 下同名暂存)。预配失败时保留这些文件以便排查。最终工作目录 `$INSTDIR\openxyos` 与 `%LOCALAPPDATA%\FreeOS\openxyos` 不受影响。 diff --git a/dashboard/src/api/modules/orgModule.ts b/dashboard/src/api/modules/orgModule.ts index f2c2f86d..e86cdeda 100644 --- a/dashboard/src/api/modules/orgModule.ts +++ b/dashboard/src/api/modules/orgModule.ts @@ -110,6 +110,8 @@ export interface OrgPackResult { mirrored: boolean; notes: string[]; landed?: Record; + tenant_id?: number | null; + preview_path?: string; }; } diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index dab0a7e8..e6e62758 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -123,7 +123,9 @@ "receiptTitle": "What just happened", "assembleReceipt": "Imported {{count}} employee(s) assigned to a department. In group chat call them colleagues, not experts.", "assembleNext": "Next: open Conversations and invite colleagues to a group. Unassigned helpers stay on Experts.", - "packReceipt": "Packed and tried to push to openXYOS. Details below." + "packReceipt": "Packed and pushed to the signed-in openXYOS tenant.", + "packLanded": "Signed-in tenant {{tenant}}: employees {{employees}}, talent {{talent}}, skills {{skills}}, plugins {{plugins}}", + "packPreviewEmployees": "The preview now opens Employees so you can confirm the result." }, "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 71577adc..7d22ec24 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -123,7 +123,9 @@ "receiptTitle": "刚才的结果", "assembleReceipt": "已导入 {{count}} 名员工(已入编部门)。群聊里请称他们为同事,不要叫专家。", "assembleNext": "下一步:打开「对话」建群,把同事拉进来。还没入编的帮手在「专家」页。", - "packReceipt": "已打包并尝试推送到 openXYOS。下面是落地说明。" + "packReceipt": "已打包并推送到当前登录的 openXYOS 租户。", + "packLanded": "当前登录租户 {{tenant}}:员工 {{employees}},人才 {{talent}},技能 {{skills}},插件 {{plugins}}", + "packPreviewEmployees": "右侧预览已打开员工页,可直接核对。" }, "systemSettings": { "subtitle": "工作台、远程桌面与主机管理集中在这里。" diff --git a/dashboard/src/pages/Organization/index.test.tsx b/dashboard/src/pages/Organization/index.test.tsx index 5c0900b5..a5a4c02e 100644 --- a/dashboard/src/pages/Organization/index.test.tsx +++ b/dashboard/src/pages/Organization/index.test.tsx @@ -109,4 +109,58 @@ describe("OrganizationPage", () => { screen.getByText("organization.sidecarEmbedFailed"), ).toBeInTheDocument(); }); + + it("shows pack landed counts and opens the employees preview", async () => { + vi.mocked(orgModuleApi.overview).mockResolvedValue(overview); + vi.mocked(orgModuleApi.pack).mockResolvedValue({ + pack: { + directory: "/tmp/pack", + skill_count: 12, + plugin_count: 11, + mcp_count: 1, + agent_count: 2, + notes: [], + }, + applied: { + pack_dir: "/tmp/pack", + mirror_dir: "/tmp/mirror", + remote_applied: true, + mirrored: true, + notes: ["control plane accepted the FreeOS ingest"], + tenant_id: 2, + preview_path: "/employees", + landed: { + tenant_id: 2, + preview: "/employees", + landed: { + employees: { created: 2, updated: 0 }, + talent: { created: 2, updated: 0 }, + plugins: { created: 11, updated: 0 }, + skills: { created: 12, updated: 0 }, + mcp: { created: 1, updated: 0 }, + }, + }, + }, + }); + + render(); + fireEvent.click(await screen.findByTestId("org-manage-os")); + fireEvent.click(screen.getByText("organization.packAction")); + + expect(await screen.findByTestId("org-last-receipt")).toHaveTextContent( + "organization.packReceipt", + ); + expect(screen.getByTestId("org-last-receipt")).toHaveTextContent( + "organization.packLanded", + ); + expect(screen.getByTestId("org-last-receipt")).toHaveTextContent( + "organization.packPreviewEmployees", + ); + await waitFor(() => { + expect(screen.getByTitle("organization.previewTitle")).toHaveAttribute( + "src", + expect.stringContaining("/employees"), + ); + }); + }); }); diff --git a/dashboard/src/pages/Organization/index.tsx b/dashboard/src/pages/Organization/index.tsx index 665429a6..288565d0 100644 --- a/dashboard/src/pages/Organization/index.tsx +++ b/dashboard/src/pages/Organization/index.tsx @@ -44,6 +44,32 @@ function metric(value: number | undefined): string { return typeof value === "number" ? String(value) : "—"; } +function landedCount( + landed: Record | null | undefined, + surface: string, +): number { + const inner = + landed && typeof landed.landed === "object" && landed.landed + ? (landed.landed as Record< + string, + { created?: number; updated?: number } + >) + : (landed as Record< + string, + { created?: number; updated?: number } + > | null); + const row = inner?.[surface]; + return Number(row?.created || 0) + Number(row?.updated || 0); +} + +function landedTenant( + landed: Record | null | undefined, + fallback?: number | null, +): string { + const raw = landed?.tenant_id ?? fallback; + return raw === undefined || raw === null || raw === "" ? "—" : String(raw); +} + export default function OrganizationPage() { const { t, i18n } = useTranslation(); const isZh = i18n.language?.toLowerCase().startsWith("zh") ?? false; @@ -67,6 +93,13 @@ export default function OrganizationPage() { const [landed, setLanded] = useState | null>(null); const autoStartRef = useRef(false); const iframeRef = useRef(null); + const [previewPath, setPreviewPath] = useState("/"); + const [previewNonce, setPreviewNonce] = useState(""); + + const showPreview = useCallback((path: string) => { + setPreviewPath(path.startsWith("/") ? path : `/${path}`); + setPreviewNonce(String(Date.now())); + }, []); const applyOverview = useCallback( async (quiet = false) => { @@ -325,6 +358,21 @@ export default function OrganizationPage() { const remote = result.applied.remote_applied ? t("organization.packRemoteYes") : t("organization.packRemoteNo"); + const landedLines = result.applied.remote_applied + ? [ + t("organization.packLanded", { + tenant: landedTenant( + result.applied.landed, + result.applied.tenant_id, + ), + employees: landedCount(result.applied.landed, "employees"), + talent: landedCount(result.applied.landed, "talent"), + skills: landedCount(result.applied.landed, "skills"), + plugins: landedCount(result.applied.landed, "plugins"), + }), + t("organization.packPreviewEmployees"), + ] + : []; setLastActionNotes([ ...result.pack.notes, ...result.applied.notes, @@ -332,8 +380,11 @@ export default function OrganizationPage() { ]); setLastReceipt({ kind: "pack", - lines: [t("organization.packReceipt"), remote], + lines: [t("organization.packReceipt"), remote, ...landedLines], }); + if (result.applied.remote_applied) { + showPreview(result.applied.preview_path || "/employees"); + } message.success(t("organization.packDone")); }); @@ -350,8 +401,23 @@ export default function OrganizationPage() { `${t("organization.timelineRemote")}: ${flagLabel( Boolean(proof.remote_applied), )}`, + ...(proof.remote_applied + ? [ + t("organization.packLanded", { + tenant: landedTenant(proof.landed), + employees: landedCount(proof.landed, "employees"), + talent: landedCount(proof.landed, "talent"), + skills: landedCount(proof.landed, "skills"), + plugins: landedCount(proof.landed, "plugins"), + }), + t("organization.packPreviewEmployees"), + ] + : []), ], }); + if (proof.remote_applied) { + showPreview("/employees"); + } message.success( proof.ok ? t("organization.loopOk") : t("organization.loopPartial"), ); @@ -384,12 +450,15 @@ export default function OrganizationPage() { overview?.sidecar_url || "http://127.0.0.1:3780" ).replace(/\/$/, ""); const previewUrl = useMemo(() => { - const base = `${localConsoleUrl}/`; - if (!disabledKeys.length) return base; - return `${base}?freeos_disabled=${encodeURIComponent( - disabledKeys.join(","), - )}`; - }, [localConsoleUrl, disabledKeys]); + const path = previewPath.startsWith("/") ? previewPath : `/${previewPath}`; + const qs = new URLSearchParams(); + if (disabledKeys.length) { + qs.set("freeos_disabled", disabledKeys.join(",")); + } + if (previewNonce) qs.set("freeos_sync", previewNonce); + const query = qs.toString(); + return `${localConsoleUrl}${path}${query ? `?${query}` : ""}`; + }, [localConsoleUrl, disabledKeys, previewPath, previewNonce]); const showFrame = Boolean(previewUrl); const pushTogglesToPreview = useCallback(() => { @@ -755,7 +824,7 @@ export default function OrganizationPage() { {lastReceipt ? ( -
+

{t("organization.receiptTitle")}

diff --git a/docs/asset-loop.md b/docs/asset-loop.md index 2d76761f..109f363d 100644 --- a/docs/asset-loop.md +++ b/docs/asset-loop.md @@ -84,7 +84,7 @@ uv run freeos org employee export-profile my-analyst --tenant-id 1 ``` `assets publish` writes `{FREEOS_HOME}/asset-packs/latest/`. -`assets apply` POSTs/PUTs those drafts to the control plane when reachable and always writes `{FREEOS_HOME}/openxyos-mirror//`. Nothing is auto-enabled on the sidecar. +`assets apply` POSTs to `/api/freeos/ingest` when the sidecar is reachable and always writes `{FREEOS_HOME}/openxyos-mirror//`. Ingest targets the **signed-in openXYOS tenant** (the embed login), so employees / talent / skills / plugins show in the normal list UIs. ## Where files live diff --git a/modules/openxyos/backend/routes/freeos-bridge.ts b/modules/openxyos/backend/routes/freeos-bridge.ts index e7b613a7..f468eeaf 100644 --- a/modules/openxyos/backend/routes/freeos-bridge.ts +++ b/modules/openxyos/backend/routes/freeos-bridge.ts @@ -45,13 +45,73 @@ function requireIngestToken(req: Request, res: Response, next: NextFunction) { next(); } -function resolveTenantId(raw: unknown): number { - const n = Number(raw); - if (Number.isInteger(n) && n > 0) return n; +function tenantExists(id: number): boolean { + if (!Number.isInteger(id) || id <= 0) return false; + return Boolean(dbGet("SELECT id FROM tenants WHERE id = ?", [id])); +} + +/** Tenant the embedded / local login actually uses — never assume id=1. */ +export function resolveUiTenantId(): number { + const recent = dbGet( + `SELECT tenant_id FROM users + WHERE last_login IS NOT NULL + ORDER BY datetime(last_login) DESC, id DESC + LIMIT 1`, + ) as { tenant_id?: number } | undefined; + const recentId = Number(recent?.tenant_id); + if (tenantExists(recentId)) return recentId; + + const demo = dbGet( + `SELECT tenant_id FROM users + WHERE email IN ('demo@demo.com', 'user@demo.com') + ORDER BY CASE email WHEN 'demo@demo.com' THEN 0 ELSE 1 END + LIMIT 1`, + ) as { tenant_id?: number } | undefined; + const demoId = Number(demo?.tenant_id); + if (tenantExists(demoId)) return demoId; + + const local = dbGet( + `SELECT id FROM tenants + WHERE slug IN ('openxyos-local', 'openxyos-demo') + AND status IN ('active', 'trial') + ORDER BY id DESC + LIMIT 1`, + ) as { id?: number } | undefined; + const localId = Number(local?.id); + if (tenantExists(localId)) return localId; + const first = dbGet("SELECT id FROM tenants ORDER BY id LIMIT 1") as { id?: number } | undefined; return Number(first?.id) || 1; } +export function resolveIngestTenantIds(requested: unknown): { primary: number; mirror: number[] } { + const uiTenant = resolveUiTenantId(); + const requestedId = Number(requested); + const mirror: number[] = []; + if (tenantExists(requestedId) && requestedId !== uiTenant) { + mirror.push(requestedId); + } + return { primary: uiTenant, mirror }; +} + +function visibleEmploymentCategory(raw: unknown): string { + const value = text(raw).toLowerCase(); + if (value === "reserve") return "reserve"; + return "internal"; +} + +function visibleEmployeeStatus(raw: unknown): string { + const value = text(raw, "active").toLowerCase(); + if (value === "inactive") return "inactive"; + return "active"; +} + +function visibleTalentStatus(raw: unknown): string { + const value = text(raw).toLowerCase(); + if (value === "archived") return "archived"; + return "available"; +} + function asList(value: unknown): Record[] { if (!Array.isArray(value)) return []; return value.filter((item): item is Record => Boolean(item) && typeof item === "object"); @@ -78,8 +138,8 @@ function upsertEmployee(tenantId: number, item: Record): "creat const role = text(item.role, text(item.agent_type, "ai-colleague")); const agentType = text(item.agent_type, item.slug ? `freeos-${item.slug}` : "freeos"); const skills = skillsText(item.skills || item.capabilities); - const status = text(item.status, "active"); - const category = text(item.employment_category, "internal"); + const status = visibleEmployeeStatus(item.status); + const category = visibleEmploymentCategory(item.employment_category); const description = text(item.description); const emoji = text(item.avatar_emoji, "🤖"); if (existing?.id) { @@ -107,7 +167,7 @@ function upsertTalent(tenantId: number, item: Record): "created [tenantId, name], ) as { id?: number } | undefined; const skills = skillsText(item.skills || item.capabilities); - const status = text(item.status || item.talent_status, "available"); + const status = visibleTalentStatus(item.status || item.talent_status); const description = text(item.description); const agentType = text(item.agent_type); if (existing?.id) { @@ -195,14 +255,27 @@ freeosBridgeRoutes.get("/health", (_req, res) => { ok: true, ingest: Boolean(expectedToken()), plane: "openxyos-control", + ui_tenant_id: resolveUiTenantId(), }, }); }); freeosBridgeRoutes.use(requireIngestToken); +freeosBridgeRoutes.get("/session", (_req, res) => { + const uiTenantId = resolveUiTenantId(); + res.json({ + success: true, + data: { + ui_tenant_id: uiTenantId, + preview: "/employees", + }, + }); +}); + freeosBridgeRoutes.get("/export", (req, res) => { - const tenantId = resolveTenantId(req.query.tenant_id); + const requested = Number(req.query.tenant_id); + const tenantId = tenantExists(requested) ? requested : resolveUiTenantId(); const employees = dbAll("SELECT * FROM employees WHERE tenant_id = ? ORDER BY id", [tenantId]); const talent = dbAll("SELECT * FROM talent_pool WHERE tenant_id = ? ORDER BY id", [tenantId]); const plugins = dbAll("SELECT * FROM plugins WHERE tenant_id = ? ORDER BY id", [tenantId]); @@ -225,9 +298,7 @@ freeosBridgeRoutes.get("/export", (req, res) => { }); }); -freeosBridgeRoutes.post("/ingest", (req, res) => { - const body = (req.body && typeof req.body === "object" ? req.body : {}) as Record; - const tenantId = resolveTenantId(body.tenant_id); +function ingestIntoTenant(tenantId: number, body: Record) { const landed = { employees: { created: 0, updated: 0 }, talent: { created: 0, updated: 0 }, @@ -235,7 +306,6 @@ freeosBridgeRoutes.post("/ingest", (req, res) => { skills: { created: 0, updated: 0 }, mcp: { created: 0, updated: 0 }, }; - for (const item of asList(body.employees)) { landed.employees[upsertEmployee(tenantId, item)] += 1; } @@ -251,19 +321,31 @@ freeosBridgeRoutes.post("/ingest", (req, res) => { for (const item of asList(body.mcp)) { landed.mcp[upsertPlugin(tenantId, { ...item, category: "MCP" }, "MCP")] += 1; } + return landed; +} + +freeosBridgeRoutes.post("/ingest", (req, res) => { + const body = (req.body && typeof req.body === "object" ? req.body : {}) as Record; + const { primary, mirror } = resolveIngestTenantIds(body.tenant_id); + const landed = ingestIntoTenant(primary, body); + for (const extra of mirror) { + ingestIntoTenant(extra, body); + } res.json({ success: true, data: { - tenant_id: tenantId, + tenant_id: primary, + also_tenants: mirror, + preview: "/employees", landed, counts: { - employees: countOf("SELECT COUNT(*) as c FROM employees WHERE tenant_id = ?", tenantId), - talent: countOf("SELECT COUNT(*) as c FROM talent_pool WHERE tenant_id = ?", tenantId), - plugins: countOf("SELECT COUNT(*) as c FROM plugins WHERE tenant_id = ?", tenantId), - skills: countOf("SELECT COUNT(*) as c FROM skills WHERE tenant_id = ?", tenantId), + employees: countOf("SELECT COUNT(*) as c FROM employees WHERE tenant_id = ?", primary), + talent: countOf("SELECT COUNT(*) as c FROM talent_pool WHERE tenant_id = ?", primary), + plugins: countOf("SELECT COUNT(*) as c FROM plugins WHERE tenant_id = ?", primary), + skills: countOf("SELECT COUNT(*) as c FROM skills WHERE tenant_id = ?", primary), }, - note: "Drafts landed on the control plane. Modules are not auto-enabled.", + note: "Assets are visible on the logged-in tenant's Employees, Talent, Skills, and Plugins lists.", }, }); }); diff --git a/modules/openxyos/package.json b/modules/openxyos/package.json index 76b6a5e7..3d13c840 100644 --- a/modules/openxyos/package.json +++ b/modules/openxyos/package.json @@ -10,7 +10,7 @@ "postinstall": "node scripts/patch-express-types.cjs", "typecheck": "tsc --noEmit", "lint": "eslint . --ext .ts,.tsx --max-warnings 0", - "test": "npm run test:runtime-config && npm run test:cors-origins && npm run test:r0-access-control && npm run test:module-settings && npm run test:llm-onboarding && npm run test:agent-studio && npm run test:file-security && npm run test:download-token && npm run test:ws-ticket && npm run test:auth-provider && npm run test:pep && npm run test:audit-bundle && npm run test:tool-gateway && npm run test:v100", + "test": "npm run test:runtime-config && npm run test:cors-origins && npm run test:r0-access-control && npm run test:module-settings && npm run test:llm-onboarding && npm run test:agent-studio && npm run test:freeos-ingest && npm run test:file-security && npm run test:download-token && npm run test:ws-ticket && npm run test:auth-provider && npm run test:pep && npm run test:audit-bundle && npm run test:tool-gateway && npm run test:v100", "test:runtime-config": "tsx scripts/test-runtime-config.ts", "test:cors-origins": "tsx scripts/test-cors-origins.ts", "test:r0-access-control": "tsx scripts/test-r0-access-control.ts", @@ -30,7 +30,8 @@ "verify:open-source": "node scripts/verify-open-source.mjs", "test:module-settings": "tsx scripts/test-module-settings.ts", "test:agent-studio": "tsx scripts/test-agent-studio.ts", - "test:llm-onboarding": "tsx scripts/test-llm-onboarding.ts" + "test:llm-onboarding": "tsx scripts/test-llm-onboarding.ts", + "test:freeos-ingest": "tsx scripts/test-freeos-ingest.ts" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/modules/openxyos/scripts/test-freeos-ingest.ts b/modules/openxyos/scripts/test-freeos-ingest.ts new file mode 100644 index 00000000..91fd6973 --- /dev/null +++ b/modules/openxyos/scripts/test-freeos-ingest.ts @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { AddressInfo } from "node:net"; +import express from "express"; + +async function main() { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openxyos-freeos-ingest-")); + process.env.NODE_ENV = "development"; + process.env.FILE_SCAN_MODE = "disabled"; + process.env.JWT_SECRET = "freeos-ingest-test-secret-at-least-32-characters"; + process.env.COOKIE_SECRET = "freeos-ingest-cookie-secret-at-least-32-ch"; + process.env.DATABASE_PATH = path.join(tempRoot, "test.db"); + process.env.FREEOS_HOME = tempRoot; + process.env.FREEOS_INGEST_TOKEN = "ingest-secret"; + + const { initDatabase, dbGet, dbRun, dbAll } = await import("../backend/db"); + const { freeosBridgeRoutes } = await import("../backend/routes/freeos-bridge"); + const { employeeRoutes } = await import("../backend/routes/employees"); + const { talentRoutes } = await import("../backend/routes/talent"); + const { skillsRoutes } = await import("../backend/routes/skills"); + const { pluginRoutes } = await import("../backend/routes/plugins"); + const { signToken } = await import("../backend/middleware"); + await initDatabase(); + + dbRun( + "INSERT OR IGNORE INTO tenants (id, name, slug, status, plan) VALUES (1, ?, ?, 'active', 'community')", + ["雄元科技", "openxyos"], + ); + dbRun( + "INSERT OR IGNORE INTO tenants (id, name, slug, status, plan) VALUES (2, ?, ?, 'active', 'community')", + ["openXYOS 本地租户", "openxyos-local"], + ); + dbRun( + "INSERT INTO users (email, password_hash, nickname, role, tenant_id) VALUES (?, ?, ?, 'super_admin', 1)", + ["admin@example.com", "unused", "超级管理员"], + ); + const demoId = Number(dbRun( + "INSERT INTO users (email, password_hash, nickname, role, tenant_id) VALUES (?, ?, ?, 'admin', 2)", + ["demo@demo.com", "unused", "管理员"], + ).lastInsertRowid); + dbRun("UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?", [demoId]); + + const token = signToken({ + id: demoId, + email: "demo@demo.com", + nickname: "管理员", + role: "admin", + tenant_id: 2, + }); + + const app = express(); + app.use(express.json()); + app.use("/api/freeos", freeosBridgeRoutes); + app.use("/api/employees", employeeRoutes); + app.use("/api/talent", talentRoutes); + app.use("/api/skills", skillsRoutes); + app.use("/api/plugins", pluginRoutes); + const server = app.listen(0, "127.0.0.1"); + let baseUrl = ""; + + async function ingest(body: Record) { + return fetch(`${baseUrl}/api/freeos/ingest`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-FreeOS-Ingest-Token": "ingest-secret", + }, + body: JSON.stringify(body), + }); + } + + async function asDemo(url: string) { + return fetch(`${baseUrl}${url}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + } + + try { + await new Promise((resolve) => server.once("listening", resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + + const health = await fetch(`${baseUrl}/api/freeos/health`); + assert.equal(health.status, 200); + const healthBody = await health.json() as { data: { ui_tenant_id: number } }; + assert.equal(healthBody.data.ui_tenant_id, 2); + + const ingested = await ingest({ + tenant_id: 1, + employees: [ + { + name: "Ops Coordinator", + slug: "ops-coordinator", + status: "active", + employment_category: "staff", + skills: "ops", + }, + { + name: "Policy Analyst", + slug: "policy-analyst", + status: "active", + employment_category: "probation", + skills: "policy", + }, + ], + talent: [ + { name: "Ops Coordinator", status: "draft", skills: "ops" }, + { name: "Policy Analyst", talent_status: "recruited", skills: "policy" }, + ], + skills: [{ name: "org-governance", slug: "org-governance", content: "gate high-risk tools" }], + plugins: [{ name: "org-governance", slug: "org-governance" }], + mcp: [{ name: "xyos-governance-mcp", slug: "xyos-governance-mcp" }], + }); + assert.equal(ingested.status, 200); + const ingestBody = await ingested.json() as { + success: boolean; + data: { tenant_id: number; also_tenants: number[]; landed: Record }; + }; + assert.equal(ingestBody.success, true); + assert.equal(ingestBody.data.tenant_id, 2); + assert.deepEqual(ingestBody.data.also_tenants, [1]); + assert.equal(ingestBody.data.landed.employees.created, 2); + assert.equal(ingestBody.data.landed.talent.created, 2); + + const employees = await asDemo("/api/employees?category=internal"); + assert.equal(employees.status, 200); + const employeeBody = await employees.json() as { success: boolean; data: Array<{ name: string; employment_category: string; status: string; tenant_id: number }> }; + assert.equal(employeeBody.success, true); + const names = employeeBody.data.map((row) => row.name).sort(); + assert.deepEqual(names, ["Ops Coordinator", "Policy Analyst"]); + assert(employeeBody.data.every((row) => row.employment_category === "internal")); + assert(employeeBody.data.every((row) => row.status === "active")); + assert(employeeBody.data.every((row) => row.tenant_id === 2)); + + const talent = await asDemo("/api/talent"); + assert.equal(talent.status, 200); + const talentBody = await talent.json() as { success: boolean; data: Array<{ name: string; status: string; tenant_id: number }> }; + assert.equal(talentBody.success, true); + assert.deepEqual(talentBody.data.map((row) => row.name).sort(), ["Ops Coordinator", "Policy Analyst"]); + assert(talentBody.data.every((row) => row.status === "available")); + + const skills = await asDemo("/api/skills"); + assert.equal(skills.status, 200); + const skillBody = await skills.json() as { success: boolean; data: Array<{ name: string; enabled: number }> }; + assert(skillBody.data.some((row) => row.name === "org-governance" && row.enabled === 1)); + + const plugins = await asDemo("/api/plugins"); + assert.equal(plugins.status, 200); + const pluginBody = await plugins.json() as { success: boolean; data: Array<{ name: string; status: string }> }; + assert(pluginBody.data.some((row) => row.name === "org-governance" && row.status === "active")); + assert(pluginBody.data.some((row) => row.name === "xyos-governance-mcp" && row.status === "active")); + + const tenant1Employees = dbAll( + "SELECT name FROM employees WHERE tenant_id = 1 ORDER BY name", + ) as Array<{ name: string }>; + assert.deepEqual(tenant1Employees.map((row) => row.name), ["Ops Coordinator", "Policy Analyst"]); + const tenant2Count = dbGet( + "SELECT COUNT(*) as c FROM employees WHERE tenant_id = 2", + ) as { c: number }; + assert.equal(Number(tenant2Count.c), 2); + + console.log("freeos ingest writes to the active UI tenant and list APIs see the rows"); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py index c09786e5..ebe3b280 100644 --- a/src/octop/api/routers/org_module.py +++ b/src/octop/api/routers/org_module.py @@ -574,14 +574,17 @@ async def run_loop( from octop.modules.org_os.loop.run import run_growth_loop service = _service(server) - proof = run_growth_loop( - service.home, - tenant_id=body.tenant_id or service.tenant_id() or "1", - blueprint_path=Path(body.blueprint_path) if body.blueprint_path else None, - policies_path=Path(body.policies_path) if body.policies_path else None, - sidecar_url=body.base_url or service.sidecar_url(), - config_path=service.config_path, - ) + try: + proof = run_growth_loop( + service.home, + tenant_id=body.tenant_id or service.tenant_id() or "1", + blueprint_path=Path(body.blueprint_path) if body.blueprint_path else None, + policies_path=Path(body.policies_path) if body.policies_path else None, + sidecar_url=body.base_url or service.sidecar_url(), + config_path=service.config_path, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc payload = proof.to_dict() return localize_mapping(payload, resolve_request_locale(request), "notes") diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index f0d33994..948b740a 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -747,7 +747,11 @@ "corpus_pointer": "attached cloud corpus pointer → {path}", "corpus_distilled": "distilled {count} corpus files → {path}", "corpus_empty": "no knowledge mount; producing a corpus-ready expert workspace", - "permissions_unreachable": "sidecar permissions unreachable; pass --policies or retry when signed in" + "permissions_unreachable": "sidecar permissions unreachable; pass --policies or retry when signed in", + "loop_skip_already": "skipped promotion for {slug}: already {lifecycle}", + "loop_skip_illegal": "skipped illegal transition for {slug}: cannot move {current} → {target}", + "loop_skip_unknown": "skipped promotion for {slug}: unknown colleague", + "visible_on_ui_tenant": "Assets are visible on the logged-in tenant's Employees, Talent, Skills, and Plugins lists." }, "sidecar": { "already": "sidecar already reachable", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index 5f075ec5..191877ae 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -747,7 +747,11 @@ "corpus_pointer": "已挂接云知识库指针 → {path}", "corpus_distilled": "已蒸馏 {count} 个语料文件 → {path}", "corpus_empty": "没有知识库挂载;正在生成可供语料使用的专家工作区", - "permissions_unreachable": "边车权限接口不可达;请传入策略或登录后再试" + "permissions_unreachable": "边车权限接口不可达;请传入策略或登录后再试", + "loop_skip_already": "已跳过 {slug} 的晋升:当前已是 {lifecycle}", + "loop_skip_illegal": "已跳过 {slug} 的非法迁移:无法从 {current} 转到 {target}", + "loop_skip_unknown": "已跳过 {slug} 的晋升:未知同事", + "visible_on_ui_tenant": "资产已出现在当前登录租户的员工 / 人才 / 技能 / 插件列表中。" }, "sidecar": { "already": "边车已可达", diff --git a/src/octop/modules/org_os/apply/apply.py b/src/octop/modules/org_os/apply/apply.py index c91db23f..2cdfb573 100644 --- a/src/octop/modules/org_os/apply/apply.py +++ b/src/octop/modules/org_os/apply/apply.py @@ -156,22 +156,38 @@ def apply_asset_pack( control_plane_url=client.base_url, notes=[ "Local mirror is the durable record.", - "Nothing is auto-enabled on the control plane.", + "Assets are visible on the logged-in tenant's Employees, Talent, Skills, and Plugins lists.", ], ) - ingest_payload = { - "tenant_id": tid if tid.isdigit() else 1, + ui_tenant = client.ui_tenant_id() if client.base_url else None + if ui_tenant is not None: + ingest_tenant: int | None = ui_tenant + elif tid.isdigit() and int(tid) > 0: + ingest_tenant = int(tid) + else: + ingest_tenant = None + result.tenant_id = ingest_tenant + + ingest_payload: dict[str, Any] = { "employees": employees, "talent": talent, "plugins": plugins, "skills": skills, "mcp": mcp, } + if ingest_tenant is not None: + ingest_payload["tenant_id"] = ingest_tenant ingest = client.ingest(ingest_payload) if ingest.ok: landed = ingest.body.get("data") if isinstance(ingest.body, dict) else {} result.landed = landed if isinstance(landed, dict) else {} + raw_tid = result.landed.get("tenant_id") + if isinstance(raw_tid, int) and raw_tid > 0: + result.tenant_id = raw_tid + preview = result.landed.get("preview") + if isinstance(preview, str) and preview.startswith("/"): + result.preview_path = preview result.receipts.append( ApplyReceipt( surface="ingest", diff --git a/src/octop/modules/org_os/apply/client.py b/src/octop/modules/org_os/apply/client.py index 1edd5fc6..4702c387 100644 --- a/src/octop/modules/org_os/apply/client.py +++ b/src/octop/modules/org_os/apply/client.py @@ -174,6 +174,19 @@ def ingest(self, payload: dict[str, Any]) -> HttpResult: def export(self) -> Any | None: return self.get_json("/api/freeos/export") + def ui_tenant_id(self) -> int | None: + """Tenant the embedded openXYOS login is using, when the sidecar reports it.""" + for path in ("/api/freeos/session", "/api/freeos/health"): + body = self.get_json(path) + if not isinstance(body, dict): + continue + raw = body.get("ui_tenant_id") + if isinstance(raw, int) and raw > 0: + return raw + if isinstance(raw, str) and raw.isdigit() and int(raw) > 0: + return int(raw) + return None + def bridge_ready(self) -> bool: body = self.get_json("/api/freeos/health") return isinstance(body, dict) and bool(body.get("ok")) @@ -207,6 +220,8 @@ class ApplyResult: notes: list[str] = field(default_factory=list) control_plane_url: str = "" landed: dict[str, Any] = field(default_factory=dict) + tenant_id: int | None = None + preview_path: str = "/employees" @property def remote_applied(self) -> bool: @@ -223,6 +238,8 @@ def to_dict(self) -> dict[str, Any]: "control_plane_url": self.control_plane_url, "remote_applied": self.remote_applied, "mirrored": self.mirrored, + "tenant_id": self.tenant_id, + "preview_path": self.preview_path, "landed": dict(self.landed), "receipts": [item.to_dict() for item in self.receipts], "notes": list(self.notes), diff --git a/src/octop/modules/org_os/assets/pack.py b/src/octop/modules/org_os/assets/pack.py index 2237829a..43e7a0d4 100644 --- a/src/octop/modules/org_os/assets/pack.py +++ b/src/octop/modules/org_os/assets/pack.py @@ -173,7 +173,8 @@ def publish_asset_pack( "name": item["name"], "talent_type": "ai", "agent_type": item.get("agent_type"), - "status": item.get("talent_status") or "draft", + "status": "available", + "talent_status": "available", "skills": item.get("skills"), "source": "FreeOS", "integration_type": "agent-blueprint-v1", @@ -182,7 +183,6 @@ def publish_asset_pack( "enabled_by_default": False, } for item in employees - if item.get("lifecycle") in {"market", "recruit", "shadow", "active"} ] (dest / "openxyos" / "org-talent.publish.json").write_text( json.dumps( diff --git a/src/octop/modules/org_os/lifecycle/__init__.py b/src/octop/modules/org_os/lifecycle/__init__.py index 4e2bbeb2..97a3ee5b 100644 --- a/src/octop/modules/org_os/lifecycle/__init__.py +++ b/src/octop/modules/org_os/lifecycle/__init__.py @@ -5,12 +5,19 @@ ColleagueRecord, LifecycleStore, ) -from octop.modules.org_os.lifecycle.transitions import register_compiled, transition +from octop.modules.org_os.lifecycle.transitions import ( + already_at_or_beyond, + can_transition, + register_compiled, + transition, +) __all__ = [ "LIFECYCLE_STATES", "ColleagueRecord", "LifecycleStore", + "already_at_or_beyond", + "can_transition", "register_compiled", "transition", ] diff --git a/src/octop/modules/org_os/lifecycle/transitions.py b/src/octop/modules/org_os/lifecycle/transitions.py index a70a99ef..88aeff30 100644 --- a/src/octop/modules/org_os/lifecycle/transitions.py +++ b/src/octop/modules/org_os/lifecycle/transitions.py @@ -27,6 +27,31 @@ "offboard": frozenset(), } +# Happy-path ladder used by the self-growth loop. ``offboard`` is terminal. +PROMOTION_ORDER: tuple[str, ...] = ("draft", "market", "recruit", "shadow", "active") + + +def can_transition(current: str, target: str) -> bool: + """Return True when *target* is a no-op or an allowed FSM edge.""" + if target not in LIFECYCLE_STATES: + return False + if current == target: + return True + return target in ALLOWED.get(current, frozenset()) + + +def already_at_or_beyond(current: str, target: str) -> bool: + """True when *current* is at or past *target* on the happy-path ladder. + + ``offboard`` is terminal and treated as beyond every promotion target. + """ + if current == "offboard": + return True + try: + return PROMOTION_ORDER.index(current) >= PROMOTION_ORDER.index(target) + except ValueError: + return False + def _apply_flags(record: ColleagueRecord) -> None: record.read_only = record.lifecycle in {"shadow", "offboard"} diff --git a/src/octop/modules/org_os/loop/run.py b/src/octop/modules/org_os/loop/run.py index 5a8659a6..42643a8f 100644 --- a/src/octop/modules/org_os/loop/run.py +++ b/src/octop/modules/org_os/loop/run.py @@ -17,7 +17,11 @@ gate_tool_call, ) from octop.modules.org_os.lifecycle.store import LifecycleStore -from octop.modules.org_os.lifecycle.transitions import transition +from octop.modules.org_os.lifecycle.transitions import ( + already_at_or_beyond, + can_transition, + transition, +) from octop.modules.org_os.loop.fixtures import ( blueprint_fixture, extra_blueprint_fixtures, @@ -76,13 +80,49 @@ def _wait_control_plane(url: str, *, home: Path, attempts: int = 12) -> bool: return False -def _promote(store: LifecycleStore, slug: str) -> dict[str, str]: +def _promote(store: LifecycleStore, slug: str) -> tuple[dict[str, str], list[str]]: + """Walk *slug* toward ``active`` along allowed edges only. + + Already-deployed colleagues (e.g. ``active``) and illegal pairs such as + ``active → market`` are recorded as skips instead of raising. + """ states = ("market", "recruit", "shadow", "active") - seen: dict[str, str] = {"draft": "draft"} + record = store.get(slug) + if record is None: + return {}, [f"skipped promotion for {slug}: unknown colleague"] + + initial = record.lifecycle + seen: dict[str, str] = {} + notes: list[str] = [] + skipped_states: list[str] = [] + if initial == "draft": + seen["draft"] = "draft" + for state in states: - record = transition(store, slug, state, reason="org loop run") + current = record.lifecycle + if current == state: + seen[state] = current + continue + if already_at_or_beyond(current, state): + seen[state] = current + skipped_states.append(state) + continue + if not can_transition(current, state): + seen[state] = current + notes.append(f"skipped illegal transition for {slug}: cannot move {current} → {state}") + continue + try: + record = transition(store, slug, state, reason="org loop run") + except ValueError as exc: + notes.append(f"skipped illegal transition for {slug}: {exc}") + seen[state] = record.lifecycle + continue seen[state] = record.lifecycle - return seen + + seen[record.lifecycle] = record.lifecycle + if skipped_states: + notes.append(f"skipped promotion for {slug}: already {initial}") + return seen, notes def run_growth_loop( @@ -150,8 +190,11 @@ def run_growth_loop( store = LifecycleStore(home, tid) lifecycle: dict[str, str] = {} spawned: list[SpawnedAgent] = [] - for slug in imported.employees: - lifecycle.update(_promote(store, slug)) + promotion_notes: list[str] = [] + for slug in dict.fromkeys(imported.employees): + advanced, skipped = _promote(store, slug) + lifecycle.update(advanced) + promotion_notes.extend(skipped) record = store.get(slug) if record is None: continue @@ -218,6 +261,7 @@ def run_growth_loop( *imported.notes, *applied.notes, *feedback.notes, + *promotion_notes, "Loop produced colleagues, published an asset pack, applied department employees, and imported back.", ], ) diff --git a/src/octop/modules/org_os/notes.py b/src/octop/modules/org_os/notes.py index a955b491..8b087f5d 100644 --- a/src/octop/modules/org_os/notes.py +++ b/src/octop/modules/org_os/notes.py @@ -202,6 +202,28 @@ "org.notes.permissions_unreachable", (), ), + ( + re.compile(r"^skipped promotion for (.+): already (.+)$"), + "org.notes.loop_skip_already", + ("slug", "lifecycle"), + ), + ( + re.compile(r"^skipped illegal transition for (.+): cannot move (.+) → (.+)$"), + "org.notes.loop_skip_illegal", + ("slug", "current", "target"), + ), + ( + re.compile(r"^skipped promotion for (.+): unknown colleague$"), + "org.notes.loop_skip_unknown", + ("slug",), + ), + ( + re.compile( + r"^Assets are visible on the logged-in tenant's Employees, Talent, Skills, and Plugins lists\.$" + ), + "org.notes.visible_on_ui_tenant", + (), + ), ) diff --git a/tests/support/openxyos_harness.py b/tests/support/openxyos_harness.py index 7f1c39d9..d7ae67af 100644 --- a/tests/support/openxyos_harness.py +++ b/tests/support/openxyos_harness.py @@ -18,6 +18,7 @@ def __init__(self) -> None: self.module_settings: dict[str, Any] = {} self.ingest_token: str = "" self.ingested: dict[str, Any] = {} + self.ui_tenant_id: int = 2 self.permissions: list[dict[str, Any]] = [ {"category": "delete", "allow": False, "reason": "harness deny"} ] @@ -60,7 +61,32 @@ def do_GET(self) -> None: # noqa: N802 self._json(200, {"ok": True}) return if path == "/api/freeos/health": - self._json(200, {"success": True, "data": {"ok": True, "ingest": True}}) + self._json( + 200, + { + "success": True, + "data": { + "ok": True, + "ingest": True, + "ui_tenant_id": store.ui_tenant_id, + }, + }, + ) + return + if path == "/api/freeos/session": + if not self._ingest_ok(): + self._json(401, {"success": False, "error": "invalid ingest token"}) + return + self._json( + 200, + { + "success": True, + "data": { + "ui_tenant_id": store.ui_tenant_id, + "preview": "/employees", + }, + }, + ) return if path == "/api/freeos/export": if not self._ingest_ok(): @@ -112,6 +138,9 @@ def do_POST(self) -> None: # noqa: N802 return if isinstance(payload, dict): store.ingested = payload + tenant_id = payload.get("tenant_id") + if tenant_id in (None, "", 0): + payload["tenant_id"] = store.ui_tenant_id if isinstance(payload.get("employees"), list): store.employees.extend( item for item in payload["employees"] if isinstance(item, dict) @@ -133,6 +162,10 @@ def do_POST(self) -> None: # noqa: N802 { "success": True, "data": { + "tenant_id": payload.get("tenant_id", store.ui_tenant_id) + if isinstance(payload, dict) + else store.ui_tenant_id, + "preview": "/employees", "landed": { "employees": {"created": len(store.employees)}, "talent": {"created": len(store.talent)}, diff --git a/tests/unit/test_asset_loop.py b/tests/unit/test_asset_loop.py index 966272e9..24bef40e 100644 --- a/tests/unit/test_asset_loop.py +++ b/tests/unit/test_asset_loop.py @@ -118,4 +118,8 @@ def test_publish_asset_pack_includes_skills_mcp_agents(tmp_path: Path) -> None: ) assert employees["enabled_by_default"] is False assert employees["employees"][0]["slug"] == "policy-analyst" - assert (pack.directory / "openxyos" / "org-talent.publish.json").is_file() + talent = json.loads( + (pack.directory / "openxyos" / "org-talent.publish.json").read_text(encoding="utf-8") + ) + assert any(item.get("name") == employees["employees"][0]["name"] for item in talent["talent"]) + assert all(item.get("status") == "available" for item in talent["talent"]) diff --git a/tests/unit/test_lifecycle.py b/tests/unit/test_lifecycle.py index a06fac30..e2235021 100644 --- a/tests/unit/test_lifecycle.py +++ b/tests/unit/test_lifecycle.py @@ -8,7 +8,12 @@ from octop.modules.org_os.compiler.compile import compile_blueprint from octop.modules.org_os.lifecycle.store import LifecycleStore -from octop.modules.org_os.lifecycle.transitions import register_compiled, transition +from octop.modules.org_os.lifecycle.transitions import ( + already_at_or_beyond, + can_transition, + register_compiled, + transition, +) _FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "agent-blueprint.v1.json" @@ -54,6 +59,13 @@ def test_illegal_skip_rejected(tmp_path: Path) -> None: store, slug, _workspace = _spawn(tmp_path) with pytest.raises(ValueError, match="cannot move"): transition(store, slug, "active") + assert can_transition("draft", "active") is False + assert can_transition("active", "market") is False + assert can_transition("active", "active") is True + assert can_transition("shadow", "active") is True + assert already_at_or_beyond("active", "market") is True + assert already_at_or_beyond("draft", "market") is False + assert already_at_or_beyond("offboard", "active") is True def test_offboard_revokes_and_archives_memory(tmp_path: Path) -> None: diff --git a/tests/unit/test_openxyos_apply.py b/tests/unit/test_openxyos_apply.py index a6d3b239..4599efa5 100644 --- a/tests/unit/test_openxyos_apply.py +++ b/tests/unit/test_openxyos_apply.py @@ -82,6 +82,30 @@ def test_apply_uses_ingest_token(tmp_path: Path) -> None: assert accepted.remote_applied is True assert state.employees assert "/api/freeos/ingest" in state.posts + assert accepted.tenant_id == state.ui_tenant_id + assert accepted.preview_path == "/employees" + assert state.ingested.get("tenant_id") == state.ui_tenant_id + finally: + server.shutdown() + server.server_close() + + +def test_apply_sends_ui_tenant_not_configured_tenant(tmp_path: Path) -> None: + import_openxyos_assets( + tmp_path, tenant_id="1", catalog=True, blueprint_path=_FIXTURE, spawn_agents=False + ) + pack = publish_asset_pack(tmp_path, tenant_id="1", out_dir=tmp_path / "asset-packs" / "latest") + state = ControlPlaneState() + state.ui_tenant_id = 2 + url, server = start_control_plane(state) + try: + result = apply_asset_pack(pack.directory, home=tmp_path, tenant_id="1", base_url=url) + assert result.remote_applied is True + assert result.tenant_id == 2 + assert state.ingested.get("tenant_id") == 2 + assert result.landed.get("tenant_id") == 2 + assert result.preview_path == "/employees" + assert result.landed.get("landed", {}).get("employees", {}).get("created", 0) >= 1 finally: server.shutdown() server.server_close() diff --git a/tests/unit/test_org_loop.py b/tests/unit/test_org_loop.py index b75e59d2..0623e680 100644 --- a/tests/unit/test_org_loop.py +++ b/tests/unit/test_org_loop.py @@ -4,14 +4,41 @@ import json from pathlib import Path +from typing import Any + +import pytest from octop.modules.org_os.catalog import catalog_keys -from octop.modules.org_os.loop.run import run_growth_loop +from octop.modules.org_os.lifecycle.store import LifecycleStore +from octop.modules.org_os.lifecycle.transitions import ( + can_transition, + register_compiled, + transition, +) +from octop.modules.org_os.loop.run import _promote, run_growth_loop from octop.modules.org_os.runtime.routing import ColleagueRouter, resolve_routed_agent from octop.modules.org_os.runtime.spawn import list_spawned_agents from tests.support.openxyos_harness import ControlPlaneState, start_control_plane +def _register(tmp_path: Path, slug: str, name: str) -> LifecycleStore: + workspace = tmp_path / "tenants" / "loop" / "employees" / slug + workspace.mkdir(parents=True) + store = LifecycleStore(tmp_path, "loop") + register_compiled(store, slug=slug, name=name, workspace=workspace, lifecycle="draft") + return store + + +def _walk(store: LifecycleStore, slug: str, *states: str) -> None: + for state in states: + record = store.get(slug) + if record is None or record.lifecycle == state: + continue + if not can_transition(record.lifecycle, state): + continue + transition(store, slug, state) + + def test_growth_loop_with_fixtures(tmp_path: Path) -> None: proof = run_growth_loop(tmp_path, tenant_id="loop") assert proof.ok is True @@ -41,6 +68,69 @@ def test_growth_loop_with_fixtures(tmp_path: Path) -> None: assert saved["ok"] is True +def test_promote_skips_already_active_without_raising(tmp_path: Path) -> None: + store = _register(tmp_path, "ops-coordinator", "Ops Coordinator") + _walk(store, "ops-coordinator", "market", "recruit", "shadow", "active") + + seen, notes = _promote(store, "ops-coordinator") + + record = store.get("ops-coordinator") + assert record is not None + assert record.lifecycle == "active" + assert seen["active"] == "active" + assert any("already active" in note for note in notes) + + +def test_promote_skips_illegal_offboard_without_raising(tmp_path: Path) -> None: + store = _register(tmp_path, "policy-analyst", "Policy Analyst") + _walk(store, "policy-analyst", "offboard") + + seen, notes = _promote(store, "policy-analyst") + + record = store.get("policy-analyst") + assert record is not None + assert record.lifecycle == "offboard" + assert seen["offboard"] == "offboard" + assert any("already offboard" in note for note in notes) + + +def test_promote_from_shadow_reaches_active(tmp_path: Path) -> None: + store = _register(tmp_path, "policy-analyst", "Policy Analyst") + _walk(store, "policy-analyst", "market", "recruit", "shadow") + + seen, notes = _promote(store, "policy-analyst") + + record = store.get("policy-analyst") + assert record is not None + assert record.lifecycle == "active" + assert seen["active"] == "active" + assert any("already shadow" in note for note in notes) + + +def test_growth_loop_succeeds_when_employees_already_active( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from octop.modules.org_os.loop import run as loop_mod + + real_import = loop_mod.import_openxyos_assets + + def _import_then_activate(*args: Any, **kwargs: Any) -> Any: + result = real_import(*args, **kwargs) + home = Path(args[0] if args else kwargs["home"]) + tid = str(kwargs.get("tenant_id") or "loop") + store = LifecycleStore(home, tid) + for slug in result.employees: + _walk(store, slug, "market", "recruit", "shadow", "active") + return result + + monkeypatch.setattr(loop_mod, "import_openxyos_assets", _import_then_activate) + proof = run_growth_loop(tmp_path, tenant_id="loop") + assert proof.ok is True + assert proof.lifecycle.get("active") == "active" + assert any("already active" in note for note in proof.notes) + assert (tmp_path / "asset-packs" / "loop-proof.json").is_file() + + def test_growth_loop_applies_to_live_control_plane(tmp_path: Path) -> None: state = ControlPlaneState() url, server = start_control_plane(state) diff --git a/tests/unit/test_org_notes.py b/tests/unit/test_org_notes.py index c543eda2..d590fa78 100644 --- a/tests/unit/test_org_notes.py +++ b/tests/unit/test_org_notes.py @@ -10,6 +10,9 @@ def test_localize_loop_notes_zh() -> None: "Local mirror is the durable record. HTTP apply is best-effort.", "control plane accepted the FreeOS ingest", "Loop produced colleagues, published an asset pack, applied department employees, and imported back.", + "skipped promotion for ops-coordinator: already active", + "skipped illegal transition for policy-analyst: cannot move active → market", + "Assets are visible on the logged-in tenant's Employees, Talent, Skills, and Plugins lists.", ], "zh", ) @@ -19,6 +22,9 @@ def test_localize_loop_notes_zh() -> None: assert "本地镜像" in notes[3] assert "控制面已接受" in notes[4] assert "循环已生成同事" in notes[5] + assert "ops-coordinator" in notes[6] and "active" in notes[6] + assert "policy-analyst" in notes[7] and "market" in notes[7] + assert "当前登录租户" in notes[8] def test_localize_unknown_note_passthrough() -> None: