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:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user