ARCH - CODEX AGENTS: стартовый каркас Agent Gateway

This commit is contained in:
DCCONSTRUCTIONS 2026-05-14 19:16:43 +03:00
parent 97d98a7bcb
commit e95cb3af33
15 changed files with 1589 additions and 0 deletions

13
.env.example Normal file
View File

@ -0,0 +1,13 @@
NODE_ENV=development
HOST=0.0.0.0
PORT=4100
LOG_LEVEL=info
NODEDC_AGENT_GATEWAY_PUBLIC_URL=http://agents.local.nodedc
NODEDC_LAUNCHER_INTERNAL_URL=http://launcher.local.nodedc
NODEDC_TASKER_INTERNAL_URL=http://task.local.nodedc
NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-local-dev-token
# Phase 1 can run without DB. Phase 2 will require it.
DATABASE_URL=

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules/
dist/
.env
.env.*
!.env.example
coverage/
.DS_Store
npm-debug.log*

21
Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM node:24-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:24-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package*.json tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:24-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package*.json ./
EXPOSE 4100
CMD ["node", "dist/server.js"]

9
docker-compose.local.yml Normal file
View File

@ -0,0 +1,9 @@
services:
agent-gateway:
build:
context: .
env_file:
- .env
ports:
- "${PORT:-4100}:${PORT:-4100}"

1198
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "@nodedc/tasker-codex-api",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"check": "tsc --noEmit -p tsconfig.json",
"start": "node dist/server.js"
},
"dependencies": {
"fastify": "^5.8.5",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^25.7.0",
"tsx": "^4.22.0",
"typescript": "^6.0.3"
}
}

28
src/app.ts Normal file
View File

@ -0,0 +1,28 @@
import Fastify, { type FastifyInstance } from "fastify";
import type { AppConfig } from "./config.js";
import { registerAgentRoutes } from "./routes/agents.js";
import { registerHealthRoutes } from "./routes/health.js";
export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
const app = Fastify({
logger: {
level: config.LOG_LEVEL,
},
});
app.setErrorHandler((error, _request, reply) => {
app.log.error(error);
void reply.status(500).send({
ok: false,
error: "internal_server_error",
message: "Agent Gateway request failed.",
});
});
await registerHealthRoutes(app, config);
await registerAgentRoutes(app);
return app;
}

29
src/config.ts Normal file
View File

@ -0,0 +1,29 @@
import { z } from "zod";
const optionalUrl = z.preprocess((value) => (value === "" ? undefined : value), z.string().url().optional());
const configSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
HOST: z.string().min(1).default("0.0.0.0"),
PORT: z.coerce.number().int().min(1).max(65535).default(4100),
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("info"),
NODEDC_AGENT_GATEWAY_PUBLIC_URL: z.string().url().default("http://agents.local.nodedc"),
NODEDC_LAUNCHER_INTERNAL_URL: z.string().url().default("http://launcher.local.nodedc"),
NODEDC_TASKER_INTERNAL_URL: z.string().url().default("http://task.local.nodedc"),
NODEDC_INTERNAL_ACCESS_TOKEN: z.string().min(1).optional(),
DATABASE_URL: optionalUrl,
});
export type AppConfig = z.infer<typeof configSchema>;
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const parsed = configSchema.safeParse(env);
if (!parsed.success) {
const details = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
throw new Error(`Invalid Agent Gateway configuration: ${details}`);
}
return parsed.data;
}

54
src/domain/scopes.ts Normal file
View File

@ -0,0 +1,54 @@
export const allowedAgentScopes = [
"workspace:read",
"project:read",
"project:member:add_existing",
"issue:read",
"issue:create",
"issue:update",
"issue:move",
"issue:comment",
"issue:label",
"issue:assign",
"issue:structured_blocks:write",
] as const;
export type AgentScope = (typeof allowedAgentScopes)[number];
export const deniedMvpCapabilities = [
"issue:delete",
"issue:archive",
"comment:delete",
"label:delete",
"state:create",
"state:delete",
"project:create",
"project:delete",
"workspace:settings",
"workspace:member:invite",
"workspace:member:remove",
"raw_tasker_api",
] as const;
export const taskAuthorPresetScopes: AgentScope[] = [
"workspace:read",
"project:read",
"project:member:add_existing",
"issue:read",
"issue:create",
"issue:update",
"issue:move",
"issue:comment",
"issue:label",
"issue:assign",
"issue:structured_blocks:write",
];
export const reporterPresetScopes: AgentScope[] = [
"workspace:read",
"project:read",
"issue:read",
"issue:update",
"issue:comment",
"issue:structured_blocks:write",
];

View File

