diff --git a/AGENT_TASKS.md b/AGENT_TASKS.md index 4626484..79a1f3d 100644 --- a/AGENT_TASKS.md +++ b/AGENT_TASKS.md @@ -39,9 +39,9 @@ ## Phase 2 — v1 增强(P1) - [ ] 模糊/正则性能优化(索引/缓存) -- [ ] 管理命令与校验(重命名/删除/迁移) -- [ ] 完善错误码与审计日志 -- [ ] 增加导入/导出工具 +- [x] 管理命令与校验(重命名/删除/迁移) +- [x] 完善错误码与审计日志 +- [x] 增加导入/导出工具 ## Phase 3 — 体验与文档(P1) - [ ] README(安装/配置/示例) diff --git a/README.md b/README.md index c42e012..8c24554 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,14 @@ Implemented in this branch: - `queryAgents` - Query parser/executor with pagination - Scope memory adapter (`put/search`) +- Management APIs: + - `renameDepartment` + - `renameTeam` + - `migrateTeam` + - `deleteDepartment` + - `deleteTeam` +- Error code model (`YonexusError`) and audit logs +- Import/export APIs (`importData` / `exportData`) ## Notes diff --git a/src/index.ts b/src/index.ts index 04da636..1db0285 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ import path from "node:path"; import { DEFAULT_SCHEMA } from "./config/defaults"; +import type { AuditLogEntry } from "./models/audit"; +import { YonexusError } from "./models/errors"; import type { Actor, Agent, @@ -10,6 +12,7 @@ import type { YonexusSchema } from "./models/types"; import { authorize } from "./permissions/authorize"; +import { AuditStore } from "./store/auditStore"; import { JsonStore } from "./store/jsonStore"; import { queryIdentities } from "./tools/query"; import { makeId } from "./utils/id"; @@ -24,6 +27,7 @@ export class Yonexus { private readonly schema: YonexusSchema; private readonly registrars: Set; private readonly store: JsonStore; + private readonly audit = new AuditStore(); constructor(options: YonexusOptions = {}) { const dataFile = options.dataFile ?? path.resolve(process.cwd(), "data/org.json"); @@ -32,32 +36,62 @@ export class Yonexus { this.registrars = new Set(options.registrars ?? []); } + private log(entry: Omit): void { + this.audit.append({ + id: makeId("audit", `${entry.actorId}-${entry.action}-${Date.now()}`), + ts: new Date().toISOString(), + ...entry + }); + } + createDepartment(actor: Actor, name: string, orgId: string) { - authorize("create_department", actor, { orgId }, this.store); - const dept = { id: makeId("dept", name), name, orgId }; - return this.store.addDepartment(dept); + try { + authorize("create_department", actor, { orgId }, this.store); + const dept = { id: makeId("dept", name), name, orgId }; + const result = this.store.addDepartment(dept); + this.log({ actorId: actor.agentId, action: "create_department", target: result.id, status: "ok" }); + return result; + } catch (error) { + this.log({ + actorId: actor.agentId, + action: "create_department", + target: name, + status: "error", + message: error instanceof Error ? error.message : String(error) + }); + throw error; + } } createTeam(actor: Actor, name: string, deptId: string) { authorize("create_team", actor, { deptId }, this.store); - if (!this.store.findDepartment(deptId)) throw new Error(`department_not_found: ${deptId}`); + if (!this.store.findDepartment(deptId)) { + throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`, { deptId }); + } const team = { id: makeId("team", `${deptId}-${name}`), name, deptId }; - return this.store.addTeam(team); + const result = this.store.addTeam(team); + this.log({ actorId: actor.agentId, action: "create_team", target: result.id, status: "ok" }); + return result; } registerAgent(actor: Actor, agentId: string, name: string, roles: Agent["roles"] = ["agent"]) { if (this.registrars.size > 0 && !this.registrars.has(actor.agentId)) { - authorize("register_agent", actor, {}, this.store); + throw new YonexusError("REGISTRAR_DENIED", `registrar_denied: ${actor.agentId}`); } - if (this.store.findAgent(agentId)) throw new Error(`agent_exists: ${agentId}`); - return this.store.addAgent({ id: agentId, name, roles }); + authorize("register_agent", actor, {}, this.store); + if (this.store.findAgent(agentId)) { + throw new YonexusError("ALREADY_EXISTS", `agent_exists: ${agentId}`, { agentId }); + } + const result = this.store.addAgent({ id: agentId, name, roles }); + this.log({ actorId: actor.agentId, action: "register_agent", target: result.id, status: "ok" }); + return result; } assignIdentity(actor: Actor, agentId: string, deptId: string, teamId: string, meta: Record): Identity { authorize("assign_identity", actor, { deptId, teamId }, this.store); - if (!this.store.findAgent(agentId)) throw new Error(`agent_not_found: ${agentId}`); - if (!this.store.findDepartment(deptId)) throw new Error(`department_not_found: ${deptId}`); - if (!this.store.findTeam(teamId)) throw new Error(`team_not_found: ${teamId}`); + if (!this.store.findAgent(agentId)) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`); + if (!this.store.findDepartment(deptId)) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`); + if (!this.store.findTeam(teamId)) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`); const validatedMeta: Record = {}; for (const [field, value] of Object.entries(meta)) { @@ -65,26 +99,30 @@ export class Yonexus { validatedMeta[field] = String(value); } - return this.store.addIdentity({ + const result = this.store.addIdentity({ id: makeId("identity", `${agentId}-${deptId}-${teamId}-${Date.now()}`), agentId, deptId, teamId, meta: validatedMeta }); + this.log({ actorId: actor.agentId, action: "assign_identity", target: result.id, status: "ok" }); + return result; } setSupervisor(actor: Actor, agentId: string, supervisorId: string, deptId?: string) { authorize("set_supervisor", actor, { deptId }, this.store); - if (!this.store.findAgent(agentId)) throw new Error(`agent_not_found: ${agentId}`); - if (!this.store.findAgent(supervisorId)) throw new Error(`supervisor_not_found: ${supervisorId}`); - if (agentId === supervisorId) throw new Error("invalid_supervisor: self_reference"); - return this.store.upsertSupervisor({ agentId, supervisorId }); + if (!this.store.findAgent(agentId)) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`); + if (!this.store.findAgent(supervisorId)) throw new YonexusError("NOT_FOUND", `supervisor_not_found: ${supervisorId}`); + if (agentId === supervisorId) throw new YonexusError("INVALID_SUPERVISOR", "invalid_supervisor: self_reference"); + const result = this.store.upsertSupervisor({ agentId, supervisorId }); + this.log({ actorId: actor.agentId, action: "set_supervisor", target: `${agentId}->${supervisorId}`, status: "ok" }); + return result; } whoami(agentId: string) { const agent = this.store.findAgent(agentId); - if (!agent) throw new Error(`agent_not_found: ${agentId}`); + if (!agent) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`); const identities = this.store.listIdentities().filter((x) => x.agentId === agentId); const supervisor = this.store.findSupervisor(agentId); @@ -97,6 +135,64 @@ export class Yonexus { return queryIdentities(identities, query, this.schema); } + renameDepartment(actor: Actor, deptId: string, newName: string) { + authorize("create_department", actor, {}, this.store); + const updated = this.store.renameDepartment(deptId, newName); + if (!updated) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`); + this.log({ actorId: actor.agentId, action: "rename_department", target: deptId, status: "ok" }); + return updated; + } + + renameTeam(actor: Actor, teamId: string, newName: string, deptId?: string) { + authorize("create_team", actor, { deptId }, this.store); + const updated = this.store.renameTeam(teamId, newName); + if (!updated) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`); + this.log({ actorId: actor.agentId, action: "rename_team", target: teamId, status: "ok" }); + return updated; + } + + migrateTeam(actor: Actor, teamId: string, newDeptId: string) { + authorize("create_team", actor, { deptId: newDeptId }, this.store); + if (!this.store.findDepartment(newDeptId)) throw new YonexusError("NOT_FOUND", `department_not_found: ${newDeptId}`); + const updated = this.store.migrateTeam(teamId, newDeptId); + if (!updated) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`); + this.log({ actorId: actor.agentId, action: "migrate_team", target: `${teamId}->${newDeptId}`, status: "ok" }); + return updated; + } + + deleteDepartment(actor: Actor, deptId: string) { + authorize("create_department", actor, {}, this.store); + const ok = this.store.deleteDepartment(deptId); + if (!ok) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`); + this.log({ actorId: actor.agentId, action: "delete_department", target: deptId, status: "ok" }); + return { ok }; + } + + deleteTeam(actor: Actor, teamId: string, deptId?: string) { + authorize("create_team", actor, { deptId }, this.store); + const ok = this.store.deleteTeam(teamId); + if (!ok) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`); + this.log({ actorId: actor.agentId, action: "delete_team", target: teamId, status: "ok" }); + return { ok }; + } + + exportData(actor: Actor): StoreState { + authorize("query_agents", actor, {}, this.store); + this.log({ actorId: actor.agentId, action: "export_data", status: "ok" }); + return this.store.snapshot(); + } + + importData(actor: Actor, state: StoreState): { ok: true } { + authorize("create_department", actor, {}, this.store); + this.store.replace(state); + this.log({ actorId: actor.agentId, action: "import_data", status: "ok" }); + return { ok: true }; + } + + listAuditLogs(limit = 100, offset = 0): AuditLogEntry[] { + return this.audit.list(limit, offset); + } + debugSnapshot(): StoreState { return this.store.snapshot(); } diff --git a/src/models/audit.ts b/src/models/audit.ts new file mode 100644 index 0000000..7ff07fd --- /dev/null +++ b/src/models/audit.ts @@ -0,0 +1,10 @@ +export interface AuditLogEntry { + id: string; + ts: string; + actorId: string; + action: string; + target?: string; + status: 'ok' | 'error'; + message?: string; + meta?: Record; +} diff --git a/src/models/errors.ts b/src/models/errors.ts new file mode 100644 index 0000000..ddfbf23 --- /dev/null +++ b/src/models/errors.ts @@ -0,0 +1,19 @@ +export type ErrorCode = + | 'PERMISSION_DENIED' + | 'NOT_FOUND' + | 'ALREADY_EXISTS' + | 'VALIDATION_ERROR' + | 'FIELD_NOT_QUERYABLE' + | 'INVALID_SUPERVISOR' + | 'REGISTRAR_DENIED'; + +export class YonexusError extends Error { + constructor( + public readonly code: ErrorCode, + message: string, + public readonly details?: Record + ) { + super(message); + this.name = 'YonexusError'; + } +} diff --git a/src/permissions/authorize.ts b/src/permissions/authorize.ts index a0b1918..61ed8b6 100644 --- a/src/permissions/authorize.ts +++ b/src/permissions/authorize.ts @@ -1,3 +1,4 @@ +import { YonexusError } from '../models/errors'; import type { Action, Actor, Scope } from "../models/types"; import { JsonStore } from "../store/jsonStore"; @@ -29,6 +30,10 @@ export function authorize(action: Action, actor: Actor, scope: Scope, store: Jso (action === "query_agents" && (orgAdmin || deptAdmin || teamLead || agent)); if (!allowed) { - throw new Error(`permission_denied: ${action}`); + throw new YonexusError('PERMISSION_DENIED', `permission_denied: ${action}`, { + action, + actorId: actor.agentId, + scope + }); } } diff --git a/src/store/auditStore.ts b/src/store/auditStore.ts new file mode 100644 index 0000000..907afa2 --- /dev/null +++ b/src/store/auditStore.ts @@ -0,0 +1,19 @@ +import type { AuditLogEntry } from '../models/audit'; + +const MAX_AUDIT = 1000; + +export class AuditStore { + private logs: AuditLogEntry[] = []; + + append(entry: AuditLogEntry): AuditLogEntry { + this.logs.push(entry); + if (this.logs.length > MAX_AUDIT) this.logs.shift(); + return entry; + } + + list(limit = 100, offset = 0): AuditLogEntry[] { + const safeLimit = Math.min(Math.max(1, limit), 500); + const safeOffset = Math.max(0, offset); + return this.logs.slice(safeOffset, safeOffset + safeLimit); + } +} diff --git a/src/store/jsonStore.ts b/src/store/jsonStore.ts index 4462530..a86e259 100644 --- a/src/store/jsonStore.ts +++ b/src/store/jsonStore.ts @@ -25,6 +25,11 @@ export class JsonStore { return JSON.parse(JSON.stringify(this.state)) as StoreState; } + replace(state: StoreState): void { + this.state = JSON.parse(JSON.stringify(state)) as StoreState; + this.save(); + } + addOrganization(org: Organization): Organization { this.state.organizations.push(org); this.save(); @@ -37,12 +42,58 @@ export class JsonStore { return dept; } + renameDepartment(deptId: string, name: string): Department | undefined { + const dept = this.findDepartment(deptId); + if (!dept) return undefined; + dept.name = name; + this.save(); + return dept; + } + + deleteDepartment(deptId: string): boolean { + const before = this.state.departments.length; + this.state.departments = this.state.departments.filter((d) => d.id !== deptId); + this.state.teams = this.state.teams.filter((t) => t.deptId !== deptId); + this.state.identities = this.state.identities.filter((i) => i.deptId !== deptId); + const changed = this.state.departments.length !== before; + if (changed) this.save(); + return changed; + } + addTeam(team: Team): Team { this.state.teams.push(team); this.save(); return team; } + renameTeam(teamId: string, name: string): Team | undefined { + const team = this.findTeam(teamId); + if (!team) return undefined; + team.name = name; + this.save(); + return team; + } + + migrateTeam(teamId: string, newDeptId: string): Team | undefined { + const team = this.findTeam(teamId); + if (!team) return undefined; + team.deptId = newDeptId; + for (const identity of this.state.identities) { + if (identity.teamId === teamId) identity.deptId = newDeptId; + } + this.save(); + return team; + } + + deleteTeam(teamId: string): boolean { + const before = this.state.teams.length; + this.state.teams = this.state.teams.filter((t) => t.id !== teamId); + this.state.identities = this.state.identities.filter((i) => i.teamId !== teamId); + const changed = before !== this.state.teams.length; + if (changed) this.save(); + return changed; + } + addAgent(agent: Agent): Agent { this.state.agents.push(agent); this.save(); diff --git a/src/tools/query.ts b/src/tools/query.ts index 48e928b..6852165 100644 --- a/src/tools/query.ts +++ b/src/tools/query.ts @@ -1,3 +1,4 @@ +import { YonexusError } from '../models/errors'; import type { Identity, QueryFilter, QueryInput, QueryOptions, YonexusSchema } from "../models/types"; const DEFAULT_LIMIT = 20; @@ -33,7 +34,9 @@ function normalizeOptions(options?: QueryOptions): Required { export function queryIdentities(identities: Identity[], input: QueryInput, schema: YonexusSchema): Identity[] { for (const filter of input.filters) { if (!isQueryable(filter.field, schema)) { - throw new Error(`field_not_queryable: ${filter.field}`); + throw new YonexusError('FIELD_NOT_QUERYABLE', `field_not_queryable: ${filter.field}`, { + field: filter.field + }); } }