@gajae-code/coding-agent 0.11.2 → 0.11.4
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/CHANGELOG.md +34 -0
- package/dist/types/cli/launch-disposition.d.ts +23 -0
- package/dist/types/cli/worktree-cli.d.ts +8 -25
- package/dist/types/commands/gc.d.ts +4 -0
- package/dist/types/commands/worktree.d.ts +7 -32
- package/dist/types/config/settings-schema.d.ts +85 -10
- package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
- package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
- package/dist/types/main.d.ts +1 -0
- package/dist/types/modes/components/custom-editor.d.ts +8 -3
- package/dist/types/modes/components/tool-transcript-format.d.ts +19 -0
- package/dist/types/modes/controllers/input-controller.d.ts +8 -2
- package/dist/types/modes/prompt-action-autocomplete.d.ts +4 -0
- package/dist/types/sdk/broker/broker.d.ts +1 -0
- package/dist/types/sdk/broker/discovery.d.ts +10 -0
- package/dist/types/sdk/broker/ensure.d.ts +24 -1
- package/dist/types/sdk/broker/internal.d.ts +3 -0
- package/dist/types/sdk/broker/session-index.d.ts +12 -0
- package/dist/types/sdk/broker/state-version.d.ts +2 -1
- package/dist/types/sdk/bus/index.d.ts +2 -0
- package/dist/types/sdk/session.d.ts +2 -0
- package/dist/types/tools/ask.d.ts +39 -12
- package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
- package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
- package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
- package/dist/types/tools/path-utils.d.ts +9 -14
- package/dist/types/tools/read.d.ts +2 -0
- package/dist/types/tools/render-utils.d.ts +1 -8
- package/dist/types/tools/resource-gc.d.ts +14 -0
- package/dist/types/tools/subagent.d.ts +1 -0
- package/dist/types/utils/file-mentions.d.ts +3 -3
- package/dist/types/utils/image-loading.d.ts +23 -0
- package/dist/types/utils/image-resize.d.ts +2 -0
- package/dist/types/utils/pasted-image-loading.d.ts +39 -0
- package/dist/types/utils/pasted-image-path.d.ts +21 -19
- package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
- package/dist/types/web/scrapers/types.d.ts +1 -5
- package/package.json +24 -8
- package/scripts/run-threat-model-check.ts +60 -0
- package/src/cli/launch-disposition.ts +42 -0
- package/src/cli/worktree-cli.ts +9 -287
- package/src/cli.ts +10 -9
- package/src/commands/gc.ts +11 -1
- package/src/commands/sdk.ts +3 -2
- package/src/commands/worktree.ts +10 -53
- package/src/config/model-profiles.ts +15 -15
- package/src/config/settings-schema.ts +55 -10
- package/src/eval/js/shared/runtime.ts +1 -1
- package/src/gjc-runtime/gc-render.ts +19 -1
- package/src/gjc-runtime/gc-runtime.ts +68 -3
- package/src/gjc-runtime/state-argv.ts +292 -0
- package/src/gjc-runtime/state-runtime.ts +51 -212
- package/src/main.ts +99 -22
- package/src/modes/components/custom-editor.ts +103 -32
- package/src/modes/components/custom-provider-wizard.ts +26 -1
- package/src/modes/components/session-observer-overlay.ts +10 -23
- package/src/modes/components/tool-transcript-format.ts +72 -0
- package/src/modes/components/transcript-viewer-overlay.ts +42 -2
- package/src/modes/controllers/input-controller.ts +98 -37
- package/src/modes/controllers/selector-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +13 -2
- package/src/modes/prompt-action-autocomplete.ts +7 -0
- package/src/sdk/broker/broker.ts +139 -32
- package/src/sdk/broker/discovery.ts +125 -0
- package/src/sdk/broker/ensure.ts +103 -19
- package/src/sdk/broker/internal.ts +10 -0
- package/src/sdk/broker/session-index.ts +228 -15
- package/src/sdk/broker/state-version.ts +5 -2
- package/src/sdk/bus/index.ts +4 -1
- package/src/sdk/bus/telegram-daemon.ts +50 -10
- package/src/sdk/host/host.ts +13 -1
- package/src/sdk/session.ts +4 -1
- package/src/session/internal/managed-session-scope.ts +15 -1
- package/src/tools/ask.ts +216 -41
- package/src/tools/bash-allowed-prefixes.ts +79 -29
- package/src/tools/browser/launch.ts +3 -1
- package/src/tools/browser/readable.ts +1 -1
- package/src/tools/browser/screenshot-format.ts +34 -0
- package/src/tools/browser/tab-protocol.ts +3 -0
- package/src/tools/browser/tab-supervisor.ts +125 -122
- package/src/tools/browser/tab-worker-entry.ts +33 -14
- package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
- package/src/tools/browser/tab-worker-smoke.ts +87 -0
- package/src/tools/browser/tab-worker.ts +7 -5
- package/src/tools/output-meta.ts +6 -2
- package/src/tools/path-utils.ts +42 -35
- package/src/tools/read.ts +189 -64
- package/src/tools/render-utils.ts +1 -27
- package/src/tools/resource-gc.ts +123 -34
- package/src/tools/subagent.ts +43 -2
- package/src/utils/file-mentions.ts +4 -4
- package/src/utils/image-loading.ts +85 -34
- package/src/utils/image-resize.ts +16 -9
- package/src/utils/pasted-image-loading.ts +514 -0
- package/src/utils/pasted-image-path.ts +223 -98
- package/src/web/scrapers/html-to-markdown.ts +68 -0
- package/src/web/scrapers/readthedocs.ts +1 -1
- package/src/web/scrapers/types.ts +1 -68
- package/src/web/search/providers/openai-compatible.ts +76 -23
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
} from "./components/pet-capability";
|
|
63
63
|
import type { ToolExecutionHandle } from "./components/tool-execution";
|
|
64
64
|
import { StatusLineComponent } from "./components/tool-status-header";
|
|
65
|
+
import { composeToolText } from "./components/tool-transcript-format";
|
|
65
66
|
import {
|
|
66
67
|
WelcomeComponent,
|
|
67
68
|
type WelcomeLogoMode,
|
|
@@ -1817,17 +1818,27 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1817
1818
|
result?.content
|
|
1818
1819
|
.filter(part => part.type === "text")
|
|
1819
1820
|
.map(part => part.text)
|
|
1820
|
-
.join("\n")
|
|
1821
|
+
.join("\n")
|
|
1822
|
+
.trim() ?? "";
|
|
1821
1823
|
items.push({
|
|
1822
1824
|
kind: "tool",
|
|
1823
1825
|
source: { toolCallId: content.id, content, result },
|
|
1824
1826
|
getPayload: () => ({
|
|
1825
|
-
text:
|
|
1827
|
+
text: composeToolText({
|
|
1828
|
+
name: content.name,
|
|
1829
|
+
args: content.arguments,
|
|
1830
|
+
intent: content.intent,
|
|
1831
|
+
resultText,
|
|
1832
|
+
isError: result?.isError ?? false,
|
|
1833
|
+
hasResult: toolResults.has(content.id),
|
|
1834
|
+
}),
|
|
1826
1835
|
metadata: {
|
|
1827
1836
|
name: content.name,
|
|
1828
1837
|
arguments: content.arguments,
|
|
1829
1838
|
intent: content.intent,
|
|
1830
1839
|
isError: result?.isError ?? false,
|
|
1840
|
+
resultText,
|
|
1841
|
+
hasResult: toolResults.has(content.id),
|
|
1831
1842
|
},
|
|
1832
1843
|
source: { content, result },
|
|
1833
1844
|
}),
|
|
@@ -248,6 +248,13 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
|
|
|
248
248
|
|
|
249
249
|
return this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol);
|
|
250
250
|
}
|
|
251
|
+
getForceFileSuggestions(
|
|
252
|
+
lines: string[],
|
|
253
|
+
cursorLine: number,
|
|
254
|
+
cursorCol: number,
|
|
255
|
+
): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
|
|
256
|
+
return this.#baseProvider.getForceFileSuggestions(lines, cursorLine, cursorCol);
|
|
257
|
+
}
|
|
251
258
|
|
|
252
259
|
applyCompletion(
|
|
253
260
|
lines: string[],
|
package/src/sdk/broker/broker.ts
CHANGED
|
@@ -12,12 +12,14 @@ import {
|
|
|
12
12
|
type BrokerDiscovery,
|
|
13
13
|
brokerDiscoveryPath,
|
|
14
14
|
brokerProcessIncarnation,
|
|
15
|
+
heartbeatBrokerDiscoveryRetained,
|
|
15
16
|
isPidAlive,
|
|
16
17
|
newBrokerToken,
|
|
18
|
+
publishBrokerDiscovery,
|
|
17
19
|
type RedactedBrokerDiscovery,
|
|
20
|
+
type RetainedBrokerDiscovery,
|
|
18
21
|
readBrokerDiscovery,
|
|
19
22
|
redactBrokerDiscovery,
|
|
20
|
-
writeBrokerDiscovery,
|
|
21
23
|
} from "./discovery";
|
|
22
24
|
import { deriveIdempotencyIdentity } from "./identity";
|
|
23
25
|
import { executeLifecycle, isCanonicalSessionId } from "./lifecycle";
|
|
@@ -362,6 +364,17 @@ type BrokerLockSnapshot = {
|
|
|
362
364
|
identity: string;
|
|
363
365
|
};
|
|
364
366
|
|
|
367
|
+
const BROKER_PUBLICATION_CADENCE_MS = 5_000;
|
|
368
|
+
const BROKER_PUBLICATION_GRACE_MS = 15_000;
|
|
369
|
+
const BROKER_SETTLEMENT_MS = 2_000;
|
|
370
|
+
type BrokerPublicationState =
|
|
371
|
+
| "healthy-owned"
|
|
372
|
+
| "suspect-unpublished"
|
|
373
|
+
| "observation-ambiguous"
|
|
374
|
+
| "heartbeat-ambiguous"
|
|
375
|
+
| "stopping";
|
|
376
|
+
type BrokerStopMode = "owned-root" | "lost-root";
|
|
377
|
+
|
|
365
378
|
const terminalPersistenceHooksForTest = new WeakMap<Broker, () => void>();
|
|
366
379
|
|
|
367
380
|
export class Broker {
|
|
@@ -372,10 +385,17 @@ export class Broker {
|
|
|
372
385
|
#lock: string;
|
|
373
386
|
#owner = randomBytes(12).toString("hex");
|
|
374
387
|
#chains = new Map<string, Promise<void>>();
|
|
388
|
+
#admitted = new Set<Promise<void>>();
|
|
389
|
+
#publication: RetainedBrokerDiscovery | null = null;
|
|
390
|
+
#publicationState: BrokerPublicationState = "healthy-owned";
|
|
391
|
+
#lossAt: bigint | null = null;
|
|
375
392
|
#stopping = false;
|
|
376
393
|
#transport: BrokerTransport | null = null;
|
|
377
394
|
#heartbeatTimer: NodeJS.Timeout | null = null;
|
|
378
|
-
#
|
|
395
|
+
#completionTask: Promise<void> | null = null;
|
|
396
|
+
#completion!: Promise<void>;
|
|
397
|
+
#resolveCompletion!: () => void;
|
|
398
|
+
#rejectCompletion!: (error: unknown) => void;
|
|
379
399
|
constructor(settings: BrokerSettings) {
|
|
380
400
|
this.settings = {
|
|
381
401
|
agentDir: settings.agentDir,
|
|
@@ -387,6 +407,10 @@ export class Broker {
|
|
|
387
407
|
this.index = new SessionIndex(settings.agentDir);
|
|
388
408
|
this.ledger = new LifecycleLedger(settings.agentDir);
|
|
389
409
|
this.#lock = path.join(settings.agentDir, "sdk", "broker.lock");
|
|
410
|
+
const completion = Promise.withResolvers<void>();
|
|
411
|
+
this.#completion = completion.promise;
|
|
412
|
+
this.#resolveCompletion = completion.resolve;
|
|
413
|
+
this.#rejectCompletion = completion.reject;
|
|
390
414
|
}
|
|
391
415
|
#lockRecordPath(): string {
|
|
392
416
|
return path.join(this.#lock, BROKER_LOCK_RECORD);
|
|
@@ -490,7 +514,17 @@ export class Broker {
|
|
|
490
514
|
}
|
|
491
515
|
|
|
492
516
|
async start(): Promise<BrokerDiscovery> {
|
|
517
|
+
if (this.#completionTask) {
|
|
518
|
+
await this.#completionTask;
|
|
519
|
+
const completion = Promise.withResolvers<void>();
|
|
520
|
+
this.#completion = completion.promise;
|
|
521
|
+
this.#resolveCompletion = completion.resolve;
|
|
522
|
+
this.#rejectCompletion = completion.reject;
|
|
523
|
+
this.#completionTask = null;
|
|
524
|
+
}
|
|
493
525
|
this.#stopping = false;
|
|
526
|
+
this.#publicationState = "healthy-owned";
|
|
527
|
+
this.#lossAt = null;
|
|
494
528
|
await Promise.all([this.ledger.assertSupportedStateVersions(), readBrokerDiscovery(this.settings.agentDir)]);
|
|
495
529
|
await fs.mkdir(path.dirname(this.#lock), { recursive: true, mode: 0o700 });
|
|
496
530
|
for (;;) {
|
|
@@ -544,15 +578,19 @@ export class Broker {
|
|
|
544
578
|
startedAt: now,
|
|
545
579
|
heartbeatAt: now,
|
|
546
580
|
};
|
|
547
|
-
await
|
|
548
|
-
this.#
|
|
549
|
-
|
|
550
|
-
|
|
581
|
+
this.#publication = await publishBrokerDiscovery(this.settings.agentDir, this.discovery);
|
|
582
|
+
this.#publicationState = "healthy-owned";
|
|
583
|
+
const cadenceMs = Math.max(
|
|
584
|
+
10,
|
|
585
|
+
Math.min(BROKER_PUBLICATION_CADENCE_MS, Math.floor(this.settings.heartbeatTtlMs / 3)),
|
|
551
586
|
);
|
|
587
|
+
this.#heartbeatTimer = setInterval(() => void this.#watchPublication(), cadenceMs);
|
|
552
588
|
return this.discovery;
|
|
553
589
|
} catch (error) {
|
|
554
590
|
await this.#transport?.stop();
|
|
555
591
|
this.#transport = null;
|
|
592
|
+
this.#publication?.close();
|
|
593
|
+
this.#publication = null;
|
|
556
594
|
this.discovery = null;
|
|
557
595
|
await this.#releaseOwnedLock();
|
|
558
596
|
throw error;
|
|
@@ -561,38 +599,96 @@ export class Broker {
|
|
|
561
599
|
get ownsDiscovery(): boolean {
|
|
562
600
|
return this.discovery?.ownerId === this.#owner;
|
|
563
601
|
}
|
|
602
|
+
get completion(): Promise<void> {
|
|
603
|
+
return this.#completion;
|
|
604
|
+
}
|
|
564
605
|
status(): RedactedBrokerDiscovery | null {
|
|
565
606
|
return this.discovery ? redactBrokerDiscovery(this.discovery) : null;
|
|
566
607
|
}
|
|
567
|
-
|
|
568
|
-
if (
|
|
569
|
-
this
|
|
570
|
-
|
|
571
|
-
this.#
|
|
572
|
-
await this.#heartbeatWrite;
|
|
608
|
+
#fence(kind: "suspect-unpublished" | "observation-ambiguous" | "heartbeat-ambiguous"): void {
|
|
609
|
+
if (this.#publicationState === "stopping") return;
|
|
610
|
+
this.#publicationState = kind;
|
|
611
|
+
if (kind === "suspect-unpublished") this.#lossAt ??= process.hrtime.bigint();
|
|
612
|
+
else this.#lossAt = null;
|
|
573
613
|
}
|
|
574
|
-
async
|
|
575
|
-
this.#stopping
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
614
|
+
async #watchPublication(writeHeartbeat = true): Promise<void> {
|
|
615
|
+
if (!this.#publication || this.#publicationState === "stopping") return;
|
|
616
|
+
let observation: ReturnType<RetainedBrokerDiscovery["observe"]>;
|
|
617
|
+
try {
|
|
618
|
+
observation = this.#publication.observe();
|
|
619
|
+
} catch {
|
|
620
|
+
this.#fence("observation-ambiguous");
|
|
621
|
+
return;
|
|
579
622
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
if (this.discovery?.ownerId === this.#owner) {
|
|
585
|
-
try {
|
|
586
|
-
const disk = JSON.parse(await fs.readFile(brokerDiscoveryPath(this.settings.agentDir), "utf8")) as {
|
|
587
|
-
ownerId?: string;
|
|
588
|
-
};
|
|
589
|
-
if (disk.ownerId === this.#owner) await fs.unlink(brokerDiscoveryPath(this.settings.agentDir));
|
|
590
|
-
} catch (e) {
|
|
591
|
-
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
623
|
+
if (observation === "owned") {
|
|
624
|
+
if (this.#publicationState === "heartbeat-ambiguous") {
|
|
625
|
+
if (writeHeartbeat) await this.#writeHeartbeat();
|
|
626
|
+
return;
|
|
592
627
|
}
|
|
593
|
-
|
|
628
|
+
this.#publicationState = "healthy-owned";
|
|
629
|
+
this.#lossAt = null;
|
|
630
|
+
if (writeHeartbeat) await this.#writeHeartbeat();
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
this.#fence(observation === "ambiguous" ? "observation-ambiguous" : "suspect-unpublished");
|
|
634
|
+
if (
|
|
635
|
+
this.#lossAt !== null &&
|
|
636
|
+
process.hrtime.bigint() - this.#lossAt >= BigInt(BROKER_PUBLICATION_GRACE_MS) * 1_000_000n
|
|
637
|
+
)
|
|
638
|
+
void this.#complete("lost-root");
|
|
639
|
+
}
|
|
640
|
+
async #writeHeartbeat(): Promise<void> {
|
|
641
|
+
if (!this.discovery || !this.#publication || this.#publicationState === "stopping") return;
|
|
642
|
+
const heartbeatAt = Date.now();
|
|
643
|
+
try {
|
|
644
|
+
if (!(await heartbeatBrokerDiscoveryRetained(this.#publication, heartbeatAt))) {
|
|
645
|
+
this.#fence("heartbeat-ambiguous");
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
} catch {
|
|
649
|
+
this.#fence("heartbeat-ambiguous");
|
|
650
|
+
return;
|
|
594
651
|
}
|
|
595
|
-
this
|
|
652
|
+
this.#publicationState = "healthy-owned";
|
|
653
|
+
this.#lossAt = null;
|
|
654
|
+
this.discovery = { ...this.discovery, heartbeatAt };
|
|
655
|
+
}
|
|
656
|
+
async heartbeat(): Promise<void> {
|
|
657
|
+
if (this.#publicationState !== "healthy-owned") return;
|
|
658
|
+
await this.#writeHeartbeat();
|
|
659
|
+
}
|
|
660
|
+
async #complete(mode: BrokerStopMode): Promise<void> {
|
|
661
|
+
if (this.#completionTask) return this.#completionTask;
|
|
662
|
+
this.#stopping = true;
|
|
663
|
+
this.#publicationState = "stopping";
|
|
664
|
+
if (this.#heartbeatTimer) clearInterval(this.#heartbeatTimer);
|
|
665
|
+
this.#heartbeatTimer = null;
|
|
666
|
+
this.#completionTask = (async () => {
|
|
667
|
+
await this.#transport?.stop();
|
|
668
|
+
this.#transport = null;
|
|
669
|
+
if (mode === "lost-root")
|
|
670
|
+
await Promise.race([Promise.allSettled(this.#admitted), Bun.sleep(BROKER_SETTLEMENT_MS)]);
|
|
671
|
+
else await Promise.allSettled(this.#admitted);
|
|
672
|
+
this.#publication?.close();
|
|
673
|
+
this.#publication = null;
|
|
674
|
+
if (mode === "owned-root" && this.discovery?.ownerId === this.#owner) {
|
|
675
|
+
try {
|
|
676
|
+
const disk = JSON.parse(await fs.readFile(brokerDiscoveryPath(this.settings.agentDir), "utf8")) as {
|
|
677
|
+
ownerId?: string;
|
|
678
|
+
};
|
|
679
|
+
if (disk.ownerId === this.#owner) await fs.unlink(brokerDiscoveryPath(this.settings.agentDir));
|
|
680
|
+
} catch (e) {
|
|
681
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
682
|
+
}
|
|
683
|
+
await this.#releaseOwnedLock();
|
|
684
|
+
}
|
|
685
|
+
this.discovery = null;
|
|
686
|
+
})();
|
|
687
|
+
void this.#completionTask.then(this.#resolveCompletion, this.#rejectCompletion);
|
|
688
|
+
return this.#completionTask;
|
|
689
|
+
}
|
|
690
|
+
async stop(): Promise<void> {
|
|
691
|
+
await this.#complete("owned-root");
|
|
596
692
|
}
|
|
597
693
|
async #endpoint(input: Record<string, unknown>): Promise<BrokerResponse> {
|
|
598
694
|
const sessionId = input.sessionId;
|
|
@@ -634,7 +730,18 @@ export class Broker {
|
|
|
634
730
|
throw e;
|
|
635
731
|
}
|
|
636
732
|
}
|
|
637
|
-
|
|
733
|
+
handleRequest(operation: string, input: Record<string, unknown>, idempotencyKey?: string): Promise<BrokerResponse> {
|
|
734
|
+
if (this.#stopping || (this.#publication !== null && this.#publicationState !== "healthy-owned"))
|
|
735
|
+
return Promise.resolve(error("unavailable", "broker publication is unavailable"));
|
|
736
|
+
let release!: () => void;
|
|
737
|
+
const admission = new Promise<void>(resolve => (release = resolve));
|
|
738
|
+
this.#admitted.add(admission);
|
|
739
|
+
return this.#handleRequest(operation, input, idempotencyKey).finally(() => {
|
|
740
|
+
release();
|
|
741
|
+
this.#admitted.delete(admission);
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
async #handleRequest(
|
|
638
745
|
operation: string,
|
|
639
746
|
input: Record<string, unknown>,
|
|
640
747
|
idempotencyKey?: string,
|
|
@@ -1,9 +1,97 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { type NativeRetainedBrokerPublication, retainBrokerPublication } from "@gajae-code/natives";
|
|
4
5
|
import { processIncarnation } from "./process-incarnation";
|
|
5
6
|
import { assertSupportedStateVersion, SDK_STATE_VERSION } from "./state-version";
|
|
6
7
|
|
|
8
|
+
export type BrokerPublicationObservation = "owned" | "absent" | "replaced" | "ambiguous";
|
|
9
|
+
export interface RetainedBrokerDiscovery {
|
|
10
|
+
observe(): BrokerPublicationObservation;
|
|
11
|
+
heartbeat(heartbeatAt: number): Promise<boolean>;
|
|
12
|
+
close(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function requireRetainedBrokerPublication(agentDir: string): NativeRetainedBrokerPublication {
|
|
16
|
+
if (typeof retainBrokerPublication !== "function") {
|
|
17
|
+
throw new Error("Loaded native bindings do not expose retained broker publication authority.");
|
|
18
|
+
}
|
|
19
|
+
return retainBrokerPublication(agentDir);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function rollbackPublishedBrokerDiscovery(agentDir: string, discovery: BrokerDiscovery): Promise<void> {
|
|
23
|
+
const file = brokerDiscoveryPath(agentDir);
|
|
24
|
+
try {
|
|
25
|
+
const raw: unknown = JSON.parse(await fs.readFile(file, "utf8"));
|
|
26
|
+
if (
|
|
27
|
+
!raw ||
|
|
28
|
+
typeof raw !== "object" ||
|
|
29
|
+
(raw as BrokerDiscovery).ownerId !== discovery.ownerId ||
|
|
30
|
+
(raw as BrokerDiscovery).pid !== discovery.pid ||
|
|
31
|
+
(raw as BrokerDiscovery).incarnation !== discovery.incarnation ||
|
|
32
|
+
(raw as BrokerDiscovery).token !== discovery.token ||
|
|
33
|
+
(raw as BrokerDiscovery).startedAt !== discovery.startedAt ||
|
|
34
|
+
(raw as BrokerDiscovery).heartbeatAt !== discovery.heartbeatAt
|
|
35
|
+
)
|
|
36
|
+
return;
|
|
37
|
+
await fs.unlink(file);
|
|
38
|
+
await syncDirectory(path.dirname(file));
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class NativeRetainedBrokerDiscovery implements RetainedBrokerDiscovery {
|
|
45
|
+
#closed = false;
|
|
46
|
+
#publication: NativeRetainedBrokerPublication;
|
|
47
|
+
constructor(publication: NativeRetainedBrokerPublication) {
|
|
48
|
+
this.#publication = publication;
|
|
49
|
+
}
|
|
50
|
+
observe(): BrokerPublicationObservation {
|
|
51
|
+
if (this.#closed) return "ambiguous";
|
|
52
|
+
const kind = this.#publication.observe().kind;
|
|
53
|
+
return kind === "owned" || kind === "absent" || kind === "replaced" || kind === "ambiguous" ? kind : "ambiguous";
|
|
54
|
+
}
|
|
55
|
+
async heartbeat(heartbeatAt: number): Promise<boolean> {
|
|
56
|
+
if (this.#closed || !isFixedWidthHeartbeat(heartbeatAt)) return false;
|
|
57
|
+
const write = this.#publication.heartbeat(String(heartbeatAt));
|
|
58
|
+
if (write.kind !== "written") return false;
|
|
59
|
+
return this.#publication.sync().kind === "synced";
|
|
60
|
+
}
|
|
61
|
+
close(): void {
|
|
62
|
+
if (this.#closed) return;
|
|
63
|
+
this.#closed = true;
|
|
64
|
+
this.#publication.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class LegacyWindowsBrokerDiscovery implements RetainedBrokerDiscovery {
|
|
69
|
+
#closed = false;
|
|
70
|
+
#discovery: BrokerDiscovery;
|
|
71
|
+
#agentDir: string;
|
|
72
|
+
constructor(agentDir: string, discovery: BrokerDiscovery) {
|
|
73
|
+
this.#agentDir = agentDir;
|
|
74
|
+
this.#discovery = discovery;
|
|
75
|
+
}
|
|
76
|
+
observe(): BrokerPublicationObservation {
|
|
77
|
+
return this.#closed ? "ambiguous" : "owned";
|
|
78
|
+
}
|
|
79
|
+
async heartbeat(heartbeatAt: number): Promise<boolean> {
|
|
80
|
+
if (this.#closed || !isFixedWidthHeartbeat(heartbeatAt)) return false;
|
|
81
|
+
const next = { ...this.#discovery, heartbeatAt };
|
|
82
|
+
await writeBrokerDiscovery(this.#agentDir, next);
|
|
83
|
+
this.#discovery = next;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
close(): void {
|
|
87
|
+
this.#closed = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isFixedWidthHeartbeat(value: number): boolean {
|
|
92
|
+
return Number.isSafeInteger(value) && value >= 1_000_000_000_000 && value <= 9_999_999_999_999;
|
|
93
|
+
}
|
|
94
|
+
|
|
7
95
|
export const BROKER_HEARTBEAT_TTL_MS = 15_000;
|
|
8
96
|
export interface BrokerDiscovery {
|
|
9
97
|
version: typeof SDK_STATE_VERSION;
|
|
@@ -63,6 +151,9 @@ export async function writeBrokerDiscovery(agentDir: string, discovery: BrokerDi
|
|
|
63
151
|
const incarnation = discovery.incarnation ?? brokerProcessIncarnation(discovery.pid);
|
|
64
152
|
if (!incarnation) throw new Error(`Broker process incarnation is unavailable for pid ${discovery.pid}.`);
|
|
65
153
|
const record: BrokerDiscovery = { ...discovery, incarnation };
|
|
154
|
+
if (!isFixedWidthHeartbeat(discovery.heartbeatAt)) {
|
|
155
|
+
throw new Error("Broker heartbeatAt must be a fixed-width 13-digit millisecond timestamp.");
|
|
156
|
+
}
|
|
66
157
|
const file = brokerDiscoveryPath(agentDir);
|
|
67
158
|
const dir = path.dirname(file);
|
|
68
159
|
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
@@ -78,6 +169,40 @@ export async function writeBrokerDiscovery(agentDir: string, discovery: BrokerDi
|
|
|
78
169
|
await fs.rm(temp, { force: true });
|
|
79
170
|
}
|
|
80
171
|
}
|
|
172
|
+
|
|
173
|
+
/** Publish once by name, then retain no-follow native authority for recurring heartbeats. */
|
|
174
|
+
export async function publishBrokerDiscovery(
|
|
175
|
+
agentDir: string,
|
|
176
|
+
discovery: BrokerDiscoveryWrite,
|
|
177
|
+
platform: NodeJS.Platform = process.platform,
|
|
178
|
+
): Promise<RetainedBrokerDiscovery> {
|
|
179
|
+
const incarnation = discovery.incarnation ?? brokerProcessIncarnation(discovery.pid);
|
|
180
|
+
if (!incarnation) throw new Error(`Broker process incarnation is unavailable for pid ${discovery.pid}.`);
|
|
181
|
+
const published: BrokerDiscovery = { ...discovery, incarnation };
|
|
182
|
+
await writeBrokerDiscovery(agentDir, published);
|
|
183
|
+
if (platform === "win32") return new LegacyWindowsBrokerDiscovery(agentDir, published);
|
|
184
|
+
try {
|
|
185
|
+
return new NativeRetainedBrokerDiscovery(requireRetainedBrokerPublication(agentDir));
|
|
186
|
+
} catch (error) {
|
|
187
|
+
try {
|
|
188
|
+
await rollbackPublishedBrokerDiscovery(agentDir, published);
|
|
189
|
+
} catch (rollbackError) {
|
|
190
|
+
throw new AggregateError(
|
|
191
|
+
[error, rollbackError],
|
|
192
|
+
"Broker publication authority acquisition and exact rollback both failed.",
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Heartbeat through retained authority, or the explicit Windows compatibility path. */
|
|
200
|
+
export function heartbeatBrokerDiscoveryRetained(
|
|
201
|
+
publication: RetainedBrokerDiscovery,
|
|
202
|
+
heartbeatAt: number,
|
|
203
|
+
): Promise<boolean> {
|
|
204
|
+
return publication.heartbeat(heartbeatAt);
|
|
205
|
+
}
|
|
81
206
|
export async function readBrokerDiscovery(
|
|
82
207
|
agentDir: string,
|
|
83
208
|
ttlMs = BROKER_HEARTBEAT_TTL_MS,
|
package/src/sdk/broker/ensure.ts
CHANGED
|
@@ -19,12 +19,32 @@ const FIXTURE_DISCOVERY_TIMEOUT_MS = 30_000;
|
|
|
19
19
|
const REAP_GRACEFUL_MS = 2_000;
|
|
20
20
|
const REAP_SIGKILL_CAP_MS = 2_000;
|
|
21
21
|
export interface FixtureBrokerLease {
|
|
22
|
+
/** Backward-compatible fixture cleanup alias for exact child termination. */
|
|
22
23
|
close(): Promise<void>;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
export interface ExactFixtureBrokerLease extends FixtureBrokerLease {
|
|
27
|
+
/** Observes the retained child only; it never signals a process. */
|
|
28
|
+
waitForExit(timeoutMs: number): Promise<boolean>;
|
|
29
|
+
/** Signals only the retained ChildProcess, never a discovery-derived PID. */
|
|
30
|
+
terminateExactChild(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FixtureBrokerCommand {
|
|
34
|
+
file: string;
|
|
35
|
+
args: readonly string[];
|
|
36
|
+
cwd?: string;
|
|
37
|
+
env?: NodeJS.ProcessEnv;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface StartedFixtureBrokerCommand {
|
|
41
|
+
lease: ExactFixtureBrokerLease;
|
|
42
|
+
control: NodeJS.WritableStream;
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
export interface StartedFixtureBroker {
|
|
26
46
|
discovery: BrokerDiscovery;
|
|
27
|
-
lease:
|
|
47
|
+
lease: ExactFixtureBrokerLease;
|
|
28
48
|
}
|
|
29
49
|
|
|
30
50
|
interface BrokerOwner {
|
|
@@ -37,7 +57,7 @@ type EnsureOutcome =
|
|
|
37
57
|
| { kind: "external-discovery"; discovery: BrokerDiscovery }
|
|
38
58
|
| { kind: "prior-local-owner"; discovery: BrokerDiscovery; owner: BrokerOwner }
|
|
39
59
|
| { kind: "local-started-discovery"; discovery: BrokerDiscovery }
|
|
40
|
-
| { kind: "local-started-fixture"; discovery: BrokerDiscovery; owner: BrokerOwner };
|
|
60
|
+
| { kind: "local-started-fixture"; discovery: BrokerDiscovery; owner: BrokerOwner; child: ChildProcess };
|
|
41
61
|
interface EnsureInFlight {
|
|
42
62
|
initiator: EnsureInitiator;
|
|
43
63
|
promise: Promise<EnsureOutcome>;
|
|
@@ -167,27 +187,48 @@ function fixtureLeaseUnavailable(): Error {
|
|
|
167
187
|
return new Error("fixture_broker_lease_unavailable");
|
|
168
188
|
}
|
|
169
189
|
|
|
170
|
-
function
|
|
171
|
-
let
|
|
172
|
-
|
|
190
|
+
function createFixtureLeaseFromChild(child: ChildProcess, terminate: () => Promise<void>): ExactFixtureBrokerLease {
|
|
191
|
+
let termination: Promise<void> | undefined;
|
|
192
|
+
const hasExited = (): boolean => child.exitCode !== null || child.signalCode !== null || child.pid === undefined;
|
|
193
|
+
const waitForExit = (timeoutMs: number): Promise<boolean> => {
|
|
194
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0)
|
|
195
|
+
return Promise.reject(new Error("Invalid fixture broker exit timeout."));
|
|
196
|
+
if (hasExited()) return Promise.resolve(true);
|
|
197
|
+
return new Promise(resolve => {
|
|
198
|
+
let settled = false;
|
|
199
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
200
|
+
const onExit = (): void => finish(true);
|
|
201
|
+
const finish = (exited: boolean): void => {
|
|
202
|
+
if (settled) return;
|
|
203
|
+
settled = true;
|
|
204
|
+
if (timer) clearTimeout(timer);
|
|
205
|
+
child.off("exit", onExit);
|
|
206
|
+
child.off("close", onExit);
|
|
207
|
+
resolve(exited && hasExited());
|
|
208
|
+
};
|
|
209
|
+
timer = setTimeout(() => finish(false), timeoutMs);
|
|
210
|
+
child.once("exit", onExit);
|
|
211
|
+
child.once("close", onExit);
|
|
212
|
+
if (hasExited()) finish(true);
|
|
213
|
+
});
|
|
214
|
+
};
|
|
173
215
|
return {
|
|
216
|
+
waitForExit,
|
|
217
|
+
terminateExactChild(): Promise<void> {
|
|
218
|
+
if (!termination) termination = terminate();
|
|
219
|
+
return termination;
|
|
220
|
+
},
|
|
174
221
|
close(): Promise<void> {
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
closeAttempt = owner.stop().then(
|
|
178
|
-
() => {
|
|
179
|
-
closed = true;
|
|
180
|
-
},
|
|
181
|
-
(error: unknown) => {
|
|
182
|
-
closeAttempt = undefined;
|
|
183
|
-
throw error;
|
|
184
|
-
},
|
|
185
|
-
);
|
|
186
|
-
return closeAttempt;
|
|
222
|
+
if (!termination) termination = terminate();
|
|
223
|
+
return termination;
|
|
187
224
|
},
|
|
188
225
|
};
|
|
189
226
|
}
|
|
190
227
|
|
|
228
|
+
function createFixtureLease(owner: BrokerOwner, child: ChildProcess): ExactFixtureBrokerLease {
|
|
229
|
+
return createFixtureLeaseFromChild(child, () => owner.stop());
|
|
230
|
+
}
|
|
231
|
+
|
|
191
232
|
async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: EnsureInitiator): Promise<EnsureOutcome> {
|
|
192
233
|
const priorOwner = owners.get(settings.agentDir);
|
|
193
234
|
const existing = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs);
|
|
@@ -226,7 +267,7 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
226
267
|
if (discovered) {
|
|
227
268
|
if (owner.markReady(discovered)) {
|
|
228
269
|
return initiator === "fixture-lease"
|
|
229
|
-
? { kind: "local-started-fixture", discovery: discovered, owner }
|
|
270
|
+
? { kind: "local-started-fixture", discovery: discovered, owner, child }
|
|
230
271
|
: { kind: "local-started-discovery", discovery: discovered };
|
|
231
272
|
}
|
|
232
273
|
await owner.stop();
|
|
@@ -280,10 +321,53 @@ export function startFixtureBrokerWithLeaseForTest(settings: EnsureBrokerSetting
|
|
|
280
321
|
const inFlight = startEnsure(settings, "fixture-lease");
|
|
281
322
|
return inFlight.promise.then(outcome => {
|
|
282
323
|
if (outcome.kind !== "local-started-fixture") throw fixtureLeaseUnavailable();
|
|
283
|
-
return { discovery: outcome.discovery, lease: createFixtureLease(outcome.owner) };
|
|
324
|
+
return { discovery: outcome.discovery, lease: createFixtureLease(outcome.owner, outcome.child) };
|
|
284
325
|
});
|
|
285
326
|
}
|
|
286
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Test-only launch surface for topology fixtures. It accepts an already-resolved
|
|
330
|
+
* command and retains the exact spawned child; no production selection path
|
|
331
|
+
* reaches this function.
|
|
332
|
+
*/
|
|
333
|
+
export function startFixtureBrokerCommandWithLeaseForTest(command: FixtureBrokerCommand): StartedFixtureBrokerCommand {
|
|
334
|
+
if (!command.file || !Array.isArray(command.args)) throw new Error("Invalid fixture broker command.");
|
|
335
|
+
const child = spawn(command.file, [...command.args], {
|
|
336
|
+
cwd: command.cwd,
|
|
337
|
+
detached: true,
|
|
338
|
+
stdio: ["ignore", "ignore", "ignore", "pipe"],
|
|
339
|
+
env: command.env,
|
|
340
|
+
});
|
|
341
|
+
child.unref();
|
|
342
|
+
let spawnError: Error | undefined;
|
|
343
|
+
child.once("error", error => {
|
|
344
|
+
spawnError = error;
|
|
345
|
+
});
|
|
346
|
+
const control = child.stdio[3];
|
|
347
|
+
if (!control || typeof (control as NodeJS.WritableStream).write !== "function") {
|
|
348
|
+
try {
|
|
349
|
+
if (!child.kill("SIGKILL"))
|
|
350
|
+
throw new Error(
|
|
351
|
+
"Fixture broker fd 3 is unavailable and the exact child could not be synchronously terminated.",
|
|
352
|
+
);
|
|
353
|
+
} catch (reapError) {
|
|
354
|
+
throw new AggregateError(
|
|
355
|
+
[reapError],
|
|
356
|
+
"Fixture broker fd 3 is unavailable and the exact child could not be synchronously terminated.",
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
if (spawnError) throw new Error(`Failed to spawn fixture broker: ${spawnError.message}`);
|
|
360
|
+
throw new Error("Fixture broker fd 3 is unavailable.");
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
lease: createFixtureLeaseFromChild(child, async () => {
|
|
364
|
+
await reapSpawnedBroker(child);
|
|
365
|
+
if (spawnError) throw new Error(`Failed to spawn fixture broker: ${spawnError.message}`);
|
|
366
|
+
}),
|
|
367
|
+
control: control as NodeJS.WritableStream,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
287
371
|
/** Test hook: returns a stop handle for the detached broker this process spawned. */
|
|
288
372
|
export function brokerOwnerForTest(agentDir: string): BrokerOwner | undefined {
|
|
289
373
|
return owners.get(agentDir);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Broker } from "./broker";
|
|
2
|
+
|
|
3
|
+
/** Wait for broker-local completion, then terminate only the current broker process. */
|
|
4
|
+
export async function completeBrokerProcess(
|
|
5
|
+
broker: Broker,
|
|
6
|
+
exit: (code: number) => never = code => process.exit(code),
|
|
7
|
+
): Promise<never> {
|
|
8
|
+
await broker.completion;
|
|
9
|
+
return exit(0);
|
|
10
|
+
}
|