borgmcp-shared 0.5.1 → 0.6.1

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.
@@ -1,19 +1,32 @@
1
1
  import { ErrorCode } from './errors.js';
2
2
  import { PROTOCOL_VERSION, type ProtocolVersion } from './version.js';
3
+ import {
4
+ RuntimeMetadataValidationError,
5
+ validateRuntimeMetadata,
6
+ validateRuntimeMetadataPatch,
7
+ validateRuntimeMetadataReportState,
8
+ } from '../runtime-metadata.js';
9
+ import type {
10
+ DroneRuntimeMetadata,
11
+ DroneRuntimeMetadataPatch,
12
+ } from './types.js';
3
13
 
4
14
  export const SHARED_PACKAGE_NAME = 'borgmcp-shared' as const;
5
- export const SHARED_PACKAGE_VERSION = '0.5.1' as const;
15
+ export const SHARED_PACKAGE_VERSION = '0.6.1' as const;
6
16
 
7
17
  export const HEALTH_PATH = '/healthz' as const;
8
18
  export const PROTOCOL_INFO_PATH = '/api/protocol' as const;
9
19
  export const ENROLLMENT_EXCHANGE_PATH = '/api/enrollment/exchange' as const;
10
20
  export const CUBES_PATH = '/api/cubes' as const;
21
+ export const ATTACH_PATH = '/api/client/attach' as const;
22
+ export const SELF_RUNTIME_METADATA_PATH = '/api/cubes/:cubeId/drones/self/metadata' as const;
11
23
 
12
24
  export const PROTOCOL_HTTP_CONTRACT = {
13
25
  health: { method: 'GET', path: HEALTH_PATH, authenticated: false, success_status: 204, bodyless: true },
14
26
  protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated: false, success_status: 200 },
15
27
  enrollment: { method: 'POST', path: ENROLLMENT_EXCHANGE_PATH, authenticated: 'invitation', success_status: 201 },
16
28
  cubes: { method: 'POST', path: CUBES_PATH, authenticated: true, success_status: 201 },
29
+ attach: { method: 'POST', path: ATTACH_PATH, authenticated: true, success_status: 200 },
17
30
  drone_reassign: {
18
31
  method: 'PATCH',
19
32
  path: '/api/cubes/:cubeId/drones/:droneId',
@@ -26,6 +39,12 @@ export const PROTOCOL_HTTP_CONTRACT = {
26
39
  authenticated: true,
27
40
  success_status: 200,
28
41
  },
42
+ drone_self_metadata: {
43
+ method: 'PATCH',
44
+ path: SELF_RUNTIME_METADATA_PATH,
45
+ authenticated: 'drone-session',
46
+ success_status: 200,
47
+ },
29
48
  auth_missing_status: 401,
30
49
  auth_invalid_status: 401,
31
50
  auth_expired_status: 401,
@@ -607,15 +626,86 @@ export function maxLogCursor(a: LogCursor | null, b: LogCursor | null): LogCurso
607
626
  return compareLogCursor(a, b) >= 0 ? decodeLogCursor(a) : decodeLogCursor(b);
608
627
  }
609
628
 
610
- // ── v3 clean-slate wire types ──────────────────────────────────────────────
629
+ function metadataValidation<T>(fn: () => T): T {
630
+ try {
631
+ return fn();
632
+ } catch (error) {
633
+ if (error instanceof RuntimeMetadataValidationError) {
634
+ fail(`${error.field}: ${error.reason}`, [error.field]);
635
+ }
636
+ throw error;
637
+ }
638
+ }
611
639
 
