borgmcp-shared 0.6.3 → 0.6.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.
@@ -1,8 +1,10 @@
1
1
  import type { BroadcastHwm } from '../log-stream-hwm.js';
2
2
  import type {
3
3
  AttachResponse,
4
+ AssociateRepositoryCubeRequest,
4
5
  CreateCubeRequest,
5
6
  EnrollmentExchangeRequest,
7
+ ResolveRepositoryCubeRequest,
6
8
  } from '../protocol/contract.js';
7
9
 
8
10
  export * from './adapter.js';
@@ -291,6 +293,150 @@ readonly CreateCubeAssociationConformanceVector[] = [
291
293
  },
292
294
  ];
293
295
 
296
+ export interface ResolveRepositoryCubeConformanceVector {
297
+ name: string;
298
+ request: ResolveRepositoryCubeRequest;
299
+ associated: boolean;
300
+ expected:
301
+ | { outcome: 'none'; status: 200; authority_state_delta: Record<string, never> }
302
+ | { outcome: 'resolved'; status: 200; authority_state_delta: Record<string, never> };
303
+ }
304
+
305
+ const REPOSITORY_CUBE_ONE = '00000000-0000-4000-8000-000000000131';
306
+ const REPOSITORY_ONE: ResolveRepositoryCubeRequest = {
307
+ working_repo_name: 'repository-one',
308
+ repository: { kind: 'origin', value: 'https://github.com/Byte-Ventures/repository-one' },
309
+ };
310
+ const REPOSITORY_TWO: ResolveRepositoryCubeRequest = {
311
+ working_repo_name: 'repository-two',
312
+ repository: { kind: 'origin', value: 'https://github.com/Byte-Ventures/repository-two' },
313
+ };
314
+
315
+ /** Resolution is read-only and returns only explicit none or authoritative stored fields. */
316
+ export const RESOLVE_REPOSITORY_CUBE_CONFORMANCE:
317
+ readonly ResolveRepositoryCubeConformanceVector[] = [
318
+ {
319
+ name: 'unassociated repository resolves explicit none without mutation',
320
+ request: REPOSITORY_ONE,
321
+ associated: false,
322
+ expected: { outcome: 'none', status: 200, authority_state_delta: {} },
323
+ },
324
+ {
325
+ name: 'associated repository resolves authoritative stored fields without mutation',
326
+ request: { ...REPOSITORY_ONE, working_repo_name: 'ignored-new-display' },
327
+ associated: true,
328
+ expected: { outcome: 'resolved', status: 200, authority_state_delta: {} },
329
+ },
330
+ ];
331
+
332
+ export interface AssociateRepositoryCubeConformanceVector {
333
+ name: string;
334
+ initial: AssociateRepositoryCubeRequest;
335
+ retry: AssociateRepositoryCubeRequest;
336
+ expected:
337
+ | {
338
+ outcome: 'resolved';
339
+ status: 200;
340
+ initial_authority_state_delta: { repository_associations: 1 };
341
+ retry_authority_state_delta: Record<string, never>;
342
+ }
343
+ | {
344
+ outcome: 'repository_conflict' | 'cube_conflict';
345
+ status: 409;
346
+ error: 'REPOSITORY_ALREADY_ASSOCIATED' | 'CUBE_ALREADY_ASSOCIATED';
347
+ diagnostic_disclosure: 'none';
348
+ retry_authority_state_delta: Record<string, never>;
349
+ };
350
+ }
351
+
352
+ /** Explicit cube IDs bind atomically; names never select or identify a cube. */
353
+ export const ASSOCIATE_REPOSITORY_CUBE_CONFORMANCE:
354
+ readonly AssociateRepositoryCubeConformanceVector[] = [
355
+ {
356
+ name: 'same explicit cube and repository binding is idempotent',
357
+ initial: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_ONE },
358
+ retry: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_ONE },
359
+ expected: {
360
+ outcome: 'resolved',
361
+ status: 200,
362
+ initial_authority_state_delta: { repository_associations: 1 },
363
+ retry_authority_state_delta: {},
364
+ },
365
+ },
366
+ {
367
+ name: 'repository already bound to another cube conflicts without mutation',
368
+ initial: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_ONE },
369
+ retry: { cube_id: '00000000-0000-4000-8000-000000000132', ...REPOSITORY_ONE },
370
+ expected: {
371
+ outcome: 'repository_conflict',
372
+ status: 409,
373
+ error: 'REPOSITORY_ALREADY_ASSOCIATED',
374
+ diagnostic_disclosure: 'none',
375
+ retry_authority_state_delta: {},
376
+ },
377
+ },
378
+ {
379
+ name: 'cube already bound to another repository conflicts without mutation',
380
+ initial: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_ONE },
381
+ retry: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_TWO },
382
+ expected: {
383
+ outcome: 'cube_conflict',
384
+ status: 409,
385
+ error: 'CUBE_ALREADY_ASSOCIATED',
386
+ diagnostic_disclosure: 'none',
387
+ retry_authority_state_delta: {},
388
+ },
389
+ },
390
+ ];
391
+
392
+ export const REPOSITORY_CUBE_PERMISSION_CONFORMANCE = [
393
+ {
394
+ name: 'association denies an inaccessible explicit cube without mutation',
395
+ request: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_ONE },
396
+ expected: { status: 403, error: 'ACCESS_DENIED', authority_state_delta: {} },
397
+ },
398
+ {
399
+ name: 'same-client binding to an inaccessible cube is non-enumerating',
400
+ request: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_ONE },
401
+ precondition: 'repository_bound_to_inaccessible_cube',
402
+ expected: {
403
+ resolve: { status: 200, outcome: 'none', authority_state_delta: {} },
404
+ associate: {
405
+ status: 403,
406
+ error: 'ACCESS_DENIED',
407
+ diagnostic_disclosure: 'none',
408
+ authority_state_delta: {},
409
+ },
410
+ },
411
+ },
412
+ {
413
+ name: 'another client binding is neither resolved nor treated as a conflict',
414
+ request: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_ONE },
415
+ precondition: 'repository_bound_by_another_client',
416
+ expected: {
417
+ resolve: { status: 200, outcome: 'none', authority_state_delta: {} },
418
+ associate: {
419
+ status: 200,
420
+ outcome: 'resolved',
421
+ authority_state_delta: { repository_associations: 1 },
422
+ },
423
+ },
424
+ },
425
+ ] as const;
426
+
427
+ export const REPOSITORY_CUBE_AUTHORITATIVE_STATE_CONFORMANCE = [
428
+ {
429
+ name: 'legacy cube with invalid authoritative roles is rejected without mutation',
430
+ request: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_ONE },
431
+ expected: {
432
+ status: 409,
433
+ error: 'INVALID_INPUT',
434
+ diagnostic_disclosure: 'none',
435
+ authority_state_delta: {},
436
+ },
437
+ },
438
+ ] as const;
439
+
294
440
  export const ENROLLMENT_AUTHORITY_CONFORMANCE = [
295
441
  {
296
442
  name: 'ordinary enrollment creates no authority or cube state',
@@ -13,12 +13,14 @@ import type {
13
13
  } from './types.js';
14
14
 
15
15
  export const SHARED_PACKAGE_NAME = 'borgmcp-shared' as const;
16
- export const SHARED_PACKAGE_VERSION = '0.6.3' as const;
16
+ export const SHARED_PACKAGE_VERSION = '0.6.4' as const;
17
17
 
18
18
  export const HEALTH_PATH = '/healthz' as const;
19
19
  export const PROTOCOL_INFO_PATH = '/api/protocol' as const;
20
20
  export const ENROLLMENT_EXCHANGE_PATH = '/api/enrollment/exchange' as const;
21
21
  export const CUBES_PATH = '/api/cubes' as const;
22
+ export const REPOSITORY_CUBE_RESOLVE_PATH = '/api/repository-cubes/resolve' as const;
23
+ export const REPOSITORY_CUBE_ASSOCIATION_PATH = '/api/repository-cubes/association' as const;
22
24
  export const ATTACH_PATH = '/api/client/attach' as const;
23
25
  export const SELF_RUNTIME_METADATA_PATH = '/api/cubes/:cubeId/drones/self/metadata' as const;
24
26
 
@@ -27,6 +29,20 @@ export const PROTOCOL_HTTP_CONTRACT = {
27
29
  protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated: false, success_status: 200 },
28
30
  enrollment: { method: 'POST', path: ENROLLMENT_EXCHANGE_PATH, authenticated: 'invitation', success_status: 201 },
29
31
  cubes: { method: 'POST', path: CUBES_PATH, authenticated: true, success_status: 201 },
32
+ repository_cube_resolve: {
33
+ method: 'POST',
34
+ path: REPOSITORY_CUBE_RESOLVE_PATH,
35
+ authenticated: true,
36
+ success_status: 200,
37
+ mutation: false,
38
+ },
39
+ repository_cube_association: {
40
+ method: 'PUT',
41
+ path: REPOSITORY_CUBE_ASSOCIATION_PATH,
42
+ authenticated: true,
43
+ success_status: 200,
44
+ mutation: true,
45
+ },
30
46
  attach: { method: 'POST', path: ATTACH_PATH, authenticated: true, success_status: 200 },
31
47
  drone_reassign: {
32
48
  method: 'PATCH',
@@ -148,6 +164,33 @@ export interface CreateCubeResponse {
148
164
  access: 'manage';
149
165
  }
150
166
 
167
+ export interface ResolveRepositoryCubeRequest {
168
+ working_repo_name: string;
169
+ repository: CreateCubeRepository;
170
+ }
171
+
172
+ export interface AssociateRepositoryCubeRequest extends ResolveRepositoryCubeRequest {
173
+ cube_id: string;
174
+ }
175
+
176
+ export interface ResolvedRepositoryCube {
177
+ result: 'resolved';
178
+ cube_id: string;
179
+ name: string;
180
+ working_repo_name: string;
181
+ repository: CreateCubeRepository;
182
+ template: CubeTemplate;
183
+ human_seat_role_id: string;
184
+ default_worker_role_id: string;
185
+ access: 'manage';
186
+ }
187
+
188
+ export type ResolveRepositoryCubeResponse =
189
+ | { result: 'none' }
190
+ | ResolvedRepositoryCube;
191
+
192
+ export type AssociateRepositoryCubeResponse = ResolvedRepositoryCube;
193
+
151
194
  export interface AckLogRequest {
152
195
  entry_id: string;
153
196
  kind: 'ack' | 'claim';
@@ -455,10 +498,7 @@ export function decodeCreateCubeRequest(value: unknown): CreateCubeRequest {
455
498
  if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(name)) {
456
499
  fail('Cube name contains unsupported characters.', ['name']);
457
500
  }
458
- const workingRepoName = boundedString(input.working_repo_name, 1, 120, ['working_repo_name']);
459
- if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(workingRepoName)) {
460
- fail('Cube name contains unsupported characters.', ['working_repo_name']);
461
- }
501
+ const workingRepoName = decodeWorkingRepositoryName(input.working_repo_name, ['working_repo_name']);
462
502
  if (!CUBE_TEMPLATES.includes(input.template as CubeTemplate)) {
463
503
  fail('Unsupported cube template.', ['template']);
464
504
  }
@@ -509,10 +549,7 @@ export function decodeCreateCubeResponse(value: unknown): CreateCubeResponse {
509
549
  if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(name)) {
510
550
  fail('Cube name contains unsupported characters.', ['name']);
511
551
  }
512
- const workingRepoName = boundedString(input.working_repo_name, 1, 120, ['working_repo_name']);
513
- if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(workingRepoName)) {
514
- fail('Cube name contains unsupported characters.', ['working_repo_name']);
515
- }
552
+ const workingRepoName = decodeWorkingRepositoryName(input.working_repo_name, ['working_repo_name']);
516
553
  if (!CUBE_TEMPLATES.includes(input.template as CubeTemplate)) {
517
554
  fail('Unsupported cube template.', ['template']);
518
555
  }
@@ -559,6 +596,126 @@ export function decodeCreateCubeResponseEnvelope(value: unknown): ProtocolEnvelo
559
596
  return decodeProtocolEnvelope(value, decodeCreateCubeResponse);
560
597
  }
561
598
 
599
+ export function decodeResolveRepositoryCubeRequest(value: unknown): ResolveRepositoryCubeRequest {
600
+ const input = record(value);
601
+ exactKeys(input, ['working_repo_name', 'repository'], ['working_repo_name', 'repository']);
602
+ return {
603
+ working_repo_name: decodeWorkingRepositoryName(input.working_repo_name, ['working_repo_name']),
604
+ repository: decodeCreateCubeRepository(input.repository, ['repository']),
605
+ };
606
+ }
607
+
608
+ export function decodeResolveRepositoryCubeRequestEnvelope(
609
+ value: unknown,
610
+ ): ProtocolEnvelope<ResolveRepositoryCubeRequest> {
611
+ return decodeProtocolEnvelope(value, decodeResolveRepositoryCubeRequest);
612
+ }
613
+
614
+ export function decodeAssociateRepositoryCubeRequest(value: unknown): AssociateRepositoryCubeRequest {
615
+ const input = record(value);
616
+ exactKeys(
617
+ input,
618
+ ['cube_id', 'working_repo_name', 'repository'],
619
+ ['cube_id', 'working_repo_name', 'repository'],
620
+ );
621
+ return {
622
+ cube_id: decodeUuid(input.cube_id, ['cube_id']),
623
+ working_repo_name: decodeWorkingRepositoryName(input.working_repo_name, ['working_repo_name']),
624
+ repository: decodeCreateCubeRepository(input.repository, ['repository']),
625
+ };
626
+ }
627
+
628
+ export function decodeAssociateRepositoryCubeRequestEnvelope(
629
+ value: unknown,
630
+ ): ProtocolEnvelope<AssociateRepositoryCubeRequest> {
631
+ return decodeProtocolEnvelope(value, decodeAssociateRepositoryCubeRequest);
632
+ }
633
+
634
+ function decodeResolvedRepositoryCube(value: unknown): ResolvedRepositoryCube {
635
+ const input = record(value);
636
+ exactKeys(
637
+ input,
638
+ [
639
+ 'result',
640
+ 'cube_id',
641
+ 'name',
642
+ 'working_repo_name',
643
+ 'repository',
644
+ 'template',
645
+ 'human_seat_role_id',
646
+ 'default_worker_role_id',
647
+ 'access',
648
+ ],
649
+ [
650
+ 'result',
651
+ 'cube_id',
652
+ 'name',
653
+ 'working_repo_name',
654
+ 'repository',
655
+ 'template',
656
+ 'human_seat_role_id',
657
+ 'default_worker_role_id',
658
+ 'access',
659
+ ],
660
+ );
661
+ if (input.result !== 'resolved') fail('Invalid repository cube result.', ['result']);
662
+ const name = boundedString(input.name, 1, 120, ['name']);
663
+ if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(name)) {
664
+ fail('Cube name contains unsupported characters.', ['name']);
665
+ }
666
+ if (!CUBE_TEMPLATES.includes(input.template as CubeTemplate)) {
667
+ fail('Unsupported cube template.', ['template']);
668
+ }
669
+ if (input.access !== 'manage') fail('Repository cube access must be manage.', ['access']);
670
+ return {
671
+ result: 'resolved',
672
+ cube_id: decodeUuid(input.cube_id, ['cube_id']),
673
+ name,
674
+ working_repo_name: decodeWorkingRepositoryName(input.working_repo_name, ['working_repo_name']),
675
+ repository: decodeCreateCubeRepository(input.repository, ['repository']),
676
+ template: input.template as CubeTemplate,
677
+ human_seat_role_id: decodeUuid(input.human_seat_role_id, ['human_seat_role_id']),
678
+ default_worker_role_id: decodeUuid(input.default_worker_role_id, ['default_worker_role_id']),
679
+ access: 'manage',
680
+ };
681
+ }
682
+
683
+ export function decodeResolveRepositoryCubeResponse(value: unknown): ResolveRepositoryCubeResponse {
684
+ const input = record(value);
685
+ if (input.result === 'none') {
686
+ exactKeys(input, ['result'], ['result']);
687
+ return { result: 'none' };
688
+ }
689
+ return decodeResolvedRepositoryCube(input);
690
+ }
691
+
692
+ export function decodeResolveRepositoryCubeResponseEnvelope(
693
+ value: unknown,
694
+ ): ProtocolEnvelope<ResolveRepositoryCubeResponse> {
695
+ return decodeProtocolEnvelope(value, decodeResolveRepositoryCubeResponse);
696
+ }
697
+
698
+ export function decodeAssociateRepositoryCubeResponse(value: unknown): AssociateRepositoryCubeResponse {
699
+ return decodeResolvedRepositoryCube(value);
700
+ }
701
+
702
+ export function decodeAssociateRepositoryCubeResponseEnvelope(
703
+ value: unknown,
704
+ ): ProtocolEnvelope<AssociateRepositoryCubeResponse> {
705
+ return decodeProtocolEnvelope(value, decodeAssociateRepositoryCubeResponse);
706
+ }
707
+
708
+ function decodeWorkingRepositoryName(
709
+ value: unknown,
710
+ path: readonly (string | number)[],
711
+ ): string {
712
+ const name = boundedString(value, 1, 120, path);
713
+ if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(name)) {
714
+ fail('Repository name contains unsupported characters.', path);
715
+ }
716
+ return name;
717
+ }
718
+
562
719
  export function decodeAppendLogRequest(value: unknown): import('./types.js').AppendLogRequest {
563
720
  const input = record(value);
564
721
  exactKeys(
@@ -13,6 +13,8 @@ export enum ErrorCode {
13
13
  DATABASE_ERROR = 'DATABASE_ERROR',
14
14
  EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR',
15
15
  NOT_FOUND = 'NOT_FOUND',
16
+ REPOSITORY_ALREADY_ASSOCIATED = 'REPOSITORY_ALREADY_ASSOCIATED',
17
+ CUBE_ALREADY_ASSOCIATED = 'CUBE_ALREADY_ASSOCIATED',
16
18
  ROLE_IN_USE = 'ROLE_IN_USE',
17
19
  ROLE_HAS_FROZEN_DRONES = 'ROLE_HAS_FROZEN_DRONES',
18
20
  DRONE_EVICTED = 'DRONE_EVICTED',
@@ -1,4 +1,4 @@
1
- /** Current Borg coordination protocol generation. Clean-slate v4. */
2
- export const PROTOCOL_VERSION = '4' as const;
1
+ /** Current Borg coordination protocol generation. Clean-slate v5. */
2
+ export const PROTOCOL_VERSION = '5' as const;
3
3
 
4
4
  export type ProtocolVersion = typeof PROTOCOL_VERSION;