ARCH - CODEX AGENTS: стартовый каркас Agent Gateway
This commit is contained in:
parent
97d98a7bcb
commit
e95cb3af33
|
|
@ -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=
|
||||||
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
services:
|
||||||
|
agent-gateway:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "${PORT:-4100}:${PORT:-4100}"
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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",
|
||||||
|
];
|
||||||
|
|
||||||
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
|
@ -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.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue