borgmcp 5.5.0 → 5.7.0
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 +7 -4
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +12 -0
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +16 -6
- package/dist/cli-help.js.map +1 -1
- package/dist/docs-sections.js +1 -1
- package/dist/docs-sections.js.map +1 -1
- package/dist/hermes-plugin-install.d.ts +19 -0
- package/dist/hermes-plugin-install.d.ts.map +1 -0
- package/dist/hermes-plugin-install.js +131 -0
- package/dist/hermes-plugin-install.js.map +1 -0
- package/dist/local-server-cursor.d.ts +9 -0
- package/dist/local-server-cursor.d.ts.map +1 -1
- package/dist/local-server-cursor.js +50 -1
- package/dist/local-server-cursor.js.map +1 -1
- package/dist/log-stream.d.ts +13 -0
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +45 -13
- package/dist/log-stream.js.map +1 -1
- package/dist/remote-client.d.ts +3 -0
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +7 -1
- package/dist/remote-client.js.map +1 -1
- package/dist/representative-cmd.d.ts +11 -0
- package/dist/representative-cmd.d.ts.map +1 -1
- package/dist/representative-cmd.js +48 -15
- package/dist/representative-cmd.js.map +1 -1
- package/dist/representative-core.d.ts +53 -7
- package/dist/representative-core.d.ts.map +1 -1
- package/dist/representative-core.js +229 -43
- package/dist/representative-core.js.map +1 -1
- package/dist/representative-delivery-store.d.ts +79 -0
- package/dist/representative-delivery-store.d.ts.map +1 -0
- package/dist/representative-delivery-store.js +233 -0
- package/dist/representative-delivery-store.js.map +1 -0
- package/dist/representative-listener-store.d.ts +46 -0
- package/dist/representative-listener-store.d.ts.map +1 -0
- package/dist/representative-listener-store.js +119 -0
- package/dist/representative-listener-store.js.map +1 -0
- package/dist/representative-listener.d.ts +32 -0
- package/dist/representative-listener.d.ts.map +1 -0
- package/dist/representative-listener.js +293 -0
- package/dist/representative-listener.js.map +1 -0
- package/dist/representative-mcp.d.ts +10 -3
- package/dist/representative-mcp.d.ts.map +1 -1
- package/dist/representative-mcp.js +48 -19
- package/dist/representative-mcp.js.map +1 -1
- package/dist/representative-store.d.ts +7 -0
- package/dist/representative-store.d.ts.map +1 -1
- package/dist/representative-store.js +17 -2
- package/dist/representative-store.js.map +1 -1
- package/dist/seat-probe.d.ts +1 -0
- package/dist/seat-probe.d.ts.map +1 -1
- package/dist/seat-probe.js +1 -1
- package/dist/seat-probe.js.map +1 -1
- package/dist/server-trust.d.ts +10 -0
- package/dist/server-trust.d.ts.map +1 -1
- package/dist/server-trust.js +23 -6
- package/dist/server-trust.js.map +1 -1
- package/dist/stream-owner.d.ts.map +1 -1
- package/dist/stream-owner.js +32 -3
- package/dist/stream-owner.js.map +1 -1
- package/docs/HUMAN_REPRESENTATIVE.md +310 -36
- package/hermes-plugin/borg-representative-push/__init__.py +743 -0
- package/hermes-plugin/borg-representative-push/plugin.yaml +39 -0
- package/package.json +3 -1
- package/src/claude.ts +12 -0
- package/src/cli-help.ts +16 -6
- package/src/docs-sections.ts +1 -1
- package/src/hermes-plugin-install.ts +147 -0
- package/src/local-server-cursor.ts +45 -1
- package/src/log-stream.ts +47 -14
- package/src/remote-client.ts +9 -1
- package/src/representative-cmd.ts +52 -20
- package/src/representative-core.ts +255 -46
- package/src/representative-delivery-store.ts +235 -0
- package/src/representative-listener-store.ts +115 -0
- package/src/representative-listener.ts +215 -0
- package/src/representative-mcp.ts +58 -17
- package/src/representative-store.ts +18 -2
- package/src/seat-probe.ts +1 -1
- package/src/server-trust.ts +25 -6
- package/src/stream-owner.ts +34 -2
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from './representative-core.js';
|
|
30
30
|
import {
|
|
31
31
|
RepresentativeStoreError,
|
|
32
|
+
bindingFingerprint,
|
|
32
33
|
representativeRecoveryCommand,
|
|
33
34
|
type RepresentativeBinding,
|
|
34
35
|
type RepresentativeStore,
|
|
@@ -41,7 +42,9 @@ export const DEFAULT_REPRESENTATIVE_ROLE = 'hermes-representative';
|
|
|
41
42
|
export type RepresentativeCommand =
|
|
42
43
|
| { action: 'prepare'; coordinator: string; role: string; rebind: boolean; worktreeName?: string; host?: string }
|
|
43
44
|
| { action: 'status'; worktree?: string }
|
|
44
|
-
| { action: 'mcp'; worktree?: string }
|
|
45
|
+
| { action: 'mcp'; worktree?: string }
|
|
46
|
+
| { action: 'listen'; worktree?: string; replayAfter?: string }
|
|
47
|
+
| { action: 'hermes-plugin-install'; hermesHome?: string; force: boolean };
|
|
45
48
|
|
|
46
49
|
export type ParsedRepresentativeArgs =
|
|
47
50
|
| { ok: true; command: RepresentativeCommand }
|
|
@@ -62,12 +65,13 @@ export interface RepresentativeCmdDeps {
|
|
|
62
65
|
|
|
63
66
|
export function parseRepresentativeArgs(args: readonly string[]): ParsedRepresentativeArgs {
|
|
64
67
|
const [action, ...rest] = args;
|
|
65
|
-
if (action
|
|
66
|
-
|
|
68
|
+
if (action === 'hermes-plugin') return parseHermesPluginArgs(rest);
|
|
69
|
+
if (action !== 'prepare' && action !== 'status' && action !== 'mcp' && action !== 'listen') {
|
|
70
|
+
return { ok: false, error: 'expected one of: prepare, status, mcp, listen, hermes-plugin' };
|
|
67
71
|
}
|
|
68
72
|
const values: Record<string, string> = {};
|
|
69
73
|
let rebind = false;
|
|
70
|
-
const valueFlags = action === 'prepare' ? ['--coordinator', '--role', '--worktree', '--host'] : ['--worktree'];
|
|
74
|
+
const valueFlags = action === 'prepare' ? ['--coordinator', '--role', '--worktree', '--host'] : action === 'listen' ? ['--worktree', '--replay-after'] : ['--worktree'];
|
|
71
75
|
for (let i = 0; i < rest.length; i += 1) {
|
|
72
76
|
const arg = rest[i];
|
|
73
77
|
if (action === 'prepare' && arg === '--rebind') {
|
|
@@ -91,7 +95,11 @@ export function parseRepresentativeArgs(args: readonly string[]): ParsedRepresen
|
|
|
91
95
|
if (worktree !== undefined && !isAbsolute(worktree)) {
|
|
92
96
|
return { ok: false, error: '--worktree must be an absolute path to the representative worktree' };
|
|
93
97
|
}
|
|
94
|
-
|
|
98
|
+
const replayAfter = values['--replay-after'];
|
|
99
|
+
if (replayAfter && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(replayAfter)) {
|
|
100
|
+
return { ok: false, error: '--replay-after must be an entry UUID' };
|
|
101
|
+
}
|
|
102
|
+
return { ok: true, command: { action, ...(worktree ? { worktree } : {}), ...(replayAfter ? { replayAfter } : {}) } };
|
|
95
103
|
}
|
|
96
104
|
const coordinator = values['--coordinator'];
|
|
97
105
|
if (!coordinator) {
|
|
@@ -118,6 +126,30 @@ export function parseRepresentativeArgs(args: readonly string[]): ParsedRepresen
|
|
|
118
126
|
};
|
|
119
127
|
}
|
|
120
128
|
|
|
129
|
+
function parseHermesPluginArgs(args: readonly string[]): ParsedRepresentativeArgs {
|
|
130
|
+
const [subcommand, ...rest] = args;
|
|
131
|
+
if (subcommand !== 'install') return { ok: false, error: 'expected: hermes-plugin install [--hermes-home <path>] [--force]' };
|
|
132
|
+
let hermesHome: string | undefined;
|
|
133
|
+
let force = false;
|
|
134
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
135
|
+
const arg = rest[i];
|
|
136
|
+
if (arg === '--force') {
|
|
137
|
+
force = true;
|
|
138
|
+
} else if (arg === '--hermes-home') {
|
|
139
|
+
const next = rest[i + 1];
|
|
140
|
+
if (typeof next !== 'string' || next.length === 0 || next.startsWith('-')) {
|
|
141
|
+
return { ok: false, error: '--hermes-home requires a value' };
|
|
142
|
+
}
|
|
143
|
+
if (!isAbsolute(next)) return { ok: false, error: '--hermes-home must be an absolute path' };
|
|
144
|
+
hermesHome = next;
|
|
145
|
+
i += 1;
|
|
146
|
+
} else {
|
|
147
|
+
return { ok: false, error: `unknown argument: ${arg}. Supported: --hermes-home, --force` };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { ok: true, command: { action: 'hermes-plugin-install', force, ...(hermesHome ? { hermesHome } : {}) } };
|
|
151
|
+
}
|
|
152
|
+
|
|
121
153
|
function canonicalWorktree(path: string, deps: Pick<RepresentativeCmdDeps, 'findProjectRoot'>): string {
|
|
122
154
|
let real = resolve(path);
|
|
123
155
|
try {
|
|
@@ -267,7 +299,9 @@ export async function runRepresentativeStatus(
|
|
|
267
299
|
const status = await representativeStatus(ctx);
|
|
268
300
|
const { representativeOwnership } = await import('./representative-owner.js');
|
|
269
301
|
const ownership = await representativeOwnership(ctx.binding);
|
|
270
|
-
|
|
302
|
+
const { representativeListenerStatus } = await import('./representative-listener.js');
|
|
303
|
+
const listener = await representativeListenerStatus(ctx.binding);
|
|
304
|
+
deps.stdout(`${JSON.stringify({ ...status, ownership, listener }, null, 2)}\n`);
|
|
271
305
|
return status.connected ? 0 : 1;
|
|
272
306
|
} catch (error) {
|
|
273
307
|
deps.stderr(`◼ borg representative status: ${describeError(error)}\n`);
|
|
@@ -303,20 +337,9 @@ export async function runRepresentativeMcp(
|
|
|
303
337
|
heartbeatIntervalMs: io.heartbeatIntervalMs,
|
|
304
338
|
...(io.stdin ? { stdin: io.stdin } : {}),
|
|
305
339
|
...(io.stdout ? { stdout: io.stdout } : {}),
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
ctx.binding.cubeId !== pinned.cubeId ||
|
|
310
|
-
ctx.binding.coordinatorDroneId !== pinned.coordinatorDroneId ||
|
|
311
|
-
ctx.binding.representativeDroneId !== pinned.representativeDroneId
|
|
312
|
-
) {
|
|
313
|
-
throw new RepresentativeError(
|
|
314
|
-
'BINDING_MISMATCH',
|
|
315
|
-
'The operator changed this connection\'s cube or Coordinator while it was running. Restart the MCP server to use the new binding.',
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
|
-
return ctx;
|
|
319
|
-
},
|
|
340
|
+
// The full generation, so any rebind (same selection included) is refused.
|
|
341
|
+
pinnedFingerprint: bindingFingerprint(pinned),
|
|
342
|
+
context: () => resolveRepresentativeContext(worktree, deps),
|
|
320
343
|
});
|
|
321
344
|
const stdin = io.stdin ?? process.stdin;
|
|
322
345
|
stdin.once('end', () => { void served.close(); });
|
|
@@ -361,3 +384,12 @@ export async function buildDefaultRepresentativeDeps(): Promise<RepresentativeCm
|
|
|
361
384
|
stderr: (text) => { process.stderr.write(text); },
|
|
362
385
|
};
|
|
363
386
|
}
|
|
387
|
+
|
|
388
|
+
export async function runRepresentativeListen(
|
|
389
|
+
command: Extract<RepresentativeCommand, { action: 'listen' }>,
|
|
390
|
+
deps: RepresentativeCmdDeps,
|
|
391
|
+
options: import('./representative-listener.js').ListenerOptions = {},
|
|
392
|
+
): Promise<number> {
|
|
393
|
+
const { runListener } = await import('./representative-listener.js');
|
|
394
|
+
return runListener(command, deps, options);
|
|
395
|
+
}
|
|
@@ -25,21 +25,20 @@ import {
|
|
|
25
25
|
BorgServerTrustError,
|
|
26
26
|
BorgServerUnreachableError,
|
|
27
27
|
} from './server-errors.js';
|
|
28
|
-
import { representativeRecoveryCommand, isRepresentativeUuid, type RepresentativeBinding, type RepresentativeStore } from './representative-store.js';
|
|
28
|
+
import { bindingFingerprint, representativeRecoveryCommand, isRepresentativeUuid, type RepresentativeBinding, type RepresentativeStore } from './representative-store.js';
|
|
29
|
+
import { comparePoints, createDeliveryStore, type DeliveryState } from './representative-delivery-store.js';
|
|
30
|
+
import { readPrivateLocalServerCursor, type LocalServerCursor } from './local-server-cursor.js';
|
|
31
|
+
import { validatePrivateDirectory } from './representative-listener-store.js';
|
|
32
|
+
import { borgConfigRoot } from './private-root.js';
|
|
29
33
|
|
|
30
34
|
const UUID_SCAN_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
|
|
31
35
|
export const REPRESENTATIVE_MESSAGE_LIMIT_BYTES = 3000;
|
|
32
36
|
|
|
33
37
|
export const REPRESENTATIVE_DELIVERY_NOTE =
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'reply cannot be retrieved through this connection. The first send/read/ack takes an exclusive process lease for this ' +
|
|
39
|
-
'representative drone; other processes refuse those calls without ledger, cursor or network activity. Status stays ' +
|
|
40
|
-
'read-only and reports ownership. After owner exit, death or lease expiry another process can take over. ' +
|
|
41
|
-
'A process that loses its lease refuses further calls until restarted. Ownership does not route conversations inside the host. ' +
|
|
42
|
-
'A local lease cannot cancel an in-flight request; retry an ambiguous send with its original request_id.';
|
|
38
|
+
'read returns undelivered replies without consuming them; persist, route by in_reply_to (unknown: hold for the ' +
|
|
39
|
+
'human), then deliver through the last persisted entry_id. ack only notifies the Coordinator. A separate ' +
|
|
40
|
+
'borg representative listen process emits body-free wake hints. One process at a time owns send/read/deliver/ack; ' +
|
|
41
|
+
'status is read-only. Stop routing if binding_fingerprint changes.';
|
|
43
42
|
|
|
44
43
|
export type RepresentativeErrorCode =
|
|
45
44
|
| typeof ErrorCode.INVALID_INPUT
|
|
@@ -59,7 +58,9 @@ export type RepresentativeErrorCode =
|
|
|
59
58
|
| 'COORDINATOR_UNAVAILABLE'
|
|
60
59
|
| 'REPRESENTATIVE_ROLE_NOT_PERMITTED'
|
|
61
60
|
| 'REPRESENTATIVE_ROLE_MISMATCH'
|
|
62
|
-
| 'NOT_A_COORDINATOR_REPLY'
|
|
61
|
+
| 'NOT_A_COORDINATOR_REPLY'
|
|
62
|
+
| 'REPRESENTATIVE_DELIVER_UNKNOWN_ENTRY'
|
|
63
|
+
| 'REPRESENTATIVE_READ_OVERSIZE';
|
|
63
64
|
|
|
64
65
|
export interface RepresentativeErrorDetails {
|
|
65
66
|
owner?: import('./stream-owner.js').StreamOwnershipSnapshot;
|
|
@@ -67,6 +68,9 @@ export interface RepresentativeErrorDetails {
|
|
|
67
68
|
cause_code?: string;
|
|
68
69
|
cause_message?: string;
|
|
69
70
|
recovery?: string;
|
|
71
|
+
entry_id?: string;
|
|
72
|
+
measured_bytes?: number;
|
|
73
|
+
bound?: number;
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
export class RepresentativeError extends Error {
|
|
@@ -99,11 +103,12 @@ export interface RepresentativeBackend {
|
|
|
99
103
|
unreachableRecipients?: Array<{ id: string; label: string }>;
|
|
100
104
|
}>;
|
|
101
105
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* out. `limit` is a page-size hint: the client's digest mode may return more.
|
|
106
|
+
* One stateless page of the cube log strictly after an exact (created_at, id)
|
|
107
|
+
* cursor, ascending. Reads and advances no unread cursor; never digest mode.
|
|
105
108
|
*/
|
|
106
|
-
|
|
109
|
+
readAfter(cursor: LocalServerCursor | null, limit: number, continuationGuard?: () => Promise<void>): Promise<{ entries: LogEntry[]; has_more?: boolean }>;
|
|
110
|
+
/** This seat's client-owned unread cursor, read only; the slice 2 migration input. */
|
|
111
|
+
unreadCursor(): Promise<LocalServerCursor | null>;
|
|
107
112
|
readEntry(entryId: string): Promise<{ entry: LogEntry }>;
|
|
108
113
|
ack(entryId: string): Promise<void>;
|
|
109
114
|
}
|
|
@@ -113,6 +118,8 @@ export interface RepresentativeContext {
|
|
|
113
118
|
backend: RepresentativeBackend;
|
|
114
119
|
store: RepresentativeStore;
|
|
115
120
|
now?: () => Date;
|
|
121
|
+
/** Checked immediately before each private delivery-state write (the tools lease in MCP). */
|
|
122
|
+
guard?: () => Promise<void>;
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
/** Real backend: the existing seat-scoped client calls for one hydrated seat. */
|
|
@@ -126,8 +133,20 @@ export async function createSeatBackend(active: ActiveCube): Promise<Representat
|
|
|
126
133
|
client.appendLog(active.sessionToken, active.apiUrl, message, {
|
|
127
134
|
to, postId, transportRetry: false, serverTrustIdentity: trust,
|
|
128
135
|
}),
|
|
129
|
-
|
|
130
|
-
client.readLog(active.sessionToken, active.apiUrl, {
|
|
136
|
+
readAfter: (cursor, limit, continuationGuard) =>
|
|
137
|
+
client.readLog(active.sessionToken, active.apiUrl, { cursor, limit, serverTrustIdentity: trust, continuationGuard }),
|
|
138
|
+
// Migration input only: an unsafe private root or cursor file is no cursor,
|
|
139
|
+
// so the checkpoint starts empty and replays instead of trusting it.
|
|
140
|
+
unreadCursor: async () => {
|
|
141
|
+
try {
|
|
142
|
+
if (!await validatePrivateDirectory(borgConfigRoot(), false)) return null;
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return readPrivateLocalServerCursor({
|
|
147
|
+
origin: active.apiUrl, trustIdentity: trust!, cubeId: active.cubeId, droneId: active.droneId,
|
|
148
|
+
});
|
|
149
|
+
},
|
|
131
150
|
readEntry: (entryId) =>
|
|
132
151
|
client.readLogEntry(active.sessionToken, active.apiUrl, { entry_id: entryId }, trust),
|
|
133
152
|
ack: (entryId) => client.ackLogEntry(active.sessionToken, active.apiUrl, entryId, 'ack', trust),
|
|
@@ -184,7 +203,7 @@ export function resolveCoordinator(
|
|
|
184
203
|
}
|
|
185
204
|
|
|
186
205
|
/** Re-prove, against the live cube, that this seat and the bound Coordinator are still the bound ones. */
|
|
187
|
-
async function verifyLiveBinding(ctx: RepresentativeContext): Promise<{ coordinator: RosterDrone; self: RosterDrone }> {
|
|
206
|
+
export async function verifyLiveBinding(ctx: RepresentativeContext): Promise<{ coordinator: RosterDrone; self: RosterDrone }> {
|
|
188
207
|
const { binding, backend } = ctx;
|
|
189
208
|
const me = await backend.whoami();
|
|
190
209
|
if (me.cube_id !== binding.cubeId || me.drone_id !== binding.representativeDroneId) {
|
|
@@ -402,6 +421,13 @@ function classifyAppendFailure(error: unknown, binding: RepresentativeBinding):
|
|
|
402
421
|
export async function sendRepresentativeMessage(
|
|
403
422
|
ctx: RepresentativeContext,
|
|
404
423
|
raw: unknown,
|
|
424
|
+
): Promise<RepresentativeSendResult & { binding_fingerprint: string }> {
|
|
425
|
+
return { ...await sendOnce(ctx, raw), binding_fingerprint: bindingFingerprint(ctx.binding) };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function sendOnce(
|
|
429
|
+
ctx: RepresentativeContext,
|
|
430
|
+
raw: unknown,
|
|
405
431
|
): Promise<RepresentativeSendResult> {
|
|
406
432
|
const input = validateSendInput(raw);
|
|
407
433
|
const { binding, store } = ctx;
|
|
@@ -574,6 +600,10 @@ export interface RepresentativeReply {
|
|
|
574
600
|
/** A ledger request id quoted in the reply text. Textual correlation only. */
|
|
575
601
|
in_reply_to: string | null;
|
|
576
602
|
message: string;
|
|
603
|
+
/** This entry alone exceeds max_bytes; it is returned whole and alone. */
|
|
604
|
+
oversize?: true;
|
|
605
|
+
/** Oversize and still above the envelope bound: citations were reduced to ids. */
|
|
606
|
+
documents_reduced?: true;
|
|
577
607
|
}
|
|
578
608
|
|
|
579
609
|
function isAddressedCoordinatorEntry(binding: RepresentativeBinding, entry: LogEntry): 'direct' | 'broadcast' | null {
|
|
@@ -584,48 +614,215 @@ function isAddressedCoordinatorEntry(binding: RepresentativeBinding, entry: LogE
|
|
|
584
614
|
return 'broadcast';
|
|
585
615
|
}
|
|
586
616
|
|
|
617
|
+
/** The exact text an MCP tool result carries; `max_bytes` measures this. */
|
|
618
|
+
export function serializeRepresentativeResult(body: unknown): string {
|
|
619
|
+
return JSON.stringify(body, null, 2);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const READ_SCAN_PAGE = 500;
|
|
623
|
+
/**
|
|
624
|
+
* A serialized read result never exceeds max(max_bytes, this). An oversize
|
|
625
|
+
* entry is returned alone; if it still exceeds the bound, its citations are
|
|
626
|
+
* reduced to ids. Message text is never cut: the server caps a post (4096
|
|
627
|
+
* bytes by default), so the reduced entry fits.
|
|
628
|
+
*/
|
|
629
|
+
export const REPRESENTATIVE_ENVELOPE_FLOOR = 16384;
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Run `use` with this generation's delivery state. On the first call for a
|
|
633
|
+
* generation, `use` runs alone in the per-seat queue with the proposed start,
|
|
634
|
+
* and nothing is written until it calls `commit`: a read refused as oversize
|
|
635
|
+
* leaves no tombstone and no checkpoint behind.
|
|
636
|
+
*/
|
|
637
|
+
async function withDeliveryState<T>(
|
|
638
|
+
ctx: RepresentativeContext,
|
|
639
|
+
use: (state: DeliveryState, commit: () => Promise<void>) => Promise<T>,
|
|
640
|
+
): Promise<T> {
|
|
641
|
+
const store = createDeliveryStore(ctx.binding);
|
|
642
|
+
const saved = await store.load();
|
|
643
|
+
if (saved) {
|
|
644
|
+
// A checkpoint always implies an upgraded seat; restore a lost tombstone.
|
|
645
|
+
if (!await store.migrated()) await store.markMigrated(ctx.guard);
|
|
646
|
+
return use(saved, async () => {});
|
|
647
|
+
}
|
|
648
|
+
return store.initialize(async () => {
|
|
649
|
+
const again = await store.load();
|
|
650
|
+
if (again) return use(again, async () => {});
|
|
651
|
+
// The seat already upgraded (a tombstone or a sibling generation exists):
|
|
652
|
+
// this generation (rebind, new Coordinator, or a removed invalid checkpoint)
|
|
653
|
+
// starts empty and replays its addressed history. Nothing on disk is ever
|
|
654
|
+
// read back as a position, and the legacy cursor is never imported again.
|
|
655
|
+
const upgraded = await store.migrated() || await store.otherGenerationExists();
|
|
656
|
+
// One-time upgrade: start where the pre-checkpoint destructive read left
|
|
657
|
+
// the unread view. The cursor is used only in memory.
|
|
658
|
+
const cursor = upgraded ? null : await ctx.backend.unreadCursor();
|
|
659
|
+
return use({ checkpoint: cursor, readThrough: cursor, returned: [] }, async () => {
|
|
660
|
+
// The tombstone is created exclusively first, so an interruption before
|
|
661
|
+
// the checkpoint replays (duplicates the host dedupes). An initializer in
|
|
662
|
+
// another process that won the create (excluded by the tools lease in
|
|
663
|
+
// practice) makes this one start empty.
|
|
664
|
+
const start = !upgraded && await store.markMigrated(ctx.guard) ? cursor : null;
|
|
665
|
+
if (upgraded) await store.markMigrated(ctx.guard);
|
|
666
|
+
await store.advance({ checkpoint: start, readThrough: start }, ctx.guard);
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const checkpointView = (point: LocalServerCursor | null) =>
|
|
672
|
+
({ entry_id: point?.id ?? null, created_at: point?.created_at ?? null });
|
|
673
|
+
|
|
587
674
|
export async function readRepresentativeReplies(
|
|
588
675
|
ctx: RepresentativeContext,
|
|
589
676
|
raw: unknown,
|
|
590
|
-
): Promise<{
|
|
677
|
+
): Promise<{
|
|
678
|
+
replies: RepresentativeReply[]; checkpoint: { entry_id: string | null; created_at: string | null };
|
|
679
|
+
has_more: boolean; ignored_entries: number; binding_fingerprint: string; delivery: string;
|
|
680
|
+
}> {
|
|
591
681
|
const input = (raw ?? {}) as Record<string, unknown>;
|
|
592
|
-
const
|
|
682
|
+
const allowed = ['include_broadcast', 'limit', 'max_bytes'];
|
|
683
|
+
const unknown = Object.keys(input).filter((key) => !allowed.includes(key) && input[key] !== undefined);
|
|
593
684
|
if (unknown.length > 0) throw new RepresentativeError(ErrorCode.INVALID_INPUT, `Unsupported field(s): ${unknown.join(', ')}.`);
|
|
594
685
|
if (input.include_broadcast !== undefined && typeof input.include_broadcast !== 'boolean') {
|
|
595
686
|
throw new RepresentativeError(ErrorCode.INVALID_INPUT, 'include_broadcast must be a boolean.');
|
|
596
687
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
688
|
+
const bounded = (name: string, min: number, max: number, fallback: number) => {
|
|
689
|
+
const value = input[name];
|
|
690
|
+
if (value === undefined) return fallback;
|
|
691
|
+
if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) {
|
|
692
|
+
throw new RepresentativeError(ErrorCode.INVALID_INPUT, `${name} must be an integer from ${min} to ${max}.`);
|
|
693
|
+
}
|
|
694
|
+
return value as number;
|
|
695
|
+
};
|
|
696
|
+
const limit = bounded('limit', 1, 50, 10);
|
|
697
|
+
const maxBytes = bounded('max_bytes', 4096, 60000, 32768);
|
|
600
698
|
await verifyLiveBinding(ctx);
|
|
601
|
-
|
|
699
|
+
return withDeliveryState(ctx, async (state, commit) => {
|
|
602
700
|
const requestIds = await ctx.store.transactRequests(ctx.binding.worktree, (records) =>
|
|
603
701
|
new Set(records.map((record) => record.requestId)));
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
702
|
+
|
|
703
|
+
// Deliberate ceiling: every read scans the cube log from the checkpoint,
|
|
704
|
+
// including entries not addressed here, so an undelivered backlog costs a
|
|
705
|
+
// growing scan. Upgrade path: a separate scan hint that deliver advances.
|
|
706
|
+
// One addressed entry beyond `limit` is collected only to answer has_more.
|
|
707
|
+
const candidates: Array<{ reply: RepresentativeReply; point: LocalServerCursor; ignoredBefore: number }> = [];
|
|
708
|
+
let ignored = 0;
|
|
709
|
+
let cursor = state.checkpoint;
|
|
710
|
+
let last = state.checkpoint;
|
|
711
|
+
scan: for (;;) {
|
|
712
|
+
const page = await ctx.backend.readAfter(cursor, READ_SCAN_PAGE);
|
|
713
|
+
for (const entry of page.entries) {
|
|
714
|
+
const point = { id: entry.id, created_at: entry.created_at };
|
|
715
|
+
// Client-side (created_at, id) filter: never repeat or regress.
|
|
716
|
+
if (comparePoints(point, last) <= 0) continue;
|
|
717
|
+
last = point;
|
|
718
|
+
const addressed = isAddressedCoordinatorEntry(ctx.binding, entry);
|
|
719
|
+
if (addressed === null || (addressed === 'broadcast' && input.include_broadcast !== true)) { ignored += 1; continue; }
|
|
720
|
+
const quoted = (entry.message.match(UUID_SCAN_RE) ?? []).map((id) => id.toLowerCase());
|
|
721
|
+
candidates.push({ point, ignoredBefore: ignored, reply: {
|
|
722
|
+
entry_id: entry.id,
|
|
723
|
+
created_at: entry.created_at,
|
|
724
|
+
from_drone_id: ctx.binding.coordinatorDroneId,
|
|
725
|
+
from_label: ctx.binding.coordinatorLabel,
|
|
726
|
+
addressed,
|
|
727
|
+
in_reply_to: quoted.find((id) => requestIds.has(id)) ?? null,
|
|
728
|
+
message: entry.message,
|
|
729
|
+
...(entry.documents?.length ? {
|
|
730
|
+
documents: entry.documents,
|
|
731
|
+
document_delivery: 'Document bodies are not included and cannot be fetched through this connection. Ask the Coordinator to provide the content through a supported channel.',
|
|
732
|
+
} : {}),
|
|
733
|
+
} });
|
|
734
|
+
if (candidates.length > limit) break scan;
|
|
735
|
+
}
|
|
736
|
+
const tail = page.entries.at(-1);
|
|
737
|
+
if (!page.has_more || !tail) break;
|
|
738
|
+
cursor = { id: tail.id, created_at: tail.created_at };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const result = (count: number, oversize = false) => {
|
|
742
|
+
const taken = candidates.slice(0, count);
|
|
743
|
+
const replies: RepresentativeReply[] = taken.map(({ reply }, index) =>
|
|
744
|
+
(oversize && index === 0 ? { ...reply, oversize: true as const } : reply));
|
|
745
|
+
return {
|
|
746
|
+
replies,
|
|
747
|
+
checkpoint: checkpointView(state.checkpoint),
|
|
748
|
+
has_more: candidates.length > count,
|
|
749
|
+
ignored_entries: count < candidates.length ? candidates[count].ignoredBefore : ignored,
|
|
750
|
+
binding_fingerprint: bindingFingerprint(ctx.binding),
|
|
751
|
+
delivery: REPRESENTATIVE_DELIVERY_NOTE,
|
|
752
|
+
};
|
|
753
|
+
};
|
|
754
|
+
// Measure the real serialized result; entries are whole or omitted.
|
|
755
|
+
let count = 0;
|
|
756
|
+
while (count < Math.min(limit, candidates.length) &&
|
|
757
|
+
Buffer.byteLength(serializeRepresentativeResult(result(count + 1))) <= maxBytes) count += 1;
|
|
758
|
+
const oversize = count === 0 && candidates.length > 0;
|
|
759
|
+
let final = result(oversize ? 1 : count, oversize);
|
|
760
|
+
const bound = Math.max(maxBytes, REPRESENTATIVE_ENVELOPE_FLOOR);
|
|
761
|
+
if (oversize && final.replies[0].documents?.length &&
|
|
762
|
+
Buffer.byteLength(serializeRepresentativeResult(final)) > bound) {
|
|
763
|
+
const reply = final.replies[0];
|
|
764
|
+
final = { ...final, replies: [{
|
|
765
|
+
...reply, documents: reply.documents!.map(({ id }) => ({ id }) as DocumentCitation), documents_reduced: true as const,
|
|
766
|
+
}] };
|
|
767
|
+
}
|
|
768
|
+
const measured = Buffer.byteLength(serializeRepresentativeResult(final));
|
|
769
|
+
if (oversize && measured > bound) {
|
|
770
|
+
// Nothing is cut or overrun, and nothing moves: no content, no deliver.
|
|
771
|
+
throw new RepresentativeError('REPRESENTATIVE_READ_OVERSIZE',
|
|
772
|
+
`The next reply does not fit ${bound} bytes even alone with its citations reduced to ids. Nothing was read or ` +
|
|
773
|
+
'advanced. Raise max_bytes (up to 60000); beyond that the operator must reduce the server post limit.',
|
|
774
|
+
{ entry_id: final.replies[0].entry_id, measured_bytes: measured, bound });
|
|
775
|
+
}
|
|
776
|
+
await commit();
|
|
777
|
+
const window = candidates.slice(0, final.replies.length).map(({ point }) => point);
|
|
778
|
+
if (window.length > 0) {
|
|
779
|
+
// The deliver fence and membership widen before the caller sees the entries.
|
|
780
|
+
await createDeliveryStore(ctx.binding).advance({ readThrough: window.at(-1)!, returned: window }, ctx.guard);
|
|
781
|
+
}
|
|
782
|
+
return final;
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export async function deliverRepresentativeReplies(
|
|
787
|
+
ctx: RepresentativeContext,
|
|
788
|
+
raw: unknown,
|
|
789
|
+
): Promise<{ checkpoint: { entry_id: string | null; created_at: string | null }; advanced: boolean; binding_fingerprint: string }> {
|
|
790
|
+
const input = (raw ?? {}) as Record<string, unknown>;
|
|
791
|
+
const unknown = Object.keys(input).filter((key) => key !== 'through');
|
|
792
|
+
if (unknown.length > 0 || !isRepresentativeUuid(input.through)) {
|
|
793
|
+
throw new RepresentativeError(ErrorCode.INVALID_INPUT, 'through must be the full UUID of a reply returned by borg_representative-read.');
|
|
794
|
+
}
|
|
795
|
+
const through = input.through;
|
|
796
|
+
await verifyLiveBinding(ctx);
|
|
797
|
+
return withDeliveryState(ctx, async (state, commit) => {
|
|
798
|
+
await commit();
|
|
799
|
+
const outside = () => new RepresentativeError('REPRESENTATIVE_DELIVER_UNKNOWN_ENTRY',
|
|
800
|
+
'That entry is not in the current read window: deliver only a reply that borg_representative-read returned. Nothing changed.');
|
|
801
|
+
let entry: LogEntry;
|
|
802
|
+
try {
|
|
803
|
+
({ entry } = await ctx.backend.readEntry(through));
|
|
804
|
+
} catch (error) {
|
|
805
|
+
if ((error as { status?: unknown })?.status === 404) throw outside();
|
|
806
|
+
throw error;
|
|
622
807
|
}
|
|
808
|
+
if (entry.id !== through || isAddressedCoordinatorEntry(ctx.binding, entry) === null) throw outside();
|
|
809
|
+
const point = { id: entry.id, created_at: entry.created_at };
|
|
810
|
+
const fingerprint = bindingFingerprint(ctx.binding);
|
|
811
|
+
if (state.checkpoint && comparePoints(point, state.checkpoint) <= 0) {
|
|
812
|
+
return { checkpoint: checkpointView(state.checkpoint), advanced: false, binding_fingerprint: fingerprint };
|
|
813
|
+
}
|
|
814
|
+
// Membership of the full tuple, not the range or the id alone: only an entry
|
|
815
|
+
// a read actually returned, as the server reports it now, inside the window.
|
|
816
|
+
if (!state.returned.some((returned) => returned.id === point.id && returned.created_at === point.created_at) ||
|
|
817
|
+
state.readThrough === null || comparePoints(point, state.readThrough) > 0) throw outside();
|
|
818
|
+
const { before, after } = await createDeliveryStore(ctx.binding).advance({ checkpoint: point }, ctx.guard);
|
|
623
819
|
return {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
820
|
+
checkpoint: checkpointView(after.checkpoint),
|
|
821
|
+
// The serialized transition, not this call's earlier snapshot.
|
|
822
|
+
advanced: comparePoints(after.checkpoint!, before?.checkpoint ?? null) > 0,
|
|
823
|
+
binding_fingerprint: fingerprint,
|
|
628
824
|
};
|
|
825
|
+
});
|
|
629
826
|
}
|
|
630
827
|
|
|
631
828
|
export async function ackRepresentativeReply(
|
|
@@ -661,6 +858,9 @@ export async function representativeStatus(ctx: RepresentativeContext): Promise<
|
|
|
661
858
|
unresolved_requests: Array<{ request_id: string; state: string; kind: string; updated_at: string }>;
|
|
662
859
|
delivery: string;
|
|
663
860
|
authority: string;
|
|
861
|
+
binding_fingerprint: string;
|
|
862
|
+
checkpoint_problem?: { code: string; message: string };
|
|
863
|
+
envelope_floor: number;
|
|
664
864
|
}> {
|
|
665
865
|
const { binding } = ctx;
|
|
666
866
|
let problem: { code: string; message: string } | undefined;
|
|
@@ -672,6 +872,12 @@ export async function representativeStatus(ctx: RepresentativeContext): Promise<
|
|
|
672
872
|
message: error instanceof Error ? error.message : 'Unknown error',
|
|
673
873
|
};
|
|
674
874
|
}
|
|
875
|
+
let checkpointProblem: { code: string; message: string } | undefined;
|
|
876
|
+
try {
|
|
877
|
+
await createDeliveryStore(binding).load();
|
|
878
|
+
} catch (error) {
|
|
879
|
+
checkpointProblem = { code: (error as { code?: string }).code ?? 'BACKEND_ERROR', message: error instanceof Error ? error.message : 'Unknown error' };
|
|
880
|
+
}
|
|
675
881
|
const unresolved = (await ctx.store.readRequests(binding.worktree))
|
|
676
882
|
.filter((record) => record.state === 'pending' || record.state === 'ambiguous')
|
|
677
883
|
.map((record) => ({
|
|
@@ -695,5 +901,8 @@ export async function representativeStatus(ctx: RepresentativeContext): Promise<
|
|
|
695
901
|
authority:
|
|
696
902
|
'Borg records these messages as posts from the representative drone. The user-authorized / model-advice label is this ' +
|
|
697
903
|
'connection\'s own attribution and is not verified or enforced by the Borg server.',
|
|
904
|
+
binding_fingerprint: bindingFingerprint(binding),
|
|
905
|
+
...(checkpointProblem ? { checkpoint_problem: checkpointProblem } : {}),
|
|
906
|
+
envelope_floor: REPRESENTATIVE_ENVELOPE_FLOOR,
|
|
698
907
|
};
|
|
699
908
|
}
|