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
HOST=0.0.0.0
PORT=4100
HOST_BIND=172.22.0.222
HOST_PORT=18190
LOG_LEVEL=info
NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=replace-with-strong-gateway-internal-token
NODEDC_LAUNCHER_INTERNAL_URL=http://127.0.0.1:18080
NODEDC_TASKER_INTERNAL_URL=http://127.0.0.1:18090
NODEDC_LAUNCHER_INTERNAL_URL=http://172.22.0.222:18080
NODEDC_TASKER_INTERNAL_URL=http://172.22.0.222:18090
NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-platform-internal-access-token
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
```
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`.

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}
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_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:-http://127.0.0.1:18080}
NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://127.0.0.1:18090}
NODEDC_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:-http://172.22.0.222:18080}
NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://172.22.0.222:18090}
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN}
depends_on:
postgres:
condition: service_healthy
ports:
- "127.0.0.1:${HOST_PORT:-18190}:${PORT:-4100}"
- "${HOST_BIND:-172.22.0.222}:${HOST_PORT:-18190}:${PORT:-4100}"
healthcheck:
test:
[

View File

@ -5,8 +5,9 @@ This service is the NODE.DC Operational Agents Gateway for Tasker/Operational Co
## Network model
- 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`.
- Docker host bind address is controlled by `HOST_BIND=172.22.0.222`.
- 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.
- 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
HOST=0.0.0.0
PORT=4100
HOST_BIND=172.22.0.222
HOST_PORT=18190
LOG_LEVEL=info
NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=<strong-random-secret>
NODEDC_LAUNCHER_INTERNAL_URL=<launcher-url-reachable-from-synology>
NODEDC_TASKER_INTERNAL_URL=<tasker-url-reachable-from-synology>
NODEDC_LAUNCHER_INTERNAL_URL=http://172.22.0.222:18080
NODEDC_TASKER_INTERNAL_URL=http://172.22.0.222:18090
NODEDC_INTERNAL_ACCESS_TOKEN=<tasker-internal-access-token>
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
```
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
Local host checks:
```bash
curl -fsS http://127.0.0.1:18190/healthz
curl -fsS http://127.0.0.1:18190/readyz
curl -fsS -i http://127.0.0.1:18190/mcp | head
curl -fsS http://172.22.0.222:18190/healthz
curl -fsS http://172.22.0.222:18190/readyz
curl -fsS -i http://172.22.0.222:18190/mcp | head
```
Public checks after DNS/reverse proxy:
@ -98,7 +100,7 @@ Expected behavior:
Tasker must call the gateway by internal URL:
```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>
```

View File

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

View File

@ -73,10 +73,16 @@ const structuredBlocksJsonSchema = {
properties: {
id: { type: "string" },
type: { const: "text" },
title: { type: "string" },
body: { type: "string" },
title: {
type: "string",
description: "Visible block title. Put headings here, not inside body markdown.",
},
body: {
type: "string",
description: "Plain block body without a leading markdown heading.",
},
},
required: ["id", "type"],
required: ["id", "type", "title", "body"],
additionalProperties: false,
},
{
@ -84,7 +90,10 @@ const structuredBlocksJsonSchema = {
properties: {
id: { type: "string" },
type: { const: "checker" },
title: { type: "string" },
title: {
type: "string",
description: "Visible checklist title. Put headings here, not inside item text.",
},
items: {
type: "array",
items: {
@ -99,7 +108,7 @@ const structuredBlocksJsonSchema = {
},
},
},
required: ["id", "type"],
required: ["id", "type", "title", "items"],
additionalProperties: false,
},
],
@ -236,10 +245,42 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
},
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",
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"],
inputSchema: {
...projectAndIssueInputSchema,
@ -308,6 +349,20 @@ const moveIssueArgsSchema = issueArgsSchema.extend({
const commentArgsSchema = issueArgsSchema.extend({
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({
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);
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": {
const input = labelsArgsSchema.parse(args);
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,
issue_id: args.issue_id,
state_id: args.state_id,
labels_count: Array.isArray(args.labels) ? args.labels.length : undefined,
has_structured_blocks: Array.isArray(args.structured_blocks),
};
}
@ -605,12 +666,19 @@ function buildAgentInstructions(session: AgentSessionRecord): Record<string, unk
card_structure: [
"Keep the main issue description concise and conceptual.",
"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.",
],
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: [
"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 use projects and workspaces present in effective grants.",
],

View File

@ -16,7 +16,7 @@ type AgentRouteDeps = {
internalAccessToken?: string;
};
const MAX_AGENT_AVATAR_URL_LENGTH = 400_000;
const MAX_AGENT_AVATAR_URL_LENGTH = 2_000_000;
function isAllowedAvatarUrl(value: string): boolean {
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.",
"- Do not call raw Tasker APIs. Use only the NODE.DC MCP tools.",
"- 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",
"",
"- Keep card titles concise and operational.",
"- 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.",
"",
"## 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",
"",
grants || "- No grants available.",

View File

@ -34,6 +34,22 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
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) => {
const session = await authenticateAgent(request, deps);
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[];
};
export type EnsureLabelsInput = {
project_id: string;
workspace_slug?: string | null;
labels: Array<{
name: string;
color?: string;
}>;
};
export type AssignIssueInput = {
project_id: string;
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> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/assignees`, {
method: "PUT",
@ -217,6 +234,7 @@ export class TaskerClient {
}
): Promise<Response> {
try {
const requestBody = attachAgentMetadata(input.session, input.body);
return await fetch(new URL(path, this.config.baseUrl), {
method: input.method,
headers: {
@ -226,7 +244,7 @@ export class TaskerClient {
"X-NODEDC-Agent-Owner-User-Id": input.session.agent.ownerUserId,
"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) {
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> {
const text = await response.text();