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