Compare commits
2 Commits
9677c455b3
...
f52a8345a1
| Author | SHA1 | Date |
|---|---|---|
|
|
f52a8345a1 | |
|
|
19d5f18bf5 |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue