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