612
- export const ATTACH_PATH = '/api/client/attach' as const;
640
+ /** Decode a complete runtime report. When present on attach, all four keys are required. */
641
+ export function decodeDroneRuntimeMetadata(value: unknown): DroneRuntimeMetadata {
642
+ return metadataValidation(() => validateRuntimeMetadata(value));
643
+ }
644
+
645
+ /** Decode an atomic self-heal patch. Omitted means unchanged; null means clear. */
646
+ export function decodeDroneRuntimeMetadataPatch(value: unknown): DroneRuntimeMetadataPatch {
647
+ return metadataValidation(() => validateRuntimeMetadataPatch(value));
648
+ }
649
+
650
+ /** Decode the flat runtime state carried by Drone objects in roster and regen responses. */
651
+ export function decodeDroneRuntimeMetadataState(value: unknown): UpdateDroneRuntimeMetadataResponse {
652
+ const input = record(value);
653
+ return metadataValidation(() => validateRuntimeMetadataReportState({
654
+ agent_kind: input.agent_kind,
655
+ reported_model: input.reported_model,
656
+ working_repo_name: input.working_repo_name,
657
+ working_repo_origin: input.working_repo_origin,
658
+ }, input.runtime_metadata_reported));
659
+ }
660
+
661
+ /** Decode the nested runtime state carried by the own-seat identity response. */
662
+ export function decodeWhoAmIRuntimeMetadataState(value: unknown): UpdateDroneRuntimeMetadataResponse {
663
+ const input = record(value);
664
+ return metadataValidation(() => validateRuntimeMetadataReportState(
665
+ input.runtime_metadata,
666
+ input.runtime_metadata_reported,
667
+ ));
668
+ }
669
+
670
+ export interface UpdateDroneRuntimeMetadataResponse {
671
+ runtime_metadata: DroneRuntimeMetadata;
672
+ runtime_metadata_reported: boolean;
673
+ }
674
+
675
+ export function decodeUpdateDroneRuntimeMetadataResponse(
676
+ value: unknown,
677
+ ): UpdateDroneRuntimeMetadataResponse {
678
+ const input = record(value);
679
+ exactKeys(input, ['runtime_metadata', 'runtime_metadata_reported'], [
680
+ 'runtime_metadata',
681
+ 'runtime_metadata_reported',
682
+ ]);
683
+ return metadataValidation(() => validateRuntimeMetadataReportState(
684
+ input.runtime_metadata,
685
+ input.runtime_metadata_reported,
686
+ ));
687
+ }
688
+
689
+ export function decodeUpdateDroneRuntimeMetadataRequestEnvelope(
690
+ value: unknown,
691
+ ): ProtocolEnvelope<DroneRuntimeMetadataPatch> {
692
+ return decodeProtocolEnvelope(value, decodeDroneRuntimeMetadataPatch);
693
+ }
694
+
695
+ export function decodeUpdateDroneRuntimeMetadataResponseEnvelope(
696
+ value: unknown,
697
+ ): ProtocolEnvelope<UpdateDroneRuntimeMetadataResponse> {
698
+ return decodeProtocolEnvelope(value, decodeUpdateDroneRuntimeMetadataResponse);
699
+ }
700
+
701
+ // ── v3 clean-slate wire types ──────────────────────────────────────────────
613
702
 
614
703
  export interface AttachRequest {
615
704
  cube_id: string;
616
705
  role_id: string;
617
706
  session_credential: string;
618
707
  prior_drone_id?: string;
708
+ runtime_metadata?: DroneRuntimeMetadata;
619
709
  }
620
710
 
