@orboto/mcp 0.222.0 → 0.222.2

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.
@@ -16,6 +16,10 @@ const MIN_BACKOFF_MS = 2_000;
16
16
  const MAX_BACKOFF_MS = 60_000;
17
17
  const HEARTBEAT_GRACE_MS = 90_000;
18
18
  const ASK = /\?|\bplease\b|\bbitte\b|\bneed\b|\bshare\b/i;
19
+ /** ORB-2563 - what the channel dedupes on: the message and its reminder count. */
20
+ export function wakeKey(m) {
21
+ return m.reminderCount ? `${m.id}#${m.reminderCount}` : m.id;
22
+ }
19
23
  /** ORB-2467 - `attachments: 1 archive, 12.4 MB (orboto messages --download <id>)`, or nothing. */
20
24
  export function attachmentLine(m) {
21
25
  return m.attachments?.count ? `attachments: ${m.attachments.label} (orboto messages --download ${m.id})` : '';
@@ -79,6 +83,8 @@ export function isImmediate(m, now, digestMinutes) {
79
83
  return true;
80
84
  if (m.kind === 'request' || m.kind === 'error')
81
85
  return true;
86
+ if (m.priority === 'urgent' || (m.reminderCount ?? 0) > 0)
87
+ return true;
82
88
  if (isForThisSession(m))
83
89
  return true;
84
90
  if (waitedMinutes(m.createdAt, now) * 60_000 >= IMMEDIATE_AFTER_MS)
@@ -90,8 +96,9 @@ export function renderEvent(m, now) {
90
96
  const to = m.to?.label ?? 'you';
91
97
  const text = firstLine(m.payload);
92
98
  const body = text.length > CONTENT_CAP ? `${text.slice(0, CONTENT_CAP)} [... cut, read the rest with orboto_messages]` : text;
99
+ const marks = [m.priority === 'urgent' ? '[urgent]' : '', m.reminderCount ? `[reminder ${m.reminderCount}]` : ''].filter(Boolean).join(' ');
93
100
  const lines = [
94
- `[${m.kind}] ${m.subject}`,
101
+ `${marks ? `${marks} ` : ''}[${m.kind}] ${m.subject}`,
95
102
  `from ${from} to ${to}${m.projectKey ? ` | project ${m.projectKey}` : ''} | id ${m.id}${m.threadId ? ` | thread ${m.threadId}` : ''}`,
96
103
  ];
97
104
  if (body)
@@ -106,6 +113,8 @@ export function renderEvent(m, now) {
106
113
  id: m.id, kind: m.kind, project: m.projectKey ?? '',
107
114
  from: from, from_session: (m.from?.sessionId ?? '').slice(0, 8), to_session: (m.to?.sessionId ?? '').slice(0, 8),
108
115
  waited_min: String(waited),
116
+ ...(m.priority === 'urgent' ? { priority: 'urgent' } : {}),
117
+ ...(m.reminderCount ? { reminder: String(m.reminderCount) } : {}),
109
118
  },
110
119
  };
111
120
  }
@@ -175,11 +184,11 @@ export class InboxChannel {
175
184
  async deliver(m) {
176
185
  if (!m?.id)
177
186
  return;
178
- if (this.seen.has(m.id)) {
187
+ if (this.seen.has(wakeKey(m))) {
179
188
  this.stats.duplicates += 1;
180
189
  return;
181
190
  }
182
- this.remember(m.id);
191
+ this.remember(wakeKey(m));
183
192
  this.lastId = m.id;
184
193
  if (isImmediate(m, this.now(), this.digestMinutes)) {
185
194
  await this.emit(renderEvent(m, this.now()));
@@ -66,6 +66,24 @@ describe('renderEvent / isImmediate', () => {
66
66
  });
67
67
  });
68
68
  describe('InboxChannel', () => {
69
+ it('ORB-2563 - a reminder of a seen message wakes again with its mark, a replay of the same reminder does not, urgent info skips the digest', async () => {
70
+ const { mcp, notification } = mockMcp();
71
+ const channel = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'mcp-proc', mcp, log: () => { }, digestMinutes: 15 });
72
+ const noSession = { email: 'claude@orboto.io', sessionId: null, label: 'claude@orboto.io' };
73
+ const base = { kind: 'info', payload: { message: 'run done' }, createdAt: '2026-09-18 09:59:50+00', to: noSession };
74
+ await channel.deliver(msg({ ...base, id: 'urgent', priority: 'urgent' }));
75
+ await channel.deliver(msg({ ...base, id: 'plain' }));
76
+ await channel.deliver(msg({ ...base, id: 'plain', reminderCount: 1, repeatCount: 3 }));
77
+ await channel.deliver(msg({ ...base, id: 'plain', reminderCount: 1, repeatCount: 3 }));
78
+ expect(notification).toHaveBeenCalledTimes(2);
79
+ const events = notification.mock.calls.map((c) => c[0].params);
80
+ expect(events[0].meta).toMatchObject({ id: 'urgent', priority: 'urgent' });
81
+ expect(events[0].content.startsWith('[urgent] [info]')).toBe(true);
82
+ expect(events[1].meta).toMatchObject({ id: 'plain', reminder: '1' });
83
+ expect(events[1].content.startsWith('[reminder 1] [info]')).toBe(true);
84
+ expect(channel.stats).toMatchObject({ delivered: 2, digested: 1, duplicates: 1 });
85
+ channel.close();
86
+ });
69
87
  it('ORB-2265 - a connected channel wakes at once for info addressed to its session and for a reply flagged as in its sent thread, and digests the account info', async () => {
70
88
  const { mcp, notification } = mockMcp();
71
89
  const mine = 'bbbbbbbb-0000-4000-8000-000000000000';
@@ -59,6 +59,9 @@ afterEach(() => { vi.restoreAllMocks(); });
59
59
  * ORB-2544 added no named tool: ending a session rides the escape hatch;
60
60
  * orboto_who `state` and the agent_presence redaction line fit after the
61
61
  * agent_presence summary shrank (full = 171,160).
62
+ * ORB-2563 gave `orboto_agent_notify` `remind` and `urgent` inside the
63
+ * ceiling: the docs `content` parameter description shrank to pay for it
64
+ * (full = 171,194).
62
65
  *
63
66
  * @see ORB-1805, ORB-1669, ORB-1910
64
67
  */
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { z } from 'zod';
5
5
  import { OrbotoApiError } from '../orboto-client.js';
6
+ import { rethrowFullExportInProgress, runningFullExportId } from './backup.js';
6
7
  function rewrite403(action) {
7
8
  return (err) => {
8
9
  if (err instanceof OrbotoApiError && err.status === 403) {
@@ -134,7 +135,7 @@ export function makeTriggerBackupHandler(client) {
134
135
  throw new Error(`Backup job "${jobName}" is currently disabled - enable it in /admin/backup first.`);
135
136
  }
136
137
  const run = await client.post(`/admin/backup/jobs/${job.id}/run`, {})
137
- .catch(rewrite403('trigger_backup'));
138
+ .catch((err) => (runningFullExportId(err) ? rethrowFullExportInProgress(err) : rewrite403('trigger_backup')(err)));
138
139
  return {
139
140
  content: [{
140
141
  type: 'text',
@@ -116,4 +116,17 @@ describe('orboto_trigger_backup', () => {
116
116
  ]);
117
117
  await expect(makeTriggerBackupHandler(client)({ jobName: 'nightly' })).rejects.toThrow(/super-admin/);
118
118
  });
119
+ it('names the running run when a full export is already in progress (ORB-2562)', async () => {
120
+ const refusal = { error: 'busy', errorKey: 'errors.backups.full_export_in_progress', errorParams: { runId: '6f0c1a52-3d4e-4b8a-9c71-2e5f8a9b0c1d' } };
121
+ const responses = [
122
+ { ok: true, status: 200, body: [{ id: 'j1', name: 'nightly', scope: 'full', schedule: null, isActive: true }] },
123
+ { ok: false, status: 409, body: refusal },
124
+ ];
125
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
126
+ const r = responses.shift();
127
+ return { ok: r.ok, status: r.status, statusText: 'x', json: async () => r.body, text: async () => JSON.stringify(r.body) };
128
+ });
129
+ await expect(makeTriggerBackupHandler(client)({ jobName: 'nightly' }))
130
+ .rejects.toThrow('A full export is already running (run 6f0c1a52-3d4e-4b8a-9c71-2e5f8a9b0c1d); nothing was started.');
131
+ });
119
132
  });
@@ -92,6 +92,14 @@ export function makeAgentPresenceHandler(client) {
92
92
  };
93
93
  };
94
94
  }
95
+ /** ORB-2563 - mirror of AGENT_MESSAGE_PRIORITIES and the sender lines in @orboto/shared-schema agent-message-dedupe.ts (drift test in agent-coordination.test.ts). */
96
+ export const NOTIFY_PRIORITIES = ['normal', 'urgent'];
97
+ export function repeatedSendNotice(repeatCount) {
98
+ return `already delivered (sent ${repeatCount} times), not sent again`;
99
+ }
100
+ export function reminderSentNotice(reminderCount, repeatCount) {
101
+ return `reminder ${reminderCount} sent (sent ${repeatCount} times)`;
102
+ }
95
103
  /** ORB-2263 - mirror of AgentNotifyRoutedToSchema in @orboto/shared-schema (drift test in agent-coordination.test.ts). */
96
104
  export const NOTIFY_ROUTED_TO = ['session', 'scope', 'account'];
97
105
  /** ORB-2264 - the 409 `no_recipient` as a tool error naming who could receive it instead. */
@@ -130,9 +138,24 @@ export function ownerMismatchText(err) {
130
138
  const text = p.ownerHint?.text ?? `"${p.topic ?? '?'}" belongs to ${p.owner ?? '?'}, not ${p.target ?? '?'}`;
131
139
  return `not sent: ${text}. Send again with route: true to deliver it to ${p.owner ?? 'the owner'}, or forceTarget: true to keep the target.`;
132
140
  }
141
+ /** ORB-2563 - the 429 `reminder_too_soon` as a tool error naming when the next reminder is allowed. */
142
+ export function reminderTooSoonText(err) {
143
+ if (err.status !== 429)
144
+ return null;
145
+ let body;
146
+ try {
147
+ body = JSON.parse(err.body);
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ if (body.errorKey !== 'errors.agents.reminder_too_soon')
153
+ return null;
154
+ return `not sent: message ${body.errorParams?.messageId ?? '?'} was sent or reminded too recently; the next reminder is allowed in ${body.errorParams?.retryAfter ?? '?'} seconds.`;
155
+ }
133
156
  export const agentNotifyToolConfig = {
134
157
  title: 'Notify an agent or user',
135
- description: 'Message an agent or user. `to` takes a role address integrator@KEY (every live session with that role on the project, any account), an account email or a session id; targetEmail names the account (with a role address it narrows to that account). kind: request|error = work inside the scope the recipient holds (request needs a target + outcome); info = information; complete = result (needs outcome). payload: free-form JSON; threadId links a reply to the message it answers. toSessionRef (instance short id, session ref or id) reaches ONE session of the account; project scopes an account message to sessions declared on it. A request no live session can receive is refused (no_recipient, naming the live sessions of the project; see orboto_who) unless queue: true holds it until a session declares the project. The answer names routedTo; routingNote "no session declared KEY" means nothing wakes - escalate. A request on a topic another role owns (orboto_who for) is refused; route: true sends it to the owner, forceTarget: true keeps the target. project is a key or UUID; senderRef defaults to this MCP session.',
158
+ description: 'Message an agent or user. `to` takes a role address integrator@KEY (every live session with that role on the project, any account), an account email or a session id; targetEmail names the account (with a role address it narrows to that account). kind: request|error = work inside the scope the recipient holds (request needs a target + outcome); info = information; complete = result (needs outcome). payload: free-form JSON; threadId links a reply to the message it answers. toSessionRef (instance short id, session ref or id) reaches ONE session of the account; project scopes an account message to sessions declared on it. A request no live session can receive is refused (no_recipient, naming the live sessions of the project; see orboto_who) unless queue: true holds it until a session declares the project. The answer names routedTo; routingNote "no session declared KEY" means nothing wakes - escalate. A request on a topic another role owns (orboto_who for) is refused; route: true sends it to the owner, forceTarget: true keeps the target. project is a key or UUID; senderRef defaults to this MCP session. An identical send (same recipient, project, kind, subject, payload; no threadId, no attachments) within the dedupe window (default 24 h) is not delivered again: the text says so and names the existing message. remind: true is a deliberate reminder of it (notifies again, at most one per 15 min, faster is refused with retryAfter); urgent: true is never suppressed and counts as a reminder. Loops and retries never set remind or urgent.',
136
159
  inputSchema: z.object({
137
160
  targetEmail: z.string().email().optional(),
138
161
  to: z.string().min(3).max(200).optional(),
@@ -147,6 +170,8 @@ export const agentNotifyToolConfig = {
147
170
  toSessionRef: z.string().min(1).max(200).optional(),
148
171
  route: z.boolean().optional(),
149
172
  forceTarget: z.boolean().optional(),
173
+ remind: z.boolean().optional(),
174
+ urgent: z.boolean().optional(),
150
175
  }).shape,
151
176
  outputSchema: z.object({
152
177
  ok: z.literal(true),
@@ -205,10 +230,11 @@ export function makeAgentNotifyHandler(client) {
205
230
  const senderRef = mcpInstanceToken(args.senderRef, extra);
206
231
  let res;
207
232
  try {
208
- res = await client.post('/v1/agent/notify', { ...args, senderRef, ownerCheck: 'refuse' }, { instanceToken: mcpInstanceToken(undefined, extra) });
233
+ const { urgent, ...rest } = args;
234
+ res = await client.post('/v1/agent/notify', { ...rest, ...(urgent ? { priority: NOTIFY_PRIORITIES[1] } : {}), senderRef, ownerCheck: 'refuse' }, { instanceToken: mcpInstanceToken(undefined, extra) });
209
235
  }
210
236
  catch (err) {
211
- const refused = err instanceof OrbotoApiError ? noRecipientText(err) ?? ownerMismatchText(err) : null;
237
+ const refused = err instanceof OrbotoApiError ? noRecipientText(err) ?? ownerMismatchText(err) ?? reminderTooSoonText(err) : null;
212
238
  if (refused)
213
239
  return { isError: true, content: [{ type: 'text', text: refused }] };
214
240
  throw err;
@@ -218,6 +244,16 @@ export function makeAgentNotifyHandler(client) {
218
244
  const target = recipients.length > 1 ? recipients.join(', ') : res.toSessionId ? `${who} session ${res.toSessionId.slice(0, 8)}` : who;
219
245
  const routedTo = res.routedTo ?? (res.toSessionId ? 'session' : 'account');
220
246
  const note = res.routingNote ? ` - ${res.routingNote}` : '';
247
+ if (res.deduplicated) {
248
+ const repeats = res.repeatCount ?? 2;
249
+ const line = res.reminded ? reminderSentNotice(res.reminderCount ?? 1, repeats) : repeatedSendNotice(repeats);
250
+ return {
251
+ content: [{ type: 'text', text: `${line}: ${target} (message ${res.messageId})` }],
252
+ structuredContent: {
253
+ ok: true, messageId: res.messageId, toSessionId: res.toSessionId ?? null, routedTo,
254
+ },
255
+ };
256
+ }
221
257
  return {
222
258
  content: [{ type: 'text', text: `${res.queued ? 'queued for' : 'notified'} ${target} (message ${res.messageId}, routed to ${routedTo}${note})${res.ownerHint?.routed ? `\nrouted to the owner of "${res.ownerHint.topic}" instead of ${res.ownerHint.target}` : ''}${res.hint ? `\nhint: ${res.hint}` : ''}` }],
223
259
  structuredContent: {
@@ -8,8 +8,8 @@
8
8
  */
9
9
  import { afterEach, describe, expect, it, vi } from 'vitest';
10
10
  import { readFileSync } from 'node:fs';
11
- import { OrbotoClient } from '../orboto-client.js';
12
- import { NOTIFY_ROUTED_TO, agentNotifyToolConfig, makeAgentHeartbeatHandler, makeAgentPresenceHandler, makeAgentNotifyHandler, } from './agent-coordination.js';
11
+ import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
12
+ import { NOTIFY_PRIORITIES, NOTIFY_ROUTED_TO, agentNotifyToolConfig, reminderSentNotice, reminderTooSoonText, repeatedSendNotice, makeAgentHeartbeatHandler, makeAgentPresenceHandler, makeAgentNotifyHandler, } from './agent-coordination.js';
13
13
  import { makeAgentMessagesHandler } from './agent-messages.js';
14
14
  afterEach(() => { vi.restoreAllMocks(); });
15
15
  function stub(responses) {
@@ -348,3 +348,41 @@ describe('orboto_agent_notify role addresses (ORB-2264)', () => {
348
348
  expect(agentNotifyToolConfig.description).toContain('integrator@KEY');
349
349
  });
350
350
  });
351
+ describe('orboto_agent_notify dedupe and reminders (ORB-2563)', () => {
352
+ afterEach(() => { vi.restoreAllMocks(); });
353
+ it('mirrors the priorities and sender lines of the shared schema', () => {
354
+ const shared = readFileSync(new URL('../../../../packages/shared-schema/src/agent-message-dedupe.ts', import.meta.url), 'utf8');
355
+ const match = shared.match(/AGENT_MESSAGE_PRIORITIES = \[([^\]]+)\]/);
356
+ expect(match[1].split(',').map((v) => v.trim().replace(/'/g, ''))).toEqual([...NOTIFY_PRIORITIES]);
357
+ expect(shared).toContain('`already delivered (sent ${repeatCount} times), not sent again`');
358
+ expect(shared).toContain('`reminder ${reminderCount} sent (sent ${repeatCount} times)`');
359
+ expect(repeatedSendNotice(3)).toBe('already delivered (sent 3 times), not sent again');
360
+ expect(reminderSentNotice(1, 4)).toBe('reminder 1 sent (sent 4 times)');
361
+ expect(agentNotifyToolConfig.description).toContain('remind: true');
362
+ expect(agentNotifyToolConfig.description).toContain('Loops and retries never set remind or urgent');
363
+ });
364
+ it('says a deduplicated send was not sent again, and a reminder that it notified again', async () => {
365
+ const answers = [
366
+ { ok: true, messageId: '00000000-0000-4000-8000-000000000001', toSessionId: null, routedTo: 'account', deduplicated: true, repeatCount: 8 },
367
+ { ok: true, messageId: '00000000-0000-4000-8000-000000000001', toSessionId: null, routedTo: 'account', deduplicated: true, repeatCount: 9, reminded: true, reminderCount: 1 },
368
+ ];
369
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
370
+ const body = answers.shift();
371
+ return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
372
+ });
373
+ const handler = makeAgentNotifyHandler(client);
374
+ const silent = await handler({ targetEmail: 'bob@example.com', subject: 'hi' });
375
+ expect(silent.content[0].text).toContain('already delivered (sent 8 times), not sent again');
376
+ expect(silent.structuredContent).toMatchObject({ messageId: '00000000-0000-4000-8000-000000000001' });
377
+ const reminder = await handler({ targetEmail: 'bob@example.com', subject: 'hi', remind: true, urgent: true });
378
+ expect(reminder.content[0].text).toContain('reminder 1 sent (sent 9 times)');
379
+ const sent = JSON.parse(String(fetch.mock.calls[1][1].body));
380
+ expect(sent).toMatchObject({ remind: true, priority: 'urgent' });
381
+ expect(sent).not.toHaveProperty('urgent');
382
+ });
383
+ it('turns the 429 reminder_too_soon into a tool error with the wait', () => {
384
+ const err = new OrbotoApiError(429, JSON.stringify({ errorKey: 'errors.agents.reminder_too_soon', errorParams: { messageId: 'm-1', retryAfter: 300 } }), 'http://x');
385
+ expect(reminderTooSoonText(err)).toContain('allowed in 300 seconds');
386
+ expect(reminderTooSoonText(new OrbotoApiError(409, '{}', 'http://x'))).toBeNull();
387
+ });
388
+ });
@@ -11,6 +11,15 @@ import { z } from 'zod';
11
11
  import { makeAgentMessageWorkHandler } from './agent-message-work.js';
12
12
  import { mcpInstanceToken } from './shared.js';
13
13
  const PartySchema = z.object({ userId: z.string(), email: z.string(), sessionId: z.string().nullable(), role: z.string().nullable(), label: z.string() });
14
+ /** ORB-2563 - `[urgent]`, `sent N times` and `reminder N` next to a message, the same words the CLI prints. */
15
+ export function repeatMarks(m) {
16
+ const marks = [
17
+ m.priority === 'urgent' ? 'urgent' : '',
18
+ m.repeatCount && m.repeatCount > 1 ? `sent ${m.repeatCount} times` : '',
19
+ m.reminderCount ? `reminder ${m.reminderCount}` : '',
20
+ ].filter(Boolean);
21
+ return marks.length ? ` [${marks.join(', ')}]` : '';
22
+ }
14
23
  const UNREGISTERED_INBOX_HINT = 'Not registered: only account mail is listed. Register with orboto_session_start to see the mail addressed to your session.';
15
24
  export const agentMessagesToolConfig = {
16
25
  title: 'Agent inbox and message work',
@@ -116,7 +125,7 @@ export function makeAgentMessagesHandler(client) {
116
125
  else
117
126
  q.set('excludeRef', instanceToken);
118
127
  const { messages, sessionId, counts } = await client.get(`/v1/agent/messages?${q.toString()}`, { instanceToken });
119
- const header = (m) => `${m.own ? `[sent] ${sentState(m)} ` : ''}[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''} ${m.subject} (from ${m.from?.label ?? m.fromUserId} to ${m.to?.label ?? m.toUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})`;
128
+ const header = (m) => `${m.own ? `[sent] ${sentState(m)} ` : ''}[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''}${repeatMarks(m)} ${m.subject} (from ${m.from?.label ?? m.fromUserId} to ${m.to?.label ?? m.toUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})`;
120
129
  const lines = messages.length === 0
121
130
  ? [acked > 0 ? `Acknowledged ${acked} message(s). Inbox empty.` : 'Inbox empty.']
122
131
  : messages.flatMap((m) => (compact ? compactLines(m, header(m)) : [`${header(m)}${m.payload ? ` payload: ${JSON.stringify(m.payload)}` : ''}`]));
@@ -8,6 +8,7 @@
8
8
  * download mechanism. `OrbotoClient.postBinary` / `getBinary` fetch the bytes.
9
9
  */
10
10
  import { z } from 'zod';
11
+ import { OrbotoApiError } from '../orboto-client.js';
11
12
  /**
12
13
  * ORB-1504 - an operator passphrase is deliberately NOT an MCP input: it
13
14
  * would land in the conversation transcript and the tool-call audit. The
@@ -16,15 +17,38 @@ import { z } from 'zod';
16
17
  */
17
18
  const seal = (run) => run.sealed ? ` [encrypted, ${run.sealCustody ?? 'platform'} key${run.sealKeyFingerprint ? ` ${run.sealKeyFingerprint}` : ''}]` : '';
18
19
  const mb = (n) => `${Math.round((n / 1024 / 1024) * 10) / 10} MB`;
20
+ /** ORB-2562 - the running run's id when the API refused a full export start with 409 errors.backups.full_export_in_progress. */
21
+ export function runningFullExportId(err) {
22
+ if (!(err instanceof OrbotoApiError) || err.status !== 409)
23
+ return null;
24
+ try {
25
+ const body = JSON.parse(err.body);
26
+ if (body.errorKey !== 'errors.backups.full_export_in_progress')
27
+ return null;
28
+ return typeof body.errorParams?.runId === 'string' ? body.errorParams.runId : null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /** Rethrows a full-export refusal as a sentence that names the running run. */
35
+ export function rethrowFullExportInProgress(err) {
36
+ const runId = runningFullExportId(err);
37
+ if (runId) {
38
+ throw new Error(`A full export is already running (run ${runId}); nothing was started. Download that run with orboto_download_backup once orboto_list_backups shows it as success.`);
39
+ }
40
+ throw err;
41
+ }
19
42
  export const createFullBackupToolConfig = {
20
43
  title: 'Create + download a full workspace backup',
21
- description: 'Create an on-demand FULL workspace backup (database + storage) and return it as a base64 resource attachment - one call; a background run this tool polls, minutes on a large workspace. Rows and file references share one snapshot; a missing file fails the run, never a partial archive. With backup encryption on the attachment is the sealed archive, not a ZIP. Requires admin:backup:export; scheduled jobs use orboto_trigger_backup.',
44
+ description: 'Create an on-demand FULL workspace backup (database + storage) and return it as a base64 resource attachment - one call; a background run this tool polls, minutes on a large workspace. Rows and file references share one snapshot; a missing file fails the run, never a partial archive. With backup encryption on the attachment is the sealed archive, not a ZIP. While another full export runs it starts nothing and names that run. Requires admin:backup:export; scheduled jobs use orboto_trigger_backup.',
22
45
  inputSchema: z.object({}).shape,
23
46
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
24
47
  };
25
48
  export function makeCreateFullBackupHandler(client) {
26
49
  return async () => {
27
- const { runId, sealed } = await client.post('/admin/backup/full', {});
50
+ const { runId, sealed } = await client.post('/admin/backup/full', {})
51
+ .catch(rethrowFullExportInProgress);
28
52
  const t0 = Date.now();
29
53
  for (;;) {
30
54
  await new Promise((r) => setTimeout(r, 3000));
@@ -67,6 +67,16 @@ describe('orboto_create_full_backup (ORB-1301 / async since ORB-1717)', () => {
67
67
  vi.useRealTimers();
68
68
  }
69
69
  });
70
+ it('names the running run instead of starting a second full export (ORB-2562)', async () => {
71
+ const refusal = { error: 'busy', errorKey: 'errors.backups.full_export_in_progress', errorParams: { runId: '6f0c1a52-3d4e-4b8a-9c71-2e5f8a9b0c1d' } };
72
+ const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
73
+ ok: false, status: 409, statusText: 'Conflict', headers: new Headers({ 'content-type': 'application/json' }),
74
+ json: async () => refusal, text: async () => JSON.stringify(refusal),
75
+ });
76
+ await expect(makeCreateFullBackupHandler(client)())
77
+ .rejects.toThrow('A full export is already running (run 6f0c1a52-3d4e-4b8a-9c71-2e5f8a9b0c1d); nothing was started.');
78
+ expect(fetch).toHaveBeenCalledTimes(1);
79
+ });
70
80
  it.each(['disk full', 'Backup source object is missing, unreadable or incomplete'])('a failed run surfaces %s instead of a partial ZIP', async (message) => {
71
81
  vi.useFakeTimers();
72
82
  const download = vi.spyOn(client, 'getBinary');
@@ -276,7 +276,7 @@ export const createDocToolConfig = {
276
276
  inputSchema: z.object({
277
277
  spaceId: z.string().min(1).describe('Target doc space - key (e.g. ORB-S1), name, or UUID. Discover via orboto_list_doc_spaces.'),
278
278
  title: z.string().min(1).max(255),
279
- content: z.string().optional().describe('Markdown body (CommonMark + GFM). Pass an empty string or omit for a blank page. Images are inline elements; status pills use `:badge[LABEL]{tone=success}` (neutral/info/success/warning/danger/accent, unknown reads as neutral, label <= 24 chars).'),
279
+ content: z.string().optional().describe('Markdown body (CommonMark + GFM). Empty or omitted: a blank page. Images are inline elements; status pills use `:badge[LABEL]{tone=success}` (neutral/info/success/warning/danger/accent, unknown = neutral, label <= 24 chars).'),
280
280
  parentDocId: z.string().uuid().nullable().optional().describe('Nest under another doc.'),
281
281
  visibility: z.enum(['public', 'workspace', 'members', 'specific']).optional().describe('Defaults to "workspace".'),
282
282
  icon: z.string().nullish().describe('Single emoji shown in the tree.'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.222.0",
3
+ "version": "0.222.2",
4
4
  "description": "Model Context Protocol server for orboto - exposes tickets, projects, milestones, time, docs, primer-facts, alerts and absences as MCP tools for Claude Desktop / Cursor / Copilot Chat / any MCP-aware AI client.",
5
5
  "license": "MIT",
6
6
  "type": "module",