borgmcp 2.0.8 → 2.0.10

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.
Files changed (61) hide show
  1. package/README.md +2 -2
  2. package/dist/agent-runtime.d.ts +2 -0
  3. package/dist/agent-runtime.d.ts.map +1 -1
  4. package/dist/agent-runtime.js +5 -1
  5. package/dist/agent-runtime.js.map +1 -1
  6. package/dist/assimilate-cmd.d.ts +2 -0
  7. package/dist/assimilate-cmd.d.ts.map +1 -1
  8. package/dist/assimilate-cmd.js +2 -0
  9. package/dist/assimilate-cmd.js.map +1 -1
  10. package/dist/assimilate-deps.d.ts.map +1 -1
  11. package/dist/assimilate-deps.js +6 -0
  12. package/dist/assimilate-deps.js.map +1 -1
  13. package/dist/index.d.ts +13 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +38 -23
  16. package/dist/index.js.map +1 -1
  17. package/dist/regen-format.d.ts.map +1 -1
  18. package/dist/regen-format.js +6 -4
  19. package/dist/regen-format.js.map +1 -1
  20. package/dist/regen.js +2 -0
  21. package/dist/regen.js.map +1 -1
  22. package/dist/remote-client.d.ts +24 -9
  23. package/dist/remote-client.d.ts.map +1 -1
  24. package/dist/remote-client.js +329 -49
  25. package/dist/remote-client.js.map +1 -1
  26. package/dist/roster-render.d.ts +11 -0
  27. package/dist/roster-render.d.ts.map +1 -1
  28. package/dist/roster-render.js +41 -8
  29. package/dist/roster-render.js.map +1 -1
  30. package/dist/runtime-metadata.d.ts +13 -0
  31. package/dist/runtime-metadata.d.ts.map +1 -0
  32. package/dist/runtime-metadata.js +52 -0
  33. package/dist/runtime-metadata.js.map +1 -0
  34. package/dist/server-handshake.d.ts +2 -1
  35. package/dist/server-handshake.d.ts.map +1 -1
  36. package/dist/server-handshake.js +3 -0
  37. package/dist/server-handshake.js.map +1 -1
  38. package/dist/sync-roles-render.d.ts +2 -0
  39. package/dist/sync-roles-render.d.ts.map +1 -1
  40. package/dist/sync-roles-render.js +26 -7
  41. package/dist/sync-roles-render.js.map +1 -1
  42. package/dist/working-repo.d.ts +5 -7
  43. package/dist/working-repo.d.ts.map +1 -1
  44. package/dist/working-repo.js +30 -40
  45. package/dist/working-repo.js.map +1 -1
  46. package/docs/EXTRACTION_PROVENANCE.md +8 -7
  47. package/docs/LOCAL_SERVER.md +1 -1
  48. package/docs/RELEASING.md +19 -1
  49. package/package.json +2 -2
  50. package/src/agent-runtime.ts +8 -1
  51. package/src/assimilate-cmd.ts +3 -1
  52. package/src/assimilate-deps.ts +10 -4
  53. package/src/index.ts +58 -27
  54. package/src/regen-format.ts +12 -5
  55. package/src/regen.ts +2 -0
  56. package/src/remote-client.ts +382 -43
  57. package/src/roster-render.ts +58 -8
  58. package/src/runtime-metadata.ts +67 -0
  59. package/src/server-handshake.ts +7 -2
  60. package/src/sync-roles-render.ts +24 -7
  61. package/src/working-repo.ts +30 -41
@@ -11,10 +11,13 @@
11
11
  */
12
12
  import { getServerCredential, } from './config.js';
13
13
  import { randomUUID } from 'node:crypto';
14
- import { createProtocolEnvelope, decodeEvictDroneResult, decodeProtocolEnvelope, decodeProtocolErrorEnvelope, decodeReassignDroneResult, ErrorCode, } from 'borgmcp-shared/protocol';
14
+ import { createProtocolEnvelope, decodeDroneRuntimeMetadataState, decodeEvictDroneResult, decodeProtocolEnvelope, decodeProtocolErrorEnvelope, decodeReassignDroneResult, decodeUpdateDroneRuntimeMetadataResponse, ErrorCode, } from 'borgmcp-shared/protocol';
15
15
  import { debugLog } from './debug.js';
16
16
  import { assertUuidShape } from './evict-drone.js';
