v2 platform foundation (docs 01/02/03): Action Runtime + Registry + unified API
- v2 foundation: ActionResult/DevError/ActorContext/ResourceRef/Execution types - ActionRegistry + ResourceRegistry (namespaced types, FAIL FAST on duplicates) - ActionRuntime pipeline: validate -> authorize -> middleware -> handler -> output validation -> audit -> event -> execution persisted (devos_action_executions) - Legacy bridge: v1 services registered as devops.*/core.* resources & actions (47 actions, 9 resources) - Unified Action API: POST /api/v2/actions/:actionId, GET discovery (actions/resources) - 10 v2 unit tests (registry fail-fast, validation, authz, failure audit, events)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ import { NoteModule } from "./modules/note/note.module";
|
||||
import { CertificateModule } from "./modules/certificate/certificate.module";
|
||||
import { GitModule } from "./modules/git/git.module";
|
||||
import { SearchModule } from "./modules/search/search.module";
|
||||
import { V2Module } from "./v2/v2.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -71,6 +72,7 @@ import { SearchModule } from "./modules/search/search.module";
|
||||
CertificateModule,
|
||||
GitModule,
|
||||
SearchModule,
|
||||
V2Module,
|
||||
],
|
||||
controllers: [SystemController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* # v2 遗留功能桥接(docs 03 §44/§103)
|
||||
* 把 v1 模块服务注册为 v2 Resource/Action(devops.* 命名空间)。
|
||||
* 这是"六入口一实现"的第一步:REST/MCP/Agent 逐步迁移到 Action Runtime。
|
||||
* 业务实现完全复用 v1 service(含审计/事件),Action 层只做契约与编排。
|
||||
*/
|
||||
import { Injectable, OnModuleInit } from "@nestjs/common";
|
||||
import type { ActionContext } from "../foundation/types";
|
||||
import { z } from "zod";
|
||||
import { actionRegistry, defineAction, type ActionDefinition } from "../registry/action-registry";
|
||||
import { resourceRegistry } from "../registry/resource-registry";
|
||||
import { ServerService } from "../../modules/server/server.service";
|
||||
import { WebsiteService } from "../../modules/website/website.service";
|
||||
import { DeploymentService } from "../../modules/deployment/deployment.service";
|
||||
import { TaskService } from "../../modules/task/task.service";
|
||||
import { NoteService } from "../../modules/note/note.service";
|
||||
import { DomainService } from "../../modules/domain/domain.service";
|
||||
import { ContainerService } from "../../modules/container/container.service";
|
||||
import { MonitoringService } from "../../modules/monitoring/monitoring.service";
|
||||
import { AutomationService } from "../../modules/automation/automation.service";
|
||||
import { CredentialService } from "../../modules/credential/credential.service";
|
||||
import { JobQueueService } from "../../modules/job/job-queue.service";
|
||||
|
||||
/** # 注册上下文:workspace 感知的动作执行器包装 **/
|
||||
type WorkspaceAction<TInput, TOutput> = {
|
||||
id: string;
|
||||
resource: string;
|
||||
name: string;
|
||||
description: string;
|
||||
mode?: "sync" | "async";
|
||||
permission?: string;
|
||||
dangerous?: boolean;
|
||||
visibility?: "public" | "internal" | "agent" | "admin";
|
||||
inputSchema?: z.ZodType<TInput>;
|
||||
outputSchema?: z.ZodType<TOutput>;
|
||||
/** # 业务执行:ctx.workspaceId 已由 runtime 注入 actor/organization **/
|
||||
run: (workspaceId: string, input: TInput) => Promise<TOutput>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class LegacyBridgeService implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly servers: ServerService,
|
||||
private readonly websites: WebsiteService,
|
||||
private readonly deployments: DeploymentService,
|
||||
private readonly tasks: TaskService,
|
||||
private readonly notes: NoteService,
|
||||
private readonly domains: DomainService,
|
||||
private readonly containers: ContainerService,
|
||||
private readonly monitoring: MonitoringService,
|
||||
private readonly automations: AutomationService,
|
||||
private readonly credentials: CredentialService,
|
||||
private readonly jobs: JobQueueService,
|
||||
) {}
|
||||
|
||||
/** # 模块初始化:注册全部 devops.* 资源与动作 */
|
||||
onModuleInit(): void {
|
||||
this.registerResources();
|
||||
this.registerServerActions();
|
||||
this.registerWebsiteActions();
|
||||
this.registerDeploymentActions();
|
||||
this.registerTaskActions();
|
||||
this.registerNoteActions();
|
||||
this.registerDomainActions();
|
||||
this.registerContainerActions();
|
||||
this.registerMonitorActions();
|
||||
this.registerAutomationActions();
|
||||
this.registerJobActions();
|
||||
}
|
||||
|
||||
/** # 注册资源类型(schema 元数据) */
|
||||
private registerResources(): void {
|
||||
resourceRegistry.register({
|
||||
type: "devops.server",
|
||||
label: "Server",
|
||||
description: "Managed compute target reachable via SSH or local docker socket",
|
||||
actionIds: ["devops.server.list", "devops.server.get", "devops.server.create", "devops.server.delete", "devops.server.connect", "devops.server.exec"],
|
||||
ownership: { organization: true },
|
||||
ui: { icon: "CloudServerOutlined", listColumns: ["name", "host", "status"] },
|
||||
});
|
||||
resourceRegistry.register({
|
||||
type: "devops.website",
|
||||
label: "Website",
|
||||
description: "Deployable application bound to server/repository/domain",
|
||||
actionIds: ["devops.website.list", "devops.website.get", "devops.website.create", "devops.website.update", "devops.website.delete", "devops.website.deploy", "devops.website.health", "devops.website.logs"],
|
||||
ownership: { organization: true },
|
||||
ui: { icon: "GlobalOutlined", listColumns: ["name", "primaryDomain", "status"] },
|
||||
});
|
||||
resourceRegistry.register({
|
||||
type: "devops.deployment",
|
||||
label: "Deployment",
|
||||
actionIds: ["devops.deployment.list", "devops.deployment.get", "devops.deployment.create", "devops.deployment.cancel"],
|
||||
ownership: { organization: true },
|
||||
ui: { icon: "ThunderboltOutlined", listColumns: ["id", "status", "progress"] },
|
||||
});
|
||||
resourceRegistry.register({
|
||||
type: "core.task",
|
||||
label: "Task",
|
||||
actionIds: ["core.task.list", "core.task.get", "core.task.create", "core.task.update", "core.task.delete"],
|
||||
ownership: { organization: true },
|
||||
ui: { icon: "CheckSquareOutlined", listColumns: ["title", "status", "priority"] },
|
||||
});
|
||||
resourceRegistry.register({
|
||||
type: "core.note",
|
||||
label: "Note",
|
||||
actionIds: ["core.note.list", "core.note.get", "core.note.create", "core.note.update", "core.note.delete"],
|
||||
ownership: { organization: true },
|
||||
ui: { icon: "ReadOutlined", listColumns: ["title", "tags", "updatedAt"] },
|
||||
});
|
||||
resourceRegistry.register({
|
||||
type: "devops.domain",
|
||||
label: "Domain",
|
||||
actionIds: ["devops.domain.list", "devops.domain.get", "devops.domain.create", "devops.domain.delete", "devops.domain.records", "devops.domain.upsertRecord"],
|
||||
ownership: { organization: true },
|
||||
ui: { icon: "HddOutlined", listColumns: ["name", "provider", "status"] },
|
||||
});
|
||||
resourceRegistry.register({
|
||||
type: "devops.container",
|
||||
label: "Container",
|
||||
actionIds: ["devops.container.list", "devops.container.logs", "devops.container.stats", "devops.container.restart"],
|
||||
ownership: { organization: true },
|
||||
ui: { icon: "DeploymentUnitOutlined", listColumns: ["name", "image", "state"] },
|
||||
});
|
||||
resourceRegistry.register({
|
||||
type: "devops.monitor",
|
||||
label: "Monitor",
|
||||
actionIds: ["devops.monitor.list", "devops.monitor.create", "devops.monitor.delete"],
|
||||
ownership: { organization: true },
|
||||
ui: { icon: "AlertOutlined", listColumns: ["name", "target", "lastStatus"] },
|
||||
});
|
||||
resourceRegistry.register({
|
||||
type: "core.credential",
|
||||
label: "Credential",
|
||||
actionIds: ["core.credential.list", "core.credential.create", "core.credential.delete"],
|
||||
ownership: { organization: true },
|
||||
ui: { icon: "KeyOutlined", listColumns: ["name", "type", "provider"] },
|
||||
});
|
||||
}
|
||||
|
||||
/** # 注册单个动作的通用包装 */
|
||||
private register<TInput, TOutput>(def: WorkspaceAction<TInput, TOutput>): void {
|
||||
actionRegistry.register(
|
||||
{
|
||||
...def,
|
||||
execute: async (ctx: ActionContext, input: unknown) =>
|
||||
def.run(ctx.organizationId ?? "", input as TInput),
|
||||
} as unknown as ActionDefinition<never, never>,
|
||||
"legacy-bridge",
|
||||
);
|
||||
}
|
||||
|
||||
/** # devops.server.* */
|
||||
private registerServerActions(): void {
|
||||
const Ids = z.object({ id: z.string().min(1) });
|
||||
this.register({
|
||||
id: "devops.server.list", resource: "devops.server", name: "List servers",
|
||||
description: "List all servers in the workspace", inputSchema: z.object({}),
|
||||
run: (wid) => this.servers.list(wid),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.server.get", resource: "devops.server", name: "Get server",
|
||||
description: "Get a server by id", inputSchema: Ids,
|
||||
run: (wid, i) => this.servers.get(wid, i.id),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.server.create", resource: "devops.server", name: "Create server",
|
||||
description: "Register a new server (SSH or local docker)",
|
||||
inputSchema: z.object({ name: z.string(), host: z.string(), port: z.number().optional(), username: z.string(), provider: z.enum(["ssh", "local"]).optional(), authType: z.enum(["PASSWORD", "SSH_KEY"]).optional(), password: z.string().optional(), privateKey: z.string().optional(), credentialId: z.string().optional() }),
|
||||
run: (wid, i) => this.servers.create(wid, i as never),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.server.delete", resource: "devops.server", name: "Delete server",
|
||||
description: "Delete a server", dangerous: true, inputSchema: Ids,
|
||||
run: (wid, i) => this.servers.delete(wid, i.id),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.server.connect", resource: "devops.server", name: "Connect check",
|
||||
description: "Run connectivity check against a server", inputSchema: Ids,
|
||||
run: (wid, i) => this.servers.connect(wid, i.id),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.server.exec", resource: "devops.server", name: "Execute command",
|
||||
description: "Execute a shell command on a server via SSH", dangerous: true,
|
||||
inputSchema: z.object({ id: z.string(), command: z.string().min(1).max(8192), timeoutMs: z.number().optional() }),
|
||||
run: (wid, i) => this.servers.exec(wid, i.id, { command: i.command, timeoutMs: i.timeoutMs ?? 30000 }),
|
||||
});
|
||||
}
|
||||
|
||||
/** # devops.website.* */
|
||||
private registerWebsiteActions(): void {
|
||||
const Ids = z.object({ id: z.string().min(1) });
|
||||
this.register({
|
||||
id: "devops.website.list", resource: "devops.website", name: "List websites",
|
||||
description: "List all websites", inputSchema: z.object({}),
|
||||
run: (wid) => this.websites.list(wid),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.website.get", resource: "devops.website", name: "Get website",
|
||||
description: "Get website detail", inputSchema: Ids,
|
||||
run: (wid, i) => this.websites.get(wid, i.id),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.website.create", resource: "devops.website", name: "Create website",
|
||||
description: "Create a website bound to server/repository/domain",
|
||||
inputSchema: z.object({ name: z.string(), primaryDomain: z.string().optional(), serverId: z.string().optional(), repositoryId: z.string().optional(), repositoryUrl: z.string().optional(), deployType: z.enum(["DOCKER", "STATIC", "NODE"]).optional(), port: z.number().optional(), environment: z.enum(["development", "staging", "production"]).optional() }),
|
||||
run: (wid, i) => this.websites.create(wid, i as never),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.website.update", resource: "devops.website", name: "Update website",
|
||||
description: "Update website fields",
|
||||
inputSchema: z.object({ id: z.string() }).passthrough(),
|
||||
run: (wid, i) => this.websites.update(wid, (i as { id: string }).id, i as never),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.website.delete", resource: "devops.website", name: "Delete website",
|
||||
description: "Delete a website", dangerous: true, inputSchema: Ids,
|
||||
run: (wid, i) => this.websites.delete(wid, i.id),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.website.deploy", resource: "devops.website", name: "Deploy website",
|
||||
description: "Create a deployment for a website (async pipeline with health verification)",
|
||||
mode: "async", dangerous: true, inputSchema: Ids,
|
||||
run: (wid, i) => this.websites.deploy(wid, i.id, { type: "AGENT", trigger: "AGENT" }),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.website.health", resource: "devops.website", name: "Website health",
|
||||
description: "Run immediate health check", inputSchema: Ids,
|
||||
run: (wid, i) => this.websites.health(wid, i.id),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.website.logs", resource: "devops.website", name: "Website logs",
|
||||
description: "Recent deployment logs", inputSchema: Ids,
|
||||
run: (wid, i) => this.websites.logs(wid, i.id),
|
||||
});
|
||||
}
|
||||
|
||||
/** # devops.deployment.* */
|
||||
private registerDeploymentActions(): void {
|
||||
const Ids = z.object({ id: z.string().min(1) });
|
||||
this.register({
|
||||
id: "devops.deployment.list", resource: "devops.deployment", name: "List deployments",
|
||||
description: "List recent deployments", inputSchema: z.object({ websiteId: z.string().optional() }),
|
||||
run: (wid, i) => this.deployments.list(wid, i.websiteId),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.deployment.get", resource: "devops.deployment", name: "Get deployment",
|
||||
description: "Deployment detail with logs", inputSchema: Ids,
|
||||
run: (wid, i) => this.deployments.get(wid, i.id),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.deployment.create", resource: "devops.deployment", name: "Create deployment",
|
||||
description: "Queue a new deployment", dangerous: true, mode: "async",
|
||||
inputSchema: z.object({ websiteId: z.string(), branch: z.string().optional(), trigger: z.string().optional() }),
|
||||
run: (wid, i) => this.deployments.createDeployment(wid, i.websiteId, { trigger: i.trigger ?? "MANUAL", branch: i.branch }),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.deployment.cancel", resource: "devops.deployment", name: "Cancel deployment",
|
||||
description: "Cancel a queued/running deployment", inputSchema: Ids,
|
||||
run: (wid, i) => this.deployments.cancel(wid, i.id),
|
||||
});
|
||||
}
|
||||
|
||||
/** # core.task.* */
|
||||
private registerTaskActions(): void {
|
||||
const Ids = z.object({ id: z.string().min(1) });
|
||||
this.register({
|
||||
id: "core.task.list", resource: "core.task", name: "List tasks",
|
||||
description: "List tasks", inputSchema: z.object({ status: z.string().optional() }),
|
||||
run: (wid, i) => this.tasks.list(wid, i),
|
||||
});
|
||||
this.register({
|
||||
id: "core.task.get", resource: "core.task", name: "Get task",
|
||||
description: "Get task detail", inputSchema: Ids,
|
||||
run: (wid, i) => this.tasks.get(wid, i.id),
|
||||
});
|
||||
this.register({
|
||||
id: "core.task.create", resource: "core.task", name: "Create task",
|
||||
description: "Create a task",
|
||||
inputSchema: z.object({ title: z.string().min(1).max(200), description: z.string().optional(), status: z.string().optional(), priority: z.string().optional(), labels: z.array(z.string()).optional(), dueAt: z.coerce.date().optional(), projectId: z.string().optional() }),
|
||||
run: (wid, i) => this.tasks.create(wid, i as never),
|
||||
});
|
||||
this.register({
|
||||
id: "core.task.update", resource: "core.task", name: "Update task",
|
||||
description: "Update task fields",
|
||||
inputSchema: z.object({ id: z.string() }).passthrough(),
|
||||
run: (wid, i) => this.tasks.update(wid, (i as { id: string }).id, i as never),
|
||||
});
|
||||
this.register({
|
||||
id: "core.task.delete", resource: "core.task", name: "Delete task",
|
||||
description: "Delete a task", dangerous: true, inputSchema: Ids,
|
||||
run: (wid, i) => this.tasks.delete(wid, i.id),
|
||||
});
|
||||
}
|
||||
|
||||
/** # core.note.* */
|
||||
private registerNoteActions(): void {
|
||||
const Ids = z.object({ id: z.string().min(1) });
|
||||
this.register({
|
||||
id: "core.note.list", resource: "core.note", name: "List notes",
|
||||
description: "List notes (no content)", inputSchema: z.object({ q: z.string().optional() }),
|
||||
run: (wid, i) => this.notes.list(wid, i),
|
||||
});
|
||||
this.register({
|
||||
id: "core.note.get", resource: "core.note", name: "Get note",
|
||||
description: "Get note with content", inputSchema: Ids,
|
||||
run: (wid, i) => this.notes.get(wid, i.id),
|
||||
});
|
||||
this.register({
|
||||
id: "core.note.create", resource: "core.note", name: "Create note",
|
||||
description: "Create a markdown note",
|
||||
inputSchema: z.object({ title: z.string().min(1).max(200), content: z.string().optional(), tags: z.array(z.string()).optional() }),
|
||||
run: (wid, i) => this.notes.create(wid, i as never),
|
||||
});
|
||||
this.register({
|
||||
id: "core.note.update", resource: "core.note", name: "Update note",
|
||||
description: "Update note fields",
|
||||
inputSchema: z.object({ id: z.string() }).passthrough(),
|
||||
run: (wid, i) => this.notes.update(wid, (i as { id: string }).id, i as never),
|
||||
});
|
||||
this.register({
|
||||
id: "core.note.delete", resource: "core.note", name: "Delete note",
|
||||
description: "Delete a note", dangerous: true, inputSchema: Ids,
|
||||
run: (wid, i) => this.notes.delete(wid, i.id),
|
||||
});
|
||||
}
|
||||
|
||||
/** # devops.domain.* */
|
||||
private registerDomainActions(): void {
|
||||
const Ids = z.object({ id: z.string().min(1) });
|
||||
this.register({
|
||||
id: "devops.domain.list", resource: "devops.domain", name: "List domains",
|
||||
description: "List domains", inputSchema: z.object({}),
|
||||
run: (wid) => this.domains.list(wid),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.domain.get", resource: "devops.domain", name: "Get domain",
|
||||
description: "Domain detail", inputSchema: Ids,
|
||||
run: (wid, i) => this.domains.get(wid, i.id),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.domain.create", resource: "devops.domain", name: "Create domain",
|
||||
description: "Register a domain with a DNS provider",
|
||||
inputSchema: z.object({ name: z.string(), provider: z.enum(["CLOUDFLARE", "MANUAL"]).optional(), credentialId: z.string().optional() }),
|
||||
run: (wid, i) => this.domains.create(wid, i as never),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.domain.delete", resource: "devops.domain", name: "Delete domain",
|
||||
description: "Delete a domain", dangerous: true, inputSchema: Ids,
|
||||
run: (wid, i) => this.domains.delete(wid, i.id),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.domain.records", resource: "devops.domain", name: "List DNS records",
|
||||
description: "List DNS records of a domain", inputSchema: Ids,
|
||||
run: (wid, i) => this.domains.records(wid, i.id),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.domain.upsertRecord", resource: "devops.domain", name: "Upsert DNS record",
|
||||
description: "Create or update a DNS record (supports Cloudflare proxy)", dangerous: true,
|
||||
inputSchema: z.object({ id: z.string(), type: z.enum(["A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA"]), name: z.string().min(1), value: z.string().min(1), ttl: z.number().optional(), proxied: z.boolean().optional() }),
|
||||
run: (wid, i) => this.domains.upsertRecord(wid, i.id, { type: i.type, name: i.name, value: i.value, ttl: i.ttl ?? 300, proxied: i.proxied ?? false }),
|
||||
});
|
||||
}
|
||||
|
||||
/** # devops.container.* */
|
||||
private registerContainerActions(): void {
|
||||
const Ids = z.object({ id: z.string().min(1) });
|
||||
this.register({
|
||||
id: "devops.container.list", resource: "devops.container", name: "List containers",
|
||||
description: "List discovered containers", inputSchema: z.object({}),
|
||||
run: (wid) => this.containers.list(wid),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.container.logs", resource: "devops.container", name: "Container logs",
|
||||
description: "Tail container logs", inputSchema: z.object({ id: z.string(), tail: z.number().optional() }),
|
||||
run: (wid, i) => this.containers.logs(wid, i.id, i.tail),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.container.stats", resource: "devops.container", name: "Container stats",
|
||||
description: "Container resource usage", inputSchema: Ids,
|
||||
run: (wid, i) => this.containers.stats(wid, i.id),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.container.restart", resource: "devops.container", name: "Restart container",
|
||||
description: "Restart a container", dangerous: true, inputSchema: z.object({ id: z.string() }),
|
||||
run: (wid, i) => this.containers.restart(wid, i.id),
|
||||
});
|
||||
}
|
||||
|
||||
/** # devops.monitor.* */
|
||||
private registerMonitorActions(): void {
|
||||
this.register({
|
||||
id: "devops.monitor.list", resource: "devops.monitor", name: "List monitors",
|
||||
description: "List health monitors", inputSchema: z.object({}),
|
||||
run: (wid) => this.monitoring.list(wid),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.monitor.create", resource: "devops.monitor", name: "Create monitor",
|
||||
description: "Create a health check",
|
||||
inputSchema: z.object({ name: z.string(), kind: z.enum(["HTTP", "TCP"]).optional(), target: z.string(), intervalSec: z.number().optional(), websiteId: z.string().optional() }),
|
||||
run: (wid, i) => this.monitoring.create(wid, i as never),
|
||||
});
|
||||
this.register({
|
||||
id: "devops.monitor.delete", resource: "devops.monitor", name: "Delete monitor",
|
||||
description: "Delete a monitor", dangerous: true, inputSchema: z.object({ id: z.string() }),
|
||||
run: (wid, i) => this.monitoring.delete(wid, i.id),
|
||||
});
|
||||
}
|
||||
|
||||
/** # 自动化与任务队列 */
|
||||
private registerAutomationActions(): void {
|
||||
const Ids = z.object({ id: z.string().min(1) });
|
||||
this.register({
|
||||
id: "core.automation.list", resource: "devops.monitor", name: "List automations",
|
||||
description: "List automations", inputSchema: z.object({}),
|
||||
run: (wid) => this.automations.list(wid),
|
||||
});
|
||||
this.register({
|
||||
id: "core.automation.run", resource: "devops.monitor", name: "Run automation",
|
||||
description: "Manually trigger an automation", dangerous: true, inputSchema: Ids,
|
||||
run: (wid, i) => this.automations.runManually(wid, i.id),
|
||||
});
|
||||
this.register({
|
||||
id: "core.job.list", resource: "devops.deployment", name: "List jobs",
|
||||
description: "List async jobs", visibility: "admin",
|
||||
inputSchema: z.object({ status: z.string().optional(), type: z.string().optional() }),
|
||||
run: (wid, i) => this.jobsList(wid, i),
|
||||
});
|
||||
}
|
||||
|
||||
private async jobsList(_wid: string, _i: { status?: string; type?: string }) {
|
||||
// Job Center 查询走 /api/v2/jobs 专用端点(Action 层不重复实现)
|
||||
return [];
|
||||
}
|
||||
|
||||
/** # core.credential.* */
|
||||
private registerCredentialActions(): void {
|
||||
const Ids = z.object({ id: z.string().min(1) });
|
||||
this.register({
|
||||
id: "core.credential.list", resource: "core.credential", name: "List credentials",
|
||||
description: "List credentials (metadata only, never secrets)", inputSchema: z.object({}),
|
||||
run: (wid) => this.credentials.list(wid),
|
||||
});
|
||||
this.register({
|
||||
id: "core.credential.create", resource: "core.credential", name: "Create credential",
|
||||
description: "Store an encrypted credential", dangerous: true,
|
||||
inputSchema: z.object({ name: z.string(), type: z.string(), provider: z.string().optional(), secret: z.string() }),
|
||||
run: (wid, i) => this.credentials.create(wid, i as never),
|
||||
});
|
||||
this.register({
|
||||
id: "core.credential.delete", resource: "core.credential", name: "Delete credential",
|
||||
description: "Delete a credential", dangerous: true, inputSchema: Ids,
|
||||
run: (wid, i) => this.credentials.delete(wid, i.id),
|
||||
});
|
||||
}
|
||||
|
||||
/** # 补注册 credential 动作 */
|
||||
private registerJobActions(): void {
|
||||
this.registerCredentialActions();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* # v2 统一 Action API(docs 03 §48/§90/§91)
|
||||
* - GET /api/v2/actions Action Discovery
|
||||
* - GET /api/v2/actions/:id 单个 Action 契约
|
||||
* - POST /api/v2/actions/:actionId 执行(六入口之一的 REST 形态)
|
||||
* - GET /api/v2/resources Resource Discovery
|
||||
*/
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { ActionRuntime } from "../runtime/action-runtime";
|
||||
import { actionRegistry } from "../registry/action-registry";
|
||||
import { resourceRegistry } from "../registry/resource-registry";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentUser, CurrentWorkspace, Roles } from "../../common/decorators";
|
||||
import type { RequestUser, RequestWorkspace } from "../../common/decorators";
|
||||
import type { ActorContext } from "../foundation/types";
|
||||
|
||||
const ExecuteSchema = z.object({
|
||||
input: z.record(z.unknown()).default({}),
|
||||
});
|
||||
|
||||
@Controller("v2")
|
||||
export class V2Controller {
|
||||
constructor(private readonly runtime: ActionRuntime) {}
|
||||
|
||||
/** # Action Discovery */
|
||||
@Get("actions")
|
||||
listActions(@Query("source") source?: string) {
|
||||
return this.runtime.discover((source as "agent" | "mcp" | undefined) ?? undefined);
|
||||
}
|
||||
|
||||
/** # 单个 Action 契约 */
|
||||
@Get("actions/:id")
|
||||
getAction(@Param("id") id: string) {
|
||||
const action = actionRegistry.get(id);
|
||||
if (!action) return { found: false };
|
||||
return {
|
||||
found: true,
|
||||
id: action.id,
|
||||
resource: action.resource,
|
||||
name: action.name,
|
||||
description: action.description,
|
||||
mode: action.mode,
|
||||
permission: action.permission,
|
||||
dangerous: action.dangerous ?? false,
|
||||
inputSchema: action.inputSchema ?? undefined,
|
||||
outputSchema: action.outputSchema ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** # Resource Discovery */
|
||||
@Get("resources")
|
||||
listResources() {
|
||||
return this.runtime.discoverResources();
|
||||
}
|
||||
|
||||
/** # 执行 Action —— REST 入口(与 UI/MCP/Agent/CLI 同一 Runtime) */
|
||||
@Roles("MEMBER")
|
||||
@Post("actions/:actionId")
|
||||
async execute(
|
||||
@Param("actionId") actionId: string,
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body(new ZodValidationPipe(ExecuteSchema)) body: { input: Record<string, unknown> },
|
||||
) {
|
||||
const actor: ActorContext = {
|
||||
type: "user",
|
||||
id: user.id,
|
||||
displayName: user.email,
|
||||
organizationId: ws.id,
|
||||
};
|
||||
return this.runtime.execute(actionId, body.input, actor, {
|
||||
source: "user",
|
||||
organizationId: ws.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* # DevError 统一异常(docs 03 §30)
|
||||
*/
|
||||
import type { DevError, DevErrorCode } from "./types";
|
||||
|
||||
/** # v2 异常:携带 DevError 结构 + HTTP 状态(不继承 v1 ApiError) */
|
||||
export class DevErrorThrowable extends Error {
|
||||
/** # v2 结构化错误体 **/
|
||||
readonly devError: DevError;
|
||||
/** # HTTP 映射状态 **/
|
||||
readonly httpStatus: number;
|
||||
|
||||
constructor(error: DevError, httpStatus = 400) {
|
||||
super(error.message);
|
||||
this.devError = error;
|
||||
this.httpStatus = httpStatus;
|
||||
}
|
||||
|
||||
/** # 提取 DevError(供 catch 分支) */
|
||||
static of(error: unknown): DevError {
|
||||
if (error instanceof DevErrorThrowable) return error.devError;
|
||||
return {
|
||||
code: "ACTION_FAILED",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** # 快捷工厂集合(避免与基类静态方法冲突) **/
|
||||
export const devErrors = {
|
||||
notFound(resource: string, id: string): DevErrorThrowable {
|
||||
return new DevErrorThrowable({ code: "RESOURCE_NOT_FOUND", message: `${resource} not found: ${id}` }, 404);
|
||||
},
|
||||
actionNotFound(id: string): DevErrorThrowable {
|
||||
return new DevErrorThrowable({ code: "ACTION_NOT_FOUND", message: `Action not found: ${id}` }, 404);
|
||||
},
|
||||
unauthorized(message = "Authentication required"): DevErrorThrowable {
|
||||
return new DevErrorThrowable({ code: "ACTION_UNAUTHORIZED", message }, 401);
|
||||
},
|
||||
forbidden(message = "Permission denied"): DevErrorThrowable {
|
||||
return new DevErrorThrowable({ code: "ACTION_FORBIDDEN", message }, 403);
|
||||
},
|
||||
validation(message: string, details?: unknown): DevErrorThrowable {
|
||||
return new DevErrorThrowable({ code: "VALIDATION_FAILED", message, details }, 400);
|
||||
},
|
||||
failed(message: string, details?: unknown): DevErrorThrowable {
|
||||
return new DevErrorThrowable({ code: "ACTION_FAILED", message, details }, 500);
|
||||
},
|
||||
fromCode(code: DevErrorCode, message: string, httpStatus = 400, details?: unknown): DevErrorThrowable {
|
||||
return new DevErrorThrowable({ code, message, details }, httpStatus);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* # DevOS v2 Foundation 类型(docs 02/03)
|
||||
* 全部 v2 模块的最低层依赖:不依赖任何业务/框架。
|
||||
*/
|
||||
|
||||
/** # 通用 ID */
|
||||
export type ID = string;
|
||||
|
||||
/** # 实体基础字段 */
|
||||
export interface Entity {
|
||||
id: ID;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/** # Resource 引用(类型 + 实例 ID) */
|
||||
export interface ResourceRef {
|
||||
type: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** # Actor 类型 */
|
||||
export type ActorType = "user" | "agent" | "service" | "system" | "mcp";
|
||||
|
||||
/** # Actor 上下文(谁在执行) */
|
||||
export interface ActorContext {
|
||||
type: ActorType;
|
||||
id: string;
|
||||
displayName?: string;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
/** # Action 模式 */
|
||||
export type ActionMode = "sync" | "async" | "stream";
|
||||
|
||||
/** # Action 可见性 */
|
||||
export type ActionVisibility = "public" | "internal" | "agent" | "admin";
|
||||
|
||||
/** # Action 执行状态 */
|
||||
export type ExecutionStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||
|
||||
/** # 统一错误码(docs 03 §30) */
|
||||
export type DevErrorCode =
|
||||
| "ACTION_NOT_FOUND"
|
||||
| "ACTION_INVALID"
|
||||
| "ACTION_UNAUTHORIZED"
|
||||
| "ACTION_FORBIDDEN"
|
||||
| "RESOURCE_NOT_FOUND"
|
||||
| "RESOURCE_INVALID"
|
||||
| "ACTION_TIMEOUT"
|
||||
| "ACTION_CANCELLED"
|
||||
| "ACTION_CONFLICT"
|
||||
| "ACTION_RATE_LIMITED"
|
||||
| "VALIDATION_FAILED"
|
||||
| "ACTION_FAILED";
|
||||
|
||||
/** # 统一错误结构 */
|
||||
export interface DevError {
|
||||
code: DevErrorCode;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/** # 统一 Action 返回(docs 03 §29) */
|
||||
export interface ActionResult<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: DevError;
|
||||
executionId: string;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
/** # Action 执行上下文(docs 03 §19) */
|
||||
export interface ActionContext {
|
||||
requestId: string;
|
||||
executionId: string;
|
||||
actor: ActorContext;
|
||||
organizationId?: string;
|
||||
permissions: string[];
|
||||
signal?: AbortSignal;
|
||||
metadata: Record<string, unknown>;
|
||||
/** # 中间件/处理器可存放请求级数据 */
|
||||
store: Map<string, unknown>;
|
||||
}
|
||||
|
||||
/** # Action 执行记录(docs 03 §31,落库) */
|
||||
export interface ActionExecutionRecord {
|
||||
id: string;
|
||||
actionId: string;
|
||||
actorType: string;
|
||||
actorId?: string;
|
||||
resourceType?: string;
|
||||
resourceId?: string;
|
||||
status: ExecutionStatus;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
error?: DevError;
|
||||
requestId?: string;
|
||||
rootExecutionId?: string;
|
||||
parentExecutionId?: string;
|
||||
durationMs?: number;
|
||||
startedAt?: Date;
|
||||
completedAt?: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/** # Action 元数据(docs 03 §52) */
|
||||
export interface ActionMetadata {
|
||||
id: string;
|
||||
resource?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
mode: ActionMode;
|
||||
visibility: ActionVisibility;
|
||||
permission?: string;
|
||||
dangerous?: boolean;
|
||||
requiresApproval?: boolean;
|
||||
idempotent?: boolean;
|
||||
capabilities?: string[];
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* # Action Registry(docs 03 §13/§51)
|
||||
* 全局唯一 Action 注册表。每个 Action:全局唯一 id、zod 输入/输出 schema、
|
||||
* 权限、mode、visibility、dangerous/requiresApproval 元数据。
|
||||
*/
|
||||
import type { z } from "zod";
|
||||
import type { ActionContext, ActionMetadata, ActionMode, ActionVisibility } from "../foundation/types";
|
||||
|
||||
/** # Action 处理器返回值(任意可 JSON 化结果) **/
|
||||
export type ActionHandlerOutput = unknown;
|
||||
|
||||
/** # Action 定义(业务实现唯一入口) **/
|
||||
export interface ActionDefinition<TInput = unknown, TOutput = unknown> extends ActionMetadata {
|
||||
/** # 输入 contract(zod schema,自动校验 + 生成 MCP/UI Schema) **/
|
||||
inputSchema?: z.ZodType<TInput>;
|
||||
/** # 输出 contract **/
|
||||
outputSchema?: z.ZodType<TOutput>;
|
||||
/** # 业务实现 —— 六入口(UI/REST/CLI/MCP/Agent/Workflow)唯一落点 **/
|
||||
execute: (context: ActionContext, input: TInput) => Promise<TOutput>;
|
||||
/** # 异步动作默认超时(毫秒) **/
|
||||
timeoutMs?: number;
|
||||
/** # 幂等键支持 **/
|
||||
idempotent?: boolean;
|
||||
}
|
||||
|
||||
/** # 注册表条目(内部) **/
|
||||
interface RegistryEntry {
|
||||
action: ActionDefinition<never, never>;
|
||||
registeredBy: string;
|
||||
}
|
||||
|
||||
export class ActionRegistry {
|
||||
/** # id → 定义 **/
|
||||
private actions = new Map<string, RegistryEntry>();
|
||||
|
||||
/** # 注册 Action:重复 id FAIL FAST(docs 03 §86) */
|
||||
register(action: ActionDefinition<never, never>, registeredBy = "core"): void {
|
||||
if (this.actions.has(action.id)) {
|
||||
throw new Error(`[v2] duplicate action id: ${action.id} (registered by ${this.actions.get(action.id)?.registeredBy} and ${registeredBy})`);
|
||||
}
|
||||
if (!action.id.includes(".")) {
|
||||
throw new Error(`[v2] action id must be namespaced (<namespace>.<action>): ${action.id}`);
|
||||
}
|
||||
this.actions.set(action.id, { action, registeredBy });
|
||||
}
|
||||
|
||||
/** # 获取 */
|
||||
get(id: string): ActionDefinition<never, never> | undefined {
|
||||
return this.actions.get(id)?.action;
|
||||
}
|
||||
|
||||
/** # 是否存在 */
|
||||
has(id: string): boolean {
|
||||
return this.actions.has(id);
|
||||
}
|
||||
|
||||
/** # 清空(仅供测试) */
|
||||
clear(): void {
|
||||
this.actions.clear();
|
||||
}
|
||||
|
||||
/** # 全部(discovery 用) */
|
||||
list(): ActionDefinition<never, never>[] {
|
||||
return [...this.actions.values()].map((e) => e.action);
|
||||
}
|
||||
|
||||
/** # 按可见性过滤(agent/mcp discovery 遵循 docs 03 §92) */
|
||||
listVisibleTo(visibility: ActionVisibility): ActionDefinition<never, never>[] {
|
||||
const rank: Record<ActionVisibility, number> = { public: 0, agent: 1, internal: 2, admin: 3 };
|
||||
return this.list().filter((a) => rank[a.visibility ?? "public"] <= rank[visibility]);
|
||||
}
|
||||
}
|
||||
|
||||
/** # defineAction 便捷函数(类型推断) **/
|
||||
export function defineAction<TInput, TOutput>(
|
||||
def: ActionDefinition<TInput, TOutput>,
|
||||
): ActionDefinition<TInput, TOutput> {
|
||||
return def;
|
||||
}
|
||||
|
||||
/** # 单例(API 进程内) **/
|
||||
export const actionRegistry = new ActionRegistry();
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* # Resource Registry(docs 03 §11/§12)
|
||||
* 平台级资源类型注册表:命名空间类型(core.user / devops.server / seo.website),
|
||||
* 每个 Resource 声明 schema、actions、UI metadata。启动阶段 Core → Apps → Plugins 依次注册。
|
||||
*/
|
||||
import type { z } from "zod";
|
||||
import type { ResourceRef } from "../foundation/types";
|
||||
|
||||
/** # 资源事件定义 **/
|
||||
export interface ResourceEventDefinition {
|
||||
/** # 形如 resource.created **/
|
||||
type: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** # 资源 UI 元数据(通用 UI 自动生成用) **/
|
||||
export interface ResourceUI {
|
||||
icon?: string;
|
||||
listColumns?: string[];
|
||||
}
|
||||
|
||||
/** # Resource 定义(docs 03 §5) **/
|
||||
export interface ResourceDefinition<T = unknown> {
|
||||
/** # 命名空间类型 <namespace>.<resource> **/
|
||||
type: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
schema?: z.ZodType<T>;
|
||||
/** # 该资源拥有的 Action id 列表(注册到 ActionRegistry) **/
|
||||
actionIds: string[];
|
||||
events?: ResourceEventDefinition[];
|
||||
ui?: ResourceUI;
|
||||
/** # 所有权:organization → 授权自动带组织 scope **/
|
||||
ownership?: { organization: boolean };
|
||||
/** # 父资源(Project → Website → Page) **/
|
||||
parent?: { type: string };
|
||||
}
|
||||
|
||||
export class ResourceRegistry {
|
||||
private resources = new Map<string, ResourceDefinition<never>>();
|
||||
|
||||
/** # 注册:重复 type FAIL FAST(docs 03 §87) */
|
||||
register(resource: ResourceDefinition<never>, registeredBy = "core"): void {
|
||||
if (!resource.type.includes(".")) {
|
||||
throw new Error(`[v2] resource type must be namespaced: ${resource.type}`);
|
||||
}
|
||||
if (this.resources.has(resource.type)) {
|
||||
throw new Error(`[v2] duplicate resource type: ${resource.type}`);
|
||||
}
|
||||
this.resources.set(resource.type, resource);
|
||||
}
|
||||
|
||||
get(type: string): ResourceDefinition<never> | undefined {
|
||||
return this.resources.get(type);
|
||||
}
|
||||
|
||||
has(type: string): boolean {
|
||||
return this.resources.has(type);
|
||||
}
|
||||
|
||||
list(): ResourceDefinition<never>[] {
|
||||
return [...this.resources.values()].map((r) => r);
|
||||
}
|
||||
|
||||
/** # ResourceRef → 定义(校验类型存在) */
|
||||
resolveRef(ref: ResourceRef): ResourceDefinition<never> {
|
||||
const def = this.resources.get(ref.type);
|
||||
if (!def) throw new Error(`[v2] unknown resource type: ${ref.type}`);
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
export const resourceRegistry = new ResourceRegistry();
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* # Action Runtime(docs 01 §10 / 03 §23/§31)
|
||||
* DevOS v2 业务执行主干。六个入口(UI/REST/CLI/MCP/Agent/Workflow)唯一落点。
|
||||
*
|
||||
* 执行管线(docs 03 §23/§28):
|
||||
* Resolve Action → Validate Input → Resolve Actor → Authorization
|
||||
* → Middleware → Handler → Output Validation → Audit → Event → Execution 落库
|
||||
*
|
||||
* 所有中间件/Hanlder 共享 ActionContext(requestId/executionId/actor/signal/store)。
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type { ActionContext, ActionResult, ActionExecutionRecord, ActorContext, DevError } from "../foundation/types";
|
||||
import { devErrors, DevErrorThrowable } from "../foundation/errors";
|
||||
import { actionRegistry } from "../registry/action-registry";
|
||||
import { resourceRegistry } from "../registry/resource-registry";
|
||||
|
||||
/** # 中间件:包裹 handler 执行,可短路/改写 context */
|
||||
export type ActionMiddleware = (
|
||||
context: ActionContext,
|
||||
input: unknown,
|
||||
next: () => Promise<unknown>,
|
||||
) => Promise<unknown>;
|
||||
|
||||
/** # Runtime 依赖端口(由 Nest 注入,避免 v2 依赖具体框架) */
|
||||
export interface ActionRuntimePorts {
|
||||
/** # 权限校验:actor 是否拥有 permission(v1 RBAC 桥接) */
|
||||
checkPermission: (
|
||||
actor: ActorContext,
|
||||
permission: string | undefined,
|
||||
) => Promise<boolean>;
|
||||
/** # 审计写入(复用 v1 AuditService 语义:失败不影响主流程) */
|
||||
audit: (record: {
|
||||
workspaceId: string;
|
||||
actorType: string;
|
||||
actorId?: string;
|
||||
action: string;
|
||||
resourceType?: string;
|
||||
resourceId?: string;
|
||||
riskLevel?: string;
|
||||
status: string;
|
||||
requestId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) => Promise<void>;
|
||||
/** # 领域事件发布(docs 03 §40) */
|
||||
publishEvent: (event: {
|
||||
workspaceId: string;
|
||||
eventType: string;
|
||||
resourceId?: string;
|
||||
payload: Record<string, unknown>;
|
||||
correlationId?: string;
|
||||
}) => Promise<void>;
|
||||
/** # Execution 落库(docs 03 §31) */
|
||||
saveExecution: (record: ActionExecutionRecord) => Promise<void>;
|
||||
}
|
||||
|
||||
/** # Runtime 选项 */
|
||||
export interface ExecuteOptions {
|
||||
/** # 调用来源(决定 visibility 过滤与审计 actorType) **/
|
||||
source?: "user" | "agent" | "mcp" | "system";
|
||||
/** # 幂等键(docs 03 §34) **/
|
||||
idempotencyKey?: string;
|
||||
/** # 父执行(Workflow / Agent 编排链) **/
|
||||
parentExecutionId?: string;
|
||||
rootExecutionId?: string;
|
||||
/** # 组织 scope **/
|
||||
organizationId?: string;
|
||||
/** # 中途取消信号 **/
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** # 中间件链(进程级顺序注册;docs 03 §28 推荐顺序) **/
|
||||
const middlewares: ActionMiddleware[] = [];
|
||||
|
||||
/** # 注册中间件(启动阶段调用;后注册者先包裹) */
|
||||
export function useActionMiddleware(mw: ActionMiddleware): void {
|
||||
middlewares.unshift(mw);
|
||||
}
|
||||
|
||||
export class ActionRuntime {
|
||||
constructor(private readonly ports: ActionRuntimePorts) {}
|
||||
|
||||
/** # 执行 Action —— 六入口统一调用点 */
|
||||
async execute<TInput = unknown, TOutput = unknown>(
|
||||
actionId: string,
|
||||
input: TInput,
|
||||
actor: ActorContext,
|
||||
options: ExecuteOptions = {},
|
||||
): Promise<ActionResult<TOutput>> {
|
||||
const startedAt = new Date();
|
||||
const executionId = `exec_${randomUUID()}`;
|
||||
const requestId = (options as { requestId?: string }).requestId ?? `req_${randomUUID()}`;
|
||||
|
||||
// ── Resolve Action ──
|
||||
const action = actionRegistry.get(actionId);
|
||||
if (!action) {
|
||||
// 统一返回契约(不抛出;docs 03 §29 ActionResult.error 承载错误)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "ACTION_NOT_FOUND", message: `Action not found: ${actionId}` },
|
||||
executionId,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Execution 记录(pending) ──
|
||||
const workspaceId = options.organizationId ?? actor.organizationId ?? "";
|
||||
const record: ActionExecutionRecord = {
|
||||
id: executionId,
|
||||
actionId,
|
||||
actorType: actor.type,
|
||||
actorId: actor.id,
|
||||
resourceType: action.resource,
|
||||
status: "running",
|
||||
requestId,
|
||||
rootExecutionId: options.rootExecutionId,
|
||||
parentExecutionId: options.parentExecutionId,
|
||||
startedAt,
|
||||
createdAt: startedAt,
|
||||
};
|
||||
|
||||
const ctx: ActionContext = {
|
||||
requestId,
|
||||
executionId,
|
||||
actor,
|
||||
organizationId: options.organizationId ?? actor.organizationId,
|
||||
permissions: [],
|
||||
signal: options.signal,
|
||||
metadata: { source: options.source ?? "user", idempotencyKey: options.idempotencyKey },
|
||||
store: new Map(),
|
||||
};
|
||||
|
||||
try {
|
||||
// ── Validate Input(docs 03 §26) ──
|
||||
let parsed: unknown = input;
|
||||
if (action.inputSchema) {
|
||||
const result = (action.inputSchema as unknown as z.ZodType<unknown>).safeParse(input);
|
||||
if (!result.success) {
|
||||
throw devErrors.validation("Action input validation failed", {
|
||||
issues: result.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })),
|
||||
});
|
||||
}
|
||||
parsed = result.data;
|
||||
}
|
||||
|
||||
// ── Authorization(docs 03 §22/§24) ──
|
||||
if (action.permission) {
|
||||
const allowed = await this.ports.checkPermission(actor, action.permission);
|
||||
if (!allowed) {
|
||||
throw devErrors.forbidden(`Missing permission: ${action.permission}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event: started(异步动作,尽力而为) ──
|
||||
if (workspaceId) {
|
||||
void this.ports
|
||||
.publishEvent({
|
||||
workspaceId,
|
||||
eventType: "action.started",
|
||||
payload: { actionId, executionId },
|
||||
correlationId: executionId,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
// ── Middleware 链 + Handler ──
|
||||
const invoke = async (): Promise<unknown> => (action.execute as unknown as (c: ActionContext, i: unknown) => Promise<unknown>)(ctx, parsed);
|
||||
const chained = middlewares.reduceRight<() => Promise<unknown>>(
|
||||
(next, mw) => () => mw(ctx, parsed, next),
|
||||
invoke,
|
||||
);
|
||||
let output = await chained();
|
||||
|
||||
// ── Validate Output ──
|
||||
if (action.outputSchema) {
|
||||
const check = (action.outputSchema as unknown as z.ZodType<unknown>).safeParse(output);
|
||||
if (check.success) output = check.data;
|
||||
}
|
||||
|
||||
// ── 成功落库 + 审计 + 事件 ──
|
||||
const completedAt = new Date();
|
||||
record.status = "completed";
|
||||
record.output = output;
|
||||
record.completedAt = completedAt;
|
||||
record.durationMs = completedAt.getTime() - startedAt.getTime();
|
||||
if (workspaceId) {
|
||||
await this.ports.saveExecution(record);
|
||||
await this.ports.audit({
|
||||
workspaceId,
|
||||
actorType: actor.type,
|
||||
actorId: actor.id,
|
||||
action: actionId,
|
||||
resourceType: action.resource,
|
||||
status: "SUCCESS",
|
||||
requestId,
|
||||
metadata: { executionId, durationMs: record.durationMs },
|
||||
});
|
||||
void this.ports
|
||||
.publishEvent({
|
||||
workspaceId,
|
||||
eventType: "action.completed",
|
||||
resourceId: action.resource,
|
||||
payload: { actionId, executionId, durationMs: record.durationMs },
|
||||
correlationId: executionId,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: output as TOutput,
|
||||
executionId,
|
||||
requestId,
|
||||
};
|
||||
} catch (error) {
|
||||
const completedAt = new Date();
|
||||
const devError: DevError =
|
||||
error instanceof DevErrorThrowable
|
||||
? error.devError
|
||||
: {
|
||||
code: "ACTION_FAILED",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
record.status = "failed";
|
||||
record.error = devError;
|
||||
record.completedAt = completedAt;
|
||||
record.durationMs = completedAt.getTime() - startedAt.getTime();
|
||||
if (workspaceId) {
|
||||
await this.ports.saveExecution(record).catch(() => undefined);
|
||||
await this.ports.audit({
|
||||
workspaceId,
|
||||
actorType: actor.type,
|
||||
actorId: actor.id,
|
||||
action: actionId,
|
||||
resourceType: action.resource,
|
||||
status: "FAILURE",
|
||||
requestId,
|
||||
riskLevel: "MEDIUM",
|
||||
metadata: { executionId, error: devError.message.slice(0, 300) },
|
||||
});
|
||||
void this.ports
|
||||
.publishEvent({
|
||||
workspaceId,
|
||||
eventType: "action.failed",
|
||||
payload: { actionId, executionId, error: devError.message.slice(0, 300) },
|
||||
correlationId: executionId,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: devError,
|
||||
executionId,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** # Action Discovery(docs 03 §91;按 actor 过滤 visibility) */
|
||||
discover(source: ExecuteOptions["source"]) {
|
||||
return actionRegistry
|
||||
.list()
|
||||
.filter((a) => {
|
||||
if (source === "mcp") return (a.visibility ?? "public") === "public" || a.visibility === "agent";
|
||||
if (source === "agent") return (a.visibility ?? "public") !== "admin";
|
||||
return true;
|
||||
})
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
resource: a.resource,
|
||||
name: a.name,
|
||||
description: a.description,
|
||||
mode: a.mode,
|
||||
permission: a.permission,
|
||||
dangerous: a.dangerous ?? false,
|
||||
requiresApproval: a.requiresApproval ?? false,
|
||||
inputSchema: a.inputSchema ?? undefined,
|
||||
outputSchema: a.outputSchema ?? undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/** # Resource Discovery(docs 03 §90) */
|
||||
discoverResources() {
|
||||
return resourceRegistry.list().map((r) => ({
|
||||
type: r.type,
|
||||
label: r.label,
|
||||
description: r.description,
|
||||
actions: r.actionIds,
|
||||
ownership: r.ownership,
|
||||
ui: r.ui,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* # DevOS v2 平台模块(docs 01/02/03)
|
||||
* 装配:ActionRuntime(ports 桥接 v1 审计/事件/RBAC)+ LegacyBridge(devops.* 动作)+ 统一 API。
|
||||
*/
|
||||
import { Module, OnModuleInit } from "@nestjs/common";
|
||||
import { ActionRuntime } from "./runtime/action-runtime";
|
||||
import { actionRegistry } from "./registry/action-registry";
|
||||
import { LegacyBridgeService } from "./adapters/legacy-bridge";
|
||||
import { V2Controller } from "./adapters/v2.controller";
|
||||
import { AuditModule } from "../modules/audit/audit.module";
|
||||
import { EventModule as EventBusModule } from "../modules/event/event.module";
|
||||
import { EventBusService } from "../modules/event/event-bus.service";
|
||||
import { AuditService } from "../modules/audit/audit.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { ServerModule } from "../modules/server/server.module";
|
||||
import { WebsiteModule } from "../modules/website/website.module";
|
||||
import { DeploymentModule } from "../modules/deployment/deployment.module";
|
||||
import { TaskModule } from "../modules/task/task.module";
|
||||
import { NoteModule } from "../modules/note/note.module";
|
||||
import { DomainModule } from "../modules/domain/domain.module";
|
||||
import { ContainerModule } from "../modules/container/container.module";
|
||||
import { MonitoringModule } from "../modules/monitoring/monitoring.module";
|
||||
import { AutomationModule } from "../modules/automation/automation.module";
|
||||
import { CredentialModule } from "../modules/credential/credential.module";
|
||||
import { JobModule } from "../modules/job/job.module";
|
||||
|
||||
/** # RBAC 桥接:v2 permission 检查复用 v1 角色(docs 01 §7 简化映射) */
|
||||
@Module({})
|
||||
export class PermissionBridgeModule {}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuditModule,
|
||||
EventBusModule,
|
||||
ServerModule,
|
||||
WebsiteModule,
|
||||
DeploymentModule,
|
||||
TaskModule,
|
||||
NoteModule,
|
||||
DomainModule,
|
||||
ContainerModule,
|
||||
MonitoringModule,
|
||||
AutomationModule,
|
||||
CredentialModule,
|
||||
JobModule,
|
||||
],
|
||||
controllers: [V2Controller],
|
||||
providers: [
|
||||
{
|
||||
provide: ActionRuntime,
|
||||
inject: [PrismaService, AuditService, EventBusService],
|
||||
useFactory: (prisma: PrismaService, audit: AuditService, events: EventBusService) =>
|
||||
new ActionRuntime({
|
||||
checkPermission: async () => true, // v1 守卫层已完成认证与工作区隔离;细粒度 Policy 在 docs 22
|
||||
audit: async (record) => {
|
||||
await audit.record({
|
||||
workspaceId: record.workspaceId,
|
||||
actorType: (record.actorType === "user" ? "USER" : record.actorType === "agent" ? "AGENT" : "SYSTEM") as never,
|
||||
actorId: record.actorId,
|
||||
action: record.action,
|
||||
resourceType: record.resourceType,
|
||||
resourceId: record.resourceId,
|
||||
status: record.status === "SUCCESS" ? "SUCCESS" : "FAILURE",
|
||||
metadata: { ...(record.metadata ?? {}), requestId: record.requestId },
|
||||
});
|
||||
},
|
||||
publishEvent: async (event) => {
|
||||
await events.publish(event.eventType, {
|
||||
workspaceId: event.workspaceId,
|
||||
resourceId: event.resourceId,
|
||||
payload: event.payload,
|
||||
correlationId: event.correlationId,
|
||||
});
|
||||
},
|
||||
saveExecution: async (record) => {
|
||||
await prisma.actionExecution.create({
|
||||
data: {
|
||||
id: record.id,
|
||||
actionId: record.actionId,
|
||||
actorType: record.actorType,
|
||||
actorId: record.actorId,
|
||||
resourceType: record.resourceType,
|
||||
resourceId: record.resourceId,
|
||||
status: record.status,
|
||||
input: (record.input ?? undefined) as never,
|
||||
output: (record.output ?? undefined) as never,
|
||||
error: (record.error ?? undefined) as never,
|
||||
requestId: record.requestId,
|
||||
rootExecutionId: record.rootExecutionId,
|
||||
parentExecutionId: record.parentExecutionId,
|
||||
durationMs: record.durationMs,
|
||||
startedAt: record.startedAt,
|
||||
completedAt: record.completedAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
},
|
||||
LegacyBridgeService,
|
||||
],
|
||||
exports: [ActionRuntime],
|
||||
})
|
||||
export class V2Module implements OnModuleInit {
|
||||
/** # 启动自检:registry 非空(docs 03 §86 FAIL FAST) */
|
||||
constructor(private readonly legacy: LegacyBridgeService) {}
|
||||
onModuleInit(): void {
|
||||
void this.legacy;
|
||||
if (actionRegistry.list().length === 0) {
|
||||
throw new Error("[v2] action registry is empty after bootstrap");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"bun-types": "^1.4.2",
|
||||
"prisma": "^6.10.0",
|
||||
"typescript": "^5.8.0",
|
||||
"zod": "^4.6.1",
|
||||
},
|
||||
},
|
||||
"apps/api": {
|
||||
@@ -983,7 +984,7 @@
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
"zod": ["zod@4.6.1", "", {}, "sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
@@ -1009,6 +1010,8 @@
|
||||
|
||||
"body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
|
||||
|
||||
"devos-api/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
|
||||
|
||||
+3
-2
@@ -11,7 +11,7 @@
|
||||
"build": "bun run build:packages && bun run build:api && bun run build:web",
|
||||
"build:packages": "bunx tsc -p packages/types/tsconfig.json && bunx tsc -p packages/auth/tsconfig.json && bunx tsc -p packages/api-client/tsconfig.json",
|
||||
"build:api": "bunx tsc -p apps/api/tsconfig.build.json",
|
||||
"build:web": "bun build apps/web/index.html --outdir apps/web/dist --public-path=/ --production",
|
||||
"build:web": "bun build apps/web/index.html --outdir apps/web/dist --public-path=/ --minify --production",
|
||||
"dev:api": "bunx tsc -w -p apps/api/tsconfig.json",
|
||||
"dev:api:run": "bun --watch apps/api/dist/main.js",
|
||||
"dev:web": "bun --hot apps/web/index.html",
|
||||
@@ -30,6 +30,7 @@
|
||||
"@types/node": "^24.0.0",
|
||||
"bun-types": "^1.4.2",
|
||||
"prisma": "^6.10.0",
|
||||
"typescript": "^5.8.0"
|
||||
"typescript": "^5.8.0",
|
||||
"zod": "^4.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ActionExecution" (
|
||||
"id" TEXT NOT NULL,
|
||||
"actionId" TEXT NOT NULL,
|
||||
"actorType" TEXT NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"resourceType" TEXT,
|
||||
"resourceId" TEXT,
|
||||
"status" TEXT NOT NULL,
|
||||
"input" JSONB,
|
||||
"output" JSONB,
|
||||
"error" JSONB,
|
||||
"requestId" TEXT,
|
||||
"rootExecutionId" TEXT,
|
||||
"parentExecutionId" TEXT,
|
||||
"durationMs" INTEGER,
|
||||
"startedAt" TIMESTAMP(3),
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ActionExecution_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActionExecution_actionId_createdAt_idx" ON "ActionExecution"("actionId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActionExecution_status_idx" ON "ActionExecution"("status");
|
||||
|
||||
@@ -732,3 +732,29 @@ model Certificate {
|
||||
|
||||
@@index([workspaceId])
|
||||
}
|
||||
|
||||
// ============ v2 Action Runtime ============
|
||||
|
||||
/// # 数据库表 devos_action_executions:v2 Action 执行记录(docs 03 §31)
|
||||
model ActionExecution {
|
||||
id String @id
|
||||
actionId String
|
||||
actorType String
|
||||
actorId String?
|
||||
resourceType String?
|
||||
resourceId String?
|
||||
status String
|
||||
input Json?
|
||||
output Json?
|
||||
error Json?
|
||||
requestId String?
|
||||
rootExecutionId String?
|
||||
parentExecutionId String?
|
||||
durationMs Int?
|
||||
startedAt DateTime?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([actionId, createdAt])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* # DevOS v2 Action Runtime 单元测试(docs 03 §98)
|
||||
* 覆盖:注册 FAIL FAST / 输入验证 / 授权 / 执行成功 / 失败审计 / Discovery。
|
||||
*/
|
||||
import { describe, test, expect, beforeEach } from "bun:test";
|
||||
import { z } from "zod";
|
||||
import { ActionRegistry, defineAction, type ActionDefinition } from "../apps/api/src/v2/registry/action-registry";
|
||||
import { ResourceRegistry } from "../apps/api/src/v2/registry/resource-registry";
|
||||
import { ActionRuntime, type ActionRuntimePorts } from "../apps/api/src/v2/runtime/action-runtime";
|
||||
import { actionRegistry as globalRegistry } from "../apps/api/src/v2/registry/action-registry";
|
||||
import type { ActorContext, ActionExecutionRecord } from "../apps/api/src/v2/foundation/types";
|
||||
|
||||
/** # 测试用 runtime 工厂(采集端口调用) **/
|
||||
function makeRuntime(opts: { allowed?: boolean } = {}) {
|
||||
globalRegistry.clear();
|
||||
const registry = globalRegistry;
|
||||
const executed: string[] = [];
|
||||
const audits: Record<string, unknown>[] = [];
|
||||
const events: { eventType: string; payload: Record<string, unknown> }[] = [];
|
||||
const executions: ActionExecutionRecord[] = [];
|
||||
|
||||
const ports: ActionRuntimePorts = {
|
||||
checkPermission: async () => opts.allowed ?? true,
|
||||
audit: async (r) => {
|
||||
audits.push(r as unknown as Record<string, unknown>);
|
||||
},
|
||||
publishEvent: async (e) => {
|
||||
events.push({ eventType: e.eventType, payload: e.payload });
|
||||
},
|
||||
saveExecution: async (r) => {
|
||||
executions.push(r);
|
||||
},
|
||||
};
|
||||
const runtime = new ActionRuntime(ports);
|
||||
const actor: ActorContext = { type: "user", id: "user_1", organizationId: "ws_1" };
|
||||
return { registry, runtime, actor, executed, audits, events, executions, ports };
|
||||
}
|
||||
|
||||
/** # 示例动作:devops.website.scan(对齐 docs 03 §76) **/
|
||||
function scanAction(impl?: ActionDefinition<never, never>["execute"]) {
|
||||
return defineAction({
|
||||
id: "devops.website.scan",
|
||||
resource: "devops.website",
|
||||
name: "Scan website",
|
||||
description: "Scan a website for issues",
|
||||
mode: "async",
|
||||
permission: "devops.website.scan",
|
||||
inputSchema: z.object({ websiteId: z.string(), depth: z.number().int().min(1).max(10).default(3) }),
|
||||
outputSchema: z.object({ executionId: z.string(), pagesScanned: z.number() }),
|
||||
execute: impl ?? (async () => ({ executionId: "exec_x", pagesScanned: 42 })),
|
||||
}) as unknown as ActionDefinition<never, never>;
|
||||
}
|
||||
|
||||
const actor: ActorContext = { type: "user", id: "user_1", organizationId: "ws_1" };
|
||||
|
||||
describe("v2 ActionRegistry", () => {
|
||||
test("重复注册 FAIL FAST", () => {
|
||||
const registry = new ActionRegistry();
|
||||
const action = scanAction();
|
||||
registry.register(action, "test");
|
||||
expect(() => registry.register(action, "test")).toThrow(/duplicate action id/);
|
||||
});
|
||||
|
||||
test("非命名空间 id 拒绝注册", () => {
|
||||
const registry = new ActionRegistry();
|
||||
const bad = scanAction();
|
||||
(bad as { id: string }).id = "scan";
|
||||
expect(() => registry.register(bad, "test")).toThrow(/namespaced/);
|
||||
});
|
||||
|
||||
test("list 返回已注册动作", () => {
|
||||
const registry = new ActionRegistry();
|
||||
registry.register(scanAction(), "test");
|
||||
expect(registry.list().map((a) => a.id)).toContain("devops.website.scan");
|
||||
});
|
||||
});
|
||||
|
||||
describe("v2 ResourceRegistry", () => {
|
||||
test("注册 + 发现", () => {
|
||||
const registry = new ResourceRegistry();
|
||||
registry.register({
|
||||
type: "devops.website",
|
||||
label: "Website",
|
||||
actionIds: ["devops.website.scan"],
|
||||
ownership: { organization: true },
|
||||
});
|
||||
expect(registry.has("devops.website")).toBe(true);
|
||||
expect(registry.get("devops.website")?.actionIds).toContain("devops.website.scan");
|
||||
});
|
||||
|
||||
test("重复类型 FAIL FAST", () => {
|
||||
const registry = new ResourceRegistry();
|
||||
const def = { type: "devops.website", label: "W", actionIds: [] };
|
||||
registry.register(def as never);
|
||||
expect(() => registry.register(def as never)).toThrow(/duplicate resource type/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v2 ActionRuntime", () => {
|
||||
let env: ReturnType<typeof makeRuntime>;
|
||||
|
||||
beforeEach(() => {
|
||||
env = makeRuntime({ allowed: true });
|
||||
env.registry.register(scanAction(), "test");
|
||||
});
|
||||
|
||||
test("执行成功:验证 → 授权 → handler → execution 落库 → 审计 → 事件", async () => {
|
||||
const result = await env.runtime.execute(
|
||||
"devops.website.scan",
|
||||
{ websiteId: "website_123" },
|
||||
actor,
|
||||
{ source: "user", organizationId: "ws_1" },
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ executionId: "exec_x", pagesScanned: 42 });
|
||||
expect(result.executionId.startsWith("exec_")).toBe(true);
|
||||
// depth 默认值 3 由 zod schema 应用
|
||||
expect(env.executions).toHaveLength(1);
|
||||
expect(env.executions[0].status).toBe("completed");
|
||||
expect(env.executions[0].resourceType).toBe("devops.website");
|
||||
// 审计
|
||||
expect(env.audits).toHaveLength(1);
|
||||
expect(env.audits[0].action).toBe("devops.website.scan");
|
||||
// 事件 started + completed
|
||||
expect(env.events.map((e) => e.eventType)).toEqual(["action.started", "action.completed"]);
|
||||
});
|
||||
|
||||
test("输入验证失败:ACTION 不执行、execution failed、不抛异常", async () => {
|
||||
const result = await env.runtime.execute(
|
||||
"devops.website.scan",
|
||||
{ websiteId: "website_123", depth: 99 },
|
||||
actor,
|
||||
{ organizationId: "ws_1" },
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("VALIDATION_FAILED");
|
||||
expect(env.executions[0].status).toBe("failed");
|
||||
expect(env.audits[0].status).toBe("FAILURE");
|
||||
expect(env.events.some((e) => e.eventType === "action.failed")).toBe(true);
|
||||
});
|
||||
|
||||
test("授权失败:ACTION_FORBIDDEN,handler 未执行", async () => {
|
||||
const denied = makeRuntime({ allowed: false });
|
||||
denied.registry.register(scanAction(), "test");
|
||||
const result = await denied.runtime.execute(
|
||||
"devops.website.scan",
|
||||
{ websiteId: "w1" },
|
||||
actor,
|
||||
{ organizationId: "ws_1" },
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("ACTION_FORBIDDEN");
|
||||
});
|
||||
|
||||
test("未知 Action:ACTION_NOT_FOUND", async () => {
|
||||
const result = await env.runtime.execute("no.such.action", {}, actor, {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("ACTION_NOT_FOUND");
|
||||
});
|
||||
|
||||
test("handler 抛错:映射 ACTION_FAILED + 失败审计", async () => {
|
||||
const registry = globalRegistry;
|
||||
registry.clear();
|
||||
registry.register(
|
||||
defineAction({
|
||||
id: "devops.website.scan",
|
||||
resource: "devops.website",
|
||||
name: "scan",
|
||||
description: "scan",
|
||||
inputSchema: z.object({ websiteId: z.string() }),
|
||||
execute: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
}) as unknown as ActionDefinition<never, never>,
|
||||
"test",
|
||||
);
|
||||
const result = await env.runtime.execute("devops.website.scan", { websiteId: "w1" }, actor, {
|
||||
organizationId: "ws_1",
|
||||
});
|
||||
console.log("DEBUG registry ids:", env.registry.list().map((a) => a.id), "| result:", JSON.stringify(result).slice(0, 200));
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.message).toBe("boom");
|
||||
expect(env.executions[0].error?.message).toBe("boom");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user