@ -0,0 +1,39 @@
import { z } from "zod";
export const STRUCTURED_BLOCKS_KEY = "nodedc_structured_blocks";
export const textBlockSchema = z.object({
id: z.string().min(1),
type: z.literal("text"),
title: z.string().optional(),
body: z.string().default(""),
});
export const checkerItemSchema = z.object({
id: z.string().min(1),
text: z.string().min(1),
checked: z.boolean().default(false),
});
export const checkerBlockSchema = z.object({
id: z.string().min(1),
type: z.literal("checker"),
title: z.string().optional(),
items: z.array(checkerItemSchema).default([]),
});
export const structuredBlockSchema = z.discriminatedUnion("type", [textBlockSchema, checkerBlockSchema]);
export const structuredBlocksSchema = z.array(structuredBlockSchema);
export type StructuredBlock = z.infer<typeof structuredBlockSchema>;
export type DetailLayout = Record<string, unknown> & {
[STRUCTURED_BLOCKS_KEY]?: StructuredBlock[];
};
export function buildDetailLayout(blocks: StructuredBlock[], currentLayout: Record<string, unknown> = {}): DetailLayout {
return {
...currentLayout,
[STRUCTURED_BLOCKS_KEY]: structuredBlocksSchema.parse(blocks),
};
}

84
src/mcp/tools.ts Normal file
View File

@ -0,0 +1,84 @@
import type { AgentScope } from "../domain/scopes.js";
export type McpToolDefinition = {
name: string;
description: string;
requiredScopes: AgentScope[];
status: "planned";
};
export const mcpToolDefinitions: McpToolDefinition[] = [
{
name: "tasker_get_agent_instructions",
description: "Return effective NODE.DC Tasker card rules, allowed projects, scopes, and reporting expectations.",
requiredScopes: ["workspace:read"],
status: "planned",
},
{
name: "tasker_list_projects",
description: "List Tasker projects granted to the current agent.",
requiredScopes: ["project:read"],
status: "planned",
},
{
name: "tasker_get_project_context",
description: "Return states, labels, members, and card-writing rules for one granted project.",
requiredScopes: ["project:read"],
status: "planned",
},
{
name: "tasker_search_issues",
description: "Search work items inside granted projects.",
requiredScopes: ["issue:read"],
status: "planned",
},
{
name: "tasker_create_issue",
description: "Create a Tasker work item with NODE.DC structured content support.",
requiredScopes: ["issue:create"],
status: "planned",
},
{
name: "tasker_update_issue",
description: "Patch allowed issue fields without delete, archive, or project transfer.",
requiredScopes: ["issue:update"],
status: "planned",
},
{
name: "tasker_move_issue",
description: "Move an issue to an existing state in the same granted project.",
requiredScopes: ["issue:move"],
status: "planned",
},
{
name: "tasker_set_issue_labels",
description: "Apply existing project labels to an issue.",
requiredScopes: ["issue:label"],
status: "planned",
},
{
name: "tasker_assign_issue",
description: "Assign existing project members to an issue.",
requiredScopes: ["issue:assign"],
status: "planned",
},
{
name: "tasker_add_existing_project_member",
description: "Add an existing workspace member to a granted project when explicitly allowed.",
requiredScopes: ["project:member:add_existing"],
status: "planned",
},
{
name: "tasker_append_comment",
description: "Append a comment to a granted issue.",
requiredScopes: ["issue:comment"],
status: "planned",
},
{
name: "tasker_update_structured_blocks",
description: "Patch NODE.DC text/checker blocks in issue.detail_layout.",
requiredScopes: ["issue:structured_blocks:write"],
status: "planned",
},
];

26
src/routes/agents.ts Normal file
View File

@ -0,0 +1,26 @@
import type { FastifyInstance } from "fastify";
import { allowedAgentScopes, deniedMvpCapabilities, reporterPresetScopes, taskAuthorPresetScopes } from "../domain/scopes.js";
import { mcpToolDefinitions } from "../mcp/tools.js";
export async function registerAgentRoutes(app: FastifyInstance): Promise<void> {
app.get("/api/v1/meta/capabilities", async () => ({
ok: true,
presets: {
task_author: taskAuthorPresetScopes,
reporter: reporterPresetScopes,
},
allowed_scopes: allowedAgentScopes,
denied_mvp_capabilities: deniedMvpCapabilities,
mcp_tools: mcpToolDefinitions,
}));
app.post("/api/v1/agents", async (_request, reply) =>
reply.status(501).send({
ok: false,
error: "not_implemented",
message: "Agent persistence starts in Phase 1 after database migrations are added.",
})
);
}

22
src/routes/health.ts Normal file
View File

@ -0,0 +1,22 @@
import type { FastifyInstance } from "fastify";
import type { AppConfig } from "../config.js";
export async function registerHealthRoutes(app: FastifyInstance, config: AppConfig): Promise<void> {
app.get("/healthz", async () => ({
ok: true,
service: "nodedc-tasker-codex-api",
}));
app.get("/readyz", async () => ({
ok: true,
service: "nodedc-tasker-codex-api",
dependencies: {
database: config.DATABASE_URL ? "configured" : "not_configured",
launcher: config.NODEDC_LAUNCHER_INTERNAL_URL,
tasker: config.NODEDC_TASKER_INTERNAL_URL,
internal_token: config.NODEDC_INTERNAL_ACCESS_TOKEN ? "configured" : "not_configured",
},
}));
}

16
src/server.ts Normal file
View File

@ -0,0 +1,16 @@
import { buildApp } from "./app.js";
import { loadConfig } from "./config.js";
const config = loadConfig();
const app = await buildApp(config);
try {
await app.listen({
host: config.HOST,
port: config.PORT,
});
} catch (error) {
app.log.error(error);
process.exit(1);
}

16
tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}