17
17
  import { DroneEvictedError, DRONE_EVICTED_CODE } from './drone-lifecycle.js';
18
+ import { getTemplate } from 'borgmcp-shared/templates';
19
+ import { parseRoleSections } from 'borgmcp-shared/role-section';
20
+ import { buildRuntimeMetadataPatch } from './runtime-metadata.js';
18
21
  import { loadBorgServerTrust } from './server-trust.js';
19
22
  import { BorgServerError, BorgServerHttpError, BorgServerTrustError, BorgServerUnreachableError, LocalManageCredentialUnavailableError, LocalManageRequiredError, } from './server-errors.js';
20
23
  import { getActiveCube } from './cubes.js';
@@ -186,8 +189,11 @@ async function localManageConnection(active, operation) {
186
189
  }
187
190
  return { apiUrl: active.apiUrl, authToken, serverTrustIdentity: trustIdentity };
188
191
  }
189
- async function localManageRequest(active, path, method, operation, payload, decodePayload) {
190
- const connection = await localManageConnection(active, operation);
192
+ export async function resolveLocalManageAuthority(active, operation) {
193
+ return { active, connection: await localManageConnection(active, operation) };
194
+ }
195
+ async function localManageRequest(active, path, method, operation, payload, decodePayload, connectionOverride) {
196
+ const connection = connectionOverride ?? await localManageConnection(active, operation);
191
197
  try {
192
198
  return await decodeLocalProtocolResponse((signal) => authedFetch(path, {
193
199
  method,
@@ -224,6 +230,18 @@ async function localConnectionRequest(connection, path) {
224
230
  headers: { Accept: 'application/json' },
225
231
  }), false);
226
232
  }
233
+ async function localConnectionMutation(connection, path, method, payload) {
234
+ return decodeLocalProtocolResponse((signal) => authedFetch(path, {
235
+ method,
236
+ signal,
237
+ apiUrl: connection.apiUrl,
238
+ authToken: connection.authToken,
239
+ serverTrustIdentity: connection.serverTrustIdentity,
240
+ redirect: 'error',
241
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
242
+ body: JSON.stringify(createProtocolEnvelope(randomUUID(), payload)),
243
+ }), false);
244
+ }
227
245
  async function localOwnerConnection(connection) {
228
246
  if (connection)
229
247
  return connection;
@@ -250,18 +268,33 @@ async function localCubeComposition(active) {
250
268
  if (!cubePayload || !rolePayload || !dronePayload) {
251
269
  throw new Error('Local Borg server returned an incomplete cube response');
252
270
  }
253
- const drone = dronePayload.drones.find((candidate) => candidate.id === active.droneId);
271
+ const drones = dronePayload.drones.map(withValidatedRuntimeMetadata);
272
+ const drone = drones.find((candidate) => candidate.id === active.droneId);
254
273
  const role = rolePayload.roles.find((candidate) => candidate.id === drone?.role_id);
255
274
  if (!drone || !role)
256
275
  throw new Error('Local Borg server no longer recognizes this drone seat');
257
276
  return {
258
277
  cube: cubePayload.cube,
259
278
  roles: rolePayload.roles,
260
- drones: dronePayload.drones,
279
+ drones,
261
280
  role,
262
281
  drone,
263
282
  };
264
283
  }
284
+ function withValidatedRuntimeMetadata(drone) {
285
+ const state = decodeDroneRuntimeMetadataState(drone);
286
+ return {
287
+ ...drone,
288
+ ...state.runtime_metadata,
289
+ runtime_metadata_reported: state.runtime_metadata_reported,
290
+ };
291
+ }
292
+ async function updateOwnRuntimeMetadata(active, patch) {
293
+ const payload = await localServerRequest(active, `/api/cubes/${active.cubeId}/drones/self/metadata`, 'PATCH', { ...patch });
294
+ if (!payload)
295
+ throw new Error('Local Borg server returned an empty runtime metadata response');
296
+ decodeUpdateDroneRuntimeMetadataResponse(payload);
297
+ }
265
298
  function localCursorBinding(active) {
266
299
  return {
267
300
  origin: active.apiUrl,
@@ -503,6 +536,13 @@ export async function whoami(sessionToken, apiUrl, serverTrustIdentity) {
503
536
  drone_label: composed.drone.label,
504
537
  role_id: composed.role.id,
505
538
  role_name: composed.role.name,
539
+ runtime_metadata: {
540
+ agent_kind: composed.drone.agent_kind,
541
+ reported_model: composed.drone.reported_model,
542
+ working_repo_name: composed.drone.working_repo_name,
543
+ working_repo_origin: composed.drone.working_repo_origin,
544
+ },
545
+ runtime_metadata_reported: composed.drone.runtime_metadata_reported,
506
546
  };
507
547
  }
508
548
  /**
@@ -519,10 +559,28 @@ export async function whoami(sessionToken, apiUrl, serverTrustIdentity) {
519
559
  */
520
560
  export async function getRoster(sessionToken, apiUrl, since, serverTrustIdentity) {
521
561
  const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
522
- if (since !== undefined)
523
- localUnsupported('roster liveness filtering');
562
+ if (since !== undefined) {
563
+ const [dronePayload, rolePayload, cubePayload] = await Promise.all([
564
+ localServerRequest(local, `/api/cubes/${local.cubeId}/drones?since=${encodeURIComponent(since)}`, 'GET'),
565
+ localServerRequest(local, `/api/cubes/${local.cubeId}/roles`, 'GET'),
566
+ localServerRequest(local, `/api/cubes/${local.cubeId}`, 'GET'),
567
+ ]);
568
+ if (!dronePayload || !rolePayload || !cubePayload) {
569
+ throw new Error('Local Borg server returned an incomplete roster response');
570
+ }
571
+ return {
572
+ drones: dronePayload.drones.map(withValidatedRuntimeMetadata),
573
+ roles: rolePayload.roles,
574
+ message_taxonomy: cubePayload.cube.message_taxonomy ?? null,
575
+ since: dronePayload.since ?? since,
576
+ };
577
+ }
524
578
  const composed = await localCubeComposition(local);
525
- return { drones: composed.drones, roles: composed.roles, message_taxonomy: null };
579
+ return {
580
+ drones: composed.drones,
581
+ roles: composed.roles,
582
+ message_taxonomy: composed.cube.message_taxonomy ?? null,
583
+ };
526
584
  }
527
585
  /**
528
586
  * Read recent log entries for the cube.
@@ -612,6 +670,23 @@ export async function removeDecision(sessionToken, apiUrl, selector, serverTrust
612
670
  */
613
671
  export async function regen(sessionToken, apiUrl, opts = {}) {
614
672
  const local = await localAuthorityContext(sessionToken, apiUrl, opts.serverTrustIdentity);
673
+ if (opts.agentKind !== undefined ||
674
+ opts.reportedModel !== undefined ||
675
+ opts.workingRepo !== undefined) {
676
+ const patch = buildRuntimeMetadataPatch({
677
+ agentKind: opts.agentKind ?? null,
678
+ reportedModel: opts.reportedModel,
679
+ workingRepo: opts.workingRepo,
680
+ });
681
+ try {
682
+ await updateOwnRuntimeMetadata(local, patch);
683
+ }
684
+ catch {
685
+ // Metadata is advisory. Preserve the prior server value and continue with
686
+ // the authenticated identity read; never echo rejected local input.
687
+ console.warn('Local regen: runtime metadata update unavailable; preserving the prior safe report.');
688
+ }
689
+ }
615
690
  const composed = await localCubeComposition(local);
616
691
  const cursor = opts.since === undefined
617
692
  ? await getLocalServerCursor(localCursorBinding(local))
@@ -707,19 +782,32 @@ export async function listCubes(connection) {
707
782
  * Existing callers that read `body.cube` keep working (forward-compat).
708
783
  */
709
784
  export async function createCube(name, cubeDirective, opts, connection) {
710
- void name;
711
- void cubeDirective;
712
- void opts;
713
- void connection;
714
- localUnsupported('cube creation');
785
+ if (!name?.trim())
786
+ throw new Error('Local Borg server cube creation requires a cube name');
787
+ if (opts?.template !== undefined && opts.template !== 'default') {
788
+ throw new Error('Local Borg server supports only the default cube seed');
789
+ }
790
+ const resolved = await localOwnerConnection(connection);
791
+ const created = await localConnectionMutation(resolved, '/api/cubes', 'POST', {
792
+ retry_key: randomUUID(),
793
+ name: name.trim(),
794
+ template: 'default',
795
+ });
796
+ if (!created?.cube_id)
797
+ throw new Error('Local Borg server returned an invalid cube creation response');
798
+ const patch = { cube_directive: cubeDirective };
799
+ if (opts?.message_taxonomy !== undefined)
800
+ patch.message_taxonomy = opts.message_taxonomy;
801
+ await localConnectionMutation(resolved, `/api/cubes/${created.cube_id}`, 'PATCH', patch);
802
+ return getCube(created.cube_id, resolved);
715
803
  }
716
804
  /**
717
805
  * Update a cube's name and/or cube_directive. Both fields are optional;
718
806
  * pass only what changes.
719
807
  */
720
- export async function updateCube(cubeId, updates) {
808
+ export async function updateCube(cubeId, updates, activeOverride, connectionOverride) {
721
809
  assertUuidShape(cubeId, 'cube_id');
722
- const active = await getActiveCube();
810
+ const active = activeOverride ?? await getActiveCube();
723
811
  if (!active?.serverTrustIdentity)
724
812
  throw new Error('Selected Borg server authority state is missing or unreadable');
725
813
  if (updates.name !== undefined)
@@ -734,7 +822,7 @@ export async function updateCube(cubeId, updates) {
734
822
  operation: `update cube settings in cube ${manageCopyValue(cubeId === active.cubeId ? active.name : cubeId)}`,
735
823
  cubeName: cubeId === active.cubeId ? active.name : cubeId,
736
824
  noMutation: 'No cube settings were changed.',
737
- }, payload);
825
+ }, payload, undefined, connectionOverride);
738
826
  if (!result)
739
827
  throw new Error('Local Borg server returned an empty cube response');
740
828
  return result;
@@ -745,9 +833,9 @@ export async function updateCube(cubeId, updates) {
745
833
  * other classes unchanged. The server re-validates the full resulting
746
834
  * array and requires the selected local client's live cube-manage grant.
747
835
  */
748
- export async function patchTaxonomyClass(cubeId, op) {
836
+ export async function patchTaxonomyClass(cubeId, op, activeOverride, connectionOverride) {
749
837
  assertUuidShape(cubeId, 'cube_id');
750
- const active = await getActiveCube();
838
+ const active = activeOverride ?? await getActiveCube();
751
839
  if (!active?.serverTrustIdentity)
752
840
  throw new Error('Selected Borg server authority state is missing or unreadable');
753
841
  const className = op.action === 'remove' ? op.class : op.class_def.class;
@@ -758,7 +846,7 @@ export async function patchTaxonomyClass(cubeId, op) {
758
846
  operation: `${op.action} message class ${manageCopyValue(className)} ${preposition} cube ${manageCopyValue(cubeName)}`,
759
847
  cubeName,
760
848
  noMutation: `No message class was ${pastTense}.`,
761
- }, op);
849
+ }, op, undefined, connectionOverride);
762
850
  if (!result)
763
851
  throw new Error('Local Borg server returned an empty taxonomy response');
764
852
  return result;
@@ -775,9 +863,9 @@ export async function deleteCube(cubeId) {
775
863
  * Create a role inside a cube. is_default=true demotes the previous
776
864
  * default role; the cube always has exactly one default.
777
865
  */
778
- export async function createRole(cubeId, data) {
866
+ export async function createRole(cubeId, data, activeOverride, connectionOverride) {
779
867
  assertUuidShape(cubeId, 'cube_id');
780
- const active = await getActiveCube();
868
+ const active = activeOverride ?? await getActiveCube();
781
869
  if (!active?.serverTrustIdentity)
782
870
  throw new Error('Selected Borg server authority state is missing or unreadable');
783
871
  if (data.default_model !== undefined)
@@ -786,7 +874,7 @@ export async function createRole(cubeId, data) {
786
874
  operation: `create role ${manageCopyValue(data.name)} in cube ${manageCopyValue(cubeId === active.cubeId ? active.name : cubeId)}`,
787
875
  cubeName: cubeId === active.cubeId ? active.name : cubeId,
788
876
  noMutation: 'No role was created.',
789
- }, buildLocalRoleFields(data));
877
+ }, buildLocalRoleFields(data), undefined, connectionOverride);
790
878
  if (!result)
791
879
  throw new Error('Local Borg server returned an empty role response');
792
880
  return result;
@@ -794,19 +882,20 @@ export async function createRole(cubeId, data) {
794
882
  /**
795
883
  * Update a role. All fields optional; pass only what changes.
796
884
  */
797
- export async function updateRole(roleId, updates) {
885
+ export async function updateRole(roleId, updates, targetCubeId, activeOverride, connectionOverride) {
798
886
  assertUuidShape(roleId, 'role_id');
799
- const active = await getActiveCube();
887
+ const active = activeOverride ?? await getActiveCube();
800
888
  if (!active?.serverTrustIdentity)
801
889
  throw new Error('Selected Borg server authority state is missing or unreadable');
802
- assertUuidShape(active.cubeId, 'cube_id');
890
+ const cubeId = targetCubeId ?? active.cubeId;
891
+ assertUuidShape(cubeId, 'cube_id');
803
892
  if (updates.default_model !== undefined)
804
893
  localUnsupported('per-role default model');
805
- const result = await localManageRequest(active, `/api/cubes/${active.cubeId}/roles/${roleId}`, 'PATCH', {
806
- operation: `update role ${manageCopyValue(roleId)} in cube ${manageCopyValue(active.name)}`,
807
- cubeName: active.name,
894
+ const result = await localManageRequest(active, `/api/cubes/${cubeId}/roles/${roleId}`, 'PATCH', {
895
+ operation: `update role ${manageCopyValue(roleId)} in cube ${manageCopyValue(cubeId === active.cubeId ? active.name : cubeId)}`,
896
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
808
897
  noMutation: 'No role was updated.',
809
- }, buildLocalRoleFields(updates));
898
+ }, buildLocalRoleFields(updates), undefined, connectionOverride);
810
899
  if (!result)
811
900
  throw new Error('Local Borg server returned an empty role response');
812
901
  return result;
@@ -842,17 +931,18 @@ function buildLocalRoleFields(fields) {
842
931
  * client's live cube-manage grant. Sections are delimited by plain-label lines (e.g.
843
932
  * `Workflow:`), NOT markdown headings.
844
933
  */
845
- export async function patchRoleSection(roleId, op) {
934
+ export async function patchRoleSection(roleId, op, targetCubeId, activeOverride, connectionOverride) {
846
935
  assertUuidShape(roleId, 'role_id');
847
- const active = await getActiveCube();
936
+ const active = activeOverride ?? await getActiveCube();
848
937
  if (!active?.serverTrustIdentity)
849
938
  throw new Error('Selected Borg server authority state is missing or unreadable');
850
- assertUuidShape(active.cubeId, 'cube_id');
851
- const result = await localManageRequest(active, `/api/cubes/${active.cubeId}/roles/${roleId}/section-patch`, 'POST', {
852
- operation: `${op.action} section ${manageCopyValue(op.heading)} ${op.action === 'delete' ? 'from' : 'in'} role ${manageCopyValue(roleId)} in cube ${manageCopyValue(active.name)}`,
853
- cubeName: active.name,
939
+ const cubeId = targetCubeId ?? active.cubeId;
940
+ assertUuidShape(cubeId, 'cube_id');
941
+ const result = await localManageRequest(active, `/api/cubes/${cubeId}/roles/${roleId}/section-patch`, 'POST', {
942
+ operation: `${op.action} section ${manageCopyValue(op.heading)} ${op.action === 'delete' ? 'from' : 'in'} role ${manageCopyValue(roleId)} in cube ${manageCopyValue(cubeId === active.cubeId ? active.name : cubeId)}`,
943
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
854
944
  noMutation: `No role section was ${op.action === 'insert' ? 'inserted' : op.action === 'replace' ? 'replaced' : 'deleted'}.`,
855
- }, { ...op });
945
+ }, { ...op }, undefined, connectionOverride);
856
946
  if (!result)
857
947
  throw new Error('Local Borg server returned an empty role response');
858
948
  return result;
@@ -920,12 +1010,12 @@ export async function listRoles(cubeId) {
920
1010
  }
921
1011
  return result.roles;
922
1012
  }
923
- export async function getCubeForManagement(cubeId, operation, activeOverride) {
1013
+ export async function getCubeForManagement(cubeId, operation, activeOverride, connectionOverride) {
924
1014
  assertUuidShape(cubeId, 'cube_id');
925
1015
  const active = activeOverride ?? await getActiveCube();
926
1016
  if (!active?.serverTrustIdentity)
927
1017
  throw new Error('Selected Borg server authority state is missing or unreadable');
928
- return getCube(cubeId, await localManageConnection(active, operation));
1018
+ return getCube(cubeId, connectionOverride ?? await localManageConnection(active, operation));
929
1019
  }
930
1020
  /**
931
1021
  * Fetch a cube's full detail: directive, roles (with detailed
@@ -944,7 +1034,7 @@ export async function getCube(cubeId, connection) {
944
1034
  return {
945
1035
  ...cubePayload.cube,
946
1036
  roles: rolePayload.roles,
947
- drones: dronePayload.drones,
1037
+ drones: dronePayload.drones.map(withValidatedRuntimeMetadata),
948
1038
  };
949
1039
  }
950
1040
  /**
@@ -956,10 +1046,45 @@ export async function getCube(cubeId, connection) {
956
1046
  * `{ created, updated }` counts. To selectively take template versions of
957
1047
  * conflicting fragments, use `syncRoles` with a `decisions` map instead.
958
1048
  */
959
- export async function applyTemplate(cubeId, templateName) {
960
- void cubeId;
961
- void templateName;
962
- localUnsupported('template application');
1049
+ export async function applyTemplate(cubeId, templateName, authorityOverride) {
1050
+ assertUuidShape(cubeId, 'cube_id');
1051
+ const template = getTemplate(templateName);
1052
+ if (!template)
1053
+ throw new Error(`Unknown Borg template ${JSON.stringify(templateName)}`);
1054
+ const active = authorityOverride?.active ?? await getActiveCube();
1055
+ if (!active?.serverTrustIdentity)
1056
+ throw new Error('Selected Borg server authority state is missing or unreadable');
1057
+ const authority = authorityOverride ?? await resolveLocalManageAuthority(active, {
1058
+ operation: `apply template ${manageCopyValue(templateName)}`,
1059
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1060
+ noMutation: 'No template fragments were changed.',
1061
+ });
1062
+ const current = await getCubeForManagement(cubeId, {
1063
+ operation: `apply template ${manageCopyValue(templateName)}`,
1064
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1065
+ noMutation: 'No template fragments were changed.',
1066
+ }, active, authority.connection);
1067
+ let created = 0;
1068
+ let updated = 0;
1069
+ for (const role of template.roles) {
1070
+ const existing = current.roles.find((candidate) => candidate.name === role.name);
1071
+ if (!existing) {
1072
+ await createRole(cubeId, role, active, authority.connection);
1073
+ created++;
1074
+ continue;
1075
+ }
1076
+ if (await applyMissingRoleFields(existing, role, cubeId, active, authority.connection))
1077
+ updated++;
1078
+ updated += await applyMissingRoleSections(existing, role, cubeId, active, authority.connection);
1079
+ }
1080
+ for (const classDef of template.message_taxonomy ?? []) {
1081
+ const currentClasses = (current.message_taxonomy ?? []);
1082
+ if (!currentClasses.some((candidate) => candidate.class === classDef.class)) {
1083
+ await patchTaxonomyClass(cubeId, { action: 'add', class_def: classDef }, active, authority.connection);
1084
+ updated++;
1085
+ }
1086
+ }
1087
+ return { created, updated };
963
1088
  }
964
1089
  /**
965
1090
  * gh#473 PR2 — NON-CLOBBERING sync of a cube's roles + message_taxonomy
@@ -972,11 +1097,166 @@ export async function applyTemplate(cubeId, templateName) {
972
1097
  * never silently overwritten. Custom roles (names not in template) are
973
1098
  * never touched. Returns a NonClobberSyncResult.
974
1099
  */
975
- export async function syncRoles(cubeId, templateName = 'software-dev', apply = false, decisions) {
976
- void cubeId;
977
- void templateName;
978
- void apply;
979
- void decisions;
980
- localUnsupported('role synchronization');
1100
+ export async function syncRoles(cubeId, templateName = 'software-dev', apply = false, decisions, authorityOverride) {
1101
+ assertUuidShape(cubeId, 'cube_id');
1102
+ const template = getTemplate(templateName);
1103
+ if (!template)
1104
+ throw new Error(`Unknown Borg template ${JSON.stringify(templateName)}`);
1105
+ const active = authorityOverride?.active ?? await getActiveCube();
1106
+ if (!active?.serverTrustIdentity)
1107
+ throw new Error('Selected Borg server authority state is missing or unreadable');
1108
+ const authority = authorityOverride ?? await resolveLocalManageAuthority(active, {
1109
+ operation: `sync template ${manageCopyValue(templateName)}`,
1110
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1111
+ noMutation: 'No role synchronization changes were applied.',
1112
+ });
1113
+ const current = await getCubeForManagement(cubeId, {
1114
+ operation: `sync template ${manageCopyValue(templateName)}`,
1115
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1116
+ noMutation: 'No role synchronization changes were applied.',
1117
+ }, active, authority.connection);
1118
+ const roles = [];
1119
+ const taxonomy = [];
1120
+ const additions = [];
1121
+ const conflictKeys = new Set();
1122
+ for (const role of template.roles) {
1123
+ const existing = current.roles.find((candidate) => candidate.name === role.name);
1124
+ if (!existing) {
1125
+ const key = `role:${role.name}`;
1126
+ const fragments = [{
1127
+ key,
1128
+ kind: 'add',
1129
+ label: 'role',
1130
+ cubeValue: null,
1131
+ templateValue: role.name,
1132
+ }];
1133
+ roles.push({ name: role.name, status: 'new', fragments });
1134
+ additions.push({ key, run: async () => { await createRole(cubeId, role, active, authority.connection); } });
1135
+ continue;
1136
+ }
1137
+ const fragments = [];
1138
+ addRoleScalarFragment(fragments, additions, conflictKeys, decisions, existing, role, 'short_description', 'short description', cubeId, active, authority.connection);
1139
+ for (const field of ['is_default', 'is_mandatory', 'is_human_seat', 'can_broadcast', 'receives_all_direct']) {
1140
+ if (role[field] !== undefined)
1141
+ addRoleScalarFragment(fragments, additions, conflictKeys, decisions, existing, role, field, field, cubeId, active, authority.connection);
1142
+ }
1143
+ const currentSections = new Map(parseRoleSections(String(existing.detailed_description ?? '')).map((section) => [section.heading, section]));
1144
+ for (const section of parseRoleSections(role.detailed_description)) {
1145
+ if (!section.heading)
1146
+ continue;
1147
+ const key = `role:${role.name}:section:${section.heading}`;
1148
+ const previous = currentSections.get(section.heading);
1149
+ const kind = !previous ? 'add' : previous.body === section.body ? 'unchanged' : 'conflict';
1150
+ fragments.push({ key, kind, label: `section ${section.heading}`, cubeValue: previous?.body ?? null, templateValue: section.body });
1151
+ if (kind === 'add')
1152
+ additions.push({ key, run: async () => { await patchRoleSection(existing.id, { action: 'insert', heading: section.heading, body: section.body }, cubeId, active, authority.connection); } });
1153
+ if (kind === 'conflict')
1154
+ conflictKeys.add(key);
1155
+ if (kind === 'conflict' && decisions?.[key] === 'accept')
1156
+ additions.push({ key, run: async () => { await patchRoleSection(existing.id, { action: 'replace', heading: section.heading, body: section.body }, cubeId, active, authority.connection); } });
1157
+ }
1158
+ roles.push({ name: role.name, status: 'existing', fragments });
1159
+ }
1160
+ for (const existing of current.roles) {
1161
+ if (!template.roles.some((role) => role.name === existing.name)) {
1162
+ roles.push({ name: existing.name, status: 'custom-skipped', fragments: [] });
1163
+ }
1164
+ }
1165
+ for (const classDef of template.message_taxonomy ?? []) {
1166
+ const key = `taxonomy:${classDef.class}`;
1167
+ const currentClass = (current.message_taxonomy ?? []).find((candidate) => candidate.class === classDef.class);
1168
+ const currentValue = currentClass ? stableJson(currentClass) : null;
1169
+ const templateValue = stableJson(classDef);
1170
+ const kind = !currentClass ? 'add' : currentValue === templateValue ? 'unchanged' : 'conflict';
1171
+ taxonomy.push({ key, kind, label: `taxonomy class ${classDef.class}`, cubeValue: currentValue, templateValue });
1172
+ if (kind === 'add')
1173
+ additions.push({ key, run: async () => { await patchTaxonomyClass(cubeId, { action: 'add', class_def: classDef }, active, authority.connection); } });
1174
+ if (kind === 'conflict')
1175
+ conflictKeys.add(key);
1176
+ if (kind === 'conflict' && decisions?.[key] === 'accept')
1177
+ additions.push({ key, run: async () => { await patchTaxonomyClass(cubeId, { action: 'replace', class_def: classDef }, active, authority.connection); } });
1178
+ }
1179
+ const acceptedConflicts = [...conflictKeys].filter((key) => decisions?.[key] === 'accept');
1180
+ const rejectedConflicts = [...conflictKeys].filter((key) => decisions?.[key] !== 'accept');
1181
+ const classifiedKeys = new Set([...conflictKeys]);
1182
+ const unmatchedDecisions = Object.keys(decisions ?? {}).filter((key) => !classifiedKeys.has(key));
1183
+ const addedKeys = additions.filter(({ key }) => !conflictKeys.has(key)).map(({ key }) => key);
1184
+ if (apply) {
1185
+ for (const addition of additions) {
1186
+ if (conflictKeys.has(addition.key) && decisions?.[addition.key] !== 'accept')
1187
+ continue;
1188
+ await addition.run();
1189
+ }
1190
+ }
1191
+ return {
1192
+ dryRun: !apply,
1193
+ roles,
1194
+ taxonomy,
1195
+ applied: {
1196
+ added: apply ? addedKeys : [],
1197
+ acceptedConflicts: apply ? acceptedConflicts : [],
1198
+ },
1199
+ rejectedConflicts,
1200
+ unmatchedDecisions,
1201
+ };
1202
+ }
1203
+ function stableJson(value) {
1204
+ if (Array.isArray(value))
1205
+ return `[${value.map(stableJson).join(',')}]`;
1206
+ if (value !== null && typeof value === 'object') {
1207
+ return `{${Object.entries(value)
1208
+ .sort(([left], [right]) => left.localeCompare(right))
1209
+ .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
1210
+ .join(',')}}`;
1211
+ }
1212
+ return JSON.stringify(value);
1213
+ }
1214
+ function addRoleScalarFragment(fragments, additions, conflictKeys, decisions, existing, template, field, label, cubeId, active, connection) {
1215
+ const templateValue = template[field];
1216
+ if (templateValue === undefined)
1217
+ return;
1218
+ const key = `role:${template.name}:${field}`;
1219
+ const currentValue = existing[field];
1220
+ const missing = currentValue === undefined || (field === 'short_description' && currentValue === '');
1221
+ const kind = missing ? 'add' : currentValue === templateValue ? 'unchanged' : 'conflict';
1222
+ fragments.push({ key, kind, label, cubeValue: missing ? null : String(currentValue), templateValue: String(templateValue) });
1223
+ if (kind === 'add')
1224
+ additions.push({
1225
+ key,
1226
+ run: async () => { await updateRole(existing.id, { [field]: templateValue }, cubeId, active, connection); },
1227
+ });
1228
+ if (kind === 'conflict') {
1229
+ conflictKeys.add(key);
1230
+ if (decisions?.[key] === 'accept')
1231
+ additions.push({
1232
+ key,
1233
+ run: async () => { await updateRole(existing.id, { [field]: templateValue }, cubeId, active, connection); },
1234
+ });
1235
+ }
1236
+ }
1237
+ async function applyMissingRoleFields(existing, template, cubeId, active, connection) {
1238
+ const updates = {};
1239
+ if ((existing.short_description === undefined || existing.short_description === '') && template.short_description) {
1240
+ updates.short_description = template.short_description;
1241
+ }
1242
+ for (const field of ['is_default', 'is_mandatory', 'is_human_seat', 'can_broadcast', 'receives_all_direct']) {
1243
+ if (existing[field] === undefined && template[field] !== undefined)
1244
+ updates[field] = template[field];
1245
+ }
1246
+ if (Object.keys(updates).length === 0)
1247
+ return false;
1248
+ await updateRole(existing.id, updates, cubeId, active, connection);
1249
+ return true;
1250
+ }
1251
+ async function applyMissingRoleSections(existing, template, cubeId, active, connection) {
1252
+ const currentSections = new Map(parseRoleSections(String(existing.detailed_description ?? '')).map((section) => [section.heading, section]));
1253
+ let updated = 0;
1254
+ for (const section of parseRoleSections(template.detailed_description)) {
1255
+ if (section.heading && !currentSections.has(section.heading)) {
1256
+ await patchRoleSection(existing.id, { action: 'insert', heading: section.heading, body: section.body }, cubeId, active, connection);
1257
+ updated++;
1258
+ }
1259
+ }
1260
+ return updated;
981
1261
  }
982
1262
  //# sourceMappingURL=remote-client.js.map