Compare commits

..

2 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS f52a8345a1 FEAT - GATEWAY: support Tasker labels and agent metadata 2026-05-15 14:25:36 +03:00
DCCONSTRUCTIONS 19d5f18bf5 OPS - GATEWAY: bind Synology service to NAS address 2026-05-15 10:21:16 +03:00
9 changed files with 158 additions and 25 deletions

View File

@ -1,13 +1,14 @@
NODE_ENV=production NODE_ENV=production
HOST=0.0.0.0 HOST=0.0.0.0
PORT=4100 PORT=4100
HOST_BIND=172.22.0.222
HOST_PORT=18190 HOST_PORT=18190
LOG_LEVEL=info LOG_LEVEL=info
NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=replace-with-strong-gateway-internal-token NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=replace-with-strong-gateway-internal-token
NODEDC_LAUNCHER_INTERNAL_URL=http://127.0.0.1:18080 NODEDC_LAUNCHER_INTERNAL_URL=http://172.22.0.222:18080
NODEDC_TASKER_INTERNAL_URL=http://127.0.0.1:18090 NODEDC_TASKER_INTERNAL_URL=http://172.22.0.222:18090
NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-platform-internal-access-token NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-platform-internal-access-token
POSTGRES_DB=nodedc_agent_gateway POSTGRES_DB=nodedc_agent_gateway

View File

@ -45,7 +45,7 @@ docker compose --env-file .env -f docker-compose.local.yml up -d --build
curl http://127.0.0.1:4100/readyz curl http://127.0.0.1:4100/readyz
``` ```
The `agent-gateway` container waits for local Postgres, runs migrations on startup, and exposes the same `:4100` internal endpoint used by Tasker (`PLANE_NODEDC_AGENT_GATEWAY_URL=http://host.docker.internal:4100`). `HOST_PORT` controls the host-side port for reverse proxy deployments; Synology should use `docker-compose.synology.yml` with `127.0.0.1:18190:4100` because `18090` is reserved for Tasker. The user-facing setup packet uses `NODEDC_AGENT_GATEWAY_PUBLIC_URL`; product defaults point to `https://ops-agents.nodedc.ru`, not localhost. The `agent-gateway` container waits for local Postgres, runs migrations on startup, and exposes the same `:4100` internal endpoint used by Tasker (`PLANE_NODEDC_AGENT_GATEWAY_URL=http://host.docker.internal:4100` in local development). `HOST_BIND` and `HOST_PORT` control the host-side port for reverse proxy deployments; Synology should use `docker-compose.synology.yml` with `172.22.0.222:18190:4100` because `18090` is reserved for Tasker. The user-facing setup packet uses `NODEDC_AGENT_GATEWAY_PUBLIC_URL`; product defaults point to `https://ops-agents.nodedc.ru`, not localhost.
Synology deployment notes live in `docs/SYNOLOGY_DEPLOY.md`. Synology deployment notes live in `docs/SYNOLOGY_DEPLOY.md`.

View File

