@gajae-code/coding-agent 0.18.5 → 0.18.7
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 +140 -0
- package/dist/types/capability/types.d.ts +4 -0
- package/dist/types/cli-commands.d.ts +10 -0
- package/dist/types/cli-ordinary.d.ts +13 -0
- package/dist/types/cli.d.ts +12 -3
- package/dist/types/commands/contribution-prep.d.ts +0 -3
- package/dist/types/commands/sdk.d.ts +3 -0
- package/dist/types/commands/update.d.ts +1 -0
- package/dist/types/config/model-profile-activation.d.ts +19 -0
- package/dist/types/config/settings.d.ts +4 -0
- package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
- package/dist/types/discovery/helpers.d.ts +2 -2
- package/dist/types/edit/modes/replace.d.ts +3 -3
- package/dist/types/edit/streaming.d.ts +2 -2
- package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
- package/dist/types/runtime-mcp/elicitation.d.ts +12 -0
- package/dist/types/sdk/broker/discovery.d.ts +8 -0
- package/dist/types/sdk/broker/ensure.d.ts +1 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +9 -0
- package/dist/types/sdk/broker/runtime.d.ts +1 -1
- package/dist/types/sdk/broker/task-artifact-owner-validation.d.ts +55 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +4 -2
- package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
- package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
- package/dist/types/sdk/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/host.d.ts +6 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/router/session-router.d.ts +9 -0
- package/dist/types/sdk/session.d.ts +13 -0
- package/dist/types/session/agent-session.d.ts +5 -1
- package/dist/types/session/internal/managed-session-storage.d.ts +11 -1
- package/dist/types/session/internal/task-artifact-owner-access.d.ts +17 -0
- package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +3 -0
- package/dist/types/session/session-retirement.d.ts +47 -0
- package/dist/types/session/session-storage.d.ts +0 -46
- package/dist/types/session/session-transcript-header.d.ts +2 -0
- package/dist/types/session/task-artifact-owner-codec.d.ts +107 -0
- package/dist/types/session/task-artifact-owner-retirement.d.ts +7 -0
- package/dist/types/session/task-artifact-owner.d.ts +10 -0
- package/dist/types/tools/ask-contract.d.ts +75 -254
- package/dist/types/tools/bash.d.ts +1 -1
- package/package.json +7 -7
- package/scripts/build-sdk-package-smoke.ts +5 -0
- package/scripts/resident-memory-bench.ts +174 -20
- package/src/capability/index.ts +1 -0
- package/src/capability/types.ts +4 -0
- package/src/cli/config-cli.ts +8 -18
- package/src/cli/public-command-entry.ts +5 -0
- package/src/cli/ssh-cli.ts +6 -3
- package/src/cli/web-search-cli.ts +9 -3
- package/src/cli-commands.ts +14 -0
- package/src/cli-main.ts +7 -8
- package/src/cli-ordinary.ts +197 -0
- package/src/cli.ts +25 -182
- package/src/commands/contribution-prep.ts +1 -3
- package/src/commands/sdk.ts +171 -21
- package/src/commands/update.ts +15 -1
- package/src/config/file-lock.ts +91 -2
- package/src/config/model-profile-activation.ts +70 -4
- package/src/config/model-profile-contract.ts +4 -1
- package/src/config/model-profiles.ts +23 -31
- package/src/config/model-resolver.ts +9 -1
- package/src/config/settings.ts +44 -4
- package/src/coordinator-mcp/event-webhook.ts +19 -14
- package/src/coordinator-mcp/policy.ts +14 -4
- package/src/coordinator-mcp/server.ts +142 -7
- package/src/coordinator-mcp/session-reaper.ts +9 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/discovery/helpers.ts +9 -3
- package/src/discovery/ssh.ts +8 -3
- package/src/edit/modes/replace.ts +6 -6
- package/src/edit/streaming.ts +15 -15
- package/src/exec/bash-shell-supervisor.ts +60 -3
- package/src/gjc-runtime/gc-runtime.ts +2 -1
- package/src/hindsight/config.ts +9 -3
- package/src/internal-urls/docs-index.generated.ts +11 -10
- package/src/memories/index.ts +8 -2
- package/src/modes/acp/acp-agent.ts +724 -370
- package/src/modes/components/tool-execution.ts +1 -1
- package/src/prompts/tools/ask.md +2 -0
- package/src/runtime-mcp/client.ts +13 -4
- package/src/runtime-mcp/elicitation.ts +142 -0
- package/src/sdk/broker/broker.ts +54 -1
- package/src/sdk/broker/daemon-entry.ts +67 -6
- package/src/sdk/broker/discovery.ts +8 -0
- package/src/sdk/broker/ensure.ts +156 -20
- package/src/sdk/broker/lifecycle-ledger.ts +110 -12
- package/src/sdk/broker/lifecycle.ts +334 -348
- package/src/sdk/broker/runtime.ts +5 -1
- package/src/sdk/broker/task-artifact-owner-validation.ts +259 -0
- package/src/sdk/broker/transport.ts +5 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +3 -0
- package/src/sdk/bus/index.ts +3 -3
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/bus/telegram-daemon-control.ts +15 -12
- package/src/sdk/bus/telegram-daemon.ts +38 -3
- package/src/sdk/cli/diagnostics-cli.ts +247 -0
- package/src/sdk/diagnostics/observe-broker.ts +654 -0
- package/src/sdk/host/control/dispatch.ts +52 -4
- package/src/sdk/host/control/operations.ts +1 -1
- package/src/sdk/host/host.ts +6 -0
- package/src/sdk/host/session-runtime.test.ts +83 -4
- package/src/sdk/host/session-runtime.ts +15 -7
- package/src/sdk/index.ts +1 -0
- package/src/sdk/lifecycle-session.ts +1 -0
- package/src/sdk/mcp/server.ts +15 -3
- package/src/sdk/protocol/operation-registry.ts +1 -1
- package/src/sdk/router/session-router.ts +26 -3
- package/src/sdk/session.ts +51 -8
- package/src/session/agent-session.ts +169 -43
- package/src/session/blob-store.ts +78 -33
- package/src/session/internal/managed-session-storage.ts +203 -4
- package/src/session/internal/task-artifact-owner-access.ts +229 -0
- package/src/session/internal/task-artifact-owner-transcript.ts +68 -0
- package/src/session/session-retirement.ts +155 -0
- package/src/session/session-storage.ts +1 -160
- package/src/session/session-transcript-header.ts +17 -0
- package/src/session/task-artifact-owner-codec.ts +578 -0
- package/src/session/task-artifact-owner-retirement.ts +475 -0
- package/src/session/task-artifact-owner.ts +289 -0
- package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
- package/src/setup/hermes-setup.ts +4 -1
- package/src/slash-commands/builtin-registry.ts +8 -5
- package/src/tools/ask-contract.test.ts +76 -1
- package/src/tools/ask-contract.ts +57 -6
- package/src/tools/ask.ts +16 -7
- package/src/tools/bash.ts +38 -29
- package/src/tools/descriptors.test.ts +29 -0
- package/src/tools/tool-catalog.generated.ts +7 -4
- package/src/web/search/index.ts +2 -2
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
import { loadDiagnosticNativeReadOnly } from "@gajae-code/natives/diagnostic-loader";
|
|
2
|
+
import { SdkClient } from "../client/client";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Observation-only broker diagnostics facade (SPEC "Result contract").
|
|
6
|
+
*
|
|
7
|
+
* The publication is read ONLY through the approved read-only native lease
|
|
8
|
+
* (`loadDiagnosticNativeReadOnly` -> `openDiagnosticSnapshot` -> bounded read ->
|
|
9
|
+
* revalidate -> close), so every ancestor ACL/owner/mode and filesystem check of the
|
|
10
|
+
* earlier slices governs this route too. Ordinary discovery is deliberately not used: it
|
|
11
|
+
* would bypass that gate. An unsupported runtime tuple or artifact resolves to
|
|
12
|
+
* `unsupported` BEFORE any publication access. The facade then opens exactly ONE client
|
|
13
|
+
* connection with no reconnect attempts, asks the broker for its published diagnostics
|
|
14
|
+
* and returns a detached frozen snapshot. It never ensures, starts, recovers, retires or spawns
|
|
15
|
+
* anything, never touches the session index, ledger or evidence store, and never hands
|
|
16
|
+
* the caller a token, socket coordinate, path, pid or environment value.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Allowed unavailability reasons; messages are fixed literals keyed by the enum. */
|
|
20
|
+
export type BrokerObservationUnavailableReason =
|
|
21
|
+
| "absent"
|
|
22
|
+
| "stale"
|
|
23
|
+
| "incompatible"
|
|
24
|
+
| "authentication_failed"
|
|
25
|
+
| "unsupported"
|
|
26
|
+
| "generation_mismatch"
|
|
27
|
+
| "transport_unavailable"
|
|
28
|
+
| "timeout"
|
|
29
|
+
| "invalid_response"
|
|
30
|
+
| "unsafe_discovery";
|
|
31
|
+
|
|
32
|
+
const UNAVAILABLE_MESSAGES: Record<BrokerObservationUnavailableReason, string> = {
|
|
33
|
+
absent: "No broker publication was found for the requested agent directory.",
|
|
34
|
+
stale: "The broker publication is stale.",
|
|
35
|
+
incompatible: "The broker publication is not compatible with this client.",
|
|
36
|
+
authentication_failed: "The broker refused the observation credentials.",
|
|
37
|
+
unsupported: "The broker does not support diagnostic observation.",
|
|
38
|
+
generation_mismatch: "The broker generation does not match the expected generation.",
|
|
39
|
+
transport_unavailable: "The broker transport is unavailable.",
|
|
40
|
+
timeout: "The broker observation timed out.",
|
|
41
|
+
invalid_response: "The broker returned an invalid diagnostic response.",
|
|
42
|
+
unsafe_discovery: "The broker publication namespace is not trusted.",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export interface BrokerObservationBuild {
|
|
46
|
+
readonly packageVersion: string;
|
|
47
|
+
readonly buildId: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface BrokerObservationSuccess {
|
|
51
|
+
readonly schema: "gjc.broker-observation";
|
|
52
|
+
readonly version: 1;
|
|
53
|
+
readonly ok: true;
|
|
54
|
+
readonly observedAt: string;
|
|
55
|
+
readonly broker: {
|
|
56
|
+
readonly generation: string;
|
|
57
|
+
readonly build: BrokerObservationBuild;
|
|
58
|
+
readonly diagnosticProtocol: 1;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface BrokerObservationUnavailable {
|
|
63
|
+
readonly schema: "gjc.broker-observation";
|
|
64
|
+
readonly version: 1;
|
|
65
|
+
readonly ok: false;
|
|
66
|
+
readonly observedAt: string;
|
|
67
|
+
readonly unavailable: {
|
|
68
|
+
readonly reason: BrokerObservationUnavailableReason;
|
|
69
|
+
readonly message: string;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type BrokerObservation = BrokerObservationSuccess | BrokerObservationUnavailable;
|
|
74
|
+
|
|
75
|
+
export interface ObserveExistingBrokerOptions {
|
|
76
|
+
readonly agentDir: string;
|
|
77
|
+
readonly expectedGeneration?: string;
|
|
78
|
+
readonly timeoutMs?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The broker's published diagnostics operation. */
|
|
82
|
+
export const BROKER_DIAGNOSTICS_OPERATION = "broker.diagnostics";
|
|
83
|
+
|
|
84
|
+
/** The only diagnostic protocol this client understands. */
|
|
85
|
+
export const BROKER_DIAGNOSTIC_PROTOCOL = 1;
|
|
86
|
+
|
|
87
|
+
/** Contract timeout: default 2000 ms, accepted range 1..10000 ms. */
|
|
88
|
+
export const OBSERVATION_TIMEOUT_DEFAULT_MS = 2_000;
|
|
89
|
+
export const OBSERVATION_TIMEOUT_MIN_MS = 1;
|
|
90
|
+
export const OBSERVATION_TIMEOUT_MAX_MS = 10_000;
|
|
91
|
+
|
|
92
|
+
const MAX_AGENT_DIR_BYTES = 4_096;
|
|
93
|
+
const MAX_AGENT_DIR_COMPONENTS = 128;
|
|
94
|
+
/** A broker generation is 16 random bytes rendered as lowercase hex. */
|
|
95
|
+
const GENERATION_PATTERN = /^[0-9a-f]{32}$/;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Monotonic nanosecond clock for the single observation budget.
|
|
99
|
+
*
|
|
100
|
+
* Production always reads `Bun.nanoseconds()`. Tests may install a deterministic clock so
|
|
101
|
+
* the deadline's behaviour -- no socket after expiry, and a late answer discarded -- can be
|
|
102
|
+
* proven instead of argued. The seam replaces only the time source: no branch, capability
|
|
103
|
+
* or output changes with it.
|
|
104
|
+
*/
|
|
105
|
+
let observationClock: () => number = () => Bun.nanoseconds();
|
|
106
|
+
|
|
107
|
+
export function setObservationClockForTest(clock: (() => number) | null): void {
|
|
108
|
+
observationClock = clock ?? (() => Bun.nanoseconds());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Test-only observers for the native stage boundary. Production installs none, and the
|
|
113
|
+
* hooks never change a branch, a budget or an outcome: they only report what the
|
|
114
|
+
* observation did (`onNativeOpen`) and give a test a point to disturb the environment
|
|
115
|
+
* between the first publication read and the connection (`afterInitialRead`), which is how
|
|
116
|
+
* the pre-socket identity recheck is proven instead of argued.
|
|
117
|
+
*/
|
|
118
|
+
export interface ObservationTestHooks {
|
|
119
|
+
onNativeOpen?: (info: { agentDir: string; budgetMs: number }) => void;
|
|
120
|
+
afterInitialRead?: () => void;
|
|
121
|
+
/** Reports that the retained lease was released, on every exit path. */
|
|
122
|
+
onLeaseClosed?: () => void;
|
|
123
|
+
/**
|
|
124
|
+
* Decorates the retained lease so a test can identify the real operation phases and make
|
|
125
|
+
* a chosen phase refuse or throw. Production installs none.
|
|
126
|
+
*/
|
|
127
|
+
wrapLease?: (lease: DiagnosticSnapshotLease) => DiagnosticSnapshotLease;
|
|
128
|
+
/** Replaces the loader result so loader-stage outcomes can be observed deterministically. */
|
|
129
|
+
loaderOverride?: () => DiagnosticNativeLoad;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Minimal shapes this facade relies on from the approved read-only loader. */
|
|
133
|
+
interface DiagnosticSnapshotRead {
|
|
134
|
+
readonly ok: boolean;
|
|
135
|
+
readonly reason?: string | null;
|
|
136
|
+
readonly bytes?: Uint8Array | null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface DiagnosticSnapshotStatus {
|
|
140
|
+
readonly ok: boolean;
|
|
141
|
+
readonly reason?: string | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface DiagnosticSnapshotLease {
|
|
145
|
+
readonly ok: boolean;
|
|
146
|
+
readonly reason?: string | null;
|
|
147
|
+
read(): DiagnosticSnapshotRead;
|
|
148
|
+
revalidate(): DiagnosticSnapshotStatus;
|
|
149
|
+
close(): void;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
type DiagnosticNativeLoad =
|
|
153
|
+
| { ok: true; value: { openDiagnosticSnapshot(agentDir: string, budgetMs: number): DiagnosticSnapshotLease } }
|
|
154
|
+
| { ok: false; reason: string };
|
|
155
|
+
|
|
156
|
+
let observationTestHooks: ObservationTestHooks = {};
|
|
157
|
+
|
|
158
|
+
export function setObservationTestHooksForTest(hooks: ObservationTestHooks | null): void {
|
|
159
|
+
observationTestHooks = hooks ?? {};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Malformed arguments are a typed caller error, never an observed unavailability. */
|
|
163
|
+
export class DiagnosticOptionsError extends Error {
|
|
164
|
+
readonly code = "invalid_arguments" as const;
|
|
165
|
+
readonly field: string;
|
|
166
|
+
|
|
167
|
+
constructor(field: string) {
|
|
168
|
+
super(`invalid observation argument: ${field}`);
|
|
169
|
+
this.name = "DiagnosticOptionsError";
|
|
170
|
+
this.field = field;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type ObservationOptionsVerdict =
|
|
175
|
+
| { ok: true; value: { agentDir: string; expectedGeneration?: string; timeoutMs: number } }
|
|
176
|
+
| { ok: false; error: { code: "invalid_arguments"; field: string } };
|
|
177
|
+
|
|
178
|
+
const KNOWN_OPTION_FIELDS = new Set(["agentDir", "expectedGeneration", "timeoutMs"]);
|
|
179
|
+
|
|
180
|
+
/** Validate the caller's options against the exact contract. Pure and total. */
|
|
181
|
+
export function validateObservationOptions(options: unknown): ObservationOptionsVerdict {
|
|
182
|
+
const invalid = (field: string): ObservationOptionsVerdict => ({
|
|
183
|
+
ok: false,
|
|
184
|
+
error: { code: "invalid_arguments", field },
|
|
185
|
+
});
|
|
186
|
+
if (typeof options !== "object" || options === null) return invalid("options");
|
|
187
|
+
const record = options as Record<string, unknown>;
|
|
188
|
+
for (const field of Object.keys(record)) {
|
|
189
|
+
// An unknown key is reported as the fixed identifier `options`: a caller's raw key
|
|
190
|
+
// could carry a path, a token, newlines or an unbounded string, and the public error
|
|
191
|
+
// contract reflects no caller input.
|
|
192
|
+
if (!KNOWN_OPTION_FIELDS.has(field)) return invalid("options");
|
|
193
|
+
}
|
|
194
|
+
const agentDir = record.agentDir;
|
|
195
|
+
if (typeof agentDir !== "string" || agentDir.length === 0) return invalid("agentDir");
|
|
196
|
+
if (!agentDir.startsWith("/")) return invalid("agentDir");
|
|
197
|
+
if (agentDir.includes("\u0000")) return invalid("agentDir");
|
|
198
|
+
if (Buffer.byteLength(agentDir, "utf8") > MAX_AGENT_DIR_BYTES) return invalid("agentDir");
|
|
199
|
+
if (agentDir.split("/").filter(part => part.length > 0).length > MAX_AGENT_DIR_COMPONENTS) {
|
|
200
|
+
return invalid("agentDir");
|
|
201
|
+
}
|
|
202
|
+
let expectedGeneration: string | undefined;
|
|
203
|
+
if (record.expectedGeneration !== undefined) {
|
|
204
|
+
if (typeof record.expectedGeneration !== "string") return invalid("expectedGeneration");
|
|
205
|
+
if (!GENERATION_PATTERN.test(record.expectedGeneration)) return invalid("expectedGeneration");
|
|
206
|
+
expectedGeneration = record.expectedGeneration;
|
|
207
|
+
}
|
|
208
|
+
let timeoutMs = OBSERVATION_TIMEOUT_DEFAULT_MS;
|
|
209
|
+
if (record.timeoutMs !== undefined) {
|
|
210
|
+
if (typeof record.timeoutMs !== "number" || !Number.isInteger(record.timeoutMs)) return invalid("timeoutMs");
|
|
211
|
+
if (record.timeoutMs < OBSERVATION_TIMEOUT_MIN_MS || record.timeoutMs > OBSERVATION_TIMEOUT_MAX_MS) {
|
|
212
|
+
return invalid("timeoutMs");
|
|
213
|
+
}
|
|
214
|
+
timeoutMs = record.timeoutMs;
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
ok: true,
|
|
218
|
+
value: { agentDir, ...(expectedGeneration === undefined ? {} : { expectedGeneration }), timeoutMs },
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function unavailable(reason: BrokerObservationUnavailableReason): BrokerObservationUnavailable {
|
|
223
|
+
return Object.freeze({
|
|
224
|
+
schema: "gjc.broker-observation",
|
|
225
|
+
version: 1,
|
|
226
|
+
ok: false,
|
|
227
|
+
observedAt: new Date().toISOString(),
|
|
228
|
+
unavailable: Object.freeze({ reason, message: UNAVAILABLE_MESSAGES[reason] }),
|
|
229
|
+
}) as BrokerObservationUnavailable;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function success(generation: string, build: BrokerObservationBuild): BrokerObservationSuccess {
|
|
233
|
+
return Object.freeze({
|
|
234
|
+
schema: "gjc.broker-observation",
|
|
235
|
+
version: 1,
|
|
236
|
+
ok: true,
|
|
237
|
+
observedAt: new Date().toISOString(),
|
|
238
|
+
broker: Object.freeze({
|
|
239
|
+
generation,
|
|
240
|
+
build: Object.freeze({ packageVersion: build.packageVersion, buildId: build.buildId }),
|
|
241
|
+
diagnosticProtocol: BROKER_DIAGNOSTIC_PROTOCOL,
|
|
242
|
+
}),
|
|
243
|
+
}) as BrokerObservationSuccess;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Bounds that keep every rendered field inside the public output budget. */
|
|
247
|
+
const MAX_PUBLICATION_BYTES = 64 * 1024;
|
|
248
|
+
const MAX_VERSION_LENGTH = 64;
|
|
249
|
+
const MAX_BUILD_ID_LENGTH = 64;
|
|
250
|
+
const MAX_OWNER_LENGTH = 64;
|
|
251
|
+
const VERSION_PATTERN = /^[0-9A-Za-z.+-]{1,64}$/;
|
|
252
|
+
const BUILD_ID_PATTERN = /^[0-9A-Za-z._-]{1,64}$/;
|
|
253
|
+
const OWNER_PATTERN = /^[0-9a-f]{1,64}$/;
|
|
254
|
+
/**
|
|
255
|
+
* Canonical publication endpoint: the owned loopback address, a decimal port with no
|
|
256
|
+
* leading zero, and at most the root path. Anything else -- another host, scheme, path,
|
|
257
|
+
* query or userinfo -- would let the observation leave the endpoint the publisher owns.
|
|
258
|
+
*
|
|
259
|
+
* DESIGN writes the canonical form with a trailing `/`, while the actual publisher emits
|
|
260
|
+
* `ws://127.0.0.1:<port>` without one. The trailing slash is therefore accepted as
|
|
261
|
+
* OPTIONAL: that is a compatibility choice for the existing publisher, not a change to
|
|
262
|
+
* publisher semantics, which this slice deliberately leaves alone.
|
|
263
|
+
*/
|
|
264
|
+
const LOOPBACK_WS_PATTERN = /^ws:\/\/127\.0\.0\.1:([1-9]\d{0,4})\/?$/;
|
|
265
|
+
const MAX_TCP_PORT = 65_535;
|
|
266
|
+
|
|
267
|
+
/** Whether the endpoint is the canonical owned loopback endpoint with an in-range port. */
|
|
268
|
+
export function canonicalLoopbackEndpoint(url: string): boolean {
|
|
269
|
+
const match = LOOPBACK_WS_PATTERN.exec(url);
|
|
270
|
+
if (match === null) return false;
|
|
271
|
+
const port = Number(match[1]);
|
|
272
|
+
return Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT;
|
|
273
|
+
}
|
|
274
|
+
const TOKEN_PATTERN = /^[0-9a-f]{32,128}$/;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Parse verdicts for the publication bytes. A publication that is well formed but
|
|
278
|
+
* carries no diagnostic capability is NOT malformed: an old broker like that is
|
|
279
|
+
* `unsupported`, never `invalid_response` and never `absent`.
|
|
280
|
+
*/
|
|
281
|
+
export type PublicationVerdict =
|
|
282
|
+
| { kind: "ok"; value: ObservedPublication }
|
|
283
|
+
| { kind: "no-capability" }
|
|
284
|
+
| { kind: "malformed" };
|
|
285
|
+
|
|
286
|
+
/** The publication fields this route reads; every other field is ignored, never trusted. */
|
|
287
|
+
export interface ObservedPublication {
|
|
288
|
+
readonly url: string;
|
|
289
|
+
readonly token: string;
|
|
290
|
+
readonly ownerId: string;
|
|
291
|
+
readonly pid: number;
|
|
292
|
+
readonly incarnation: string | null;
|
|
293
|
+
readonly agentRoot: string | null;
|
|
294
|
+
readonly generation: string;
|
|
295
|
+
readonly diagnosticProtocol: number;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Parse the publication bytes the native lease returned. Strict, bounded, total. */
|
|
299
|
+
export function parsePublicationVerdict(bytes: Uint8Array): PublicationVerdict {
|
|
300
|
+
if (bytes.byteLength === 0 || bytes.byteLength > MAX_PUBLICATION_BYTES) return { kind: "malformed" };
|
|
301
|
+
let value: unknown;
|
|
302
|
+
try {
|
|
303
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
304
|
+
} catch {
|
|
305
|
+
return { kind: "malformed" };
|
|
306
|
+
}
|
|
307
|
+
if (typeof value !== "object" || value === null) return { kind: "malformed" };
|
|
308
|
+
const record = value as Record<string, unknown>;
|
|
309
|
+
const url = record.url;
|
|
310
|
+
const token = record.token;
|
|
311
|
+
const ownerId = record.ownerId;
|
|
312
|
+
const pid = record.pid;
|
|
313
|
+
const generation = record.diagnosticGeneration;
|
|
314
|
+
const protocol = record.diagnosticProtocol;
|
|
315
|
+
if (typeof url !== "string" || !canonicalLoopbackEndpoint(url)) return { kind: "malformed" };
|
|
316
|
+
if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) return { kind: "malformed" };
|
|
317
|
+
if (typeof ownerId !== "string" || !OWNER_PATTERN.test(ownerId)) return { kind: "malformed" };
|
|
318
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return { kind: "malformed" };
|
|
319
|
+
// An old broker omits both capability fields entirely: that is `unsupported`, decided
|
|
320
|
+
// before any socket is opened, and never reported as malformed or absent.
|
|
321
|
+
if (generation === undefined && protocol === undefined) return { kind: "no-capability" };
|
|
322
|
+
// Present but out of contract is a malformed publication, not a missing capability.
|
|
323
|
+
if (typeof generation !== "string" || !GENERATION_PATTERN.test(generation)) return { kind: "malformed" };
|
|
324
|
+
if (typeof protocol !== "number" || !Number.isInteger(protocol)) return { kind: "malformed" };
|
|
325
|
+
const incarnation =
|
|
326
|
+
typeof record.incarnation === "string" && record.incarnation.length <= MAX_OWNER_LENGTH
|
|
327
|
+
? record.incarnation
|
|
328
|
+
: null;
|
|
329
|
+
const agentRoot =
|
|
330
|
+
typeof record.agentRoot === "string" && record.agentRoot.length <= MAX_AGENT_DIR_BYTES ? record.agentRoot : null;
|
|
331
|
+
return {
|
|
332
|
+
kind: "ok",
|
|
333
|
+
value: { url, token, ownerId, pid, incarnation, agentRoot, generation, diagnosticProtocol: protocol },
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Convenience wrapper for callers that only need the accepted shape. */
|
|
338
|
+
export function parsePublication(bytes: Uint8Array): ObservedPublication | null {
|
|
339
|
+
const verdict = parsePublicationVerdict(bytes);
|
|
340
|
+
return verdict.kind === "ok" ? verdict.value : null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The exact answered payload this client accepts; unknown fields are refused. */
|
|
344
|
+
interface AnsweredDiagnostics {
|
|
345
|
+
readonly generation: string;
|
|
346
|
+
readonly build: BrokerObservationBuild;
|
|
347
|
+
readonly identity: { readonly ownerId: string; readonly pid: number; readonly agentRoot: string };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const ANSWER_FIELDS = new Set(["diagnosticProtocol", "generation", "build", "identity"]);
|
|
351
|
+
const ANSWER_BUILD_FIELDS = new Set(["packageVersion", "buildId"]);
|
|
352
|
+
const ANSWER_IDENTITY_FIELDS = new Set(["ownerId", "pid", "agentRoot"]);
|
|
353
|
+
|
|
354
|
+
/** Strictly validate the broker's answer, mapping every refusal onto the reason enum. */
|
|
355
|
+
export function parseAnsweredDiagnostics(
|
|
356
|
+
response: unknown,
|
|
357
|
+
): { kind: "ok"; value: AnsweredDiagnostics } | { kind: "unavailable"; reason: BrokerObservationUnavailableReason } {
|
|
358
|
+
const refuse = (reason: BrokerObservationUnavailableReason) => ({ kind: "unavailable" as const, reason });
|
|
359
|
+
if (typeof response !== "object" || response === null) return refuse("invalid_response");
|
|
360
|
+
const envelope = response as Record<string, unknown>;
|
|
361
|
+
if (envelope.ok !== true) {
|
|
362
|
+
const error = envelope.error;
|
|
363
|
+
const code =
|
|
364
|
+
typeof error === "object" && error !== null ? (error as Record<string, unknown>).code : envelope.code;
|
|
365
|
+
if (code === "unauthorized" || code === "forbidden") return refuse("authentication_failed");
|
|
366
|
+
if (code === "unknown_operation" || code === "unsupported_operation") return refuse("unsupported");
|
|
367
|
+
if (code === "timeout" || code === "deadline_exceeded") return refuse("timeout");
|
|
368
|
+
return refuse("transport_unavailable");
|
|
369
|
+
}
|
|
370
|
+
const result = envelope.result;
|
|
371
|
+
if (typeof result !== "object" || result === null) return refuse("invalid_response");
|
|
372
|
+
const record = result as Record<string, unknown>;
|
|
373
|
+
for (const field of Object.keys(record)) {
|
|
374
|
+
if (!ANSWER_FIELDS.has(field)) return refuse("invalid_response");
|
|
375
|
+
}
|
|
376
|
+
if (typeof record.diagnosticProtocol !== "number" || !Number.isInteger(record.diagnosticProtocol)) {
|
|
377
|
+
return refuse("invalid_response");
|
|
378
|
+
}
|
|
379
|
+
if (record.diagnosticProtocol !== BROKER_DIAGNOSTIC_PROTOCOL) return refuse("incompatible");
|
|
380
|
+
if (typeof record.generation !== "string" || !GENERATION_PATTERN.test(record.generation)) {
|
|
381
|
+
return refuse("invalid_response");
|
|
382
|
+
}
|
|
383
|
+
const build = record.build;
|
|
384
|
+
if (typeof build !== "object" || build === null) return refuse("invalid_response");
|
|
385
|
+
const buildRecord = build as Record<string, unknown>;
|
|
386
|
+
for (const field of Object.keys(buildRecord)) {
|
|
387
|
+
if (!ANSWER_BUILD_FIELDS.has(field)) return refuse("invalid_response");
|
|
388
|
+
}
|
|
389
|
+
if (typeof buildRecord.packageVersion !== "string" || !VERSION_PATTERN.test(buildRecord.packageVersion)) {
|
|
390
|
+
return refuse("invalid_response");
|
|
391
|
+
}
|
|
392
|
+
if (buildRecord.packageVersion.length > MAX_VERSION_LENGTH) return refuse("invalid_response");
|
|
393
|
+
let buildId: string | null = null;
|
|
394
|
+
if (buildRecord.buildId !== null) {
|
|
395
|
+
if (typeof buildRecord.buildId !== "string" || !BUILD_ID_PATTERN.test(buildRecord.buildId)) {
|
|
396
|
+
return refuse("invalid_response");
|
|
397
|
+
}
|
|
398
|
+
if (buildRecord.buildId.length > MAX_BUILD_ID_LENGTH) return refuse("invalid_response");
|
|
399
|
+
buildId = buildRecord.buildId;
|
|
400
|
+
}
|
|
401
|
+
const identity = record.identity;
|
|
402
|
+
if (typeof identity !== "object" || identity === null) return refuse("invalid_response");
|
|
403
|
+
const identityRecord = identity as Record<string, unknown>;
|
|
404
|
+
for (const field of Object.keys(identityRecord)) {
|
|
405
|
+
if (!ANSWER_IDENTITY_FIELDS.has(field)) return refuse("invalid_response");
|
|
406
|
+
}
|
|
407
|
+
if (typeof identityRecord.ownerId !== "string" || !OWNER_PATTERN.test(identityRecord.ownerId)) {
|
|
408
|
+
return refuse("invalid_response");
|
|
409
|
+
}
|
|
410
|
+
if (typeof identityRecord.pid !== "number" || !Number.isInteger(identityRecord.pid) || identityRecord.pid <= 0) {
|
|
411
|
+
return refuse("invalid_response");
|
|
412
|
+
}
|
|
413
|
+
if (typeof identityRecord.agentRoot !== "string" || identityRecord.agentRoot.length === 0) {
|
|
414
|
+
return refuse("invalid_response");
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
kind: "ok",
|
|
418
|
+
value: {
|
|
419
|
+
generation: record.generation,
|
|
420
|
+
build: { packageVersion: buildRecord.packageVersion, buildId },
|
|
421
|
+
identity: {
|
|
422
|
+
ownerId: identityRecord.ownerId,
|
|
423
|
+
pid: identityRecord.pid,
|
|
424
|
+
agentRoot: identityRecord.agentRoot,
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Two publication observations describe the same authority. */
|
|
431
|
+
export function sameAuthority(first: ObservedPublication, second: ObservedPublication): boolean {
|
|
432
|
+
return (
|
|
433
|
+
first.generation === second.generation &&
|
|
434
|
+
first.ownerId === second.ownerId &&
|
|
435
|
+
first.pid === second.pid &&
|
|
436
|
+
first.incarnation === second.incarnation &&
|
|
437
|
+
first.agentRoot === second.agentRoot
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** The broker's answer agrees with the publication on generation and identity. */
|
|
442
|
+
export function answerMatchesAuthority(answer: AnsweredDiagnostics, publication: ObservedPublication): boolean {
|
|
443
|
+
if (answer.generation !== publication.generation) return false;
|
|
444
|
+
if (answer.identity.ownerId !== publication.ownerId) return false;
|
|
445
|
+
if (answer.identity.pid !== publication.pid) return false;
|
|
446
|
+
if (publication.agentRoot !== null && answer.identity.agentRoot !== publication.agentRoot) return false;
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Observe an already running broker. Never starts, ensures, recovers or retires one.
|
|
452
|
+
*/
|
|
453
|
+
export async function observeExistingBroker(
|
|
454
|
+
options: ObserveExistingBrokerOptions | unknown,
|
|
455
|
+
): Promise<BrokerObservation> {
|
|
456
|
+
const verdict = validateObservationOptions(options);
|
|
457
|
+
if (!verdict.ok) throw new DiagnosticOptionsError(verdict.error.field);
|
|
458
|
+
const { agentDir, expectedGeneration, timeoutMs } = verdict.value;
|
|
459
|
+
|
|
460
|
+
// ONE monotonic budget starts before any loader or native work and governs every later
|
|
461
|
+
// step: the lease read, the socket and the response.
|
|
462
|
+
const clock = observationClock;
|
|
463
|
+
const startedAt = clock();
|
|
464
|
+
const elapsedMs = () => Math.max(0, Math.floor((clock() - startedAt) / 1_000_000));
|
|
465
|
+
const expired = () => elapsedMs() >= timeoutMs;
|
|
466
|
+
const remainingMs = () => Math.max(1, timeoutMs - elapsedMs());
|
|
467
|
+
|
|
468
|
+
// The runtime tuple and the trusted artifact decide BEFORE any publication access, so
|
|
469
|
+
// an unsupported platform or a missing/mismatched artifact never reaches the
|
|
470
|
+
// filesystem through this route.
|
|
471
|
+
let native: DiagnosticNativeLoad;
|
|
472
|
+
try {
|
|
473
|
+
native = observationTestHooks.loaderOverride?.() ?? loadDiagnosticNativeReadOnly();
|
|
474
|
+
} catch {
|
|
475
|
+
// A loader exception is a low-level native failure on this route, never an escaping
|
|
476
|
+
// error; an expired budget outranks it.
|
|
477
|
+
return unavailable(expired() ? "timeout" : "unsupported");
|
|
478
|
+
}
|
|
479
|
+
// DESIGN:157 keeps the two apart: a live-budget loader failure is `unsupported`, while a
|
|
480
|
+
// failure observed after the budget is spent is the timeout.
|
|
481
|
+
if (!native.ok) return unavailable(expired() ? "timeout" : "unsupported");
|
|
482
|
+
// The loader itself consumes budget: expiry is decided BEFORE the native stage, so an
|
|
483
|
+
// exhausted budget never opens a publication snapshot.
|
|
484
|
+
if (expired()) return unavailable("timeout");
|
|
485
|
+
const nativeBudgetMs = remainingMs();
|
|
486
|
+
// The remaining-budget read itself can cross the deadline: nothing native is entered
|
|
487
|
+
// with a budget that is already spent.
|
|
488
|
+
if (expired()) return unavailable("timeout");
|
|
489
|
+
|
|
490
|
+
let lease: DiagnosticSnapshotLease | null = null;
|
|
491
|
+
let client: SdkClient | null = null;
|
|
492
|
+
// Set while the native stage owns the work, so an exception raised there is classified as
|
|
493
|
+
// a native failure instead of a transport failure.
|
|
494
|
+
let nativeStageActive = false;
|
|
495
|
+
try {
|
|
496
|
+
observationTestHooks.onNativeOpen?.({ agentDir, budgetMs: nativeBudgetMs });
|
|
497
|
+
nativeStageActive = true;
|
|
498
|
+
const openedLease = native.value.openDiagnosticSnapshot(agentDir, nativeBudgetMs);
|
|
499
|
+
lease = observationTestHooks.wrapLease?.(openedLease) ?? openedLease;
|
|
500
|
+
// Timeout outranks a native refusal that only describes what a spent budget could no
|
|
501
|
+
// longer observe.
|
|
502
|
+
if (!lease.ok) return unavailable(expired() ? "timeout" : leaseReason(lease.reason));
|
|
503
|
+
// After the native call returns, the budget decides again before the next native step.
|
|
504
|
+
if (expired()) return unavailable("timeout");
|
|
505
|
+
const read = lease.read();
|
|
506
|
+
if (!read.ok || read.bytes === undefined || read.bytes === null) {
|
|
507
|
+
return unavailable(expired() ? "timeout" : leaseReason(read.reason));
|
|
508
|
+
}
|
|
509
|
+
// Timeout outranks a parse verdict that only describes bytes a spent budget could no
|
|
510
|
+
// longer confirm.
|
|
511
|
+
if (expired()) return unavailable("timeout");
|
|
512
|
+
const initial = parsePublicationVerdict(read.bytes);
|
|
513
|
+
if (initial.kind === "no-capability") return unavailable(expired() ? "timeout" : "unsupported");
|
|
514
|
+
if (initial.kind === "malformed") return unavailable(expired() ? "timeout" : "invalid_response");
|
|
515
|
+
const publication = initial.value;
|
|
516
|
+
observationTestHooks.afterInitialRead?.();
|
|
517
|
+
// A capability the client does not speak is unsupported before any socket.
|
|
518
|
+
if (publication.diagnosticProtocol !== BROKER_DIAGNOSTIC_PROTOCOL) return unavailable("unsupported");
|
|
519
|
+
if (expectedGeneration !== undefined && expectedGeneration !== publication.generation) {
|
|
520
|
+
return unavailable("generation_mismatch");
|
|
521
|
+
}
|
|
522
|
+
// All synchronous native work is done.
|
|
523
|
+
if (expired()) return unavailable("timeout");
|
|
524
|
+
|
|
525
|
+
// The publication is revalidated immediately BEFORE anything is connected, and the
|
|
526
|
+
// budget is re-checked AFTER that step: the revalidation itself can consume the rest
|
|
527
|
+
// of the budget, and a spent budget must never construct or open a connection.
|
|
528
|
+
const beforeSocket = lease.revalidate();
|
|
529
|
+
if (!beforeSocket.ok) return unavailable(expired() ? "timeout" : leaseReason(beforeSocket.reason));
|
|
530
|
+
// Descriptor identity is not publication identity: re-read the bounded bytes through
|
|
531
|
+
// the SAME retained lease (no reopen, no new trust path) and compare the semantic
|
|
532
|
+
// identity with the first observation. A replacement published over the same inode is
|
|
533
|
+
// refused here, before any socket exists.
|
|
534
|
+
if (expired()) return unavailable("timeout");
|
|
535
|
+
const preSocketRead = lease.read();
|
|
536
|
+
if (!preSocketRead.ok || preSocketRead.bytes === undefined || preSocketRead.bytes === null) {
|
|
537
|
+
return unavailable(expired() ? "timeout" : leaseReason(preSocketRead.reason));
|
|
538
|
+
}
|
|
539
|
+
if (expired()) return unavailable("timeout");
|
|
540
|
+
const preSocket = parsePublicationVerdict(preSocketRead.bytes);
|
|
541
|
+
if (preSocket.kind === "no-capability") return unavailable(expired() ? "timeout" : "unsupported");
|
|
542
|
+
if (preSocket.kind === "malformed") return unavailable(expired() ? "timeout" : "invalid_response");
|
|
543
|
+
if (!sameAuthority(publication, preSocket.value)) {
|
|
544
|
+
return unavailable(expired() ? "timeout" : "generation_mismatch");
|
|
545
|
+
}
|
|
546
|
+
if (expired()) return unavailable("timeout");
|
|
547
|
+
|
|
548
|
+
// The remaining budget is read first and the budget is re-checked immediately before
|
|
549
|
+
// the client exists, so no connection is ever constructed with a spent budget.
|
|
550
|
+
const connectBudgetMs = remainingMs();
|
|
551
|
+
if (expired()) return unavailable("timeout");
|
|
552
|
+
client = new SdkClient(publication.url, publication.token, {
|
|
553
|
+
// One connection: no reconnect, no late connect to a replacement publication.
|
|
554
|
+
reconnectAttempts: 0,
|
|
555
|
+
timeoutMs: connectBudgetMs,
|
|
556
|
+
// The client stage inherits the observation's ONE absolute budget, so a socket that
|
|
557
|
+
// opens late cannot buy the handshake a fresh relative timeout on top of it.
|
|
558
|
+
deadline: Date.now() + connectBudgetMs,
|
|
559
|
+
});
|
|
560
|
+
nativeStageActive = false;
|
|
561
|
+
await client.connect();
|
|
562
|
+
if (expired()) return unavailable("timeout");
|
|
563
|
+
|
|
564
|
+
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
565
|
+
const response = await Promise.race([
|
|
566
|
+
client.request({ type: "broker_request", operation: BROKER_DIAGNOSTICS_OPERATION, input: {} }),
|
|
567
|
+
new Promise<"deadline">(resolve => {
|
|
568
|
+
deadlineTimer = setTimeout(() => resolve("deadline"), remainingMs());
|
|
569
|
+
}),
|
|
570
|
+
]).finally(() => {
|
|
571
|
+
// The budget timer is always cleared: no pending handle outlives the call.
|
|
572
|
+
if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
|
|
573
|
+
});
|
|
574
|
+
// A result that arrives after the budget is discarded, not rendered.
|
|
575
|
+
if (response === "deadline" || expired()) return unavailable("timeout");
|
|
576
|
+
|
|
577
|
+
const answered = parseAnsweredDiagnostics(response);
|
|
578
|
+
if (answered.kind === "unavailable") return unavailable(answered.reason);
|
|
579
|
+
|
|
580
|
+
// After the response, the publication must still be the same authority, and the
|
|
581
|
+
// answer must agree with it on generation and the internal owner/process/root
|
|
582
|
+
// identity.
|
|
583
|
+
if (expired()) return unavailable("timeout");
|
|
584
|
+
nativeStageActive = true;
|
|
585
|
+
const afterResponse = lease.revalidate();
|
|
586
|
+
if (!afterResponse.ok) return unavailable(expired() ? "timeout" : leaseReason(afterResponse.reason));
|
|
587
|
+
if (expired()) return unavailable("timeout");
|
|
588
|
+
const finalRead = lease.read();
|
|
589
|
+
if (!finalRead.ok || finalRead.bytes === undefined || finalRead.bytes === null) {
|
|
590
|
+
return unavailable(expired() ? "timeout" : leaseReason(finalRead.reason));
|
|
591
|
+
}
|
|
592
|
+
if (expired()) return unavailable("timeout");
|
|
593
|
+
const finalVerdict = parsePublicationVerdict(finalRead.bytes);
|
|
594
|
+
if (finalVerdict.kind === "no-capability") return unavailable(expired() ? "timeout" : "unsupported");
|
|
595
|
+
if (finalVerdict.kind === "malformed") return unavailable(expired() ? "timeout" : "invalid_response");
|
|
596
|
+
const finalPublication = finalVerdict.value;
|
|
597
|
+
if (expired()) return unavailable("timeout");
|
|
598
|
+
if (!sameAuthority(publication, finalPublication)) return unavailable("generation_mismatch");
|
|
599
|
+
if (!answerMatchesAuthority(answered.value, finalPublication)) return unavailable("generation_mismatch");
|
|
600
|
+
if (expectedGeneration !== undefined && expectedGeneration !== answered.value.generation) {
|
|
601
|
+
return unavailable("generation_mismatch");
|
|
602
|
+
}
|
|
603
|
+
return success(answered.value.generation, answered.value.build);
|
|
604
|
+
} catch (error) {
|
|
605
|
+
if (error instanceof DiagnosticOptionsError) throw error;
|
|
606
|
+
// An expired budget outranks a low-level failure that only describes what a spent
|
|
607
|
+
// budget could no longer complete.
|
|
608
|
+
if (expired()) return unavailable("timeout");
|
|
609
|
+
// A native/lease exception raised while the budget was still live is a low-level
|
|
610
|
+
// native failure (DESIGN:157), distinct from a transport failure of the client stage.
|
|
611
|
+
if (nativeStageActive) return unavailable("unsupported");
|
|
612
|
+
return unavailable(clientFailureReason(error));
|
|
613
|
+
} finally {
|
|
614
|
+
// Every exit path releases the single connection and the lease.
|
|
615
|
+
try {
|
|
616
|
+
await client?.close();
|
|
617
|
+
} catch {
|
|
618
|
+
// The connection is already gone; nothing else to release.
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
lease?.close();
|
|
622
|
+
if (lease !== null) observationTestHooks.onLeaseClosed?.();
|
|
623
|
+
} catch {
|
|
624
|
+
// The lease is already closed; nothing else to release.
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Map a native lease refusal onto the public reason enum. */
|
|
630
|
+
function leaseReason(reason: string | null | undefined): BrokerObservationUnavailableReason {
|
|
631
|
+
switch (reason) {
|
|
632
|
+
case "absent":
|
|
633
|
+
return "absent";
|
|
634
|
+
case "stale":
|
|
635
|
+
return "stale";
|
|
636
|
+
case "unsafe_discovery":
|
|
637
|
+
return "unsafe_discovery";
|
|
638
|
+
case "unsupported":
|
|
639
|
+
return "unsupported";
|
|
640
|
+
case "timeout":
|
|
641
|
+
return "timeout";
|
|
642
|
+
default:
|
|
643
|
+
return "unsafe_discovery";
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Map an SdkClient failure onto the public reason enum. */
|
|
648
|
+
function clientFailureReason(error: unknown): BrokerObservationUnavailableReason {
|
|
649
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
650
|
+
if (code === "unauthorized" || code === "forbidden") return "authentication_failed";
|
|
651
|
+
if (code === "timeout" || code === "deadline_exceeded") return "timeout";
|
|
652
|
+
if (code === "unknown_operation" || code === "unsupported_operation") return "unsupported";
|
|
653
|
+
return "transport_unavailable";
|
|
654
|
+
}
|