borgmcp 2.0.1 → 2.0.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.
- package/README.md +2 -2
- package/dist/codex-app-wake.d.ts +9 -11
- package/dist/codex-app-wake.d.ts.map +1 -1
- package/dist/codex-app-wake.js +15 -11
- package/dist/codex-app-wake.js.map +1 -1
- package/dist/codex-wake-resolve.d.ts +4 -4
- package/dist/codex-wake-resolve.js +4 -4
- package/dist/log-stream.d.ts +4 -0
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +58 -2
- package/dist/log-stream.js.map +1 -1
- package/dist/remote-client.d.ts +25 -0
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +59 -4
- package/dist/remote-client.js.map +1 -1
- package/dist/seats.d.ts +72 -0
- package/dist/seats.d.ts.map +1 -1
- package/dist/seats.js +119 -0
- package/dist/seats.js.map +1 -1
- package/dist/server-errors.d.ts +1 -1
- package/dist/server-errors.d.ts.map +1 -1
- package/dist/server-errors.js.map +1 -1
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +49 -21
- package/dist/server-handshake.js.map +1 -1
- package/dist/session-continuity.d.ts +33 -0
- package/dist/session-continuity.d.ts.map +1 -0
- package/dist/session-continuity.js +220 -0
- package/dist/session-continuity.js.map +1 -0
- package/docs/EXTRACTION_PROVENANCE.md +9 -7
- package/docs/LOCAL_SERVER.md +1 -1
- package/docs/RELEASING.md +13 -4
- package/package.json +2 -2
- package/src/codex-app-wake.ts +23 -11
- package/src/codex-wake-resolve.ts +4 -4
- package/src/log-stream.ts +64 -2
- package/src/remote-client.ts +92 -14
- package/src/seats.ts +170 -0
- package/src/server-errors.ts +3 -1
- package/src/server-handshake.ts +54 -21
- package/src/session-continuity.ts +280 -0
|
@@ -8,7 +8,7 @@ The extraction copied the monorepo's `client/src/` production boundary and top-l
|
|
|
8
8
|
|
|
9
9
|
## Deliberate Transformations
|
|
10
10
|
|
|
11
|
-
- Replaced the monorepo dependency on `borgmcp-shared` with the exact audited registry release `borgmcp-shared@0.4.
|
|
11
|
+
- Replaced the monorepo dependency on `borgmcp-shared` with the exact audited registry release `borgmcp-shared@0.4.3` and a fresh standalone lockfile.
|
|
12
12
|
- Replaced local template, role-section, drone-address, and log high-water-mark implementations with `borgmcp-shared` exports.
|
|
13
13
|
- Removed monorepo-only website anti-drift tests and re-anchored remaining filesystem tests to this repository.
|
|
14
14
|
- Removed consumer lifecycle hooks, parent-directory deployment scripts, minification, and private integration-environment configuration.
|
|
@@ -18,8 +18,9 @@ The extraction copied the monorepo's `client/src/` production boundary and top-l
|
|
|
18
18
|
client-generated PENDING credential/retry tuple and capability-gated,
|
|
19
19
|
repository-idempotent cube creation required for local dogfood.
|
|
20
20
|
- Initially set the standalone package identity to `2.0.0`. Its immutable
|
|
21
|
-
lightweight release tag failed before packaging
|
|
22
|
-
|
|
21
|
+
lightweight release tag failed before packaging. The reviewed `2.0.1`
|
|
22
|
+
recovery was published and registry-verified; the next candidate identity is
|
|
23
|
+
`2.0.2`. Extraction and versioning do not authorize publication.
|
|
23
24
|
|
|
24
25
|
## Review Holds
|
|
25
26
|
|
|
@@ -34,7 +35,8 @@ artifact reaches no hosted authority.
|
|
|
34
35
|
Local enrollment now uses the reviewed client-generated credential/retry
|
|
35
36
|
contract, with a pre-request `PENDING` record in the local 0600-permission seat
|
|
36
37
|
store, exact-tuple ambiguous retry, and verified activation. The contract now
|
|
37
|
-
resolves to the audited registry `borgmcp-shared@0.4.
|
|
38
|
-
`borgmcp-server@0.1.7` release is published and registry-verified
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
resolves to the audited registry `borgmcp-shared@0.4.3`. The matching
|
|
39
|
+
`borgmcp-server@0.1.7` release is published and registry-verified. Client
|
|
40
|
+
`borgmcp@2.0.1` is published and registry-verified. Publication of the next
|
|
41
|
+
candidate remains gated by reviewed `v2.0.2` source, a fresh annotated tag, and
|
|
42
|
+
exact registry integrity and signature verification.
|
package/docs/LOCAL_SERVER.md
CHANGED
|
@@ -96,7 +96,7 @@ The default discovery endpoint is `https://127.0.0.1:7091`. Explicit `--host` va
|
|
|
96
96
|
|
|
97
97
|
## Release status
|
|
98
98
|
|
|
99
|
-
This self-hosted path consumes the published `borgmcp-shared@0.4.
|
|
99
|
+
This self-hosted path consumes the published `borgmcp-shared@0.4.3` v2 registry
|
|
100
100
|
release. The matching server owner-enrollment, cube-create, attach, restart, log,
|
|
101
101
|
and SSE implementation must also pass the full process-level local dogfood gate.
|
|
102
102
|
Until that gate opens the self-hosted path remains preview-only, and the client
|
package/docs/RELEASING.md
CHANGED
|
@@ -4,15 +4,24 @@ The GitHub Actions workflow publishes one immutable, reviewed `borgmcp` version
|
|
|
4
4
|
from a protected annotated tag. The protected publish job uses npm Trusted
|
|
5
5
|
Publishing; no long-lived npm token is stored or exposed.
|
|
6
6
|
|
|
7
|
-
## Immutable
|
|
7
|
+
## Immutable Release Evidence
|
|
8
8
|
|
|
9
9
|
The existing lightweight `v2.0.0` tag points to
|
|
10
10
|
`90a078264f4d61c0140ad0a30357a4df42c34ab0`. Immutable workflow run
|
|
11
11
|
`29693915689` rejected it at annotated-tag verification and failed before package
|
|
12
12
|
creation or npm publication; the publish job was skipped and
|
|
13
13
|
`borgmcp@2.0.0` remains absent from npm. Never delete, move, replace, reuse, or
|
|
14
|
-
rerun that tag or run.
|
|
15
|
-
|
|
14
|
+
rerun that tag or run.
|
|
15
|
+
|
|
16
|
+
The annotated `v2.0.1` tag object
|
|
17
|
+
`def12ee40af665fc6c3af4873a7d566b3f844fc1` peels to protected-main commit
|
|
18
|
+
`b30fc54a4d73bda98db4630864cca796c8923dd9`. Workflow run `29748931957`
|
|
19
|
+
successfully published that exact source as `borgmcp@2.0.1`; the registry records
|
|
20
|
+
integrity
|
|
21
|
+
`sha512-Ah8IY2izZ774gYLKthRL9lfrV+JBk2o9HSlrWUplyZgoGqwVjVboHNon0hWWF5i/fObiCGikFOMY6qZ+vaeyCw==`.
|
|
22
|
+
Never move, replace, reuse, or rerun that tag or workflow. The next candidate
|
|
23
|
+
uses the unused `v2.0.2` identity from a fresh reviewed protected-main commit
|
|
24
|
+
and requires the complete release gate again.
|
|
16
25
|
|
|
17
26
|
## Release Prerequisites
|
|
18
27
|
|
|
@@ -23,7 +32,7 @@ Before creating the release tag, independently verify all of these conditions:
|
|
|
23
32
|
- the extraction review confirms no private backend secrets, deployment
|
|
24
33
|
configuration, customer data, local state, or duplicated shared contracts
|
|
25
34
|
entered the public package;
|
|
26
|
-
- the exact audited registry dependency `borgmcp-shared@0.4.
|
|
35
|
+
- the exact audited registry dependency `borgmcp-shared@0.4.3` remains locked to
|
|
27
36
|
its canonical tarball and integrity;
|
|
28
37
|
- the client and matching server pass the complete local dogfood gate;
|
|
29
38
|
- the selected stable client version is unused and the exact release commit is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "borgmcp",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "Coordinate AI coding agents in shared cubes. Works with Claude Code, Codex, and OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
71
|
-
"borgmcp-shared": "0.4.
|
|
71
|
+
"borgmcp-shared": "0.4.3",
|
|
72
72
|
"chalk": "^5.3.0",
|
|
73
73
|
"prompts": "^2.4.2",
|
|
74
74
|
"which": "^4.0.0"
|
package/src/codex-app-wake.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getActiveCube,
|
|
3
|
+
getCodexWakeTarget,
|
|
4
|
+
setCodexWakeTarget,
|
|
5
|
+
type ActiveCube,
|
|
6
|
+
} from './cubes.js';
|
|
2
7
|
import { CodexAppServerClient } from './codex-app-server.js';
|
|
3
8
|
import { checkCodexBridgeHealthy } from './codex-remote.js';
|
|
9
|
+
import { hasPendingWakeActivity } from './remote-client.js';
|
|
4
10
|
import {
|
|
5
11
|
BORG_CODEX_REMOTE_WAKE_ENV,
|
|
6
12
|
resolveSessionAgentKind,
|
|
@@ -176,6 +182,10 @@ export interface CodexWakeDeps {
|
|
|
176
182
|
// WAKE_RETRY_MAX_ATTEMPTS); small values let tests prove the loop terminates
|
|
177
183
|
// under a non-advancing clock without running thousands of iterations.
|
|
178
184
|
maxAttempts?: number;
|
|
185
|
+
// client#76: token-free, non-mutating preflight for the periodic backstop.
|
|
186
|
+
// Per-entry and retry-drain paths do not use this: their pending obligation is
|
|
187
|
+
// already established by a concrete delivered/deferred event.
|
|
188
|
+
hasPendingWork?: (active: ActiveCube) => Promise<boolean>;
|
|
179
189
|
// gh#861 finding 2: lease-ownership gate for the heartbeat tick — a lease-LOSING
|
|
180
190
|
// duplicate child must NOT tick/inject (symmetry with the per-entry path, which
|
|
181
191
|
// only fires inside an SSE session that holds the stream lease). Heartbeat-only;
|
|
@@ -403,16 +413,13 @@ async function runRetryDrainLoop(deps: CodexWakeDeps): Promise<void> {
|
|
|
403
413
|
export const CODEX_HEARTBEAT_CADENCE_MS = 20 * 60_000;
|
|
404
414
|
|
|
405
415
|
/**
|
|
406
|
-
* gh#857
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
* delivery
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
* drain prompt is intentionally re-delivered each idle window. Best-effort: a
|
|
414
|
-
* mid-turn thread / transient error / unresolved target just skips this tick (the
|
|
415
|
-
* next tick retries). Never throws.
|
|
416
|
+
* gh#857/client#76: one tick of the codex catch-up backstop. The cadence only
|
|
417
|
+
* initiates a token-free unread-state preflight; a DRAIN turn is injected when
|
|
418
|
+
* that authoritative scan finds real work that a per-entry wake missed. Recent
|
|
419
|
+
* delivery still suppresses redundant preflights. Unlike the per-entry path it
|
|
420
|
+
* does not consult deliveredWakeKeys because the unread cursor is authoritative.
|
|
421
|
+
* Best-effort: a failed preflight, mid-turn thread, transient error, or unresolved
|
|
422
|
+
* target skips this tick and lets the next cadence retry. Never throws.
|
|
416
423
|
*/
|
|
417
424
|
export async function fireCodexHeartbeatTick(
|
|
418
425
|
deps: CodexWakeDeps = {},
|
|
@@ -434,6 +441,11 @@ export async function fireCodexHeartbeatTick(
|
|
|
434
441
|
try {
|
|
435
442
|
const active = await (deps.getActiveCube ?? getActiveCube)();
|
|
436
443
|
if (!active) return;
|
|
444
|
+
// Elapsed time alone must never cross the model boundary. Query the
|
|
445
|
+
// authoritative unread state without advancing its cursor; only then touch
|
|
446
|
+
// the app-server socket or resolve a thread.
|
|
447
|
+
const hasPendingWork = deps.hasPendingWork ?? hasPendingWakeActivity;
|
|
448
|
+
if (!(await hasPendingWork(active))) return;
|
|
437
449
|
const resolved = await resolveFreshCodexWakeTarget(active, deps);
|
|
438
450
|
if (!resolved) return; // thread not loaded yet → next tick retries
|
|
439
451
|
const client = makeCodexClient(resolved.socketPath, deps);
|
|
@@ -140,10 +140,10 @@ export function wakeRetryExpired(
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/**
|
|
143
|
-
* WI-2 double-fire avoidance: the periodic
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
143
|
+
* WI-2 double-fire avoidance: the periodic catch-up preflight runs only when no
|
|
144
|
+
* wake delivery landed within the cadence window. A never-delivered seat (null)
|
|
145
|
+
* is eligible for preflight; client#76 separately requires pending unread work
|
|
146
|
+
* before a model turn can start.
|
|
147
147
|
*/
|
|
148
148
|
export function shouldFireHeartbeat(
|
|
149
149
|
lastDeliveredAt: number | null,
|
package/src/log-stream.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { Buffer } from 'node:buffer';
|
|
|
28
28
|
import { promises as fs } from 'node:fs';
|
|
29
29
|
import path from 'node:path';
|
|
30
30
|
import { compareBroadcastHwm, type BroadcastHwm } from 'borgmcp-shared/log-stream-hwm';
|
|
31
|
+
import { decodeProtocolErrorEnvelope, ErrorCode } from 'borgmcp-shared/protocol';
|
|
31
32
|
import { getActiveCube, inboxPathForDrone } from './cubes.js';
|
|
32
33
|
import { assertUuidShape } from './evict-drone.js';
|
|
33
34
|
import { loadBorgServerTrust } from './server-trust.js';
|
|
@@ -53,6 +54,8 @@ import {
|
|
|
53
54
|
wakeCodexViaAppServer,
|
|
54
55
|
} from './codex-app-wake.js';
|
|
55
56
|
import { readBoundedResponseBody } from './server-response.js';
|
|
57
|
+
import { BorgServerError, BorgServerUnreachableError } from './server-errors.js';
|
|
58
|
+
import { recoverExpiredLocalSession } from './session-continuity.js';
|
|
56
59
|
import {
|
|
57
60
|
acquireStreamLease,
|
|
58
61
|
readOwnershipSnapshot,
|
|
@@ -97,6 +100,13 @@ export class StreamCursorExpiredError extends Error {
|
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
102
|
|
|
103
|
+
class TerminalStreamError extends Error {
|
|
104
|
+
constructor() {
|
|
105
|
+
super('terminal local session state');
|
|
106
|
+
this.name = 'TerminalStreamError';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
100
110
|
function isProcessAlive(pid: number): boolean {
|
|
101
111
|
try {
|
|
102
112
|
process.kill(pid, 0);
|
|
@@ -322,6 +332,7 @@ function runStreamLoopForever(): void {
|
|
|
322
332
|
await runLoop();
|
|
323
333
|
process.stderr.write('[borg-mcp log stream] runLoop returned unexpectedly; restarting in 5s\n');
|
|
324
334
|
} catch (err: any) {
|
|
335
|
+
if (err instanceof TerminalStreamError) return;
|
|
325
336
|
process.stderr.write(`[borg-mcp log stream] runLoop threw: ${err?.message ?? err}; restarting in 5s\n`);
|
|
326
337
|
}
|
|
327
338
|
streamState.runLoopRestartCount += 1;
|
|
@@ -417,6 +428,7 @@ export interface RunLoopTestDeps {
|
|
|
417
428
|
getActiveCube?: typeof getActiveCube;
|
|
418
429
|
acquireStreamLease?: typeof acquireStreamLease;
|
|
419
430
|
streamOnce?: typeof streamOnce;
|
|
431
|
+
recoverExpiredSession?: typeof recoverExpiredLocalSession;
|
|
420
432
|
sleep?: (ms: number) => Promise<void>;
|
|
421
433
|
maxIterations?: number;
|
|
422
434
|
}
|
|
@@ -425,6 +437,7 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
|
|
|
425
437
|
const _getActiveCube = testDeps.getActiveCube ?? getActiveCube;
|
|
426
438
|
const _acquireStreamLease = testDeps.acquireStreamLease ?? acquireStreamLease;
|
|
427
439
|
const _streamOnce = testDeps.streamOnce ?? streamOnce;
|
|
440
|
+
const _recoverExpiredSession = testDeps.recoverExpiredSession ?? recoverExpiredLocalSession;
|
|
428
441
|
const _sleep = testDeps.sleep ?? sleep;
|
|
429
442
|
const _maxIterations = testDeps.maxIterations ?? Infinity;
|
|
430
443
|
let _iterations = 0;
|
|
@@ -434,6 +447,7 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
|
|
|
434
447
|
let currentCubeId: string | null = null;
|
|
435
448
|
let lease: StreamLease | null = null;
|
|
436
449
|
let leaseKey: string | null = null;
|
|
450
|
+
let lastRecoveredToken: string | null = null;
|
|
437
451
|
|
|
438
452
|
try {
|
|
439
453
|
while (_iterations < _maxIterations) {
|
|
@@ -534,6 +548,7 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
|
|
|
534
548
|
}
|
|
535
549
|
// Clean disconnect (e.g. server-side rollout). Reset backoff.
|
|
536
550
|
attempt = 0;
|
|
551
|
+
lastRecoveredToken = null;
|
|
537
552
|
streamState.reconnectAttempts = 0;
|
|
538
553
|
} catch (err: any) {
|
|
539
554
|
if (ownerLost) {
|
|
@@ -559,7 +574,28 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
|
|
|
559
574
|
process.stderr.write(
|
|
560
575
|
`[borg-mcp log stream] drone evicted — stream terminated (no reconnect).\n`
|
|
561
576
|
);
|
|
562
|
-
|
|
577
|
+
throw new TerminalStreamError();
|
|
578
|
+
}
|
|
579
|
+
if (err instanceof BorgServerError) {
|
|
580
|
+
if (err.code !== 'AUTH_EXPIRED' || lastRecoveredToken === active.sessionToken) {
|
|
581
|
+
throw new TerminalStreamError();
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
const renewed = await _recoverExpiredSession(active);
|
|
585
|
+
lastRecoveredToken = renewed.sessionToken;
|
|
586
|
+
attempt = 0;
|
|
587
|
+
streamState.reconnectAttempts = 0;
|
|
588
|
+
continue;
|
|
589
|
+
} catch (recoveryError) {
|
|
590
|
+
if (!(recoveryError instanceof BorgServerUnreachableError)) {
|
|
591
|
+
throw new TerminalStreamError();
|
|
592
|
+
}
|
|
593
|
+
// The replacement bearer is durable and intentionally survives an
|
|
594
|
+
// ambiguous transport failure. Fall through to the ordinary bounded
|
|
595
|
+
// reconnect backoff; the next AUTH_EXPIRED response resumes that exact
|
|
596
|
+
// replacement instead of permanently silencing the stream.
|
|
597
|
+
err = recoveryError;
|
|
598
|
+
}
|
|
563
599
|
}
|
|
564
600
|
streamState.connected = false;
|
|
565
601
|
const delay =
|
|
@@ -603,6 +639,8 @@ export interface ActiveCube {
|
|
|
603
639
|
sessionToken: string;
|
|
604
640
|
apiUrl: string;
|
|
605
641
|
serverTrustIdentity?: string;
|
|
642
|
+
localSessionCredentialRef?: string;
|
|
643
|
+
localSessionExpiresAt?: string | null;
|
|
606
644
|
}
|
|
607
645
|
|
|
608
646
|
export async function streamOnce(
|
|
@@ -762,7 +800,7 @@ export async function streamOnce(
|
|
|
762
800
|
// Set + FIFO array for O(1) membership + bounded memory.
|
|
763
801
|
const recentIds = new Set<string>();
|
|
764
802
|
const recentIdsOrder: string[] = [];
|
|
765
|
-
let isCatchingUp = lastEventId !== null;
|
|
803
|
+
let isCatchingUp = lastEventId !== null || cursor !== null;
|
|
766
804
|
|
|
767
805
|
// gh#29 quality-stream (#5): shared inbox-write + cursor-advance helpers,
|
|
768
806
|
// extracted from the previously-duplicated ack / regular-log branches in the
|
|
@@ -856,6 +894,30 @@ export async function streamOnce(
|
|
|
856
894
|
// off forever against a dead seat. Keyed on the structured code, not the
|
|
857
895
|
// bare status (SEC R2). Any other status falls through to the generic throw
|
|
858
896
|
// so the reconnect loop keeps retrying transient failures.
|
|
897
|
+
if (response.status === 401) {
|
|
898
|
+
const body = await readBoundedResponseBody(
|
|
899
|
+
response,
|
|
900
|
+
LOCAL_SERVER_SSE_FRAME_LIMIT_BYTES,
|
|
901
|
+
'Local Borg server SSE response exceeded the response limit',
|
|
902
|
+
ac.signal,
|
|
903
|
+
).catch(() => '');
|
|
904
|
+
let code: ErrorCode | undefined;
|
|
905
|
+
try {
|
|
906
|
+
code = decodeProtocolErrorEnvelope(JSON.parse(body)).error.code;
|
|
907
|
+
} catch {
|
|
908
|
+
code = undefined;
|
|
909
|
+
}
|
|
910
|
+
if (code === ErrorCode.AUTH_EXPIRED) {
|
|
911
|
+
throw new BorgServerError('AUTH_EXPIRED', 'Borg server session expired');
|
|
912
|
+
}
|
|
913
|
+
if (code === ErrorCode.SESSION_REJECTED) {
|
|
914
|
+
throw new BorgServerError('SESSION_REJECTED', 'Borg server session was rejected');
|
|
915
|
+
}
|
|
916
|
+
if (code === ErrorCode.SESSION_REVOKED) {
|
|
917
|
+
throw new BorgServerError('SESSION_REVOKED', 'Borg server session was revoked');
|
|
918
|
+
}
|
|
919
|
+
throw new BorgServerError('CREDENTIAL_REJECTED', 'Borg server rejected the stream credential');
|
|
920
|
+
}
|
|
859
921
|
if (response.status === 410) {
|
|
860
922
|
const body = await readBoundedResponseBody(
|
|
861
923
|
response,
|
package/src/remote-client.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
} from './local-server-cursor.js';
|
|
48
48
|
import { readBoundedResponseBody } from './server-response.js';
|
|
49
49
|
import { resolveLocalLogRecipients } from './local-log-routing.js';
|
|
50
|
+
import { ensureLocalSessionFresh, recoverExpiredLocalSession } from './session-continuity.js';
|
|
50
51
|
|
|
51
52
|
export interface RemoteConnection {
|
|
52
53
|
apiUrl: string;
|
|
@@ -228,20 +229,31 @@ async function localServerRequest<T>(
|
|
|
228
229
|
method: 'GET' | 'POST' | 'PUT' | 'PATCH',
|
|
229
230
|
payload?: Record<string, unknown>,
|
|
230
231
|
): Promise<T | null> {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
232
|
+
let current = active.localSessionCredentialRef && active.localSessionExpiresAt
|
|
233
|
+
? await ensureLocalSessionFresh(active)
|
|
234
|
+
: active;
|
|
235
|
+
const request = (candidate: ActiveCube) => decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
|
|
236
|
+
method,
|
|
237
|
+
signal,
|
|
238
|
+
droneSession: candidate.sessionToken,
|
|
239
|
+
apiUrl: candidate.apiUrl,
|
|
240
|
+
serverTrustIdentity: candidate.serverTrustIdentity,
|
|
241
|
+
redirect: 'error',
|
|
242
|
+
...(payload === undefined
|
|
243
|
+
? { headers: { Accept: 'application/json' } }
|
|
244
|
+
: {
|
|
245
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
246
|
+
body: JSON.stringify(createProtocolEnvelope(randomUUID(), payload)),
|
|
247
|
+
}),
|
|
248
|
+
}), true);
|
|
249
|
+
try {
|
|
250
|
+
return await request(current);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (!(error instanceof BorgServerError) || error.code !== 'AUTH_EXPIRED') throw error;
|
|
253
|
+
if (current.sessionToken !== active.sessionToken) throw error;
|
|
254
|
+
current = await recoverExpiredLocalSession(current);
|
|
255
|
+
return request(current);
|
|
256
|
+
}
|
|
245
257
|
}
|
|
246
258
|
|
|
247
259
|
export interface LocalManageOperation {
|
|
@@ -404,6 +416,66 @@ async function localReadLogPage(
|
|
|
404
416
|
return payload;
|
|
405
417
|
}
|
|
406
418
|
|
|
419
|
+
interface PendingWakePage {
|
|
420
|
+
entries: Array<{
|
|
421
|
+
drone_id?: unknown;
|
|
422
|
+
message?: unknown;
|
|
423
|
+
visibility?: unknown;
|
|
424
|
+
recipient_drone_ids?: unknown;
|
|
425
|
+
}>;
|
|
426
|
+
cursor: LocalServerCursor | null;
|
|
427
|
+
has_more: boolean;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function isPendingWakeEntry(
|
|
431
|
+
entry: PendingWakePage['entries'][number],
|
|
432
|
+
droneId: string,
|
|
433
|
+
): boolean {
|
|
434
|
+
if (entry.visibility === 'direct') {
|
|
435
|
+
const recipients = Array.isArray(entry.recipient_drone_ids)
|
|
436
|
+
? entry.recipient_drone_ids.filter((recipient): recipient is string => typeof recipient === 'string')
|
|
437
|
+
: [];
|
|
438
|
+
if (!recipients.includes(droneId)) return false;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const isHeartbeatPing =
|
|
442
|
+
typeof entry.message === 'string' && entry.message.startsWith('[HEARTBEAT-PING]');
|
|
443
|
+
return entry.drone_id !== droneId || isHeartbeatPing;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* client#76: inspect authoritative unread log state without advancing the
|
|
448
|
+
* agent-owned unread cursor. The scan mirrors the SSE wake filters: unaddressed
|
|
449
|
+
* direct entries and ordinary own posts are not work for this seat. A full
|
|
450
|
+
* paginated scan prevents a run of skipped entries from hiding later real work.
|
|
451
|
+
*/
|
|
452
|
+
export async function hasPendingWakeActivity(
|
|
453
|
+
active: ActiveCube,
|
|
454
|
+
deps: {
|
|
455
|
+
getCursor?: typeof getLocalServerCursor;
|
|
456
|
+
readPage?: (active: ActiveCube, opts: { cursor: LocalServerCursor | null; limit: number }) => Promise<PendingWakePage>;
|
|
457
|
+
} = {},
|
|
458
|
+
): Promise<boolean> {
|
|
459
|
+
if (!active.serverTrustIdentity) {
|
|
460
|
+
throw new Error('Selected Borg server authority state is missing or unreadable');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const getCursor = deps.getCursor ?? getLocalServerCursor;
|
|
464
|
+
const readPage = deps.readPage ?? localReadLogPage;
|
|
465
|
+
let cursor = await getCursor(localCursorBinding(active));
|
|
466
|
+
|
|
467
|
+
for (;;) {
|
|
468
|
+
const page: PendingWakePage = await readPage(active, { cursor, limit: 500 });
|
|
469
|
+
if (page.entries.some((entry) => isPendingWakeEntry(entry, active.droneId))) return true;
|
|
470
|
+
if (!page.has_more) return false;
|
|
471
|
+
if (!page.cursor ||
|
|
472
|
+
(cursor && page.cursor.id === cursor.id && page.cursor.created_at === cursor.created_at)) {
|
|
473
|
+
throw new Error('Local Borg server returned a non-advancing log cursor');
|
|
474
|
+
}
|
|
475
|
+
cursor = page.cursor;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
407
479
|
async function resolveLocalLogCursor(
|
|
408
480
|
active: ActiveCube,
|
|
409
481
|
since: string,
|
|
@@ -549,6 +621,12 @@ async function authedFetch(
|
|
|
549
621
|
} catch {
|
|
550
622
|
rejectedCode = undefined;
|
|
551
623
|
}
|
|
624
|
+
if (droneSession !== undefined && rejectedCode === ErrorCode.AUTH_EXPIRED) {
|
|
625
|
+
throw new BorgServerError(
|
|
626
|
+
'AUTH_EXPIRED',
|
|
627
|
+
'the selected Borg server session expired',
|
|
628
|
+
);
|
|
629
|
+
}
|
|
552
630
|
if (droneSession !== undefined && rejectedCode === ErrorCode.SESSION_REJECTED) {
|
|
553
631
|
throw new BorgServerError(
|
|
554
632
|
'SESSION_REJECTED',
|
package/src/seats.ts
CHANGED
|
@@ -55,6 +55,9 @@ export interface SeatRecord {
|
|
|
55
55
|
roleName?: string;
|
|
56
56
|
roleClass?: 'queen' | 'worker';
|
|
57
57
|
isHumanSeat?: boolean;
|
|
58
|
+
// One crash-safe replacement may coexist with an ACTIVE session until the
|
|
59
|
+
// server confirms the same-seat restore. It is never hydratable or bound.
|
|
60
|
+
replacement?: { credential: string };
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
interface SeatsFile {
|
|
@@ -154,6 +157,19 @@ function isValidSeatRecord(ref: string, value: unknown): value is SeatRecord {
|
|
|
154
157
|
) {
|
|
155
158
|
return false;
|
|
156
159
|
}
|
|
160
|
+
if (r.replacement !== undefined) {
|
|
161
|
+
if (
|
|
162
|
+
r.state !== 'active' ||
|
|
163
|
+
r.replacement === null ||
|
|
164
|
+
typeof r.replacement !== 'object' ||
|
|
165
|
+
Array.isArray(r.replacement) ||
|
|
166
|
+
Object.keys(r.replacement as object).length !== 1 ||
|
|
167
|
+
typeof (r.replacement as { credential?: unknown }).credential !== 'string' ||
|
|
168
|
+
!/^[A-Za-z0-9_-]{43}$/.test((r.replacement as { credential: string }).credential)
|
|
169
|
+
) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
157
173
|
// State-consistency invariants (no inconsistent active|pending).
|
|
158
174
|
if (r.state === 'active') {
|
|
159
175
|
// An ACTIVE record MUST carry its full server session + worktree binding.
|
|
@@ -281,6 +297,17 @@ export async function getActiveSeatCredential(
|
|
|
281
297
|
return record.credential;
|
|
282
298
|
}
|
|
283
299
|
|
|
300
|
+
/** Exact ACTIVE record for the internal continuity coordinator. */
|
|
301
|
+
export async function getActiveSeat(
|
|
302
|
+
ref: string,
|
|
303
|
+
binding: { origin: string; trustIdentity: string; cubeId: string },
|
|
304
|
+
): Promise<SeatRecord | null> {
|
|
305
|
+
if (!REF_RE.test(ref)) return null;
|
|
306
|
+
const store = await readStore();
|
|
307
|
+
const record = store.seats[ref];
|
|
308
|
+
return recordMatches(record, ref, binding) && record.state === 'active' ? record : null;
|
|
309
|
+
}
|
|
310
|
+
|
|
284
311
|
// ─── PREPARE / mint (no worktree yet) ────────────────────────────────────────
|
|
285
312
|
|
|
286
313
|
/**
|
|
@@ -483,6 +510,149 @@ export async function activateAndBindSeat(input: {
|
|
|
483
510
|
});
|
|
484
511
|
}
|
|
485
512
|
|
|
513
|
+
export type PrepareSeatReplacementOutcome =
|
|
514
|
+
| { ok: true; credential: string; digest: string }
|
|
515
|
+
| { ok: false; reason: 'expectation-mismatch' };
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Preserve the exact ACTIVE seat while preparing one durable replacement bearer.
|
|
519
|
+
* A lost response or process restart reuses the stored replacement; a changed
|
|
520
|
+
* authority, drone, or active bearer fails before any mutation.
|
|
521
|
+
*/
|
|
522
|
+
export async function prepareSeatReplacement(input: {
|
|
523
|
+
ref: string;
|
|
524
|
+
binding: { origin: string; trustIdentity: string; cubeId: string };
|
|
525
|
+
expectedDroneId: string;
|
|
526
|
+
expectedActiveDigest: string;
|
|
527
|
+
replacementCredential: string;
|
|
528
|
+
}): Promise<PrepareSeatReplacementOutcome> {
|
|
529
|
+
if (!REF_RE.test(input.ref) || !/^[A-Za-z0-9_-]{43}$/.test(input.replacementCredential)) {
|
|
530
|
+
return { ok: false, reason: 'expectation-mismatch' };
|
|
531
|
+
}
|
|
532
|
+
return withStore<SeatsFile, PrepareSeatReplacementOutcome>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
|
|
533
|
+
const record = txn.data.seats[input.ref];
|
|
534
|
+
if (
|
|
535
|
+
!recordMatches(record, input.ref, input.binding) ||
|
|
536
|
+
record.state !== 'active' ||
|
|
537
|
+
record.droneId !== input.expectedDroneId ||
|
|
538
|
+
digestOf(record.credential) !== input.expectedActiveDigest
|
|
539
|
+
) {
|
|
540
|
+
return { ok: false as const, reason: 'expectation-mismatch' as const };
|
|
541
|
+
}
|
|
542
|
+
if (record.replacement) {
|
|
543
|
+
return {
|
|
544
|
+
ok: true as const,
|
|
545
|
+
credential: record.replacement.credential,
|
|
546
|
+
digest: digestOf(record.replacement.credential),
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
record.replacement = { credential: input.replacementCredential };
|
|
550
|
+
await txn.commit();
|
|
551
|
+
return {
|
|
552
|
+
ok: true as const,
|
|
553
|
+
credential: input.replacementCredential,
|
|
554
|
+
digest: digestOf(input.replacementCredential),
|
|
555
|
+
};
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export type PromoteSeatReplacementOutcome = 'promoted' | 'missing' | 'replaced';
|
|
560
|
+
|
|
561
|
+
/** Update only the committed expiry for an exact same-bearer session renewal. */
|
|
562
|
+
export async function refreshActiveSeatSession(input: {
|
|
563
|
+
ref: string;
|
|
564
|
+
binding: { origin: string; trustIdentity: string; cubeId: string };
|
|
565
|
+
expectedDroneId: string;
|
|
566
|
+
expectedActiveDigest: string;
|
|
567
|
+
expectedSessionId: string;
|
|
568
|
+
expiresAt: string;
|
|
569
|
+
}): Promise<boolean> {
|
|
570
|
+
if (!UUID_RE.test(input.expectedDroneId) || !UUID_RE.test(input.expectedSessionId)) {
|
|
571
|
+
throw new Error('invalid Borg server session identity');
|
|
572
|
+
}
|
|
573
|
+
if (!Number.isFinite(Date.parse(input.expiresAt))) {
|
|
574
|
+
throw new Error('invalid Borg server session expiry');
|
|
575
|
+
}
|
|
576
|
+
return withStore<SeatsFile, boolean>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
|
|
577
|
+
const record = txn.data.seats[input.ref];
|
|
578
|
+
if (
|
|
579
|
+
!recordMatches(record, input.ref, input.binding) ||
|
|
580
|
+
record.state !== 'active' ||
|
|
581
|
+
record.droneId !== input.expectedDroneId ||
|
|
582
|
+
record.sessionId !== input.expectedSessionId ||
|
|
583
|
+
digestOf(record.credential) !== input.expectedActiveDigest
|
|
584
|
+
) {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
record.expiresAt = input.expiresAt;
|
|
588
|
+
await txn.commit();
|
|
589
|
+
return true;
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Atomically promote only the response-bound replacement onto the same ACTIVE seat. */
|
|
594
|
+
export async function promoteSeatReplacement(input: {
|
|
595
|
+
ref: string;
|
|
596
|
+
binding: { origin: string; trustIdentity: string; cubeId: string };
|
|
597
|
+
expectedDroneId: string;
|
|
598
|
+
expectedActiveDigest: string;
|
|
599
|
+
expectedReplacementDigest: string;
|
|
600
|
+
sessionId: string;
|
|
601
|
+
expiresAt: string;
|
|
602
|
+
}): Promise<PromoteSeatReplacementOutcome> {
|
|
603
|
+
if (!UUID_RE.test(input.expectedDroneId) || !UUID_RE.test(input.sessionId)) {
|
|
604
|
+
throw new Error('invalid Borg server session identity');
|
|
605
|
+
}
|
|
606
|
+
if (!Number.isFinite(Date.parse(input.expiresAt))) {
|
|
607
|
+
throw new Error('invalid Borg server session expiry');
|
|
608
|
+
}
|
|
609
|
+
return withStore<SeatsFile, PromoteSeatReplacementOutcome>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
|
|
610
|
+
const record = txn.data.seats[input.ref];
|
|
611
|
+
if (!recordMatches(record, input.ref, input.binding) || record.state !== 'active') return 'missing';
|
|
612
|
+
if (
|
|
613
|
+
record.droneId !== input.expectedDroneId ||
|
|
614
|
+
digestOf(record.credential) !== input.expectedActiveDigest ||
|
|
615
|
+
!record.replacement ||
|
|
616
|
+
digestOf(record.replacement.credential) !== input.expectedReplacementDigest
|
|
617
|
+
) {
|
|
618
|
+
return 'replaced';
|
|
619
|
+
}
|
|
620
|
+
const { replacement, ...active } = record;
|
|
621
|
+
txn.data.seats[input.ref] = {
|
|
622
|
+
...active,
|
|
623
|
+
credential: replacement.credential,
|
|
624
|
+
sessionId: input.sessionId,
|
|
625
|
+
expiresAt: input.expiresAt,
|
|
626
|
+
};
|
|
627
|
+
await txn.commit();
|
|
628
|
+
return 'promoted';
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Remove only the caller's exact pending replacement, never the ACTIVE seat. */
|
|
633
|
+
export async function scrubSeatReplacement(input: {
|
|
634
|
+
ref: string;
|
|
635
|
+
binding: { origin: string; trustIdentity: string; cubeId: string };
|
|
636
|
+
expectedActiveDigest: string;
|
|
637
|
+
expectedReplacementDigest: string;
|
|
638
|
+
}): Promise<boolean> {
|
|
639
|
+
return withStore<SeatsFile, boolean>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
|
|
640
|
+
const record = txn.data.seats[input.ref];
|
|
641
|
+
if (
|
|
642
|
+
!recordMatches(record, input.ref, input.binding) ||
|
|
643
|
+
record.state !== 'active' ||
|
|
644
|
+
digestOf(record.credential) !== input.expectedActiveDigest ||
|
|
645
|
+
!record.replacement ||
|
|
646
|
+
digestOf(record.replacement.credential) !== input.expectedReplacementDigest
|
|
647
|
+
) {
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
delete record.replacement;
|
|
651
|
+
await txn.commit();
|
|
652
|
+
return true;
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
486
656
|
// ─── CR#2: bind a PENDING record to a preserved worktree (no activation) ─────
|
|
487
657
|
|
|
488
658
|
export type BindPendingSeatOutcome = 'bound' | 'missing' | 'replaced';
|
package/src/server-errors.ts
CHANGED
|
@@ -4,7 +4,9 @@ export type BorgServerErrorCode =
|
|
|
4
4
|
| 'INVITATION_REJECTED'
|
|
5
5
|
| 'CREATE_CUBE_DENIED'
|
|
6
6
|
| 'ATTACH_CONFLICT'
|
|
7
|
-
| 'SESSION_REJECTED'
|
|
7
|
+
| 'SESSION_REJECTED'
|
|
8
|
+
| 'SESSION_REVOKED'
|
|
9
|
+
| 'AUTH_EXPIRED';
|
|
8
10
|
|
|
9
11
|
/** Safe, non-secret state code for deterministic authority recovery copy. */
|
|
10
12
|
export class BorgServerError extends Error {
|