@ -25,14 +25,14 @@ services:
DATABASE_URL: postgres://${POSTGRES_USER:-nodedc_agent_gateway}:${POSTGRES_PASSWORD:-replace-with-strong-postgres-password}@postgres:5432/${POSTGRES_DB:-nodedc_agent_gateway} DATABASE_URL: postgres://${POSTGRES_USER:-nodedc_agent_gateway}:${POSTGRES_PASSWORD:-replace-with-strong-postgres-password}@postgres:5432/${POSTGRES_DB:-nodedc_agent_gateway}
NODEDC_AGENT_GATEWAY_PUBLIC_URL: ${NODEDC_AGENT_GATEWAY_PUBLIC_URL:-https://ops-agents.nodedc.ru} NODEDC_AGENT_GATEWAY_PUBLIC_URL: ${NODEDC_AGENT_GATEWAY_PUBLIC_URL:-https://ops-agents.nodedc.ru}
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: ${NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN} NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: ${NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN}
NODEDC_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:-http://127.0.0.1:18080} NODEDC_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:-http://172.22.0.222:18080}
NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://127.0.0.1:18090} NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://172.22.0.222:18090}
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN} NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
ports: ports:
- "127.0.0.1:${HOST_PORT:-18190}:${PORT:-4100}" - "${HOST_BIND:-172.22.0.222}:${HOST_PORT:-18190}:${PORT:-4100}"
healthcheck: healthcheck:
test: test:
[ [

View File

@ -5,8 +5,9 @@ This service is the NODE.DC Operational Agents Gateway for Tasker/Operational Co
## Network model ## Network model
- Public URL: `https://ops-agents.nodedc.ru`. - Public URL: `https://ops-agents.nodedc.ru`.
- Synology reverse proxy: `HTTPS 443``HTTP 127.0.0.1:18190`. - Synology reverse proxy: `HTTPS 443``HTTP 172.22.0.222:18190`.
- Container app port stays `4100`. - Container app port stays `4100`.
- Docker host bind address is controlled by `HOST_BIND=172.22.0.222`.
- Docker host port is controlled by `HOST_PORT=18190`. - Docker host port is controlled by `HOST_PORT=18190`.
- Do not use `18090` for this module: that host port is reserved by Tasker / Operational Core. - Do not use `18090` for this module: that host port is reserved by Tasker / Operational Core.
- No router changes are required if `443` already reaches Synology and Synology owns the reverse proxy rule. - No router changes are required if `443` already reaches Synology and Synology owns the reverse proxy rule.
@ -19,13 +20,14 @@ Create `.env` from `.env.synology.example` and replace every `replace-with-*` va
NODE_ENV=production NODE_ENV=production
HOST=0.0.0.0 HOST=0.0.0.0
PORT=4100 PORT=4100
HOST_BIND=172.22.0.222
HOST_PORT=18190 HOST_PORT=18190
LOG_LEVEL=info LOG_LEVEL=info
NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=<strong-random-secret> NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=<strong-random-secret>
NODEDC_LAUNCHER_INTERNAL_URL=<launcher-url-reachable-from-synology> NODEDC_LAUNCHER_INTERNAL_URL=http://172.22.0.222:18080
NODEDC_TASKER_INTERNAL_URL=<tasker-url-reachable-from-synology> NODEDC_TASKER_INTERNAL_URL=http://172.22.0.222:18090
NODEDC_INTERNAL_ACCESS_TOKEN=<tasker-internal-access-token> NODEDC_INTERNAL_ACCESS_TOKEN=<tasker-internal-access-token>
POSTGRES_DB=nodedc_agent_gateway POSTGRES_DB=nodedc_agent_gateway
@ -66,16 +68,16 @@ docker compose --env-file .env -f docker-compose.synology.yml pull
docker compose --env-file .env -f docker-compose.synology.yml up -d --build docker compose --env-file .env -f docker-compose.synology.yml up -d --build
``` ```
If the repository is deployed from source and not from a registry image, `up -d --build` is enough. The production compose does not publish Postgres and binds the gateway to `127.0.0.1:18190`; DSM reverse proxy should target that local address. If the repository is deployed from source and not from a registry image, `up -d --build` is enough. The production compose does not publish Postgres and binds the gateway to `${HOST_BIND}:${HOST_PORT}`; DSM reverse proxy must target the same address.
## Verification ## Verification
Local host checks: Local host checks:
```bash ```bash
curl -fsS http://127.0.0.1:18190/healthz curl -fsS http://172.22.0.222:18190/healthz
curl -fsS http://127.0.0.1:18190/readyz curl -fsS http://172.22.0.222:18190/readyz
curl -fsS -i http://127.0.0.1:18190/mcp | head curl -fsS -i http://172.22.0.222:18190/mcp | head
``` ```
Public checks after DNS/reverse proxy: Public checks after DNS/reverse proxy:
@ -98,7 +100,7 @@ Expected behavior:
Tasker must call the gateway by internal URL: Tasker must call the gateway by internal URL:
```env ```env
PLANE_NODEDC_AGENT_GATEWAY_URL=http://<synology-or-gateway-host>:18190 PLANE_NODEDC_AGENT_GATEWAY_URL=http://172.22.0.222:18190
PLANE_NODEDC_AGENT_GATEWAY_TOKEN=<same value as NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN> PLANE_NODEDC_AGENT_GATEWAY_TOKEN=<same value as NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN>
``` ```

View File

@ -23,6 +23,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
internalAccessToken: config.NODEDC_INTERNAL_ACCESS_TOKEN, internalAccessToken: config.NODEDC_INTERNAL_ACCESS_TOKEN,
}); });
const app = Fastify({ const app = Fastify({
bodyLimit: 10 * 1024 * 1024,
logger: { logger: {
level: config.LOG_LEVEL, level: config.LOG_LEVEL,
}, },

View File

@ -73,10 +73,16 @@ const structuredBlocksJsonSchema = {
properties: { properties: {
id: { type: "string" }, id: { type: "string" },
type: { const: "text" }, type: { const: "text" },
title: { type: "string" }, title: {
body: { type: "string" }, type: "string",
description: "Visible block title. Put headings here, not inside body markdown.",
}, },
required: ["id", "type"], body: {
type: "string",
description: "Plain block body without a leading markdown heading.",
},
},
required: ["id", "type", "title", "body"],
additionalProperties: false, additionalProperties: false,
}, },
{ {
@ -84,7 +90,10 @@ const structuredBlocksJsonSchema = {
properties: { properties: {
id: { type: "string" }, id: { type: "string" },
type: { const: "checker" }, type: { const: "checker" },
title: { type: "string" }, title: {
type: "string",
description: "Visible checklist title. Put headings here, not inside item text.",
},
items: { items: {
type: "array", type: "array",
items: { items: {
@ -99,7 +108,7 @@ const structuredBlocksJsonSchema = {
}, },
}, },
}, },
required: ["id", "type"], required: ["id", "type", "title", "items"],
additionalProperties: false, additionalProperties: false,
}, },
], ],
@ -236,10 +245,42 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
}, },
annotations: { destructiveHint: false, idempotentHint: false }, annotations: { destructiveHint: false, idempotentHint: false },
}, },
{
name: "tasker_ensure_labels",
title: "Ensure Project Labels",
description: "Create missing labels in a granted project and return label ids for subsequent issue labeling.",
requiredScopes: ["issue:label"],
inputSchema: {
...projectInputSchema,
properties: {
...projectInputSchema.properties,
labels: {
type: "array",
minItems: 1,
maxItems: 50,
items: {
type: "object",
properties: {
name: { type: "string" },
color: {
type: "string",
description: "Optional hex color in #RRGGBB format.",
},
},
required: ["name"],
additionalProperties: false,
},
},
idempotency_key: { type: "string" },
},
required: ["project_id", "labels"],
},
annotations: { destructiveHint: false, idempotentHint: true },
},
{ {
name: "tasker_set_issue_labels", name: "tasker_set_issue_labels",
title: "Set Issue Labels", title: "Set Issue Labels",
description: "Replace issue labels with existing labels from the granted project.", description: "Replace issue labels with existing or ensured labels from the granted project.",
requiredScopes: ["issue:label"], requiredScopes: ["issue:label"],
inputSchema: { inputSchema: {
...projectAndIssueInputSchema, ...projectAndIssueInputSchema,
@ -308,6 +349,20 @@ const moveIssueArgsSchema = issueArgsSchema.extend({
const commentArgsSchema = issueArgsSchema.extend({ const commentArgsSchema = issueArgsSchema.extend({
body: z.string().min(1).max(20000), body: z.string().min(1).max(20000),
}); });
const ensureLabelsArgsSchema = projectArgsSchema.extend({
labels: z
.array(
z.object({
name: z.string().min(1).max(255),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.optional(),
})
)
.min(1)
.max(50),
});
const labelsArgsSchema = issueArgsSchema.extend({ const labelsArgsSchema = issueArgsSchema.extend({
label_ids: z.array(z.string().min(1)).default([]), label_ids: z.array(z.string().min(1)).default([]),
}); });
@ -454,6 +509,11 @@ async function executeMcpToolOnce(
requireToolAccess(session, "issue:comment", input.project_id, input.workspace_slug); requireToolAccess(session, "issue:comment", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.appendComment(session, input.issue_id, input)); return asToolResult(await deps.taskerClient.appendComment(session, input.issue_id, input));
} }
case "tasker_ensure_labels": {
const input = ensureLabelsArgsSchema.parse(args);
requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.ensureLabels(session, input));
}
case "tasker_set_issue_labels": { case "tasker_set_issue_labels": {
const input = labelsArgsSchema.parse(args); const input = labelsArgsSchema.parse(args);
requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug); requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug);
@ -580,6 +640,7 @@ function summarizeToolArguments(args: unknown): Record<string, unknown> {
project_id: args.project_id, project_id: args.project_id,
issue_id: args.issue_id, issue_id: args.issue_id,
state_id: args.state_id, state_id: args.state_id,
labels_count: Array.isArray(args.labels) ? args.labels.length : undefined,
has_structured_blocks: Array.isArray(args.structured_blocks), has_structured_blocks: Array.isArray(args.structured_blocks),
}; };
} }
@ -605,12 +666,19 @@ function buildAgentInstructions(session: AgentSessionRecord): Record<string, unk
card_structure: [ card_structure: [
"Keep the main issue description concise and conceptual.", "Keep the main issue description concise and conceptual.",
"Use structured text blocks for current architecture, planned architecture, and implementation notes.", "Use structured text blocks for current architecture, planned architecture, and implementation notes.",
"Use checker blocks for short verifiable phase items.", "Every structured text block must use its title field for the heading; do not duplicate markdown headings like ## Status inside the body.",
"Wrong structured text block: title omitted and body starts with ## Текущая архитектура. Correct: title is Текущая архитектура and body contains only the section content.",
"Use checker blocks with explicit titles for short verifiable phase items.",
"After implementation, add a factual implementation block with files touched and validation performed.", "After implementation, add a factual implementation block with files touched and validation performed.",
], ],
labels: [
"Use tasker_ensure_labels before setting a label that is not already present in project context.",
"Use tasker_set_issue_labels with label ids returned by project context or tasker_ensure_labels.",
],
hard_limits: [ hard_limits: [
"Do not delete or archive issues.", "Do not delete or archive issues.",
"Do not create projects, labels, states, workspace invites, or workspace settings changes.", "Do not create projects, states, workspace invites, or workspace settings changes.",
"Only create labels through tasker_ensure_labels inside granted projects.",
"Only assign existing project members.", "Only assign existing project members.",
"Only use projects and workspaces present in effective grants.", "Only use projects and workspaces present in effective grants.",
], ],

View File

@ -16,7 +16,7 @@ type AgentRouteDeps = {
internalAccessToken?: string; internalAccessToken?: string;
}; };
const MAX_AGENT_AVATAR_URL_LENGTH = 400_000; const MAX_AGENT_AVATAR_URL_LENGTH = 2_000_000;
function isAllowedAvatarUrl(value: string): boolean { function isAllowedAvatarUrl(value: string): boolean {
if (value.startsWith("data:image/")) { if (value.startsWith("data:image/")) {
@ -678,14 +678,23 @@ function buildAgentsMd(session: AgentSessionRecord, endpoint: string): string {
"- Never delete or archive Tasker cards, comments, labels, projects, states, members, or workspaces.", "- Never delete or archive Tasker cards, comments, labels, projects, states, members, or workspaces.",
"- Do not call raw Tasker APIs. Use only the NODE.DC MCP tools.", "- Do not call raw Tasker APIs. Use only the NODE.DC MCP tools.",
"- Only assign existing project members returned by project context.", "- Only assign existing project members returned by project context.",
"- If a needed label is missing, call `tasker_ensure_labels` first and then use returned ids with `tasker_set_issue_labels`.",
"", "",
"## Card Writing", "## Card Writing",
"", "",
"- Keep card titles concise and operational.", "- Keep card titles concise and operational.",
"- Put current architecture, planned architecture, implementation notes, and validation into structured text blocks.", "- Put current architecture, planned architecture, implementation notes, and validation into structured text blocks.",
"- Put short verifiable work items into checker blocks.", "- Put each structured block heading into the block `title` field. Do not put markdown headings like `## Status` inside block body.",
"- Do not put headings inside block body. Wrong: body starts with `## Текущая архитектура`. Correct: `title: \"Текущая архитектура\"`, body contains only content.",
"- Put short verifiable work items into checker blocks with explicit titles.",
"- After code work, update the related card with factual files touched and validation performed.", "- After code work, update the related card with factual files touched and validation performed.",
"", "",
"## Labels",
"",
"- Before assigning a new marker/label, check project context labels.",
"- If the label does not exist, call `tasker_ensure_labels` with the granted `project_id`.",
"- Use only label ids returned by project context or `tasker_ensure_labels` when calling `tasker_set_issue_labels`.",
"",
"## Effective Grants", "## Effective Grants",
"", "",
grants || "- No grants available.", grants || "- No grants available.",

View File

@ -34,6 +34,22 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
return result.structuredContent; return result.structuredContent;
}); });
app.post("/api/v1/tools/projects/:projectId/labels/ensure", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { projectId: string };
const result = await executeMcpTool(
session,
"tasker_ensure_labels",
{
...requestBodyRecord(request.body),
project_id: params.projectId,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.get("/api/v1/tools/issues", async (request) => { app.get("/api/v1/tools/issues", async (request) => {
const session = await authenticateAgent(request, deps); const session = await authenticateAgent(request, deps);
const query = request.query as { project_id?: string; workspace_slug?: string; query?: string }; const query = request.query as { project_id?: string; workspace_slug?: string; query?: string };

View File

@ -83,6 +83,15 @@ export type SetLabelsInput = {
label_ids: string[]; label_ids: string[];
}; };
export type EnsureLabelsInput = {
project_id: string;
workspace_slug?: string | null;
labels: Array<{
name: string;
color?: string;
}>;
};
export type AssignIssueInput = { export type AssignIssueInput = {
project_id: string; project_id: string;
workspace_slug?: string | null; workspace_slug?: string | null;
@ -177,6 +186,14 @@ export class TaskerClient {
}); });
} }
async ensureLabels(session: AgentSessionRecord, input: EnsureLabelsInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/projects/${encodeURIComponent(input.project_id)}/labels/ensure`, {
method: "POST",
session,
body: input,
});
}
async assignIssue(session: AgentSessionRecord, issueId: string, input: AssignIssueInput): Promise<unknown> { async assignIssue(session: AgentSessionRecord, issueId: string, input: AssignIssueInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/assignees`, { return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/assignees`, {
method: "PUT", method: "PUT",
@ -217,6 +234,7 @@ export class TaskerClient {
} }
): Promise<Response> { ): Promise<Response> {
try { try {
const requestBody = attachAgentMetadata(input.session, input.body);
return await fetch(new URL(path, this.config.baseUrl), { return await fetch(new URL(path, this.config.baseUrl), {
method: input.method, method: input.method,
headers: { headers: {
@ -226,7 +244,7 @@ export class TaskerClient {
"X-NODEDC-Agent-Owner-User-Id": input.session.agent.ownerUserId, "X-NODEDC-Agent-Owner-User-Id": input.session.agent.ownerUserId,
"X-NODEDC-Agent-Token-Id": input.session.token.id, "X-NODEDC-Agent-Token-Id": input.session.token.id,
}, },
body: input.body === undefined ? undefined : JSON.stringify(input.body), body: requestBody === undefined ? undefined : JSON.stringify(requestBody),
}); });
} catch (error) { } catch (error) {
throw new TaskerAdapterUnavailableError(error); throw new TaskerAdapterUnavailableError(error);
@ -234,6 +252,24 @@ export class TaskerClient {
} }
} }
function attachAgentMetadata(session: AgentSessionRecord, body: unknown): unknown {
if (body === undefined || !isPlainRecord(body)) {
return body;
}
return {
...body,
_agent: {
display_name: session.agent.displayName,
avatar_url: session.agent.avatarUrl,
},
};
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readResponsePayload(response: Response): Promise<unknown> { async function readResponsePayload(response: Response): Promise<unknown> {
const text = await response.text(); const text = await response.text();