621
711
  export interface AttachCube {
@@ -635,6 +725,8 @@ export interface AttachRole {
635
725
  export interface AttachDrone {
636
726
  id: string;
637
727
  label: string;
728
+ runtime_metadata: DroneRuntimeMetadata;
729
+ runtime_metadata_reported: boolean;
638
730
  }
639
731
 
640
732
  export interface AttachSession {
@@ -682,10 +774,20 @@ function decodeAttachRole(value: unknown, path: readonly (string | number)[]): A
682
774
 
683
775
  function decodeAttachDrone(value: unknown, path: readonly (string | number)[]): AttachDrone {
684
776
  const input = record(value, path);
685
- exactKeys(input, ['id', 'label'], ['id', 'label'], path);
777
+ exactKeys(
778
+ input,
779
+ ['id', 'label', 'runtime_metadata', 'runtime_metadata_reported'],
780
+ ['id', 'label', 'runtime_metadata', 'runtime_metadata_reported'],
781
+ path,
782
+ );
783
+ const state = metadataValidation(() => validateRuntimeMetadataReportState(
784
+ input.runtime_metadata,
785
+ input.runtime_metadata_reported,
786
+ ));
686
787
  return {
687
788
  id: decodeUuid(input.id, [...path, 'id']),
688
789
  label: boundedString(input.label, 1, 128, [...path, 'label']),
790
+ ...state,
689
791
  };
690
792
  }
691
793
 
@@ -703,7 +805,7 @@ function decodeAttachSession(value: unknown, path: readonly (string | number)[])
703
805
  */
704
806
  export function decodeAttachRequest(value: unknown): AttachRequest {
705
807
  const input = record(value);
706
- exactKeys(input, ['cube_id', 'role_id', 'session_credential', 'prior_drone_id'], [
808
+ exactKeys(input, ['cube_id', 'role_id', 'session_credential', 'prior_drone_id', 'runtime_metadata'], [
707
809
  'cube_id',
708
810
  'role_id',
709
811
  'session_credential',
@@ -716,6 +818,9 @@ export function decodeAttachRequest(value: unknown): AttachRequest {
716
818
  if (input.prior_drone_id !== undefined) {
717
819
  result.prior_drone_id = decodeUuid(input.prior_drone_id, ['prior_drone_id']);
718
820
  }
821
+ if (input.runtime_metadata !== undefined) {
822
+ result.runtime_metadata = decodeDroneRuntimeMetadata(input.runtime_metadata);
823
+ }
719
824
  return result;
720
825
  }
721
826
 
@@ -14,6 +14,20 @@ export type WakePathAlertClass =
14
14
  | 'systemic-wake-path-deaf'
15
15
  | 'independent';
16
16
 
17
+ export interface DroneRuntimeMetadata {
18
+ agent_kind: AgentKind | null;
19
+ reported_model: string | null;
20
+ working_repo_name: string | null;
21
+ working_repo_origin: string | null;
22
+ }
23
+
24
+ export interface DroneRuntimeMetadataPatch {
25
+ agent_kind?: AgentKind | null;
26
+ reported_model?: string | null;
27
+ working_repo_name?: string | null;
28
+ working_repo_origin?: string | null;
29
+ }
30
+
17
31
  export interface Cube {
18
32
  id: string;
19
33
  owner_id: string;
@@ -45,7 +59,7 @@ export interface Role {
45
59
 
46
60
  export type PublicRole = Omit<Role, 'detailed_description' | 'detailed_description_hash'>;
47
61
 
48
- export interface Drone {
62
+ export interface Drone extends DroneRuntimeMetadata {
49
63
  id: string;
50
64
  cube_id: string;
51
65
  role_id: string;
@@ -61,10 +75,7 @@ export interface Drone {
61
75
  wake_path_client_sse_connected?: boolean | null;
62
76
  wake_path_client_monitor_armed?: boolean | null;
63
77
  wake_path_alert_class?: WakePathAlertClass | null;
64
- agent_kind?: AgentKind | null;
65
- reported_model?: string | null;
66
- working_repo_name?: string | null;
67
- working_repo_origin?: string | null;
78
+ runtime_metadata_reported: boolean;
68
79
  evicted_at?: null;
69
80
  created_at: string;
70
81
  seen_since?: boolean;
@@ -122,6 +133,8 @@ export interface WhoAmIResponse {
122
133
  drone_label: string;
123
134
  role_id: string;
124
135
  role_name: string;
136
+ runtime_metadata: DroneRuntimeMetadata;
137
+ runtime_metadata_reported: boolean;
125
138
  }
126
139
 
127
140
  export interface RosterResponse {
@@ -0,0 +1,304 @@
1
+ import type {
2
+ DroneRuntimeMetadata,
3
+ DroneRuntimeMetadataPatch,
4
+ } from './protocol/types.js';
5
+
6
+ export const RUNTIME_METADATA_LIMITS = {
7
+ reported_model_bytes: 160,
8
+ repo_segment_bytes: 100,
9
+ repo_name_bytes: 201,
10
+ repo_origin_bytes: 512,
11
+ } as const;
12
+
13
+ export type RuntimeMetadataField =
14
+ | 'runtime_metadata'
15
+ | 'runtime_metadata_reported'
16
+ | 'agent_kind'
17
+ | 'reported_model'
18
+ | 'working_repo_name'
19
+ | 'working_repo_origin';
20
+
21
+ export class RuntimeMetadataValidationError extends Error {
22
+ constructor(
23
+ public readonly field: RuntimeMetadataField,
24
+ public readonly reason: string,
25
+ ) {
26
+ super(`${field}: ${reason}`);
27
+ this.name = 'RuntimeMetadataValidationError';
28
+ }
29
+ }
30
+
31
+ const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/@:+-]*$/;
32
+ const REPO_SEGMENT_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$/;
33
+ const UNSAFE_UNICODE_PATTERN = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]|\p{Bidi_Control}/u;
34
+
35
+ function byteLength(value: string): number {
36
+ let bytes = 0;
37
+ for (let index = 0; index < value.length; index++) {
38
+ const code = value.charCodeAt(index);
39
+ if (code <= 0x7f) bytes += 1;
40
+ else if (code <= 0x7ff) bytes += 2;
41
+ else if (code >= 0xd800 && code <= 0xdbff) {
42
+ bytes += 4;
43
+ index++;
44
+ } else bytes += 3;
45
+ }
46
+ return bytes;
47
+ }
48
+
49
+ function hasMalformedUnicode(value: string): boolean {
50
+ for (let index = 0; index < value.length; index++) {
51
+ const code = value.charCodeAt(index);
52
+ if (code >= 0xd800 && code <= 0xdbff) {
53
+ const next = value.charCodeAt(index + 1);
54
+ if (next < 0xdc00 || next > 0xdfff) return true;
55
+ index++;
56
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
57
+ return true;
58
+ }
59
+ }
60
+ return false;
61
+ }
62
+
63
+ function invalid(field: RuntimeMetadataField, reason: string): never {
64
+ throw new RuntimeMetadataValidationError(field, reason);
65
+ }
66
+
67
+ const METADATA_KEYS = [
68
+ 'agent_kind',
69
+ 'reported_model',
70
+ 'working_repo_name',
71
+ 'working_repo_origin',
72
+ ] as const;
73
+
74
+ function metadataRecord(value: unknown): Record<string, unknown> {
75
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
76
+ invalid('runtime_metadata', 'must be an object');
77
+ }
78
+ const prototype = Object.getPrototypeOf(value);
79
+ if (prototype !== Object.prototype && prototype !== null) {
80
+ invalid('runtime_metadata', 'must be a plain object');
81
+ }
82
+ const keys = Reflect.ownKeys(value);
83
+ if (keys.some((key) => typeof key !== 'string' || !METADATA_KEYS.includes(key as typeof METADATA_KEYS[number]))) {
84
+ invalid('runtime_metadata', 'contains an unsupported field');
85
+ }
86
+ if (keys.some((key) => {
87
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
88
+ return descriptor === undefined || !Object.prototype.hasOwnProperty.call(descriptor, 'value');
89
+ })) {
90
+ invalid('runtime_metadata', 'fields must be plain data values');
91
+ }
92
+ return value as Record<string, unknown>;
93
+ }
94
+
95
+ function agentKind(value: unknown): DroneRuntimeMetadata['agent_kind'] {
96
+ if (value === null || value === 'claude' || value === 'codex' || value === 'opencode') {
97
+ return value;
98
+ }
99
+ invalid('agent_kind', 'must be claude, codex, opencode, or null');
100
+ }
101
+
102
+ function nullableString(value: unknown, field: RuntimeMetadataField): string | null {
103
+ if (value === null || typeof value === 'string') return value;
104
+ invalid(field, 'must be a string or null');
105
+ }
106
+
107
+ export function validateReportedModel(value: string): string {
108
+ const bytes = byteLength(value);
109
+ if (bytes < 1 || bytes > RUNTIME_METADATA_LIMITS.reported_model_bytes) {
110
+ invalid('reported_model', 'must be 1-160 UTF-8 bytes');
111
+ }
112
+ if (hasMalformedUnicode(value) || UNSAFE_UNICODE_PATTERN.test(value) || !MODEL_PATTERN.test(value)) {
113
+ invalid('reported_model', 'must be a printable model identifier');
114
+ }
115
+ return value;
116
+ }
117
+
118
+ export function validateWorkingRepoName(value: string): string {
119
+ if (byteLength(value) > RUNTIME_METADATA_LIMITS.repo_name_bytes) {
120
+ invalid('working_repo_name', 'must be at most 201 bytes');
121
+ }
122
+ const segments = value.split('/');
123
+ if (
124
+ segments.length !== 2 ||
125
+ segments.some((segment) =>
126
+ segment === '.' ||
127
+ segment === '..' ||
128
+ byteLength(segment) > RUNTIME_METADATA_LIMITS.repo_segment_bytes ||
129
+ !REPO_SEGMENT_PATTERN.test(segment)
130
+ )
131
+ ) {
132
+ invalid('working_repo_name', 'must be a canonical owner/repository name');
133
+ }
134
+ return value;
135
+ }
136
+
137
+ function validatePublicHost(hostname: string): string {
138
+ const host = hostname.toLowerCase();
139
+ if (
140
+ host.length > 253 ||
141
+ !host.includes('.') ||
142
+ host.includes(':') ||
143
+ /^(?:0x[0-9a-f]+|\d+)(?:\.(?:0x[0-9a-f]+|\d+)){1,3}$/.test(host) ||
144
+ /(?:^|\.)(?:localhost|local|internal|lan)$/.test(host)
145
+ ) {
146
+ invalid('working_repo_origin', 'must use a public DNS host');
147
+ }
148
+ const labels = host.split('.');
149
+ if (labels.some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) {
150
+ invalid('working_repo_origin', 'must use a public DNS host');
151
+ }
152
+ return host;
153
+ }
154
+
155
+ function originParts(value: string): { host: string; path: string } {
156
+ if (
157
+ byteLength(value) > RUNTIME_METADATA_LIMITS.repo_origin_bytes ||
158
+ value.includes('%') ||
159
+ hasMalformedUnicode(value) ||
160
+ UNSAFE_UNICODE_PATTERN.test(value) ||
161
+ /[\u0080-\uffff]/u.test(value)
162
+ ) {
163
+ invalid('working_repo_origin', 'must be a safe canonical repository URL');
164
+ }
165
+
166
+ const scp = /^git@([^/:]+):(.+)$/.exec(value);
167
+ if (scp) return { host: validatePublicHost(scp[1]), path: scp[2] };
168
+
169
+ const hierarchical = /^(https|ssh):\/\/([^/]+)\/(.+)$/.exec(value);
170
+ if (!hierarchical) {
171
+ invalid('working_repo_origin', 'must be an HTTPS or Git SSH repository URL');
172
+ }
173
+ const [, scheme, authority, path] = hierarchical;
174
+ if (path.includes('?') || path.includes('#')) {
175
+ invalid('working_repo_origin', 'must not include a query or fragment');
176
+ }
177
+ if (scheme === 'https') {
178
+ if (authority.includes('@')) {
179
+ invalid('working_repo_origin', 'must not include credentials or a non-default port');
180
+ }
181
+ const hostMatch = /^([^:]+)(?::443)?$/.exec(authority);
182
+ if (!hostMatch) {
183
+ invalid('working_repo_origin', 'must not include credentials or a non-default port');
184
+ }
185
+ return { host: validatePublicHost(hostMatch[1]), path };
186
+ }
187
+ const sshMatch = /^git@([^:]+)(?::22)?$/.exec(authority);
188
+ if (!sshMatch) {
189
+ invalid('working_repo_origin', 'SSH repository URLs require the literal git user');
190
+ }
191
+ return { host: validatePublicHost(sshMatch[1]), path };
192
+ }
193
+
194
+ export interface CanonicalRepositoryIdentity {
195
+ working_repo_name: string;
196
+ working_repo_origin: string;
197
+ }
198
+
199
+ export function canonicalizeRepositoryIdentity(
200
+ inputOrigin: string,
201
+ expectedName?: string,
202
+ ): CanonicalRepositoryIdentity {
203
+ const { host, path: rawPath } = originParts(inputOrigin);
204
+ const path = rawPath.endsWith('.git') ? rawPath.slice(0, -4) : rawPath;
205
+ if (path.includes('\\') || path.startsWith('/') || path.endsWith('/')) {
206
+ invalid('working_repo_origin', 'must identify exactly one owner/repository path');
207
+ }
208
+ const name = validateWorkingRepoName(path);
209
+ if (expectedName !== undefined && validateWorkingRepoName(expectedName) !== name) {
210
+ invalid('working_repo_name', 'must match the canonical repository origin');
211
+ }
212
+ return {
213
+ working_repo_name: name,
214
+ working_repo_origin: `https://${host}/${name}`,
215
+ };
216
+ }
217
+
218
+ export function validateRuntimeMetadata(value: unknown): DroneRuntimeMetadata {
219
+ const input = metadataRecord(value);
220
+ if (METADATA_KEYS.some((key) => !Object.prototype.hasOwnProperty.call(input, key))) {
221
+ invalid('runtime_metadata', 'must contain all four metadata fields');
222
+ }
223
+ const metadata: DroneRuntimeMetadata = {
224
+ agent_kind: agentKind(input.agent_kind),
225
+ reported_model: nullableString(input.reported_model, 'reported_model'),
226
+ working_repo_name: nullableString(input.working_repo_name, 'working_repo_name'),
227
+ working_repo_origin: nullableString(input.working_repo_origin, 'working_repo_origin'),
228
+ };
229
+ if (metadata.reported_model !== null) validateReportedModel(metadata.reported_model);
230
+ if ((metadata.working_repo_name === null) !== (metadata.working_repo_origin === null)) {
231
+ invalid('working_repo_name', 'repository name and origin must be set or cleared together');
232
+ }
233
+ if (metadata.working_repo_name !== null && metadata.working_repo_origin !== null) {
234
+ const canonical = canonicalizeRepositoryIdentity(
235
+ metadata.working_repo_origin,
236
+ metadata.working_repo_name,
237
+ );
238
+ return { ...metadata, ...canonical };
239
+ }
240
+ return { ...metadata };
241
+ }
242
+
243
+ export function validateRuntimeMetadataPatch(
244
+ value: unknown,
245
+ ): DroneRuntimeMetadataPatch {
246
+ const input = metadataRecord(value);
247
+ if (Reflect.ownKeys(input).length === 0) invalid('runtime_metadata', 'patch must not be empty');
248
+ const patch: DroneRuntimeMetadataPatch = {};
249
+ if (Object.prototype.hasOwnProperty.call(input, 'agent_kind')) {
250
+ patch.agent_kind = agentKind(input.agent_kind);
251
+ }
252
+ if (Object.prototype.hasOwnProperty.call(input, 'reported_model')) {
253
+ patch.reported_model = nullableString(input.reported_model, 'reported_model');
254
+ }
255
+ if (Object.prototype.hasOwnProperty.call(input, 'working_repo_name')) {
256
+ patch.working_repo_name = nullableString(input.working_repo_name, 'working_repo_name');
257
+ }
258
+ if (Object.prototype.hasOwnProperty.call(input, 'working_repo_origin')) {
259
+ patch.working_repo_origin = nullableString(input.working_repo_origin, 'working_repo_origin');
260
+ }
261
+ if (patch.reported_model !== undefined && patch.reported_model !== null) {
262
+ validateReportedModel(patch.reported_model);
263
+ }
264
+ const hasName = patch.working_repo_name !== undefined;
265
+ const hasOrigin = patch.working_repo_origin !== undefined;
266
+ if (hasName !== hasOrigin) {
267
+ invalid('working_repo_name', 'repository name and origin must be patched together');
268
+ }
269
+ if (hasName && hasOrigin) {
270
+ const name = patch.working_repo_name;
271
+ const origin = patch.working_repo_origin;
272
+ if ((name === null) !== (origin === null)) {
273
+ invalid('working_repo_name', 'repository name and origin must be set or cleared together');
274
+ }
275
+ if (name !== null && name !== undefined && origin !== null && origin !== undefined) {
276
+ const canonical = canonicalizeRepositoryIdentity(
277
+ origin,
278
+ name,
279
+ );
280
+ return { ...patch, ...canonical };
281
+ }
282
+ }
283
+ return { ...patch };
284
+ }
285
+
286
+ export interface ValidatedRuntimeMetadataReportState {
287
+ runtime_metadata: DroneRuntimeMetadata;
288
+ runtime_metadata_reported: boolean;
289
+ }
290
+
291
+ /** Enforce the response invariant shared by attach, self-update, and identity reads. */
292
+ export function validateRuntimeMetadataReportState(
293
+ metadataValue: unknown,
294
+ reportedValue: unknown,
295
+ ): ValidatedRuntimeMetadataReportState {
296
+ const runtime_metadata = validateRuntimeMetadata(metadataValue);
297
+ if (typeof reportedValue !== 'boolean') {
298
+ invalid('runtime_metadata_reported', 'must be a boolean');
299
+ }
300
+ if (!reportedValue && Object.values(runtime_metadata).some((value) => value !== null)) {
301
+ invalid('runtime_metadata_reported', 'false requires all metadata values to be null');
302
+ }
303
+ return { runtime_metadata, runtime_metadata_reported: reportedValue };
304
+ }