borgmcp 3.11.1 → 3.13.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 +15 -0
- package/THIRD_PARTY_NOTICES.md +5 -0
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +11 -0
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +19 -7
- package/dist/cli-help.js.map +1 -1
- package/dist/clone-cmd.d.ts +4 -1
- package/dist/clone-cmd.d.ts.map +1 -1
- package/dist/clone-cmd.js +28 -6
- package/dist/clone-cmd.js.map +1 -1
- package/dist/codex-app-wake.d.ts +3 -2
- package/dist/codex-app-wake.d.ts.map +1 -1
- package/dist/codex-app-wake.js +42 -9
- package/dist/codex-app-wake.js.map +1 -1
- package/dist/cube-activity-wake-copy.d.ts +1 -1
- package/dist/cube-activity-wake-copy.d.ts.map +1 -1
- package/dist/cube-activity-wake-copy.js +1 -1
- package/dist/cube-activity-wake-copy.js.map +1 -1
- package/dist/log-stream.d.ts +3 -3
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +8 -13
- package/dist/log-stream.js.map +1 -1
- package/dist/opencode-drone.d.ts +1 -1
- package/dist/opencode-drone.d.ts.map +1 -1
- package/dist/opencode-drone.js +16 -2
- package/dist/opencode-drone.js.map +1 -1
- package/dist/parse-clone-args.d.ts +3 -2
- package/dist/parse-clone-args.d.ts.map +1 -1
- package/dist/parse-clone-args.js +32 -9
- package/dist/parse-clone-args.js.map +1 -1
- package/dist/quickstart-cmd.d.ts +5 -1
- package/dist/quickstart-cmd.d.ts.map +1 -1
- package/dist/quickstart-cmd.js +17 -6
- package/dist/quickstart-cmd.js.map +1 -1
- package/dist/remote-client.d.ts +2 -21
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +8 -6
- package/dist/remote-client.js.map +1 -1
- package/dist/server-handshake.d.ts +6 -1
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +1 -0
- package/dist/server-handshake.js.map +1 -1
- package/docs/LOCAL_SERVER.md +4 -8
- package/docs/RELEASING.md +32 -308
- package/package.json +3 -2
- package/src/assimilate-deps.ts +11 -0
- package/src/cli-help.ts +19 -7
- package/src/clone-cmd.ts +36 -7
- package/src/codex-app-wake.ts +42 -8
- package/src/cube-activity-wake-copy.ts +1 -1
- package/src/log-stream.ts +24 -15
- package/src/opencode-drone.ts +14 -1
- package/src/parse-clone-args.ts +31 -10
- package/src/quickstart-cmd.ts +34 -6
- package/src/remote-client.ts +11 -25
- package/src/server-handshake.ts +4 -1
package/src/codex-app-wake.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from './cubes.js';
|
|
7
7
|
import { CodexAppServerClient } from './codex-app-server.js';
|
|
8
8
|
import { checkCodexBridgeHealthy } from './codex-remote.js';
|
|
9
|
-
import { hasPendingWakeActivity } from './remote-client.js';
|
|
9
|
+
import { hasPendingWakeActivity, hasPendingWakeEntry } from './remote-client.js';
|
|
10
10
|
import {
|
|
11
11
|
BORG_CODEX_REMOTE_WAKE_ENV,
|
|
12
12
|
resolveSessionAgentKind,
|
|
@@ -98,6 +98,7 @@ let wakeInFlight = false;
|
|
|
98
98
|
const pendingWakeRequests: Array<{
|
|
99
99
|
reason: string;
|
|
100
100
|
deliveryIdentity?: string;
|
|
101
|
+
sourceEntryId?: string;
|
|
101
102
|
deps: CodexWakeDeps;
|
|
102
103
|
}> = [];
|
|
103
104
|
const deliveredWakeKeys = new Set<string>();
|
|
@@ -108,6 +109,8 @@ const DELIVERED_WAKE_KEY_CAP = 100;
|
|
|
108
109
|
// (mid-turn thread) or missed (transient error) into ONE retried-until-delivered
|
|
109
110
|
// drain. The coalesce gate means a burst collapses to one poller, not N.
|
|
110
111
|
let retryDrainInFlight = false;
|
|
112
|
+
const retryDrainSourceEntryIds = new Set<string>();
|
|
113
|
+
let retryDrainHasUnscopedWork = false;
|
|
111
114
|
|
|
112
115
|
// gh#857 WI-2: timestamp of the last SUCCESSFUL wake delivery (per-entry OR
|
|
113
116
|
// retry-drain OR heartbeat). The heartbeat reads this (shouldFireHeartbeat) to
|
|
@@ -192,6 +195,7 @@ export interface CodexWakeDeps {
|
|
|
192
195
|
// Per-entry and retry-drain paths do not use this: their pending obligation is
|
|
193
196
|
// already established by a concrete delivered/deferred event.
|
|
194
197
|
hasPendingWork?: (active: ActiveCube) => Promise<boolean>;
|
|
198
|
+
hasPendingEntry?: (active: ActiveCube, entryId: string) => Promise<boolean>;
|
|
195
199
|
// gh#861 finding 2: lease-ownership gate for the heartbeat tick — a lease-LOSING
|
|
196
200
|
// duplicate child must NOT tick/inject (symmetry with the per-entry path, which
|
|
197
201
|
// only fires inside an SSE session that holds the stream lease). Heartbeat-only;
|
|
@@ -280,10 +284,11 @@ export function wakeCodexViaAppServer(
|
|
|
280
284
|
env: NodeJS.ProcessEnv = process.env,
|
|
281
285
|
deps: CodexWakeDeps = {},
|
|
282
286
|
deliveryIdentity?: string,
|
|
287
|
+
sourceEntryId?: string,
|
|
283
288
|
): void {
|
|
284
289
|
const target = resolveCodexWakeTarget(env);
|
|
285
290
|
if (!target.enabled) return;
|
|
286
|
-
pendingWakeRequests.push({ reason, deliveryIdentity, deps });
|
|
291
|
+
pendingWakeRequests.push({ reason, deliveryIdentity, sourceEntryId, deps });
|
|
287
292
|
if (wakeInFlight) return;
|
|
288
293
|
|
|
289
294
|
wakeInFlight = true;
|
|
@@ -295,21 +300,30 @@ export function wakeCodexViaAppServer(
|
|
|
295
300
|
async function drainCodexWakeQueue(): Promise<void> {
|
|
296
301
|
while (pendingWakeRequests.length > 0) {
|
|
297
302
|
const request = pendingWakeRequests.shift()!;
|
|
298
|
-
await wakeCodexTargeted(
|
|
303
|
+
await wakeCodexTargeted(
|
|
304
|
+
request.reason, request.deliveryIdentity, request.sourceEntryId, request.deps,
|
|
305
|
+
);
|
|
299
306
|
}
|
|
300
307
|
}
|
|
301
308
|
|
|
302
|
-
async function wakeCodexTargeted(
|
|
309
|
+
async function wakeCodexTargeted(
|
|
310
|
+
reason: string,
|
|
311
|
+
deliveryIdentity: string | undefined,
|
|
312
|
+
sourceEntryId: string | undefined,
|
|
313
|
+
deps: CodexWakeDeps,
|
|
314
|
+
): Promise<void> {
|
|
303
315
|
// gh#861 finding 1: another path (heartbeat/retry-drain) is mid-inject into the
|
|
304
316
|
// same thread — defer to the retry-drain so this entry isn't double-injected nor
|
|
305
317
|
// lost (the drain re-syncs the whole burst via the server read-cursor).
|
|
306
318
|
if (!tryAcquireInjectLock()) {
|
|
307
|
-
scheduleRetryDrain(deps);
|
|
319
|
+
scheduleRetryDrain(deps, sourceEntryId);
|
|
308
320
|
return;
|
|
309
321
|
}
|
|
310
322
|
try {
|
|
311
323
|
const active = await (deps.getActiveCube ?? getActiveCube)();
|
|
312
324
|
if (!active) return;
|
|
325
|
+
const pendingEntry = deps.hasPendingEntry ?? hasPendingWakeEntry;
|
|
326
|
+
if (sourceEntryId && !(await pendingEntry(active, sourceEntryId))) return;
|
|
313
327
|
// gh#855: resolve FRESH (live env socket + re-resolved thread), falling back
|
|
314
328
|
// to the launch-recorded file only when the env socket is absent.
|
|
315
329
|
const resolved = await resolveFreshCodexWakeTarget(active, deps);
|
|
@@ -326,9 +340,10 @@ async function wakeCodexTargeted(reason: string, deliveryIdentity: string | unde
|
|
|
326
340
|
// now. Schedule the retry-drain (coalesced, retried-until-delivered) so
|
|
327
341
|
// the burst's entries are drained once the thread goes idle; codex has no
|
|
328
342
|
// on-disk tail fallback like Claude's borg-inbox-monitor.
|
|
329
|
-
scheduleRetryDrain(deps);
|
|
343
|
+
scheduleRetryDrain(deps, sourceEntryId);
|
|
330
344
|
return;
|
|
331
345
|
}
|
|
346
|
+
if (sourceEntryId && !(await pendingEntry(active, sourceEntryId))) return;
|
|
332
347
|
await client.startTurn(threadId, reason);
|
|
333
348
|
rememberDeliveredWake(wakeKey);
|
|
334
349
|
markDelivered(deps);
|
|
@@ -340,7 +355,7 @@ async function wakeCodexTargeted(reason: string, deliveryIdentity: string | unde
|
|
|
340
355
|
// swallowed (the old best-effort drop let a single blip lose an entry).
|
|
341
356
|
// Schedule the retry-drain so the wake is retried-until-delivered; the SSE
|
|
342
357
|
// stream is never broken (this is fire-and-forget).
|
|
343
|
-
scheduleRetryDrain(deps);
|
|
358
|
+
scheduleRetryDrain(deps, sourceEntryId);
|
|
344
359
|
} finally {
|
|
345
360
|
releaseInjectLock();
|
|
346
361
|
}
|
|
@@ -357,7 +372,9 @@ async function wakeCodexTargeted(reason: string, deliveryIdentity: string | unde
|
|
|
357
372
|
* (wakeRetryExpired); the gh#857 WI-2 heartbeat is the backstop beyond that.
|
|
358
373
|
* Never throws into the SSE path (fire-and-forget).
|
|
359
374
|
*/
|
|
360
|
-
function scheduleRetryDrain(deps: CodexWakeDeps): void {
|
|
375
|
+
function scheduleRetryDrain(deps: CodexWakeDeps, sourceEntryId?: string): void {
|
|
376
|
+
if (sourceEntryId) retryDrainSourceEntryIds.add(sourceEntryId);
|
|
377
|
+
else retryDrainHasUnscopedWork = true;
|
|
361
378
|
if (retryDrainInFlight) return; // coalesce: one loop covers all deferred/missed wakes
|
|
362
379
|
retryDrainInFlight = true;
|
|
363
380
|
void runRetryDrainLoop(deps).finally(() => {
|
|
@@ -383,6 +400,15 @@ async function runRetryDrainLoop(deps: CodexWakeDeps): Promise<void> {
|
|
|
383
400
|
try {
|
|
384
401
|
const active = await (deps.getActiveCube ?? getActiveCube)();
|
|
385
402
|
if (!active) continue; // no active cube yet → keep retrying (until age cap)
|
|
403
|
+
const pendingEntry = deps.hasPendingEntry ?? hasPendingWakeEntry;
|
|
404
|
+
for (const entryId of retryDrainSourceEntryIds) {
|
|
405
|
+
try {
|
|
406
|
+
if (!(await pendingEntry(active, entryId))) retryDrainSourceEntryIds.delete(entryId);
|
|
407
|
+
} catch {
|
|
408
|
+
// Retain the obligation until unread state can be checked.
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (!retryDrainHasUnscopedWork && retryDrainSourceEntryIds.size === 0) return;
|
|
386
412
|
// gh#855: same FRESH resolution as the per-entry wake, so a stale launch
|
|
387
413
|
// probe can't defeat the retry-drain either.
|
|
388
414
|
const resolved = await resolveFreshCodexWakeTarget(active, deps);
|
|
@@ -395,7 +421,13 @@ async function runRetryDrainLoop(deps: CodexWakeDeps): Promise<void> {
|
|
|
395
421
|
if (thread?.status?.type === 'active') {
|
|
396
422
|
continue; // re-defer: still mid-turn (backoff before next poll)
|
|
397
423
|
}
|
|
424
|
+
for (const entryId of retryDrainSourceEntryIds) {
|
|
425
|
+
if (!(await pendingEntry(active, entryId))) retryDrainSourceEntryIds.delete(entryId);
|
|
426
|
+
}
|
|
427
|
+
if (!retryDrainHasUnscopedWork && retryDrainSourceEntryIds.size === 0) return;
|
|
398
428
|
await client.startTurn(threadId, CODEX_CATCHUP_PROMPT);
|
|
429
|
+
retryDrainSourceEntryIds.clear();
|
|
430
|
+
retryDrainHasUnscopedWork = false;
|
|
399
431
|
markDelivered(deps);
|
|
400
432
|
return; // drain delivered → server read-cursor drains all unread → done
|
|
401
433
|
} finally {
|
|
@@ -515,6 +547,8 @@ export function resetCodexWakeForTests(): void {
|
|
|
515
547
|
deliveredWakeKeys.clear();
|
|
516
548
|
deliveredWakeKeyOrder.length = 0;
|
|
517
549
|
retryDrainInFlight = false;
|
|
550
|
+
retryDrainSourceEntryIds.clear();
|
|
551
|
+
retryDrainHasUnscopedWork = false;
|
|
518
552
|
lastDeliveredAt = null;
|
|
519
553
|
heartbeatInFlight = false;
|
|
520
554
|
injectInFlight = false;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const CUBE_ACTIVITY_RESUME_WAKE_MESSAGE =
|
|
2
|
-
'Borg cube activity arrived while you were busy. Reading cube messages does not end your current task. Drain `borg_read-log unread_only=true` until caught up, handle actionable entries, then RESUME the interrupted work.';
|
|
2
|
+
'Borg cube activity arrived while you were busy. Reading cube messages does not end your current task. Drain `borg_read-log unread_only=true` until caught up, handle actionable entries, then RESUME the interrupted work. If the unread drain is empty, resume silently without a liveness post or full regen.';
|
|
3
3
|
|
|
4
4
|
export function formatCubeActivityWakeMessage(detail: string): string {
|
|
5
5
|
return `${CUBE_ACTIVITY_RESUME_WAKE_MESSAGE}\n${detail}`;
|
package/src/log-stream.ts
CHANGED
|
@@ -125,12 +125,14 @@ function isProcessAlive(pid: number): boolean {
|
|
|
125
125
|
* Used by defaultDeps when no explicit injectOpenCode is supplied.
|
|
126
126
|
*/
|
|
127
127
|
let _moduleInjectOpenCode:
|
|
128
|
-
| ((text: string, entryId: string, allowSubmit: boolean, sourceEntryId?: string
|
|
128
|
+
| ((text: string, entryId: string, allowSubmit: boolean, sourceEntryId?: string,
|
|
129
|
+
isSourcePending?: () => Promise<boolean>) => Promise<boolean>)
|
|
129
130
|
| undefined;
|
|
130
131
|
let _moduleSettleOpenCodeEntry: ((sourceEntryId: string) => void) | undefined;
|
|
131
132
|
|
|
132
133
|
export function setModuleInjectOpenCode(
|
|
133
|
-
fn: (text: string, entryId: string, allowSubmit: boolean, sourceEntryId?: string
|
|
134
|
+
fn: (text: string, entryId: string, allowSubmit: boolean, sourceEntryId?: string,
|
|
135
|
+
isSourcePending?: () => Promise<boolean>) => Promise<boolean>,
|
|
134
136
|
settle: (sourceEntryId: string) => void,
|
|
135
137
|
): void {
|
|
136
138
|
_moduleInjectOpenCode = fn;
|
|
@@ -392,7 +394,7 @@ export interface StreamDeps {
|
|
|
392
394
|
renderedLine: string
|
|
393
395
|
) => Promise<boolean>;
|
|
394
396
|
/** Optional Codex app-server wake sink; tests inject a spy. */
|
|
395
|
-
wakeCodex?: (reason: string, deliveryIdentity?: string) => void;
|
|
397
|
+
wakeCodex?: (reason: string, deliveryIdentity?: string, sourceEntryId?: string) => void;
|
|
396
398
|
/** Override the heartbeat watchdog timeout. */
|
|
397
399
|
heartbeatTimeoutMs?: number;
|
|
398
400
|
/** Override HWM divergence grace for focused tests. */
|
|
@@ -409,6 +411,7 @@ export interface StreamDeps {
|
|
|
409
411
|
entryId: string,
|
|
410
412
|
allowSubmit: boolean,
|
|
411
413
|
sourceEntryId?: string,
|
|
414
|
+
isSourcePending?: () => Promise<boolean>,
|
|
412
415
|
) => Promise<boolean>;
|
|
413
416
|
/** Inspect whether one retry source remains beyond the durable unread cursor. */
|
|
414
417
|
hasPendingWakeEntry?: (active: ActiveCube, entryId: string) => Promise<boolean>;
|
|
@@ -422,16 +425,16 @@ const defaultDeps: Required<StreamDeps> = {
|
|
|
422
425
|
getCursor: getLocalServerCursor,
|
|
423
426
|
appendLine: defaultAppendLine,
|
|
424
427
|
hasInboxEntryId: defaultHasInboxEntryId,
|
|
425
|
-
wakeCodex: (reason, deliveryIdentity) =>
|
|
426
|
-
wakeCodexViaAppServer(reason, process.env, {}, deliveryIdentity),
|
|
428
|
+
wakeCodex: (reason, deliveryIdentity, sourceEntryId) =>
|
|
429
|
+
wakeCodexViaAppServer(reason, process.env, {}, deliveryIdentity, sourceEntryId),
|
|
427
430
|
heartbeatTimeoutMs: HEARTBEAT_TIMEOUT_MS,
|
|
428
431
|
hwmDivergenceGraceMs: HWM_DIVERGENCE_GRACE_MS,
|
|
429
432
|
abortSignal: new AbortController().signal,
|
|
430
433
|
ownerDeps: {},
|
|
431
434
|
ownerStaleMs: 70_000,
|
|
432
|
-
injectOpenCode: (text, entryId, allowSubmit, sourceEntryId) =>
|
|
435
|
+
injectOpenCode: (text, entryId, allowSubmit, sourceEntryId, isSourcePending) =>
|
|
433
436
|
_moduleInjectOpenCode
|
|
434
|
-
? _moduleInjectOpenCode(text, entryId, allowSubmit, sourceEntryId)
|
|
437
|
+
? _moduleInjectOpenCode(text, entryId, allowSubmit, sourceEntryId, isSourcePending)
|
|
435
438
|
: Promise.resolve(false),
|
|
436
439
|
hasPendingWakeEntry: (active, entryId) => hasPendingDurableWakeEntry(
|
|
437
440
|
active as Parameters<typeof hasPendingDurableWakeEntry>[0],
|
|
@@ -902,7 +905,10 @@ export async function streamOnce(
|
|
|
902
905
|
// OpenCode queue. A still-unread re-ping may reconcile the prior attempt;
|
|
903
906
|
// its source entry ID prevents a new nonce from resubmitting that prompt.
|
|
904
907
|
if (isReping) {
|
|
905
|
-
await injectOpenCode(
|
|
908
|
+
await injectOpenCode(
|
|
909
|
+
formatOpenCodeWakePrompt(line), deliveryId, true, ev.id,
|
|
910
|
+
() => hasPendingWakeEntry(active, ev.id),
|
|
911
|
+
);
|
|
906
912
|
}
|
|
907
913
|
markEventPersisted(ev.id, ev.data?.created_at ?? '');
|
|
908
914
|
return 'persisted-skip';
|
|
@@ -910,12 +916,12 @@ export async function streamOnce(
|
|
|
910
916
|
// The inbox append is the durable record. OpenCode injection is only the
|
|
911
917
|
// wake attempt and may return before the agent finishes processing.
|
|
912
918
|
await appendLine(active.cubeId, active.droneId, line);
|
|
913
|
-
const openCodeDelivered =
|
|
914
|
-
|
|
915
|
-
|
|
919
|
+
const openCodeDelivered = await injectOpenCode(
|
|
920
|
+
formatOpenCodeWakePrompt(line), deliveryId, true, ev.id,
|
|
921
|
+
() => hasPendingWakeEntry(active, ev.id),
|
|
922
|
+
);
|
|
916
923
|
if (!openCodeDelivered) {
|
|
917
|
-
|
|
918
|
-
else wakeCodex(formatCodexWakePrompt(line), ev.wake_nonce);
|
|
924
|
+
wakeCodex(formatCodexWakePrompt(line), ev.wake_nonce, ev.id);
|
|
919
925
|
}
|
|
920
926
|
return 'written';
|
|
921
927
|
};
|
|
@@ -1234,9 +1240,12 @@ export async function streamOnce(
|
|
|
1234
1240
|
if (
|
|
1235
1241
|
wakeNonce !== undefined &&
|
|
1236
1242
|
await shouldDeliverWakeRetry(event.id) &&
|
|
1237
|
-
!(await injectOpenCode(
|
|
1243
|
+
!(await injectOpenCode(
|
|
1244
|
+
formatOpenCodeWakePrompt(line), wakeNonce, true, event.id,
|
|
1245
|
+
() => hasPendingWakeEntry(active, event.id),
|
|
1246
|
+
))
|
|
1238
1247
|
) {
|
|
1239
|
-
wakeCodex(formatCodexWakePrompt(line), wakeNonce);
|
|
1248
|
+
wakeCodex(formatCodexWakePrompt(line), wakeNonce, event.id);
|
|
1240
1249
|
}
|
|
1241
1250
|
continue;
|
|
1242
1251
|
}
|
package/src/opencode-drone.ts
CHANGED
|
@@ -89,6 +89,7 @@ interface OpenCodeDelivery {
|
|
|
89
89
|
acceptedSubmission: boolean;
|
|
90
90
|
sessionId: string | null;
|
|
91
91
|
settled: boolean;
|
|
92
|
+
isSourcePending?: () => Promise<boolean>;
|
|
92
93
|
state: Exclude<OpenCodeDeliveryState, 'failed'>;
|
|
93
94
|
resolve: (delivered: boolean) => void;
|
|
94
95
|
promise: Promise<boolean>;
|
|
@@ -628,6 +629,14 @@ async function deliverOpenCodeEntry(
|
|
|
628
629
|
): Promise<OpenCodeDeliveryOutcome> {
|
|
629
630
|
let target: OCSession | null = null;
|
|
630
631
|
|
|
632
|
+
const sourcePending = async (): Promise<boolean> => {
|
|
633
|
+
if (!delivery.isSourcePending) return true;
|
|
634
|
+
if (await delivery.isSourcePending()) return true;
|
|
635
|
+
delivery.settled = true;
|
|
636
|
+
settleOpenCodeEntry(delivery.sourceEntryId);
|
|
637
|
+
return false;
|
|
638
|
+
};
|
|
639
|
+
|
|
631
640
|
// Before the one allowed POST, retries are safe: no submission has happened.
|
|
632
641
|
// OpenCode must generate the message ID: its run loop treats IDs as
|
|
633
642
|
// lexicographically ordered, so arbitrary caller IDs can persist without
|
|
@@ -636,6 +645,7 @@ async function deliverOpenCodeEntry(
|
|
|
636
645
|
for (let attempt = 0; attempt < OPEN_CODE_DELIVERY_RETRY_DELAYS_MS.length; attempt++) {
|
|
637
646
|
if (delivery.settled) return 'delivered';
|
|
638
647
|
if (state !== owner || !owner.connected) return 'failed';
|
|
648
|
+
if (!(await sourcePending())) return 'delivered';
|
|
639
649
|
if (attempt > 0) {
|
|
640
650
|
delivery.state = 'retried';
|
|
641
651
|
owner.totalEntriesRetried++;
|
|
@@ -688,6 +698,7 @@ async function deliverOpenCodeEntry(
|
|
|
688
698
|
delivery.state = 'delivered-unconfirmed';
|
|
689
699
|
} else {
|
|
690
700
|
if (delivery.settled) return 'delivered';
|
|
701
|
+
if (!(await sourcePending())) return 'delivered';
|
|
691
702
|
owner.pendingSubmissions.set(delivery.entryId, {
|
|
692
703
|
sourceEntryId: delivery.sourceEntryId,
|
|
693
704
|
sessionId: target.id,
|
|
@@ -864,6 +875,7 @@ export function injectOpenCodeEntry(
|
|
|
864
875
|
entryId: string = createHash('sha256').update(text).digest('hex'),
|
|
865
876
|
allowSubmit: boolean = true,
|
|
866
877
|
sourceEntryId: string = entryId,
|
|
878
|
+
isSourcePending?: () => Promise<boolean>,
|
|
867
879
|
): Promise<boolean> {
|
|
868
880
|
const owner = state;
|
|
869
881
|
if (!owner?.connected) {
|
|
@@ -882,7 +894,7 @@ export function injectOpenCodeEntry(
|
|
|
882
894
|
);
|
|
883
895
|
if (pendingSource) {
|
|
884
896
|
log(`entry ${entryId} reconciles pending source ${sourceEntryId}`);
|
|
885
|
-
return injectOpenCodeEntry(text, pendingSource[0], false, sourceEntryId);
|
|
897
|
+
return injectOpenCodeEntry(text, pendingSource[0], false, sourceEntryId, isSourcePending);
|
|
886
898
|
}
|
|
887
899
|
for (const [deliveredEntryId, record] of owner.deliveredEntries) {
|
|
888
900
|
if (deliveredEntryId !== entryId && record.sourceEntryId === sourceEntryId) {
|
|
@@ -967,6 +979,7 @@ export function injectOpenCodeEntry(
|
|
|
967
979
|
acceptedSubmission: false,
|
|
968
980
|
sessionId: null,
|
|
969
981
|
settled: false,
|
|
982
|
+
isSourcePending,
|
|
970
983
|
state: 'queued',
|
|
971
984
|
resolve: resolveDelivery,
|
|
972
985
|
promise,
|
package/src/parse-clone-args.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { redactCloneSecrets } from './clone-security.js';
|
|
2
|
+
import { parseQuickstartArgs, type QuickstartArgs } from './parse-quickstart-args.js';
|
|
2
3
|
|
|
3
|
-
export interface CloneArgs {
|
|
4
|
+
export interface CloneArgs extends QuickstartArgs {
|
|
4
5
|
repositoryUrl: string;
|
|
5
6
|
destination?: string;
|
|
6
|
-
|
|
7
|
+
checkoutOnly: boolean;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export type ParseCloneResult =
|
|
@@ -13,28 +14,48 @@ export type ParseCloneResult =
|
|
|
13
14
|
export function parseCloneArgs(rawArgs: readonly string[]): ParseCloneResult {
|
|
14
15
|
let repositoryUrl: string | undefined;
|
|
15
16
|
let destination: string | undefined;
|
|
16
|
-
let
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
let checkoutOnly = false;
|
|
18
|
+
const quickstartArgs: string[] = [];
|
|
19
|
+
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
20
|
+
const arg = rawArgs[i];
|
|
21
|
+
if (arg === '--checkout-only' || arg === '--no-launch') {
|
|
22
|
+
if (checkoutOnly) return { ok: false, error: 'checkout-only mode was provided more than once' };
|
|
23
|
+
checkoutOnly = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (arg === '--yes' || arg === '-y') {
|
|
27
|
+
quickstartArgs.push(arg);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (arg === '--template' || arg === '--role') {
|
|
31
|
+
quickstartArgs.push(arg);
|
|
32
|
+
const value = rawArgs[++i];
|
|
33
|
+
if (value !== undefined) quickstartArgs.push(value);
|
|
21
34
|
continue;
|
|
22
35
|
}
|
|
23
36
|
if (arg.startsWith('-')) {
|
|
24
|
-
|
|
25
|
-
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
error: `unknown option ${redactCloneSecrets(arg)}; supported: --template, --role, --yes/-y, --checkout-only, --no-launch`,
|
|
40
|
+
};
|
|
26
41
|
}
|
|
27
42
|
if (repositoryUrl === undefined) repositoryUrl = arg;
|
|
28
43
|
else if (destination === undefined) destination = arg;
|
|
29
44
|
else return { ok: false, error: 'unexpected extra argument' };
|
|
30
45
|
}
|
|
31
46
|
if (!repositoryUrl) return { ok: false, error: 'a repository URL is required' };
|
|
47
|
+
if (checkoutOnly && quickstartArgs.length > 0) {
|
|
48
|
+
return { ok: false, error: '--checkout-only/--no-launch cannot be combined with --template, --role, or --yes/-y' };
|
|
49
|
+
}
|
|
50
|
+
const parsedQuickstart = parseQuickstartArgs(quickstartArgs);
|
|
51
|
+
if (!parsedQuickstart.ok) return parsedQuickstart;
|
|
32
52
|
return {
|
|
33
53
|
ok: true,
|
|
34
54
|
args: {
|
|
35
55
|
repositoryUrl,
|
|
36
56
|
...(destination === undefined ? {} : { destination }),
|
|
37
|
-
|
|
57
|
+
checkoutOnly,
|
|
58
|
+
...parsedQuickstart.args,
|
|
38
59
|
},
|
|
39
60
|
};
|
|
40
61
|
}
|
package/src/quickstart-cmd.ts
CHANGED
|
@@ -39,6 +39,12 @@ export interface QuickstartDeps {
|
|
|
39
39
|
runLaunchAll?: typeof runLaunchAll;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export type QuickstartCancellation = 'declined' | 'interrupted';
|
|
43
|
+
|
|
44
|
+
export interface QuickstartRunOptions {
|
|
45
|
+
onCancelled?: (kind: QuickstartCancellation) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
42
48
|
export function buildDefaultQuickstartDeps(): QuickstartDeps {
|
|
43
49
|
const io = buildDefaultAssimilateDeps();
|
|
44
50
|
return {
|
|
@@ -92,7 +98,25 @@ function renderTemplateMenu(): string {
|
|
|
92
98
|
return `${rows.join('\n')}\n`;
|
|
93
99
|
}
|
|
94
100
|
|
|
95
|
-
|
|
101
|
+
function reportCancellation(
|
|
102
|
+
kind: QuickstartCancellation,
|
|
103
|
+
deps: QuickstartDeps,
|
|
104
|
+
options: QuickstartRunOptions,
|
|
105
|
+
): void {
|
|
106
|
+
if (options.onCancelled) {
|
|
107
|
+
options.onCancelled(kind);
|
|
108
|
+
} else if (kind === 'interrupted') {
|
|
109
|
+
deps.stderr('\nborg quickstart: cancelled before anything was created.\n');
|
|
110
|
+
} else {
|
|
111
|
+
deps.stdout('Cancelled. Nothing was created.\n');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function selectTemplate(
|
|
116
|
+
args: QuickstartArgs,
|
|
117
|
+
deps: QuickstartDeps,
|
|
118
|
+
options: QuickstartRunOptions,
|
|
119
|
+
): Promise<string | null> {
|
|
96
120
|
if (args.template) return args.template;
|
|
97
121
|
if (!deps.isTTY()) return NEW_CUBE_TEMPLATE_PRESENTATIONS[0].name;
|
|
98
122
|
deps.stdout(renderTemplateMenu());
|
|
@@ -101,7 +125,7 @@ async function selectTemplate(args: QuickstartArgs, deps: QuickstartDeps): Promi
|
|
|
101
125
|
try {
|
|
102
126
|
answer = (await deps.prompt('Choose [1]: ')).trim();
|
|
103
127
|
} catch {
|
|
104
|
-
|
|
128
|
+
reportCancellation('interrupted', deps, options);
|
|
105
129
|
return null;
|
|
106
130
|
}
|
|
107
131
|
const index = answer === '' ? 0 : /^\d+$/.test(answer) ? Number(answer) - 1 : -1;
|
|
@@ -145,7 +169,11 @@ function affirmative(value: string): boolean {
|
|
|
145
169
|
return answer === '' || answer === 'y' || answer === 'yes';
|
|
146
170
|
}
|
|
147
171
|
|
|
148
|
-
export async function runQuickstart(
|
|
172
|
+
export async function runQuickstart(
|
|
173
|
+
args: QuickstartArgs,
|
|
174
|
+
deps: QuickstartDeps,
|
|
175
|
+
options: QuickstartRunOptions = {},
|
|
176
|
+
): Promise<number> {
|
|
149
177
|
const assimilate = deps.buildAssimilateDeps();
|
|
150
178
|
let context;
|
|
151
179
|
try {
|
|
@@ -207,7 +235,7 @@ export async function runQuickstart(args: QuickstartArgs, deps: QuickstartDeps):
|
|
|
207
235
|
}
|
|
208
236
|
|
|
209
237
|
deps.stdout(`Repository ${context.derivedName}${context.publicRepository ? ` (origin: ${context.publicRepository.value})` : ''}\n`);
|
|
210
|
-
const template = existing?.template ?? await selectTemplate(args, deps);
|
|
238
|
+
const template = existing?.template ?? await selectTemplate(args, deps, options);
|
|
211
239
|
if (!template) return 130;
|
|
212
240
|
const availableRoles = existing?.roles ?? plannedTemplateRoles(template);
|
|
213
241
|
const humanSeatRole = availableRoles.find((role) => role.isHumanSeat);
|
|
@@ -278,11 +306,11 @@ export async function runQuickstart(args: QuickstartArgs, deps: QuickstartDeps):
|
|
|
278
306
|
try {
|
|
279
307
|
answer = await deps.prompt(prompt);
|
|
280
308
|
} catch {
|
|
281
|
-
|
|
309
|
+
reportCancellation('interrupted', deps, options);
|
|
282
310
|
return 130;
|
|
283
311
|
}
|
|
284
312
|
if (!affirmative(answer)) {
|
|
285
|
-
|
|
313
|
+
reportCancellation('declined', deps, options);
|
|
286
314
|
return 0;
|
|
287
315
|
}
|
|
288
316
|
}
|
package/src/remote-client.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { randomUUID } from 'node:crypto';
|
|
17
17
|
import {
|
|
18
18
|
createProtocolEnvelope,
|
|
19
|
+
decodeAppendLogResult,
|
|
19
20
|
decodeDeleteCubeResponse,
|
|
20
21
|
decodeDeleteRoleRequest,
|
|
21
22
|
decodeDeleteRoleResult,
|
|
@@ -90,7 +91,7 @@ const UNREAD_CURSOR_MAX_TRANSPORT_RETRIES = 1;
|
|
|
90
91
|
// Replay is opt-in: most local requests are mutations or have ambiguous
|
|
91
92
|
// delivery, while unread-log reads carry an explicit cursor and are safe to
|
|
92
93
|
// repeat with the exact same request body.
|
|
93
|
-
type AuthedFetchRetryMode = 'unread-cursor';
|
|
94
|
+
type AuthedFetchRetryMode = 'unread-cursor' | 'append-log';
|
|
94
95
|
export const LOCAL_SERVER_RESPONSE_LIMIT_BYTES = 32 * 1024 * 1024;
|
|
95
96
|
// A typed auth-error envelope is tiny; anything larger is hostile and the
|
|
96
97
|
// bounded read throws → the 401 fails closed to non-destructive CREDENTIAL_REJECTED.
|
|
@@ -790,17 +791,17 @@ async function authedFetch(
|
|
|
790
791
|
return res;
|
|
791
792
|
};
|
|
792
793
|
|
|
793
|
-
let transportRetriesRemaining = retryMode === 'unread-cursor'
|
|
794
|
+
let transportRetriesRemaining = retryMode === 'unread-cursor' || retryMode === 'append-log'
|
|
794
795
|
? UNREAD_CURSOR_MAX_TRANSPORT_RETRIES
|
|
795
796
|
: 0;
|
|
796
797
|
const requestWithRetry = async (): Promise<Response> => {
|
|
797
798
|
try {
|
|
798
799
|
return await buildRequest(token);
|
|
799
800
|
} catch (error) {
|
|
800
|
-
if (retryMode !== 'unread-cursor' || !isConnectionReset(error)) throw error;
|
|
801
|
+
if ((retryMode !== 'unread-cursor' && retryMode !== 'append-log') || !isConnectionReset(error)) throw error;
|
|
801
802
|
if (transportRetriesRemaining === 0) throw unreadLogTransportFailure(error);
|
|
802
803
|
transportRetriesRemaining -= 1;
|
|
803
|
-
debugLog(
|
|
804
|
+
debugLog(`↻ retrying ${retryMode === 'append-log' ? 'log append' : 'unread log read'} after ECONNRESET`);
|
|
804
805
|
try {
|
|
805
806
|
return await buildRequest(token);
|
|
806
807
|
} catch (retryError) {
|
|
@@ -1334,31 +1335,14 @@ export async function appendLog(
|
|
|
1334
1335
|
to?: string[];
|
|
1335
1336
|
serverTrustIdentity?: string;
|
|
1336
1337
|
} = {}
|
|
1337
|
-
): Promise<{
|
|
1338
|
-
entry: {
|
|
1339
|
-
id: string;
|
|
1340
|
-
cube_id: string;
|
|
1341
|
-
drone_id: string;
|
|
1342
|
-
message: string;
|
|
1343
|
-
visibility: 'broadcast' | 'direct';
|
|
1344
|
-
created_at: string;
|
|
1345
|
-
};
|
|
1346
|
-
routing?: {
|
|
1347
|
-
class: string | null;
|
|
1348
|
-
recipients: string[];
|
|
1349
|
-
fellOpen: boolean;
|
|
1350
|
-
message: string | null;
|
|
1351
|
-
} | null;
|
|
1352
|
-
// gh#534: directed recipients currently unreachable via the wake path
|
|
1353
|
-
// (wake-path:deaf). Empty/absent for broadcast or all-reachable sends.
|
|
1354
|
-
unreachableRecipients?: { id: string; label: string }[];
|
|
1355
|
-
}> {
|
|
1338
|
+
): Promise<ReturnType<typeof decodeAppendLogResult>> {
|
|
1356
1339
|
if (opts.visibility === 'broadcast' && (opts.to?.length ?? 0) > 0) {
|
|
1357
1340
|
throw new Error(
|
|
1358
1341
|
"Invalid input: visibility:'broadcast' cannot be combined with non-empty to:. " +
|
|
1359
1342
|
'Remove visibility to direct to recipients, or remove to: to broadcast.',
|
|
1360
1343
|
);
|
|
1361
1344
|
}
|
|
1345
|
+
const postId = randomUUID();
|
|
1362
1346
|
const local = await localAuthorityContext(
|
|
1363
1347
|
sessionToken,
|
|
1364
1348
|
apiUrl,
|
|
@@ -1386,11 +1370,12 @@ export async function appendLog(
|
|
|
1386
1370
|
} else if (visibility === undefined && recipientDroneIds !== undefined) {
|
|
1387
1371
|
visibility = 'direct';
|
|
1388
1372
|
}
|
|
1389
|
-
const payload = await localServerRequest<
|
|
1373
|
+
const payload = await localServerRequest<ReturnType<typeof decodeAppendLogResult>>(
|
|
1390
1374
|
local,
|
|
1391
1375
|
`/api/cubes/${local.cubeId}/logs`,
|
|
1392
1376
|
'POST',
|
|
1393
1377
|
{
|
|
1378
|
+
post_id: postId,
|
|
1394
1379
|
message,
|
|
1395
1380
|
...(visibility ? { visibility } : {}),
|
|
1396
1381
|
...(visibility === 'direct' && recipientDroneIds
|
|
@@ -1401,9 +1386,10 @@ export async function appendLog(
|
|
|
1401
1386
|
// visibility/recipients override it (server resolveMessageRouting).
|
|
1402
1387
|
...(opts.class ? { class: opts.class } : {}),
|
|
1403
1388
|
},
|
|
1389
|
+
{ retryMode: 'append-log', decodePayload: decodeAppendLogResult },
|
|
1404
1390
|
);
|
|
1405
1391
|
if (!payload) throw new Error('Local Borg server returned an empty log response');
|
|
1406
|
-
return
|
|
1392
|
+
return payload;
|
|
1407
1393
|
}
|
|
1408
1394
|
|
|
1409
1395
|
/**
|
package/src/server-handshake.ts
CHANGED
|
@@ -211,10 +211,11 @@ export interface ServerAttachResult {
|
|
|
211
211
|
sessionId: string;
|
|
212
212
|
};
|
|
213
213
|
result: 'created' | 'reused';
|
|
214
|
+
initial_log_cursor: { id: string; created_at: string } | null;
|
|
214
215
|
}
|
|
215
216
|
|
|
216
217
|
/**
|
|
217
|
-
* Attach an enrolled client principal to one granted cube/role over protocol
|
|
218
|
+
* Attach an enrolled client principal to one granted cube/role over protocol v9.
|
|
218
219
|
* The client CSPRNG-generates the session bearer and persists it PENDING in the
|
|
219
220
|
* OS keychain (keyed by the stable per-seat identity) BEFORE this request, so an
|
|
220
221
|
* interrupted/lost response is recovered by re-sending the exact same bearer —
|
|
@@ -237,6 +238,7 @@ export interface PreparedServerAttach {
|
|
|
237
238
|
drone: ServerAttachResult['drone'];
|
|
238
239
|
session: { sessionId: string };
|
|
239
240
|
result: 'created' | 'reused';
|
|
241
|
+
initialLogCursor: ServerAttachResult['initial_log_cursor'];
|
|
240
242
|
credentialRef: string;
|
|
241
243
|
pendingBearerDigest: string;
|
|
242
244
|
/** The single-store ATOMIC activate+bind (CR#2 collapse): given the worktree
|
|
@@ -391,6 +393,7 @@ export async function sendBorgServerAttach(
|
|
|
391
393
|
sessionId: decoded.session.id,
|
|
392
394
|
},
|
|
393
395
|
result: decoded.result,
|
|
396
|
+
initialLogCursor: decoded.initial_log_cursor,
|
|
394
397
|
credentialRef,
|
|
395
398
|
pendingBearerDigest,
|
|
396
399
|
// The single-store ATOMIC activate+bind — invoked by FINALIZE with the decided
|