@50arms/platform-mcp 0.0.0-stage → 0.1.436
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -2
- package/dist/access-request.js +101 -0
- package/dist/agent-gate.js +69 -0
- package/dist/board-id.js +27 -0
- package/dist/bounded-retry.js +25 -0
- package/dist/checkout-docs-root.js +54 -0
- package/dist/clear-control.js +77 -0
- package/dist/compaction-control.js +142 -0
- package/dist/credential.js +104 -0
- package/dist/danxbot-api-examples.js +97 -0
- package/dist/dashboard-identity.js +14 -0
- package/dist/design-screenshot.js +138 -0
- package/dist/digest-hold.js +67 -0
- package/dist/docs-tools.js +140 -0
- package/dist/durable-write.js +56 -0
- package/dist/entrypoint.js +35 -0
- package/dist/forbidden-hint.js +23 -0
- package/dist/guarded-calls.js +21 -0
- package/dist/handlers.js +385 -0
- package/dist/heartbeat-reporter.js +156 -0
- package/dist/http-client.js +190 -0
- package/dist/index.js +1128 -0
- package/dist/json-body.js +12 -0
- package/dist/key-revoked-halt.js +30 -0
- package/dist/lifecycle.js +113 -0
- package/dist/listen.js +999 -0
- package/dist/listener-reminder.js +119 -0
- package/dist/machine-daemon/autostart.js +160 -0
- package/dist/machine-daemon/beat.js +77 -0
- package/dist/machine-daemon/cli.js +42 -0
- package/dist/machine-daemon/install.js +168 -0
- package/dist/machine-daemon/state.js +64 -0
- package/dist/machine-daemon/version.js +13 -0
- package/dist/machine-daemon.js +553 -0
- package/dist/machine-toolkit.js +460 -0
- package/dist/mini-app-build.js +159 -0
- package/dist/mini-app-checkout.js +215 -0
- package/dist/mini-app-output.js +111 -0
- package/dist/mini-app-publish.js +154 -0
- package/dist/mini-app-restore.js +175 -0
- package/dist/one-line.js +25 -0
- package/dist/pacing-relay.js +16 -0
- package/dist/permission-request.js +134 -0
- package/dist/permission-watch.js +145 -0
- package/dist/plan-events-relay.js +572 -0
- package/dist/plan-events-tool.js +9 -0
- package/dist/plugin-publish.js +38 -0
- package/dist/plugin-screenshot.js +81 -0
- package/dist/request-bound.js +21 -0
- package/dist/restart-notice.js +271 -0
- package/dist/result-limit.js +31 -0
- package/dist/role-instruction-file.js +36 -0
- package/dist/route-board-scope.generated.js +93 -0
- package/dist/route-board-scope.js +71 -0
- package/dist/session-access.js +322 -0
- package/dist/session-connection.js +264 -0
- package/dist/session-control-file.js +58 -0
- package/dist/session-key.js +125 -0
- package/dist/session-options.js +60 -0
- package/dist/session-stats.js +235 -0
- package/dist/session-stream.js +345 -0
- package/dist/session-title.js +129 -0
- package/dist/shutdown.js +38 -0
- package/dist/stop-fixes.js +50 -0
- package/dist/subagents-live.js +162 -0
- package/dist/terms-halt.js +39 -0
- package/dist/text-limit.js +24 -0
- package/dist/tool-access.js +114 -0
- package/dist/tool-defs.js +58 -0
- package/dist/tool-surface.js +59 -0
- package/dist/transcript-lineage.js +105 -0
- package/dist/types.js +45 -0
- package/dist/urgent-records.js +47 -0
- package/dist/version-floor.js +146 -0
- package/package.json +51 -4
package/dist/handlers.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-tool handlers for the dashboard MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Each handler is a pure async function: validate args (Zod at the MCP
|
|
5
|
+
* boundary), build the HTTP request the corresponding `/api/issues/*`
|
|
6
|
+
* route expects, and pass the envelope back to the caller verbatim.
|
|
7
|
+
*
|
|
8
|
+
* BOARD-ONLY (DX-1171): board is the first-level concept; repo is
|
|
9
|
+
* DERIVED from board server-side, never passed. Every handler accepts an
|
|
10
|
+
* optional `board` arg — a board ref (the number that numbers the board within its team, DX-6486 DEC-186). When
|
|
11
|
+
* set, it overrides the dispatch's configured board for that single call
|
|
12
|
+
* (forwarded as `?board=<board>` — and into `body.board` for create — so
|
|
13
|
+
* an agent can target any board). Omitted → falls back to the dispatch's
|
|
14
|
+
* env-derived board, if any (a board-less server stamps none). The dashboard route validates the board (owning
|
|
15
|
+
* board→repo) and 404s on unknown; the MCP forwards the refusal envelope
|
|
16
|
+
* verbatim, never duplicates validation.
|
|
17
|
+
*
|
|
18
|
+
* Zero transformation of the server response. Refusal payloads carry
|
|
19
|
+
* structured detail (`failed_gate`, `non_terminal_phases`,
|
|
20
|
+
* `offending_keys`, `received_reason`, etc.) that the agent uses to
|
|
21
|
+
* decide its next move; swallowing any of it would force the agent to
|
|
22
|
+
* guess.
|
|
23
|
+
*/
|
|
24
|
+
import { posix, win32 } from "node:path";
|
|
25
|
+
import { readFile, stat } from "node:fs/promises";
|
|
26
|
+
// ---------------- plan_connect (DX-2683) ----------------
|
|
27
|
+
// DX-3465 (PLN-11 R-25) — every other per-operation handler that used to
|
|
28
|
+
// live in this file (issue_*, plan_* except plan_connect, brief_*,
|
|
29
|
+
// repo_knowledge_*, failure_category_*, dispatch_transcript_search,
|
|
30
|
+
// quality_gate_instruction) is deleted: `danxbotApi`/`danxbotApiSpec` below
|
|
31
|
+
// are the generic replacement. `plan_connect` stays a dedicated tool because
|
|
32
|
+
// it is session-bound (see `index.ts`'s own comment on this).
|
|
33
|
+
const PLAN_SESSIONS_BASE_PATH = "/api/plan-sessions";
|
|
34
|
+
/**
|
|
35
|
+
* Connect THIS session to a plan — the same write the operator's Connect
|
|
36
|
+
* action performs, reaching the same server-side code path. `me` in the URL
|
|
37
|
+
* is resolved from the forwarded session id, so this can only ever bind the
|
|
38
|
+
* caller's own session.
|
|
39
|
+
*
|
|
40
|
+
* A session already on another plan is MOVED, and the response says which
|
|
41
|
+
* plan it left (`movedFrom`).
|
|
42
|
+
*
|
|
43
|
+
* IT DOES NOTHING ABOUT HEARING THE PLAN'S EVENTS, deliberately. Delivery belongs
|
|
44
|
+
* to the plan event relay inside this MCP server (`plan_events_wait`, `plan-events-relay.ts`),
|
|
45
|
+
* which holds the session's ONE listener ticket and is started by the 50arms plugin's loop once this tool
|
|
46
|
+
* succeeds. The dashboard keeps one ticket per session, so a ticket minted here
|
|
47
|
+
* would end the relay's stream.
|
|
48
|
+
*
|
|
49
|
+
* `title` (DX-2816) rides here, not on a header, because the AGENT — not this
|
|
50
|
+
* server — can read Claude's own session title at connect time (via a
|
|
51
|
+
* Desktop-side `get_session` tool no MCP server subprocess can call). Sent
|
|
52
|
+
* only when the caller supplied one; the server leaves an unset title alone.
|
|
53
|
+
* The event relay keeps it current afterwards, from the transcript (DX-4461,
|
|
54
|
+
* `session-title.ts`).
|
|
55
|
+
*/
|
|
56
|
+
export async function planConnect(client, args) {
|
|
57
|
+
if (args.disconnect === true) {
|
|
58
|
+
if (args.title !== undefined) {
|
|
59
|
+
return { ok: false, status: 400, body: { error: "plan_connect: `disconnect: true` takes no `title` — a leave changes no title" } };
|
|
60
|
+
}
|
|
61
|
+
// DX-2985 — `DELETE /api/plan-sessions/me/plan?plan_id=`: `me` is the forwarded session id, so only the caller's own session leaves.
|
|
62
|
+
return client.request({
|
|
63
|
+
method: "DELETE",
|
|
64
|
+
path: "/me/plan",
|
|
65
|
+
basePath: PLAN_SESSIONS_BASE_PATH,
|
|
66
|
+
query: { plan_id: args.plan_id },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return client.request({
|
|
70
|
+
method: "POST",
|
|
71
|
+
path: "/me/plan",
|
|
72
|
+
basePath: PLAN_SESSIONS_BASE_PATH,
|
|
73
|
+
body: {
|
|
74
|
+
plan_id: args.plan_id,
|
|
75
|
+
...(args.title === undefined ? {} : { title: args.title }),
|
|
76
|
+
...(args.assignment_id === undefined ? {} : { assignment_id: args.assignment_id }),
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* DX-5549 - sign THIS session in as a team agent without joining a plan: `POST /api/plan-sessions/me/agent`. `me` is the forwarded session
|
|
82
|
+
* id, so it can only ever claim for the caller's own session. Answers `state: agent_held` with the agent and the instruction to use its name
|
|
83
|
+
* and personality, or `agent_needed` / `agent_limit_reached` when the session has none (see `agent-gate.ts`).
|
|
84
|
+
*/
|
|
85
|
+
export async function claimSessionAgent(client) {
|
|
86
|
+
return client.request({ method: "POST", path: "/me/agent", basePath: PLAN_SESSIONS_BASE_PATH });
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* DX-5553 - hold ONE stand-alone card (a card on no plan) instead of a plan, or let it go: `POST|DELETE /api/plan-sessions/me/card`. `me` is the forwarded session id, so only
|
|
90
|
+
* the caller's own session is ever bound. Like `plan_connect`, the answer carries what the session missed (`missedEvents`) and the exact thread title to take (`naming`); the
|
|
91
|
+
* session then hears that card's and its descendants' events through the same relay as a plan's.
|
|
92
|
+
*/
|
|
93
|
+
export async function cardConnect(client, args) {
|
|
94
|
+
if (args.disconnect === true) {
|
|
95
|
+
if (args.title !== undefined) {
|
|
96
|
+
return { ok: false, status: 400, body: { error: "card_connect: `disconnect: true` takes no `title` — a leave changes no title" } };
|
|
97
|
+
}
|
|
98
|
+
return client.request({ method: "DELETE", path: "/me/card", basePath: PLAN_SESSIONS_BASE_PATH, query: { card_id: args.card_id } });
|
|
99
|
+
}
|
|
100
|
+
return client.request({
|
|
101
|
+
method: "POST",
|
|
102
|
+
path: "/me/card",
|
|
103
|
+
basePath: PLAN_SESSIONS_BASE_PATH,
|
|
104
|
+
body: { card_id: args.card_id, ...(args.title === undefined ? {} : { title: args.title }), ...(args.assignment_id === undefined ? {} : { assignment_id: args.assignment_id }) },
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
// DX-3465 (code-architecture gate finding, at 275f82c0d) — the dedicated
|
|
108
|
+
// `resource_fields` handler (DX-3426) was deleted from here: it was a thin
|
|
109
|
+
// wrapper around `GET /api/resources/:resource/fields`, which `50arms_api`
|
|
110
|
+
// already reaches with nothing agent-specific in it. See `index.ts`'s own
|
|
111
|
+
// note at the former tool-registration site.
|
|
112
|
+
// ---------------- 50arms_api (DX-3465, PLN-11 R-25) ----------------
|
|
113
|
+
export const DANXBOT_API_METHODS = ["GET", "POST", "PATCH", "PUT", "DELETE"];
|
|
114
|
+
export function isDanxbotApiMethod(value) {
|
|
115
|
+
return typeof value === "string" && DANXBOT_API_METHODS.includes(value);
|
|
116
|
+
}
|
|
117
|
+
/** The file types a `body_file` may carry, by extension; the content type sent is the one the extension names. The one route that takes a file today re-draws each as a PNG (the proposal avatar). */
|
|
118
|
+
export const BODY_FILE_MIME_BY_EXT = {
|
|
119
|
+
".png": "image/png",
|
|
120
|
+
".jpg": "image/jpeg",
|
|
121
|
+
".jpeg": "image/jpeg",
|
|
122
|
+
".webp": "image/webp",
|
|
123
|
+
".svg": "image/svg+xml",
|
|
124
|
+
};
|
|
125
|
+
/** The largest `body_file` this server sends: the dashboard's own avatar limit (`AVATAR_MAX_BYTES`, `src/dashboard/avatar-upload.ts`), which this package cannot import. */
|
|
126
|
+
export const MAX_BODY_FILE_BYTES = 1_000_000;
|
|
127
|
+
/** DX-5633: the platform's own path rules (a Windows drive path is absolute only under win32). */
|
|
128
|
+
function pathRulesFor(platform) {
|
|
129
|
+
return (platform ?? process.platform) === "win32" ? win32 : posix;
|
|
130
|
+
}
|
|
131
|
+
/** A `body_file` checked and read as a raw request body. Every refusal throws before any request is made. */
|
|
132
|
+
async function readRawBodyFile(path, deps) {
|
|
133
|
+
const { isAbsolute, basename, extname } = pathRulesFor(deps.platform);
|
|
134
|
+
if (!isAbsolute(path))
|
|
135
|
+
throw new Error(`50arms_api: body_file must be an absolute path (got "${path}")`);
|
|
136
|
+
const contentType = BODY_FILE_MIME_BY_EXT[extname(path).toLowerCase()];
|
|
137
|
+
if (!contentType) {
|
|
138
|
+
throw new Error(`50arms_api: body_file accepts only ${Object.keys(BODY_FILE_MIME_BY_EXT).join(", ")} files (got "${extname(path) || basename(path)}")`);
|
|
139
|
+
}
|
|
140
|
+
// Stat first so an oversized file is refused without being read into memory.
|
|
141
|
+
const size = await (deps.fileSize ?? (async (p) => (await stat(p)).size))(path);
|
|
142
|
+
if (size === 0)
|
|
143
|
+
throw new Error(`50arms_api: body_file "${path}" is empty`);
|
|
144
|
+
if (size > MAX_BODY_FILE_BYTES)
|
|
145
|
+
throw new Error(`50arms_api: body_file "${path}" is ${size} bytes; the limit is ${MAX_BODY_FILE_BYTES} (1 MB)`);
|
|
146
|
+
return { bytes: await (deps.readFile ?? readFile)(path), contentType };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* `50arms_api({method, path, query, body})` — the ONE generic tool that
|
|
150
|
+
* reaches every dashboard API route this credential is allowed to call
|
|
151
|
+
* (R-25, DX-3465). Replaces the ~45 per-operation tools this package used to
|
|
152
|
+
* ship one-for-one against each route: a new route needs a permission and a
|
|
153
|
+
* spec entry (`50arms_api_spec`), never a new tool.
|
|
154
|
+
*
|
|
155
|
+
* The credential lives inside `client` (resolved once at boot — the
|
|
156
|
+
* operator's declared token file for a session, the per-dispatch
|
|
157
|
+
* `DANX_DASHBOARD_CREDENTIAL` for a worker) and never appears in this
|
|
158
|
+
* function's arguments or return value — nothing in `DanxbotApiArgs` can
|
|
159
|
+
* carry one. The session id (`x-danx-session-id`) rides every call the SAME
|
|
160
|
+
* way it already does for every other tool on this server
|
|
161
|
+
* (`http-client.ts#request` stamps it from `config.session`); this tool adds
|
|
162
|
+
* no special-casing for that.
|
|
163
|
+
*
|
|
164
|
+
* `path` MUST start with `/api/` — this is a guardrail against a caller
|
|
165
|
+
* accidentally reaching for something outside the dashboard's own API
|
|
166
|
+
* surface (there is nothing else this credential could reach through
|
|
167
|
+
* `client`'s configured `baseUrl` that would make sense to call this way),
|
|
168
|
+
* not a route allowlist: the dashboard's own route policy (`authorize()`,
|
|
169
|
+
* `src/dashboard/route-policy.ts`) is the actual authorization boundary —
|
|
170
|
+
* an unknown or unauthorized route 404s/401s/403s exactly as it would to any
|
|
171
|
+
* other caller, and that refusal body (which names the fix — CP1) is
|
|
172
|
+
* returned verbatim, unmodified, like every other route's response.
|
|
173
|
+
*
|
|
174
|
+
* The response is the target route's JSON body, VERBATIM — including its
|
|
175
|
+
* own `reminders[]` field when the route embeds one (every reminder-
|
|
176
|
+
* registry row is keyed on `{method, route}` now, DX-3465 item 5, so a
|
|
177
|
+
* route that used to attach a reminder to a deleted per-operation tool's
|
|
178
|
+
* response still attaches it here, with zero special MCP-side plumbing).
|
|
179
|
+
*/
|
|
180
|
+
export async function danxbotApi(client, args, deps = {}) {
|
|
181
|
+
// The file is checked and read BEFORE the request, so a bad path leaves nothing behind on the dashboard.
|
|
182
|
+
const rawBody = args.body_file === undefined ? undefined : await readRawBodyFile(args.body_file, deps);
|
|
183
|
+
return client.request({
|
|
184
|
+
method: args.method,
|
|
185
|
+
// `basePath: ""` — `path` here is the FULL absolute path already (see
|
|
186
|
+
// the tool's own docs), unlike every other handler in this file, which
|
|
187
|
+
// passes a path RELATIVE to a fixed `basePath` (`/api/issues`,
|
|
188
|
+
// `/api/plans`, ...). This tool has no fixed base; the caller names the
|
|
189
|
+
// whole route.
|
|
190
|
+
basePath: "",
|
|
191
|
+
path: args.path,
|
|
192
|
+
query: args.query,
|
|
193
|
+
body: args.body,
|
|
194
|
+
rawBody,
|
|
195
|
+
board: args.board,
|
|
196
|
+
background: args.background,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
// ---------------- 50arms_api_list (DX-3465 A5, PLN-11 R-25) ----------------
|
|
200
|
+
const ROUTES_BASE_PATH = "/api/routes";
|
|
201
|
+
/**
|
|
202
|
+
* `50arms_api_list()` — the routes YOUR credential may currently call,
|
|
203
|
+
* `{routes: ["METHOD /path"]}`, one string each (DX-4401). Backed by DX-3528's
|
|
204
|
+
* `GET /api/routes`, which filters the served route table through the SAME
|
|
205
|
+
* `isRouteAuthorizedForPrincipal` check `authorize()` enforces on every
|
|
206
|
+
* request — a route listed here is always callable, and a route this
|
|
207
|
+
* credential cannot call never appears. This is how you find the route for
|
|
208
|
+
* something you need to do: call this first when you don't already know the
|
|
209
|
+
* path, then `50arms_api_spec({endpoints})` for the exact contract of the
|
|
210
|
+
* one(s) you picked, then `50arms_api` to call it. No arguments — the
|
|
211
|
+
* server-side route table has no filter to pass.
|
|
212
|
+
*/
|
|
213
|
+
export async function danxbotApiList(client) {
|
|
214
|
+
return client.request({
|
|
215
|
+
method: "GET",
|
|
216
|
+
path: "",
|
|
217
|
+
basePath: ROUTES_BASE_PATH,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
// ---------------- 50arms_api_spec (DX-3465, PLN-11 R-25) ----------------
|
|
221
|
+
const OPENAPI_BASE_PATH = "/api/openapi";
|
|
222
|
+
/**
|
|
223
|
+
* `50arms_api_spec({endpoints})` — the exact OpenAPI 3.1 contract (DX-3527)
|
|
224
|
+
* for the named routes: request/response shapes, required permission/
|
|
225
|
+
* consumer action, and every documented error envelope. Call this before a
|
|
226
|
+
* `50arms_api` call whose shape you are not already sure of — "a 4xx body
|
|
227
|
+
* names the fix; check the spec" (the MCP server's own shared instructions).
|
|
228
|
+
*
|
|
229
|
+
* Backed by DX-3528's permission-aware `GET /api/openapi` — the SAME
|
|
230
|
+
* `isRouteAuthorizedForPrincipal` check `50arms_api_list` uses, so an
|
|
231
|
+
* endpoint this credential may not call refuses loud (400
|
|
232
|
+
* `invalid_endpoints`, naming it under `unknown` or `disallowed`) rather
|
|
233
|
+
* than silently documenting a route you cannot actually reach.
|
|
234
|
+
*/
|
|
235
|
+
export async function danxbotApiSpec(client, args) {
|
|
236
|
+
return client.request({
|
|
237
|
+
method: "GET",
|
|
238
|
+
path: "",
|
|
239
|
+
basePath: OPENAPI_BASE_PATH,
|
|
240
|
+
query: { endpoints: args.endpoints.join(",") },
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Extension → MIME map for the agent-attached file types that matter for
|
|
245
|
+
* downstream rendering (image previews in the dashboard drawer, Trello
|
|
246
|
+
* card, and Slack thread all key on the persisted `mime`). This package is
|
|
247
|
+
* a standalone published artifact and cannot import the dashboard
|
|
248
|
+
* backend's own MIME table, so the small map lives here. An unknown
|
|
249
|
+
* extension resolves to the generic octet-stream — a sensible default for
|
|
250
|
+
* "we don't know the type", NOT a silenced error (the bytes still upload +
|
|
251
|
+
* the row is still written).
|
|
252
|
+
*/
|
|
253
|
+
const MIME_BY_EXT = {
|
|
254
|
+
".png": "image/png",
|
|
255
|
+
".jpg": "image/jpeg",
|
|
256
|
+
".jpeg": "image/jpeg",
|
|
257
|
+
".gif": "image/gif",
|
|
258
|
+
".webp": "image/webp",
|
|
259
|
+
".svg": "image/svg+xml",
|
|
260
|
+
".pdf": "application/pdf",
|
|
261
|
+
".csv": "text/csv",
|
|
262
|
+
".txt": "text/plain",
|
|
263
|
+
".log": "text/plain",
|
|
264
|
+
".md": "text/markdown",
|
|
265
|
+
".json": "application/json",
|
|
266
|
+
".html": "text/html",
|
|
267
|
+
".zip": "application/zip",
|
|
268
|
+
".gz": "application/gzip",
|
|
269
|
+
".tgz": "application/gzip",
|
|
270
|
+
};
|
|
271
|
+
/**
|
|
272
|
+
* DX-5241: the types and ceiling of `POST /api/uploads/images` (`src/dashboard/uploads-images-route.ts`, no SVG). This package is a separate
|
|
273
|
+
* published artifact and cannot import them, so they are restated here - keep them in sync with that route. The route enforces both too;
|
|
274
|
+
* checking here first gives the agent a plain refusal without reading or sending a file the route would refuse.
|
|
275
|
+
*/
|
|
276
|
+
export const INLINE_IMAGE_MIME_BY_EXT = {
|
|
277
|
+
".png": "image/png",
|
|
278
|
+
".jpg": "image/jpeg",
|
|
279
|
+
".jpeg": "image/jpeg",
|
|
280
|
+
".gif": "image/gif",
|
|
281
|
+
".webp": "image/webp",
|
|
282
|
+
".avif": "image/avif",
|
|
283
|
+
};
|
|
284
|
+
export const MAX_INLINE_IMAGE_BYTES = 25 * 1024 * 1024;
|
|
285
|
+
export function inferAttachmentMime(filename) {
|
|
286
|
+
return MIME_BY_EXT[posix.extname(filename).toLowerCase()] ?? "application/octet-stream";
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Attach a local file to a card (`target` "card", the default; replies with the attachment row) or, with `target: "inline_image"`, upload
|
|
290
|
+
* a local image to `POST /api/uploads/images` and reply `{url, markdown}` (see `uploadInlineImage`). Reads the bytes from the dispatch's
|
|
291
|
+
* shared filesystem (this MCP server runs co-located with the agent that
|
|
292
|
+
* calls it), then forwards to the dashboard's `POST /api/issues/:id/attachments`
|
|
293
|
+
* route (or, with `replaces`, `PUT /api/issues/:id/attachments/:aid`, which swaps that row's bytes in place) — the
|
|
294
|
+
* SAME ingest the dashboard UI's own upload uses: stores the
|
|
295
|
+
* bytes in S3, inserts or updates one attachment row, and (via the dashboard's
|
|
296
|
+
* existing outbound projection) auto-mirrors the file to the card's
|
|
297
|
+
* linked Trello card and Slack thread. Board-scoped; see `board`.
|
|
298
|
+
*
|
|
299
|
+
* Fail-loud (`base:fail-loudly`): `path` is validated (non-empty +
|
|
300
|
+
* absolute) and the file is read BEFORE any HTTP call, so a bad/missing/
|
|
301
|
+
* unreadable path throws here and NEVER reaches S3, the DB row, or the
|
|
302
|
+
* projection. THE FILE'S BYTES NEVER APPEAR IN THIS FUNCTION'S ARGUMENTS
|
|
303
|
+
* OR IN THE AGENT'S OWN TRANSCRIPT — they are read directly off disk into
|
|
304
|
+
* this process's memory and sent straight to the dashboard, the same
|
|
305
|
+
* privacy property `50arms_api`'s credential handling has for tokens. An
|
|
306
|
+
* S3 / server fault on the route surfaces as a 5xx, which the HTTP client
|
|
307
|
+
* throws.
|
|
308
|
+
*/
|
|
309
|
+
export async function attachFile(client, args, deps = {}) {
|
|
310
|
+
if (typeof args.path !== "string" || args.path.trim() === "") {
|
|
311
|
+
throw new Error("attach_file: path must be a non-empty string");
|
|
312
|
+
}
|
|
313
|
+
const { isAbsolute, basename, extname } = pathRulesFor(deps.platform);
|
|
314
|
+
if (!isAbsolute(args.path)) {
|
|
315
|
+
throw new Error(`attach_file: path must be an absolute path (got "${args.path}")`);
|
|
316
|
+
}
|
|
317
|
+
const read = deps.readFile ?? readFile;
|
|
318
|
+
if (args.target === "inline_image") {
|
|
319
|
+
return uploadInlineImage(client, args, read, deps.fileSize ?? (async (p) => (await stat(p)).size), { basename, extname });
|
|
320
|
+
}
|
|
321
|
+
// DX-4386: the replaced row keeps its stored name, so a filename override has nothing to apply to - refuse it loudly, before any read.
|
|
322
|
+
if (args.replaces !== undefined && args.filename !== undefined) {
|
|
323
|
+
throw new Error("attach_file: filename cannot be set with replaces (the replaced attachment keeps its name)");
|
|
324
|
+
}
|
|
325
|
+
if (typeof args.card_id !== "string" || args.card_id.trim() === "") {
|
|
326
|
+
throw new Error("attach_file: card_id is required unless target is inline_image");
|
|
327
|
+
}
|
|
328
|
+
// A missing / unreadable file rejects here — no HTTP call is made, so no
|
|
329
|
+
// S3 upload, no row insert, and no projection are ever triggered.
|
|
330
|
+
const bytes = await read(args.path);
|
|
331
|
+
const contentTypeOf = (name) => (args.content_type?.trim() ? args.content_type.trim() : inferAttachmentMime(name));
|
|
332
|
+
const issuePath = `/${encodeURIComponent(args.card_id)}/attachments`;
|
|
333
|
+
// DX-4386: `primary` is the dashboard's own atomic option (one live cover, set in the write's own transaction); an absent flag sends no key.
|
|
334
|
+
const primary = args.primary === undefined ? {} : { primary: args.primary };
|
|
335
|
+
if (args.replaces !== undefined) {
|
|
336
|
+
return client.request({
|
|
337
|
+
method: "PUT",
|
|
338
|
+
basePath: "/api/issues",
|
|
339
|
+
path: `${issuePath}/${args.replaces}`,
|
|
340
|
+
body: { contentType: contentTypeOf(basename(args.path)), base64: bytes.toString("base64"), ...primary },
|
|
341
|
+
board: args.board,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
const filename = args.filename?.trim() ? args.filename.trim() : basename(args.path);
|
|
345
|
+
return client.request({
|
|
346
|
+
method: "POST",
|
|
347
|
+
basePath: "/api/issues",
|
|
348
|
+
path: issuePath,
|
|
349
|
+
body: { filename, contentType: contentTypeOf(filename), base64: bytes.toString("base64"), ...primary },
|
|
350
|
+
board: args.board,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* DX-5241 / PLAN-47 CAV-1: upload a local image to `POST /api/uploads/images` and answer its permanent URL plus the markdown to embed it
|
|
355
|
+
* (feedback bodies, any markdown field). `50arms_api`'s `body_file` sends one raw file of at most 1 MB to a route that takes a file; this adds the upload routes, more image types and the 25 MB limit. The bytes are read here, never through the agent's context.
|
|
356
|
+
* Everything that makes no sense for an inline image (a card, a replace, a cover flag, a name or type override) is refused before any read.
|
|
357
|
+
*/
|
|
358
|
+
async function uploadInlineImage(client, args, read, fileSize, { basename, extname }) {
|
|
359
|
+
for (const key of ["card_id", "filename", "content_type", "replaces", "primary", "board"]) {
|
|
360
|
+
if (args[key] !== undefined)
|
|
361
|
+
throw new Error(`attach_file: ${key} cannot be set with target inline_image`);
|
|
362
|
+
}
|
|
363
|
+
const ext = extname(args.path).toLowerCase();
|
|
364
|
+
const contentType = INLINE_IMAGE_MIME_BY_EXT[ext];
|
|
365
|
+
if (!contentType) {
|
|
366
|
+
throw new Error(`attach_file: inline_image accepts only ${Object.keys(INLINE_IMAGE_MIME_BY_EXT).join(", ")} files (got "${ext || basename(args.path)}"); no SVG`);
|
|
367
|
+
}
|
|
368
|
+
// Stat first so an oversized file is refused without being read into memory.
|
|
369
|
+
const size = await fileSize(args.path);
|
|
370
|
+
if (size === 0)
|
|
371
|
+
throw new Error(`attach_file: "${args.path}" is empty`);
|
|
372
|
+
if (size > MAX_INLINE_IMAGE_BYTES) {
|
|
373
|
+
throw new Error(`attach_file: "${args.path}" is ${size} bytes; the limit is ${MAX_INLINE_IMAGE_BYTES} (25 MB)`);
|
|
374
|
+
}
|
|
375
|
+
const bytes = await read(args.path);
|
|
376
|
+
const result = await client.request({ method: "POST", basePath: "/api/uploads", path: "/images", rawBody: { bytes, contentType } });
|
|
377
|
+
if (!result.ok)
|
|
378
|
+
return result;
|
|
379
|
+
const url = result.body?.url;
|
|
380
|
+
if (typeof url !== "string" || url === "") {
|
|
381
|
+
throw new Error("attach_file: the image upload answered no url");
|
|
382
|
+
}
|
|
383
|
+
const alt = basename(args.path, extname(args.path)).replace(/[[\]]/g, "");
|
|
384
|
+
return { ...result, body: { url, markdown: `` } };
|
|
385
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-3028 / DX-4233 - ONE heartbeat tick of a session's event stream, of the in-process relay behind
|
|
3
|
+
* `plan_events_wait`: what to send (connection state, session stats, the transcript's title), what
|
|
4
|
+
* to do with the answer, and the self-heal for a stream that reads as open while no heartbeat lands. WHEN a tick fires, and
|
|
5
|
+
* what an announced notice does, belongs to the owner.
|
|
6
|
+
*
|
|
7
|
+
* A tick never throws and never stops the stream: the stats and title are cosmetic and the heartbeat is liveness, so a
|
|
8
|
+
* failure of either costs that part (said once on stderr), never the beat.
|
|
9
|
+
*/
|
|
10
|
+
import { keyRevokedHalt } from "./key-revoked-halt.js";
|
|
11
|
+
import { oneLine } from "./one-line.js";
|
|
12
|
+
import { DIGEST_PREFIX } from "./digest-hold.js";
|
|
13
|
+
import { HEARTBEAT_SILENT_FAILURE_THRESHOLD, HEARTBEAT_SUSTAINED_OUTAGE_MS, sendHeartbeat, } from "./session-stream.js";
|
|
14
|
+
import { OperatorSessionStats } from "./session-stats.js";
|
|
15
|
+
import { TranscriptTitleReader } from "./session-title.js";
|
|
16
|
+
/** DX-5415: how long a stop waits for the final settle (drain + one stats flush) before giving up; shutdown must stay bounded. */
|
|
17
|
+
export const FINAL_SETTLE_BOUND_MS = 10_000;
|
|
18
|
+
function settleBound() {
|
|
19
|
+
return AbortSignal.timeout(FINAL_SETTLE_BOUND_MS);
|
|
20
|
+
}
|
|
21
|
+
export class HeartbeatReporter {
|
|
22
|
+
deps;
|
|
23
|
+
sessionStats;
|
|
24
|
+
titleReader;
|
|
25
|
+
transcriptPath;
|
|
26
|
+
// DX-4461 - the title the dashboard last answered with. Only the LATEST landed answer counts: one without a title (an older
|
|
27
|
+
// dashboard, including one rolled back to mid-session, or a refusal) stops the title until an answer carries one again,
|
|
28
|
+
// since an older dashboard refuses the `title` key and a refused heartbeat is a dead listener.
|
|
29
|
+
dashboardTitle;
|
|
30
|
+
lastTitleError = null;
|
|
31
|
+
// DX-3099 - consecutive heartbeats that failed to LAND on one open stream (DX-5088: per stream, reset whenever the state leaves `streaming`).
|
|
32
|
+
failureStreak = 0;
|
|
33
|
+
// DX-5088 - when the heartbeat first failed to land in the current outage (across every reconnect); null while the last one landed.
|
|
34
|
+
outageSince = null;
|
|
35
|
+
outageAnnounced = false;
|
|
36
|
+
// DX-4418 - the stop notice for a revoked key goes out once; every later beat is refused the same way.
|
|
37
|
+
revocationAnnounced = false;
|
|
38
|
+
constructor(deps, transcriptPath) {
|
|
39
|
+
this.deps = deps;
|
|
40
|
+
this.transcriptPath = transcriptPath;
|
|
41
|
+
this.sessionStats = new OperatorSessionStats(transcriptPath, deps.makeStatsWatcher);
|
|
42
|
+
this.titleReader = transcriptPath === null ? null : new TranscriptTitleReader(transcriptPath);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The relay learns the session's transcript from each `plan_events_wait` call, after the reporter exists: a different path
|
|
46
|
+
* (a resumed session) starts a fresh collector and title reader; the same one, or none, changes nothing.
|
|
47
|
+
*/
|
|
48
|
+
useTranscript(path) {
|
|
49
|
+
if (path === undefined || path === this.transcriptPath)
|
|
50
|
+
return null;
|
|
51
|
+
const previous = this.sessionStats;
|
|
52
|
+
this.transcriptPath = path;
|
|
53
|
+
this.sessionStats = new OperatorSessionStats(path, this.deps.makeStatsWatcher);
|
|
54
|
+
this.titleReader = new TranscriptTitleReader(path);
|
|
55
|
+
this.dashboardTitle = undefined;
|
|
56
|
+
this.lastTitleError = null;
|
|
57
|
+
// DX-5415: the old transcript's held finals are stored before this returns; the swap above is synchronous so a second call sees the new path.
|
|
58
|
+
return previous.stop(this.send(undefined), settleBound());
|
|
59
|
+
}
|
|
60
|
+
/** A fresh connect cycle earns a clean slate for the silent-failure count. */
|
|
61
|
+
resetFailureStreak() {
|
|
62
|
+
this.failureStreak = 0;
|
|
63
|
+
}
|
|
64
|
+
/** DX-5415: settle the collector's held sub-agent finals (bounded), then stop tailing the transcript. Idempotent. */
|
|
65
|
+
async stop() {
|
|
66
|
+
await this.sessionStats.stop(this.send(undefined), settleBound());
|
|
67
|
+
}
|
|
68
|
+
/** One heartbeat as the reporter sends it: the carried answer's title and revocation are applied here. */
|
|
69
|
+
send(title) {
|
|
70
|
+
return async (stats, statsHeld) => {
|
|
71
|
+
const answer = await sendHeartbeat(this.deps.auth(), this.deps.http, this.deps.connection(), stats, title, statsHeld);
|
|
72
|
+
if (answer.landed)
|
|
73
|
+
this.dashboardTitle = answer.title ?? undefined;
|
|
74
|
+
// DX-4418 - a person revoked this session's key: tell the session to stop, at once and only once. Its next
|
|
75
|
+
// dashboard call says the same.
|
|
76
|
+
if (answer.revocation !== null && !this.revocationAnnounced) {
|
|
77
|
+
this.revocationAnnounced = true;
|
|
78
|
+
this.deps.announce(keyRevokedHalt(answer.revocation, false), "key_revoked");
|
|
79
|
+
}
|
|
80
|
+
return answer;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
titleToReport() {
|
|
84
|
+
if (this.titleReader === null || this.dashboardTitle === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
// The title is cosmetic and the heartbeat is liveness: a transcript the title cannot be read from costs the title (said
|
|
87
|
+
// once per distinct failure), never the beat.
|
|
88
|
+
try {
|
|
89
|
+
const title = this.titleReader.read();
|
|
90
|
+
this.lastTitleError = null;
|
|
91
|
+
return title !== null && title !== this.dashboardTitle ? title : undefined;
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
95
|
+
if (message !== this.lastTitleError)
|
|
96
|
+
process.stderr.write(`session title: ${message}\n`);
|
|
97
|
+
this.lastTitleError = message;
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** One heartbeat. Fired without being awaited: its own failures are reported here, never thrown. */
|
|
102
|
+
tick() {
|
|
103
|
+
const title = this.titleToReport();
|
|
104
|
+
void this.sessionStats
|
|
105
|
+
.tick(this.send(title))
|
|
106
|
+
.then((landed) => {
|
|
107
|
+
const now = this.deps.now();
|
|
108
|
+
if (landed) {
|
|
109
|
+
this.failureStreak = 0;
|
|
110
|
+
this.outageSince = null;
|
|
111
|
+
this.outageAnnounced = false;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
this.outageSince ??= now;
|
|
115
|
+
// DX-5088 - the model hears of an outage only once it is sustained, in any connection state (a dashboard that stays down keeps
|
|
116
|
+
// the stream reconnecting, and that is exactly the outage worth saying); a blip such as a ~35 s deploy restart is stderr only.
|
|
117
|
+
const downMs = now - this.outageSince;
|
|
118
|
+
if (downMs >= HEARTBEAT_SUSTAINED_OUTAGE_MS && !this.outageAnnounced) {
|
|
119
|
+
this.outageAnnounced = true;
|
|
120
|
+
const minutes = Math.round(downMs / 60_000);
|
|
121
|
+
const connection = this.deps.connection();
|
|
122
|
+
// DX-5088: the wording follows the state. A stopped relay (a terminal outcome) does not reconnect by itself, so "keeps reconnecting" would be false.
|
|
123
|
+
const wording = connection.state === "stopped"
|
|
124
|
+
? `this session's listener has stopped${connection.reason === undefined ? "" : ` (${connection.reason})`} and does not reconnect by itself, ` +
|
|
125
|
+
`so the dashboard has heard nothing from it for ${minutes} minutes. The next plan_events_wait call says why it stopped and what to do; ` +
|
|
126
|
+
`a person can also restart the session.`
|
|
127
|
+
: `this session's listener has not reached the dashboard for ${minutes} minutes (its heartbeats keep failing). ` +
|
|
128
|
+
`It keeps reconnecting on its own, and nothing is needed from you.`;
|
|
129
|
+
this.deps.announce(oneLine(`${DIGEST_PREFIX} ${wording}`), "relay_notice");
|
|
130
|
+
}
|
|
131
|
+
// DX-3099 - the self-heal counts only while a stream is open: that is the desync the card found (the client's stream reads
|
|
132
|
+
// open; the dashboard has nothing recent for it). DX-5088: while it is reconnecting the reconnect already owns recovery, and
|
|
133
|
+
// aborting it would restart the backoff, so a failure then neither counts nor forces anything.
|
|
134
|
+
if (!this.deps.streaming()) {
|
|
135
|
+
this.failureStreak = 0;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
this.failureStreak += 1;
|
|
139
|
+
if (this.failureStreak < HEARTBEAT_SILENT_FAILURE_THRESHOLD)
|
|
140
|
+
return;
|
|
141
|
+
this.failureStreak = 0;
|
|
142
|
+
process.stderr.write(`plan_events_wait: ${HEARTBEAT_SILENT_FAILURE_THRESHOLD} heartbeats in a row did not land on a stream that reads open; forcing a fresh connection\n`);
|
|
143
|
+
// End the CURRENT connection now, rather than wait on the stream reader's own read-idle timeout (which this exact
|
|
144
|
+
// failure mode can outlive indefinitely: the connection reads as established and keeps producing keep-alive bytes,
|
|
145
|
+
// only the outbound heartbeat silently never lands). The new attempt reconnects with the SAME ticket; the
|
|
146
|
+
// dashboard's one-open-stream-per-ticket rule then ends the old, superseded entry, releasing the exclusive claim
|
|
147
|
+
// as a side effect of THIS reconnect, never an external watchdog. The new stream's `streaming` sends a heartbeat at once.
|
|
148
|
+
this.deps.forceReconnect();
|
|
149
|
+
})
|
|
150
|
+
.catch((err) => {
|
|
151
|
+
// DX-3962: a stats failure must never take the stream down. `tick` is fired without being awaited, so a throw here
|
|
152
|
+
// would be an unhandled rejection that kills the process and with it the session's plan events.
|
|
153
|
+
process.stderr.write(`operator session stats: heartbeat tick failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|