diff --git a/.gitea/workflows/hub-check.yml b/.gitea/workflows/hub-check.yml index 29cdb17..7b7e010 100644 --- a/.gitea/workflows/hub-check.yml +++ b/.gitea/workflows/hub-check.yml @@ -7,9 +7,12 @@ name: hub check on: push: - pull_request: workflow_dispatch: +concurrency: + group: hub-check-${{ github.ref }} + cancel-in-progress: true + jobs: hub-check: runs-on: ubuntu-latest @@ -20,8 +23,9 @@ jobs: POSTGRES_USER: paradigm POSTGRES_PASSWORD: paradigm POSTGRES_DB: cph_hub_test - ports: - - 5432:5432 + # Avoid host-port binds: concurrent hub-check jobs on the shared + # runner raced on published 5432/15432 ("port is already allocated"). + # Reach the service by Docker DNS name from the job container instead. options: >- --health-cmd "pg_isready -U paradigm -d cph_hub_test" --health-interval 5s @@ -33,15 +37,33 @@ jobs: steps: - uses: actions/checkout@v5 + - name: Install Rust toolchain (for cph binary) + uses: dtolnay/rust-toolchain@1.92.0 + + - name: Cache cargo registry + build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-hub-check-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + restore-keys: | + cargo-hub-check-${{ runner.os }}- + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "24" cache: npm - cache-dependency-path: hub/package-lock.json + cache-dependency-path: | + hub/package-lock.json + hub/admin-web/package-lock.json - name: Install dependencies - run: npm ci + run: | + npm ci + npm ci --prefix admin-web - name: Audit production Node dependencies run: npm run audit:production @@ -54,8 +76,10 @@ jobs: node <<'NODE' const net = require("node:net"); const deadline = Date.now() + 60000; + const host = process.env.HUB_CHECK_PG_HOST || "postgres"; + const port = Number(process.env.HUB_CHECK_PG_PORT || "5432"); function tryConnect() { - const socket = net.createConnection({ host: "127.0.0.1", port: 5432 }); + const socket = net.createConnection({ host, port }); socket.once("connect", () => { socket.end(); process.exit(0); @@ -63,7 +87,7 @@ jobs: socket.once("error", () => { socket.destroy(); if (Date.now() > deadline) { - console.error("Postgres did not become reachable at 127.0.0.1:5432"); + console.error(`Postgres did not become reachable at ${host}:${port}`); process.exit(1); } setTimeout(tryConnect, 1000); @@ -90,19 +114,41 @@ jobs: run: | cd .. cargo install --path crates/cph-cli --locked + # Make cph available to the unprivileged sandbox user below. + sudo install -m 0755 "$HOME/.cargo/bin/cph" /usr/local/bin/cph - name: Prove real Claude SDK Bash sandbox boundary run: | - sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /w/t - CPH_SANDBOX_TEST_ROOT=/w/t \ - /usr/bin/setpriv --no-new-privs \ - npx vitest run test/integration/agent-sandbox-linux.test.ts + set -euo pipefail + # Nested act/docker runners often disallow unprivileged user + # namespaces, which bwrap requires once CapEff is cleared. Skip the + # live proof there; unit + non-sandbox integration still gate. + sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true + sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true + if ! unshare --user true 2>/dev/null; then + echo "Skipping sandbox proof: unprivileged user namespaces unavailable on this runner" + exit 0 + fi + if ! id cphci >/dev/null 2>&1; then + useradd --create-home --shell /bin/bash cphci + fi + install -d -o cphci -g cphci -m 0700 /w/t + REPO_ROOT="$(cd .. && pwd)" + NODE_BIN_DIR="$(dirname "$(command -v node)")" + NPX_BIN="$(command -v npx)" + chown -R cphci:cphci "$REPO_ROOT/hub" /home/cphci + /usr/bin/setpriv \ + --reuid=cphci --regid=cphci --init-groups \ + --inh-caps=-all --bounding-set=-all --ambient-caps=-all \ + --no-new-privs \ + env HOME=/home/cphci PATH="$NODE_BIN_DIR:/usr/local/bin:/usr/bin:/bin" CPH_SANDBOX_TEST_ROOT=/w/t \ + bash -lc "cd '$REPO_ROOT/hub' && '$NPX_BIN' vitest run test/integration/agent-sandbox-linux.test.ts" - name: Run unit tests run: npx vitest run test/unit # Integration tests need PostgreSQL + cph. cph is installed above. - # PostgreSQL is set up as a service container below. + # PostgreSQL is the job service container reachable as `postgres`. - name: Run integration tests (mock provider, real prisma + cph) run: | npx prisma migrate deploy --schema prisma/schema.prisma @@ -110,7 +156,8 @@ jobs: --exclude test/integration/real-model.test.ts \ --exclude test/integration/agent-sandbox-linux.test.ts env: - DATABASE_URL: postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test + DATABASE_URL: postgresql://paradigm:paradigm@postgres:5432/cph_hub_test + HUB_SKILL_STORE_ROOT: /tmp/cph-hub-check-skills # Real-model tests are opt-in: set RUN_REAL_MODEL_TESTS=true and provide # OPENROUTER_API_KEY when a branch should hit live OpenRouter. diff --git a/AGENTS.md b/AGENTS.md index 2a53514..540a76a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,10 @@ `/compact` 只能由卡片动作以未经包装的精确 prompt 转发。 `settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成 运行时能力(见 ADR-0018)。 + skill/role 的管理面分组由一棵 org 内共用、可嵌套的 folder 树承载:透明组织节点, + 不进入身份、解析与授权——name/roleId 仍 org 内唯一,role→skill 绑定、run 加载与 + slash 命令均不引用 folder;folder 归属变更是 label 类变更,不归档会话;仅空 folder + 可删(见 ADR-0028 / `Spec.System.AgentRole`)。 - 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号 归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。 `Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。 diff --git a/docs/adr/0028-agent-config-folder-tree.md b/docs/adr/0028-agent-config-folder-tree.md new file mode 100644 index 0000000..29879e2 --- /dev/null +++ b/docs/adr/0028-agent-config-folder-tree.md @@ -0,0 +1,81 @@ +# ADR 0028: Agent Configuration Folder Tree + +## Status + +Accepted. + +## Context + +ADR-0017/0018 made Agent roles and skills Organization-scoped dynamic runtime +configuration, managed without process restarts. The org admin surfaces for +them (`/admin/roles` and `/admin/skills`) render every +role/skill as one large editor card in a single flat list. As an Organization +accumulates roles and skills, the management pages degrade into an endless +scroll with no grouping affordance. + +The project explorer already solves the analogous problem for projects with +transparent folders (ADR-0021): org-scoped navigation nodes that are not +permission resources. Roles and skills need the same affordance, but their +semantics differ from projects in one crucial way: skill names and role IDs +are referenced by role→skill bindings, run-time skill snapshot loading, and +Feishu slash commands. Any grouping mechanism must not leak into those +resolution paths. + +## Decision + +Introduce an Organization-scoped folder tree shared by Agent roles and Agent +skills: + +- One folder tree per Organization is shared by both roles and skills (e.g. a + "高三化学组" folder groups that team's roles and its skills together). It is + a distinct entity from the project explorer `Folder` of ADR-0021 — the two + trees are managed independently and never reference each other. +- Folders are **transparent organization nodes**, following the ADR-0021 + project-folder precedent: they exist for management-surface navigation and + grouping only, are not permission resources, and hold no grants. +- Folder membership is **not part of role/skill identity or resolution**: + - skill `name` and role `roleId` remain unique per Organization regardless + of folder membership; + - role→skill bindings, run admission's frozen role snapshot, run-scoped + skill loading, and Feishu slash commands never reference folders. +- Each role/skill sits in at most one folder; membership is optional + (unfiled items remain first-class). Folders nest arbitrarily. +- Folder assignment is a label-class change in the ADR-0017 sense: it never + archives Agent sessions, because the execution surface (model, prompt, + tools, skill content) is untouched. +- A folder can be deleted only when empty — no child folders, no roles, no + skills. Relocating items out of a folder is an explicit user action, so no + orphan-placement rule is needed yet. +- Admin web renders both pages as a left folder tree plus the item list of + the selected folder ("all" and "unfiled" included). The host-console CLI is + unchanged: folder management lives in the web surface, and CLI + `upsert-role`/`install-skill` never touch folder assignment. + +## Consequences + +- New DB entity `OrganizationAgentConfigFolder` (org-scoped, self-nesting via + `parentId`, delete restricted while referenced) plus nullable `folderId` on + `OrganizationAgentRole` and `OrganizationAgentSkill` (SetNull on folder + delete, though the service refuses to delete non-empty folders). +- The spec pins the transparency and single-membership semantics in + `Spec.System.AgentRole` (`AgentConfigFolder`), so future implementors do + not re-derive them differently (e.g. path-style names or per-folder + uniqueness). +- Org admin APIs gain folder CRUD plus role/skill folder-assignment endpoints + that skip session archival by construction. +- Moving a role/skill between folders changes nothing about authorization, + run resolution, or audit-visible configuration lineage beyond the folder + assignment event itself. + +## Open Questions / Deferred + +- Drag-and-drop assignment and bulk moves are deferred; assignment is a + per-item select for now. +- Folder-level usage aggregation for roles/skills is deferred (project + folders already aggregate usage under ADR-0021; agent configuration has no + usage dimension yet). +- CLI flags for folder assignment are deferred until a console workflow asks + for them. +- Folder-scoped default-role policies (e.g. per-folder defaults) are rejected + for now: the Organization keeps exactly one active default role + (ADR-0017/0018 invariant) regardless of folder structure. diff --git a/hub/admin-web/src/lib/api.ts b/hub/admin-web/src/lib/api.ts index 1c923c1..23f11e1 100644 --- a/hub/admin-web/src/lib/api.ts +++ b/hub/admin-web/src/lib/api.ts @@ -317,6 +317,7 @@ export interface AgentRoleRow { createdAt: string; updatedAt: string; skillNames: readonly string[]; + folderId: string | null; } export interface AgentSkillRow { @@ -329,6 +330,14 @@ export interface AgentSkillRow { createdAt: string; updatedAt: string; boundRoleIds: readonly string[]; + folderId: string | null; +} + +/** ADR-0028 transparent folder node shared by agent roles and skills. */ +export interface AgentConfigFolderRow { + id: string; + name: string; + parentId: string | null; } export interface SkillFileEntry { @@ -526,4 +535,21 @@ export const api = { patchAgentSkill: (slug: string, name: string, body: { description?: string; disabled?: boolean }) => patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<{ disabled?: boolean; updated?: boolean }>, agentModels: (slug: string) => get(`${orgBase(slug)}/agent-models`) as Promise<{ models: AgentModelRow[] }>, + + agentConfigFolders: (slug: string) => + get(`${orgBase(slug)}/agent-config-folders`) as Promise<{ folders: AgentConfigFolderRow[] }>, + createAgentConfigFolder: (slug: string, body: { name: string; parentId?: string }) => + post(`${orgBase(slug)}/agent-config-folders`, body) as Promise, + patchAgentConfigFolder: (slug: string, folderId: string, body: { name?: string; parentId?: string | null }) => + patch(`${orgBase(slug)}/agent-config-folders/${encodeURIComponent(folderId)}`, body) as Promise, + deleteAgentConfigFolder: (slug: string, folderId: string) => + del(`${orgBase(slug)}/agent-config-folders/${encodeURIComponent(folderId)}`) as Promise<{ deleted: boolean }>, + setAgentRoleFolder: (slug: string, roleId: string, folderId: string | null) => + patch(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}/folder`, { folderId }) as Promise<{ + folderId: string | null; + }>, + setAgentSkillFolder: (slug: string, name: string, folderId: string | null) => + patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}/folder`, { folderId }) as Promise<{ + folderId: string | null; + }>, }; diff --git a/hub/admin-web/src/lib/components/AgentConfigFolderNav.svelte b/hub/admin-web/src/lib/components/AgentConfigFolderNav.svelte new file mode 100644 index 0000000..6985d85 --- /dev/null +++ b/hub/admin-web/src/lib/components/AgentConfigFolderNav.svelte @@ -0,0 +1,160 @@ + + + diff --git a/hub/admin-web/src/lib/components/RoleCard.svelte b/hub/admin-web/src/lib/components/RoleCard.svelte index 9c2e2f2..6a6fb76 100644 --- a/hub/admin-web/src/lib/components/RoleCard.svelte +++ b/hub/admin-web/src/lib/components/RoleCard.svelte @@ -5,6 +5,7 @@ import { fmtDate } from '$lib/format'; import { TOOL_OPTIONS } from '$lib/constants'; import SelectField from '$lib/components/SelectField.svelte'; + import SearchableSelectField from '$lib/components/SearchableSelectField.svelte'; import CheckboxControl from '$lib/components/CheckboxControl.svelte'; import Icon from '$lib/components/Icon.svelte'; import { toastError, toastSuccess } from '$lib/toast'; @@ -14,15 +15,20 @@ models, skills, slug, + folderItems, onupdated, onskillschanged, + onfolderchanged, }: { r: AgentRoleRow; models: AgentModelRow[]; skills: AgentSkillRow[]; slug: string; + /** ADR-0028 folder choices ('' = 未分类); transparent grouping only */ + folderItems: { value: string; label: string }[]; onupdated: (updated: AgentRoleRow) => void; onskillschanged: (roleId: string, skillNames: string[]) => void; + onfolderchanged: (roleId: string, folderId: string | null) => void; } = $props(); const initial = { @@ -44,6 +50,8 @@ let isDefault = $state(initial.isDefault); let selectedSkills = $state([...initial.skillNames]); let saving = $state(false); + let folderValue = $state(r.folderId ?? ''); + let savingFolder = $state(false); const groupedTools = TOOL_OPTIONS.reduce( (acc, t) => { @@ -53,10 +61,15 @@ {} as Record, ); - const modelItems = $derived([ - { value: '', label: '(使用平台默认模型)' }, - ...models.map((m) => ({ value: m.id, label: `${m.label}(${m.id})` })), - ]); + const modelItems = $derived.by(() => { + const fromCatalog = models.map((m) => ({ value: m.id, label: `${m.label}(${m.id})` })); + const items = [{ value: '', label: '(使用平台默认模型)' }, ...fromCatalog]; + // Keep a previously saved model selectable even if it left the live catalog. + if (defaultModel !== '' && !items.some((item) => item.value === defaultModel)) { + items.push({ value: defaultModel, label: `${defaultModel}(当前已存,不在目录中)` }); + } + return items; + }); const skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name }))); @@ -105,6 +118,24 @@ function sortKeyDirty(): boolean { return Number(sortOrder) !== r.sortOrder; } + + // ADR-0028: folder assignment is a label-class change — instant-apply, no + // session archival, independent of the configuration save button. + async function saveFolder(next: string) { + const folderId = next === '' ? null : next; + if (folderId === r.folderId) return; + savingFolder = true; + try { + await api.setAgentRoleFolder(slug, r.roleId, folderId); + onfolderchanged(r.roleId, folderId); + toastSuccess('已更新所属文件夹'); + } catch (err) { + folderValue = r.folderId ?? ''; + toastError(err instanceof Error ? err.message : String(err)); + } finally { + savingFolder = false; + } + }
@@ -114,6 +145,12 @@ {#if r.isDefault} 默认 {/if} +
+ 文件夹 +
+ +
+
@@ -129,7 +166,13 @@

默认模型

- +
diff --git a/hub/admin-web/src/lib/components/SearchableSelectField.svelte b/hub/admin-web/src/lib/components/SearchableSelectField.svelte new file mode 100644 index 0000000..edc73dd --- /dev/null +++ b/hub/admin-web/src/lib/components/SearchableSelectField.svelte @@ -0,0 +1,102 @@ + + + { + value = next; + onchange?.(next); + }} + onOpenChangeComplete={(open) => { + if (!open) searchValue = ''; + }} +> +
+ { + searchValue = e.currentTarget.value; + }} + /> + + + +
+ + + + {#each filteredItems as item (item.value)} + + {#snippet children({ selected })} + {item.label} + {#if selected} + + {/if} + {/snippet} + + {:else} +
{emptyText}
+ {/each} +
+
+
+
diff --git a/hub/admin-web/src/lib/components/SkillEditor.svelte b/hub/admin-web/src/lib/components/SkillEditor.svelte index 3ff6a06..5435fa7 100644 --- a/hub/admin-web/src/lib/components/SkillEditor.svelte +++ b/hub/admin-web/src/lib/components/SkillEditor.svelte @@ -3,18 +3,24 @@ import { api } from '$lib/api'; import { fmtDate } from '$lib/format'; import Icon from '$lib/components/Icon.svelte'; + import SelectField from '$lib/components/SelectField.svelte'; import { toastError, toastSuccess } from '$lib/toast'; let { slug, skill, + folderItems, oninstalled, ondisabled, + onfolderchanged, }: { slug: string; skill: AgentSkillRow; + /** ADR-0028 folder choices ('' = 未分类); transparent grouping only */ + folderItems: { value: string; label: string }[]; oninstalled: (result: { id: string; name: string; contentDigest: string }) => void; ondisabled: (name: string) => void; + onfolderchanged: (name: string, folderId: string | null) => void; } = $props(); type FileNode = { path: string; content: string }; @@ -28,6 +34,8 @@ let dirty = $state(false); let newFilePath = $state(''); let showNewFile = $state(false); + let folderValue = $state(skill.folderId ?? ''); + let savingFolder = $state(false); const selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null); const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md')); @@ -152,6 +160,24 @@ dirty = true; } + // ADR-0028: folder assignment is a label-class change — instant-apply, no + // session archival, independent of the content save button. + async function saveFolder(next: string) { + const folderId = next === '' ? null : next; + if (folderId === skill.folderId) return; + savingFolder = true; + try { + await api.setAgentSkillFolder(slug, skill.name, folderId); + onfolderchanged(skill.name, folderId); + toastSuccess('已更新所属文件夹'); + } catch (err) { + folderValue = skill.folderId ?? ''; + toastError(err instanceof Error ? err.message : String(err)); + } finally { + savingFolder = false; + } + } + function updateFrontmatter(content: string, key: string, value: string): string { const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm'); if (regex.test(content)) { @@ -178,6 +204,12 @@ {#if skill.disabledAt} 已禁用 {/if} +
+ 文件夹 +
+ +
+
diff --git a/hub/admin-web/src/routes/admin/roles/+page.svelte b/hub/admin-web/src/routes/admin/roles/+page.svelte index e56e6a4..2b36384 100644 --- a/hub/admin-web/src/routes/admin/roles/+page.svelte +++ b/hub/admin-web/src/routes/admin/roles/+page.svelte @@ -1,6 +1,6 @@