borgmcp 2.0.8 → 2.0.9
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/README.md +2 -2
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -14
- package/dist/index.js.map +1 -1
- package/dist/remote-client.d.ts +14 -8
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +285 -45
- package/dist/remote-client.js.map +1 -1
- package/dist/sync-roles-render.d.ts +2 -0
- package/dist/sync-roles-render.d.ts.map +1 -1
- package/dist/sync-roles-render.js +26 -7
- package/dist/sync-roles-render.js.map +1 -1
- package/docs/EXTRACTION_PROVENANCE.md +7 -6
- package/docs/LOCAL_SERVER.md +1 -1
- package/docs/RELEASING.md +10 -1
- package/package.json +2 -2
- package/src/index.ts +41 -14
- package/src/remote-client.ts +323 -39
- package/src/sync-roles-render.ts +24 -7
package/src/remote-client.ts
CHANGED
|
@@ -29,6 +29,9 @@ import { debugLog } from './debug.js';
|
|
|
29
29
|
import { assertUuidShape } from './evict-drone.js';
|
|
30
30
|
import { DroneEvictedError, DRONE_EVICTED_CODE } from './drone-lifecycle.js';
|
|
31
31
|
import type { MessageTaxonomy, MessageTaxonomyClass } from 'borgmcp-shared/templates';
|
|
32
|
+
import { getTemplate, type Template, type TemplateRole } from 'borgmcp-shared/templates';
|
|
33
|
+
import { parseRoleSections } from 'borgmcp-shared/role-section';
|
|
34
|
+
import type { FragmentView, NonClobberSyncResult } from './sync-roles-render.js';
|
|
32
35
|
import type { WorkingRepo } from './working-repo.js';
|
|
33
36
|
import { loadBorgServerTrust, type ServerFetch } from './server-trust.js';
|
|
34
37
|
import {
|
|
@@ -250,6 +253,11 @@ export interface LocalManageOperation {
|
|
|
250
253
|
noMutation: string;
|
|
251
254
|
}
|
|
252
255
|
|
|
256
|
+
export interface LocalManageAuthority {
|
|
257
|
+
active: ActiveCube;
|
|
258
|
+
connection: RemoteConnection;
|
|
259
|
+
}
|
|
260
|
+
|
|
253
261
|
function manageCopyValue(value: string): string {
|
|
254
262
|
return JSON.stringify(value);
|
|
255
263
|
}
|
|
@@ -280,6 +288,13 @@ async function localManageConnection(
|
|
|
280
288
|
return { apiUrl: active.apiUrl, authToken, serverTrustIdentity: trustIdentity };
|
|
281
289
|
}
|
|
282
290
|
|
|
291
|
+
export async function resolveLocalManageAuthority(
|
|
292
|
+
active: ActiveCube,
|
|
293
|
+
operation: LocalManageOperation,
|
|
294
|
+
): Promise<LocalManageAuthority> {
|
|
295
|
+
return { active, connection: await localManageConnection(active, operation) };
|
|
296
|
+
}
|
|
297
|
+
|
|
283
298
|
async function localManageRequest<T>(
|
|
284
299
|
active: ActiveCube,
|
|
285
300
|
path: string,
|
|
@@ -287,8 +302,9 @@ async function localManageRequest<T>(
|
|
|
287
302
|
operation: LocalManageOperation,
|
|
288
303
|
payload?: Record<string, unknown>,
|
|
289
304
|
decodePayload?: (value: unknown) => T,
|
|
305
|
+
connectionOverride?: RemoteConnection,
|
|
290
306
|
): Promise<T | null> {
|
|
291
|
-
const connection = await localManageConnection(active, operation);
|
|
307
|
+
const connection = connectionOverride ?? await localManageConnection(active, operation);
|
|
292
308
|
try {
|
|
293
309
|
return await decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
|
|
294
310
|
method,
|
|
@@ -335,6 +351,24 @@ async function localConnectionRequest<T>(
|
|
|
335
351
|
}), false) as Promise<T>;
|
|
336
352
|
}
|
|
337
353
|
|
|
354
|
+
async function localConnectionMutation<T>(
|
|
355
|
+
connection: RemoteConnection,
|
|
356
|
+
path: string,
|
|
357
|
+
method: 'POST' | 'PATCH',
|
|
358
|
+
payload: Record<string, unknown>,
|
|
359
|
+
): Promise<T> {
|
|
360
|
+
return decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
|
|
361
|
+
method,
|
|
362
|
+
signal,
|
|
363
|
+
apiUrl: connection.apiUrl,
|
|
364
|
+
authToken: connection.authToken,
|
|
365
|
+
serverTrustIdentity: connection.serverTrustIdentity,
|
|
366
|
+
redirect: 'error',
|
|
367
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
368
|
+
body: JSON.stringify(createProtocolEnvelope(randomUUID(), payload)),
|
|
369
|
+
}), false) as Promise<T>;
|
|
370
|
+
}
|
|
371
|
+
|
|
338
372
|
async function localOwnerConnection(connection?: RemoteConnection): Promise<RemoteConnection> {
|
|
339
373
|
if (connection) return connection;
|
|
340
374
|
const active = await getActiveCube();
|
|
@@ -738,9 +772,32 @@ export async function getRoster(
|
|
|
738
772
|
serverTrustIdentity?: string,
|
|
739
773
|
): Promise<{ drones: any[]; roles: any[]; message_taxonomy?: MessageTaxonomy | null; since?: string | null }> {
|
|
740
774
|
const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
|
|
741
|
-
if (since !== undefined)
|
|
775
|
+
if (since !== undefined) {
|
|
776
|
+
const [dronePayload, rolePayload, cubePayload] = await Promise.all([
|
|
777
|
+
localServerRequest<{ drones: any[]; since?: string | null }>(
|
|
778
|
+
local,
|
|
779
|
+
`/api/cubes/${local.cubeId}/drones?since=${encodeURIComponent(since)}`,
|
|
780
|
+
'GET',
|
|
781
|
+
),
|
|
782
|
+
localServerRequest<{ roles: any[] }>(local, `/api/cubes/${local.cubeId}/roles`, 'GET'),
|
|
783
|
+
localServerRequest<{ cube: any }>(local, `/api/cubes/${local.cubeId}`, 'GET'),
|
|
784
|
+
]);
|
|
785
|
+
if (!dronePayload || !rolePayload || !cubePayload) {
|
|
786
|
+
throw new Error('Local Borg server returned an incomplete roster response');
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
drones: dronePayload.drones,
|
|
790
|
+
roles: rolePayload.roles,
|
|
791
|
+
message_taxonomy: cubePayload.cube.message_taxonomy ?? null,
|
|
792
|
+
since: dronePayload.since ?? since,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
742
795
|
const composed = await localCubeComposition(local);
|
|
743
|
-
return {
|
|
796
|
+
return {
|
|
797
|
+
drones: composed.drones,
|
|
798
|
+
roles: composed.roles,
|
|
799
|
+
message_taxonomy: composed.cube.message_taxonomy ?? null,
|
|
800
|
+
};
|
|
744
801
|
}
|
|
745
802
|
|
|
746
803
|
/**
|
|
@@ -1071,11 +1128,25 @@ export async function createCube(
|
|
|
1071
1128
|
opts?: { template?: string; message_taxonomy?: MessageTaxonomy | null },
|
|
1072
1129
|
connection?: RemoteConnection,
|
|
1073
1130
|
): Promise<{ id: string; name: string; cube_directive?: string; roles: any[]; drones?: any[]; [k: string]: any }> {
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1131
|
+
if (!name?.trim()) throw new Error('Local Borg server cube creation requires a cube name');
|
|
1132
|
+
if (opts?.template !== undefined && opts.template !== 'default') {
|
|
1133
|
+
throw new Error('Local Borg server supports only the default cube seed');
|
|
1134
|
+
}
|
|
1135
|
+
const resolved = await localOwnerConnection(connection);
|
|
1136
|
+
const created = await localConnectionMutation<{
|
|
1137
|
+
cube_id: string;
|
|
1138
|
+
human_seat_role_id: string;
|
|
1139
|
+
default_worker_role_id: string;
|
|
1140
|
+
}>(resolved, '/api/cubes', 'POST', {
|
|
1141
|
+
retry_key: randomUUID(),
|
|
1142
|
+
name: name.trim(),
|
|
1143
|
+
template: 'default',
|
|
1144
|
+
});
|
|
1145
|
+
if (!created?.cube_id) throw new Error('Local Borg server returned an invalid cube creation response');
|
|
1146
|
+
const patch: Record<string, unknown> = { cube_directive: cubeDirective };
|
|
1147
|
+
if (opts?.message_taxonomy !== undefined) patch.message_taxonomy = opts.message_taxonomy;
|
|
1148
|
+
await localConnectionMutation(resolved, `/api/cubes/${created.cube_id}`, 'PATCH', patch);
|
|
1149
|
+
return getCube(created.cube_id, resolved);
|
|
1079
1150
|
}
|
|
1080
1151
|
|
|
1081
1152
|
/**
|
|
@@ -1084,10 +1155,12 @@ export async function createCube(
|
|
|
1084
1155
|
*/
|
|
1085
1156
|
export async function updateCube(
|
|
1086
1157
|
cubeId: string,
|
|
1087
|
-
updates: { name?: string; cube_directive?: string; message_taxonomy?: MessageTaxonomy | null }
|
|
1158
|
+
updates: { name?: string; cube_directive?: string; message_taxonomy?: MessageTaxonomy | null },
|
|
1159
|
+
activeOverride?: ActiveCube,
|
|
1160
|
+
connectionOverride?: RemoteConnection,
|
|
1088
1161
|
): Promise<{ cube: any }> {
|
|
1089
1162
|
assertUuidShape(cubeId, 'cube_id');
|
|
1090
|
-
const active = await getActiveCube();
|
|
1163
|
+
const active = activeOverride ?? await getActiveCube();
|
|
1091
1164
|
if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
|
|
1092
1165
|
if (updates.name !== undefined) localUnsupported('cube rename');
|
|
1093
1166
|
const payload: Record<string, unknown> = {};
|
|
@@ -1105,6 +1178,8 @@ export async function updateCube(
|
|
|
1105
1178
|
noMutation: 'No cube settings were changed.',
|
|
1106
1179
|
},
|
|
1107
1180
|
payload,
|
|
1181
|
+
undefined,
|
|
1182
|
+
connectionOverride,
|
|
1108
1183
|
);
|
|
1109
1184
|
if (!result) throw new Error('Local Borg server returned an empty cube response');
|
|
1110
1185
|
return result;
|
|
@@ -1120,11 +1195,13 @@ export async function patchTaxonomyClass(
|
|
|
1120
1195
|
cubeId: string,
|
|
1121
1196
|
op:
|
|
1122
1197
|
| { action: 'add'; class_def: MessageTaxonomyClass }
|
|
1123
|
-
|
|
1124
|
-
|
|
1198
|
+
| { action: 'replace'; class_def: MessageTaxonomyClass }
|
|
1199
|
+
| { action: 'remove'; class: string },
|
|
1200
|
+
activeOverride?: ActiveCube,
|
|
1201
|
+
connectionOverride?: RemoteConnection,
|
|
1125
1202
|
): Promise<{ cube: any }> {
|
|
1126
1203
|
assertUuidShape(cubeId, 'cube_id');
|
|
1127
|
-
const active = await getActiveCube();
|
|
1204
|
+
const active = activeOverride ?? await getActiveCube();
|
|
1128
1205
|
if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
|
|
1129
1206
|
const className = op.action === 'remove' ? op.class : op.class_def.class;
|
|
1130
1207
|
const preposition = op.action === 'add' ? 'to' : op.action === 'replace' ? 'in' : 'from';
|
|
@@ -1140,6 +1217,8 @@ export async function patchTaxonomyClass(
|
|
|
1140
1217
|
noMutation: `No message class was ${pastTense}.`,
|
|
1141
1218
|
},
|
|
1142
1219
|
op,
|
|
1220
|
+
undefined,
|
|
1221
|
+
connectionOverride,
|
|
1143
1222
|
);
|
|
1144
1223
|
if (!result) throw new Error('Local Borg server returned an empty taxonomy response');
|
|
1145
1224
|
return result;
|
|
@@ -1160,10 +1239,12 @@ export async function deleteCube(cubeId: string): Promise<void> {
|
|
|
1160
1239
|
*/
|
|
1161
1240
|
export async function createRole(
|
|
1162
1241
|
cubeId: string,
|
|
1163
|
-
data: { name: string; short_description: string; detailed_description: string; is_default?: boolean; is_mandatory?: boolean; is_human_seat?: boolean; can_broadcast?: boolean; receives_all_direct?: boolean; default_model?: string; role_class?: 'queen' | 'worker' }
|
|
1242
|
+
data: { name: string; short_description: string; detailed_description: string; is_default?: boolean; is_mandatory?: boolean; is_human_seat?: boolean; can_broadcast?: boolean; receives_all_direct?: boolean; default_model?: string; role_class?: 'queen' | 'worker' },
|
|
1243
|
+
activeOverride?: ActiveCube,
|
|
1244
|
+
connectionOverride?: RemoteConnection,
|
|
1164
1245
|
): Promise<{ role: any }> {
|
|
1165
1246
|
assertUuidShape(cubeId, 'cube_id');
|
|
1166
|
-
const active = await getActiveCube();
|
|
1247
|
+
const active = activeOverride ?? await getActiveCube();
|
|
1167
1248
|
if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
|
|
1168
1249
|
if (data.default_model !== undefined) localUnsupported('per-role default model');
|
|
1169
1250
|
const result = await localManageRequest<{ role: any }>(
|
|
@@ -1176,6 +1257,8 @@ export async function createRole(
|
|
|
1176
1257
|
noMutation: 'No role was created.',
|
|
1177
1258
|
},
|
|
1178
1259
|
buildLocalRoleFields(data),
|
|
1260
|
+
undefined,
|
|
1261
|
+
connectionOverride,
|
|
1179
1262
|
);
|
|
1180
1263
|
if (!result) throw new Error('Local Borg server returned an empty role response');
|
|
1181
1264
|
return result;
|
|
@@ -1186,23 +1269,29 @@ export async function createRole(
|
|
|
1186
1269
|
*/
|
|
1187
1270
|
export async function updateRole(
|
|
1188
1271
|
roleId: string,
|
|
1189
|
-
updates: { name?: string; short_description?: string; detailed_description?: string; is_default?: boolean; is_mandatory?: boolean; is_human_seat?: boolean; can_broadcast?: boolean; receives_all_direct?: boolean; default_model?: string; role_class?: 'queen' | 'worker' }
|
|
1272
|
+
updates: { name?: string; short_description?: string; detailed_description?: string; is_default?: boolean; is_mandatory?: boolean; is_human_seat?: boolean; can_broadcast?: boolean; receives_all_direct?: boolean; default_model?: string; role_class?: 'queen' | 'worker' },
|
|
1273
|
+
targetCubeId?: string,
|
|
1274
|
+
activeOverride?: ActiveCube,
|
|
1275
|
+
connectionOverride?: RemoteConnection,
|
|
1190
1276
|
): Promise<{ role: any }> {
|
|
1191
1277
|
assertUuidShape(roleId, 'role_id');
|
|
1192
|
-
const active = await getActiveCube();
|
|
1278
|
+
const active = activeOverride ?? await getActiveCube();
|
|
1193
1279
|
if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
|
|
1194
|
-
|
|
1280
|
+
const cubeId = targetCubeId ?? active.cubeId;
|
|
1281
|
+
assertUuidShape(cubeId, 'cube_id');
|
|
1195
1282
|
if (updates.default_model !== undefined) localUnsupported('per-role default model');
|
|
1196
1283
|
const result = await localManageRequest<{ role: any }>(
|
|
1197
1284
|
active,
|
|
1198
|
-
`/api/cubes/${
|
|
1285
|
+
`/api/cubes/${cubeId}/roles/${roleId}`,
|
|
1199
1286
|
'PATCH',
|
|
1200
1287
|
{
|
|
1201
|
-
operation: `update role ${manageCopyValue(roleId)} in cube ${manageCopyValue(active.name)}`,
|
|
1202
|
-
cubeName: active.name,
|
|
1288
|
+
operation: `update role ${manageCopyValue(roleId)} in cube ${manageCopyValue(cubeId === active.cubeId ? active.name : cubeId)}`,
|
|
1289
|
+
cubeName: cubeId === active.cubeId ? active.name : cubeId,
|
|
1203
1290
|
noMutation: 'No role was updated.',
|
|
1204
1291
|
},
|
|
1205
1292
|
buildLocalRoleFields(updates),
|
|
1293
|
+
undefined,
|
|
1294
|
+
connectionOverride,
|
|
1206
1295
|
);
|
|
1207
1296
|
if (!result) throw new Error('Local Borg server returned an empty role response');
|
|
1208
1297
|
return result;
|
|
@@ -1256,22 +1345,28 @@ export async function patchRoleSection(
|
|
|
1256
1345
|
op:
|
|
1257
1346
|
| { action: 'replace'; heading: string; body: string }
|
|
1258
1347
|
| { action: 'insert'; heading: string; body: string; after?: string | null }
|
|
1259
|
-
| { action: 'delete'; heading: string }
|
|
1348
|
+
| { action: 'delete'; heading: string },
|
|
1349
|
+
targetCubeId?: string,
|
|
1350
|
+
activeOverride?: ActiveCube,
|
|
1351
|
+
connectionOverride?: RemoteConnection,
|
|
1260
1352
|
): Promise<{ role: any }> {
|
|
1261
1353
|
assertUuidShape(roleId, 'role_id');
|
|
1262
|
-
const active = await getActiveCube();
|
|
1354
|
+
const active = activeOverride ?? await getActiveCube();
|
|
1263
1355
|
if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
|
|
1264
|
-
|
|
1356
|
+
const cubeId = targetCubeId ?? active.cubeId;
|
|
1357
|
+
assertUuidShape(cubeId, 'cube_id');
|
|
1265
1358
|
const result = await localManageRequest<{ role: any }>(
|
|
1266
1359
|
active,
|
|
1267
|
-
`/api/cubes/${
|
|
1360
|
+
`/api/cubes/${cubeId}/roles/${roleId}/section-patch`,
|
|
1268
1361
|
'POST',
|
|
1269
1362
|
{
|
|
1270
|
-
operation: `${op.action} section ${manageCopyValue(op.heading)} ${op.action === 'delete' ? 'from' : 'in'} role ${manageCopyValue(roleId)} in cube ${manageCopyValue(active.name)}`,
|
|
1271
|
-
cubeName: active.name,
|
|
1363
|
+
operation: `${op.action} section ${manageCopyValue(op.heading)} ${op.action === 'delete' ? 'from' : 'in'} role ${manageCopyValue(roleId)} in cube ${manageCopyValue(cubeId === active.cubeId ? active.name : cubeId)}`,
|
|
1364
|
+
cubeName: cubeId === active.cubeId ? active.name : cubeId,
|
|
1272
1365
|
noMutation: `No role section was ${op.action === 'insert' ? 'inserted' : op.action === 'replace' ? 'replaced' : 'deleted'}.`,
|
|
1273
1366
|
},
|
|
1274
1367
|
{ ...op },
|
|
1368
|
+
undefined,
|
|
1369
|
+
connectionOverride,
|
|
1275
1370
|
);
|
|
1276
1371
|
if (!result) throw new Error('Local Borg server returned an empty role response');
|
|
1277
1372
|
return result;
|
|
@@ -1374,11 +1469,12 @@ export async function getCubeForManagement(
|
|
|
1374
1469
|
cubeId: string,
|
|
1375
1470
|
operation: LocalManageOperation,
|
|
1376
1471
|
activeOverride?: ActiveCube,
|
|
1472
|
+
connectionOverride?: RemoteConnection,
|
|
1377
1473
|
): Promise<{ id: string; name: string; roles: any[]; drones: any[]; [k: string]: any }> {
|
|
1378
1474
|
assertUuidShape(cubeId, 'cube_id');
|
|
1379
1475
|
const active = activeOverride ?? await getActiveCube();
|
|
1380
1476
|
if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
|
|
1381
|
-
return getCube(cubeId, await localManageConnection(active, operation));
|
|
1477
|
+
return getCube(cubeId, connectionOverride ?? await localManageConnection(active, operation));
|
|
1382
1478
|
}
|
|
1383
1479
|
|
|
1384
1480
|
/**
|
|
@@ -1413,11 +1509,44 @@ export async function getCube(cubeId: string, connection?: RemoteConnection): Pr
|
|
|
1413
1509
|
*/
|
|
1414
1510
|
export async function applyTemplate(
|
|
1415
1511
|
cubeId: string,
|
|
1416
|
-
templateName: string
|
|
1512
|
+
templateName: string,
|
|
1513
|
+
authorityOverride?: LocalManageAuthority,
|
|
1417
1514
|
): Promise<{ created: number; updated: number }> {
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1515
|
+
assertUuidShape(cubeId, 'cube_id');
|
|
1516
|
+
const template = getTemplate(templateName);
|
|
1517
|
+
if (!template) throw new Error(`Unknown Borg template ${JSON.stringify(templateName)}`);
|
|
1518
|
+
const active = authorityOverride?.active ?? await getActiveCube();
|
|
1519
|
+
if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
|
|
1520
|
+
const authority = authorityOverride ?? await resolveLocalManageAuthority(active, {
|
|
1521
|
+
operation: `apply template ${manageCopyValue(templateName)}`,
|
|
1522
|
+
cubeName: cubeId === active.cubeId ? active.name : cubeId,
|
|
1523
|
+
noMutation: 'No template fragments were changed.',
|
|
1524
|
+
});
|
|
1525
|
+
const current = await getCubeForManagement(cubeId, {
|
|
1526
|
+
operation: `apply template ${manageCopyValue(templateName)}`,
|
|
1527
|
+
cubeName: cubeId === active.cubeId ? active.name : cubeId,
|
|
1528
|
+
noMutation: 'No template fragments were changed.',
|
|
1529
|
+
}, active, authority.connection);
|
|
1530
|
+
let created = 0;
|
|
1531
|
+
let updated = 0;
|
|
1532
|
+
for (const role of template.roles) {
|
|
1533
|
+
const existing = current.roles.find((candidate) => candidate.name === role.name);
|
|
1534
|
+
if (!existing) {
|
|
1535
|
+
await createRole(cubeId, role, active, authority.connection);
|
|
1536
|
+
created++;
|
|
1537
|
+
continue;
|
|
1538
|
+
}
|
|
1539
|
+
if (await applyMissingRoleFields(existing, role, cubeId, active, authority.connection)) updated++;
|
|
1540
|
+
updated += await applyMissingRoleSections(existing, role, cubeId, active, authority.connection);
|
|
1541
|
+
}
|
|
1542
|
+
for (const classDef of template.message_taxonomy ?? []) {
|
|
1543
|
+
const currentClasses = (current.message_taxonomy ?? []) as MessageTaxonomy;
|
|
1544
|
+
if (!currentClasses.some((candidate) => candidate.class === classDef.class)) {
|
|
1545
|
+
await patchTaxonomyClass(cubeId, { action: 'add', class_def: classDef }, active, authority.connection);
|
|
1546
|
+
updated++;
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
return { created, updated };
|
|
1421
1550
|
}
|
|
1422
1551
|
|
|
1423
1552
|
/**
|
|
@@ -1435,11 +1564,166 @@ export async function syncRoles(
|
|
|
1435
1564
|
cubeId: string,
|
|
1436
1565
|
templateName: string = 'software-dev',
|
|
1437
1566
|
apply: boolean = false,
|
|
1438
|
-
decisions?: Record<string, 'accept' | 'reject'
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1567
|
+
decisions?: Record<string, 'accept' | 'reject'>,
|
|
1568
|
+
authorityOverride?: LocalManageAuthority,
|
|
1569
|
+
): Promise<NonClobberSyncResult> {
|
|
1570
|
+
assertUuidShape(cubeId, 'cube_id');
|
|
1571
|
+
const template = getTemplate(templateName);
|
|
1572
|
+
if (!template) throw new Error(`Unknown Borg template ${JSON.stringify(templateName)}`);
|
|
1573
|
+
const active = authorityOverride?.active ?? await getActiveCube();
|
|
1574
|
+
if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
|
|
1575
|
+
const authority = authorityOverride ?? await resolveLocalManageAuthority(active, {
|
|
1576
|
+
operation: `sync template ${manageCopyValue(templateName)}`,
|
|
1577
|
+
cubeName: cubeId === active.cubeId ? active.name : cubeId,
|
|
1578
|
+
noMutation: 'No role synchronization changes were applied.',
|
|
1579
|
+
});
|
|
1580
|
+
const current = await getCubeForManagement(cubeId, {
|
|
1581
|
+
operation: `sync template ${manageCopyValue(templateName)}`,
|
|
1582
|
+
cubeName: cubeId === active.cubeId ? active.name : cubeId,
|
|
1583
|
+
noMutation: 'No role synchronization changes were applied.',
|
|
1584
|
+
}, active, authority.connection);
|
|
1585
|
+
const roles: NonClobberSyncResult['roles'] = [];
|
|
1586
|
+
const taxonomy: FragmentView[] = [];
|
|
1587
|
+
const additions: Array<{ key: string; run: () => Promise<void> }> = [];
|
|
1588
|
+
const conflictKeys = new Set<string>();
|
|
1589
|
+
for (const role of template.roles) {
|
|
1590
|
+
const existing = current.roles.find((candidate) => candidate.name === role.name);
|
|
1591
|
+
if (!existing) {
|
|
1592
|
+
const key = `role:${role.name}`;
|
|
1593
|
+
const fragments: FragmentView[] = [{
|
|
1594
|
+
key,
|
|
1595
|
+
kind: 'add',
|
|
1596
|
+
label: 'role',
|
|
1597
|
+
cubeValue: null,
|
|
1598
|
+
templateValue: role.name,
|
|
1599
|
+
}];
|
|
1600
|
+
roles.push({ name: role.name, status: 'new', fragments });
|
|
1601
|
+
additions.push({ key, run: async () => { await createRole(cubeId, role, active, authority.connection); } });
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
const fragments: FragmentView[] = [];
|
|
1605
|
+
addRoleScalarFragment(fragments, additions, conflictKeys, decisions, existing, role, 'short_description', 'short description', cubeId, active, authority.connection);
|
|
1606
|
+
for (const field of ['is_default', 'is_mandatory', 'is_human_seat', 'can_broadcast', 'receives_all_direct'] as const) {
|
|
1607
|
+
if (role[field] !== undefined) addRoleScalarFragment(fragments, additions, conflictKeys, decisions, existing, role, field, field, cubeId, active, authority.connection);
|
|
1608
|
+
}
|
|
1609
|
+
const currentSections = new Map(parseRoleSections(String(existing.detailed_description ?? '')).map((section) => [section.heading, section]));
|
|
1610
|
+
for (const section of parseRoleSections(role.detailed_description)) {
|
|
1611
|
+
if (!section.heading) continue;
|
|
1612
|
+
const key = `role:${role.name}:section:${section.heading}`;
|
|
1613
|
+
const previous = currentSections.get(section.heading);
|
|
1614
|
+
const kind = !previous ? 'add' : previous.body === section.body ? 'unchanged' : 'conflict';
|
|
1615
|
+
fragments.push({ key, kind, label: `section ${section.heading}`, cubeValue: previous?.body ?? null, templateValue: section.body });
|
|
1616
|
+
if (kind === 'add') additions.push({ key, run: async () => { await patchRoleSection(existing.id, { action: 'insert', heading: section.heading!, body: section.body }, cubeId, active, authority.connection); } });
|
|
1617
|
+
if (kind === 'conflict') conflictKeys.add(key);
|
|
1618
|
+
if (kind === 'conflict' && decisions?.[key] === 'accept') additions.push({ key, run: async () => { await patchRoleSection(existing.id, { action: 'replace', heading: section.heading!, body: section.body }, cubeId, active, authority.connection); } });
|
|
1619
|
+
}
|
|
1620
|
+
roles.push({ name: role.name, status: 'existing', fragments });
|
|
1621
|
+
}
|
|
1622
|
+
for (const existing of current.roles) {
|
|
1623
|
+
if (!template.roles.some((role) => role.name === existing.name)) {
|
|
1624
|
+
roles.push({ name: existing.name, status: 'custom-skipped', fragments: [] });
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
for (const classDef of template.message_taxonomy ?? []) {
|
|
1628
|
+
const key = `taxonomy:${classDef.class}`;
|
|
1629
|
+
const currentClass = (current.message_taxonomy ?? []).find((candidate: MessageTaxonomyClass) => candidate.class === classDef.class);
|
|
1630
|
+
const currentValue = currentClass ? stableJson(currentClass) : null;
|
|
1631
|
+
const templateValue = stableJson(classDef);
|
|
1632
|
+
const kind = !currentClass ? 'add' : currentValue === templateValue ? 'unchanged' : 'conflict';
|
|
1633
|
+
taxonomy.push({ key, kind, label: `taxonomy class ${classDef.class}`, cubeValue: currentValue, templateValue });
|
|
1634
|
+
if (kind === 'add') additions.push({ key, run: async () => { await patchTaxonomyClass(cubeId, { action: 'add', class_def: classDef }, active, authority.connection); } });
|
|
1635
|
+
if (kind === 'conflict') conflictKeys.add(key);
|
|
1636
|
+
if (kind === 'conflict' && decisions?.[key] === 'accept') additions.push({ key, run: async () => { await patchTaxonomyClass(cubeId, { action: 'replace', class_def: classDef }, active, authority.connection); } });
|
|
1637
|
+
}
|
|
1638
|
+
const acceptedConflicts = [...conflictKeys].filter((key) => decisions?.[key] === 'accept');
|
|
1639
|
+
const rejectedConflicts = [...conflictKeys].filter((key) => decisions?.[key] !== 'accept');
|
|
1640
|
+
const classifiedKeys = new Set([...conflictKeys]);
|
|
1641
|
+
const unmatchedDecisions = Object.keys(decisions ?? {}).filter((key) => !classifiedKeys.has(key));
|
|
1642
|
+
const addedKeys = additions.filter(({ key }) => !conflictKeys.has(key)).map(({ key }) => key);
|
|
1643
|
+
if (apply) {
|
|
1644
|
+
for (const addition of additions) {
|
|
1645
|
+
if (conflictKeys.has(addition.key) && decisions?.[addition.key] !== 'accept') continue;
|
|
1646
|
+
await addition.run();
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
return {
|
|
1650
|
+
dryRun: !apply,
|
|
1651
|
+
roles,
|
|
1652
|
+
taxonomy,
|
|
1653
|
+
applied: {
|
|
1654
|
+
added: apply ? addedKeys : [],
|
|
1655
|
+
acceptedConflicts: apply ? acceptedConflicts : [],
|
|
1656
|
+
},
|
|
1657
|
+
rejectedConflicts,
|
|
1658
|
+
unmatchedDecisions,
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function stableJson(value: unknown): string {
|
|
1663
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
|
1664
|
+
if (value !== null && typeof value === 'object') {
|
|
1665
|
+
return `{${Object.entries(value as Record<string, unknown>)
|
|
1666
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
1667
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
|
|
1668
|
+
.join(',')}}`;
|
|
1669
|
+
}
|
|
1670
|
+
return JSON.stringify(value);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function addRoleScalarFragment(
|
|
1674
|
+
fragments: FragmentView[],
|
|
1675
|
+
additions: Array<{ key: string; run: () => Promise<void> }>,
|
|
1676
|
+
conflictKeys: Set<string>,
|
|
1677
|
+
decisions: Record<string, 'accept' | 'reject'> | undefined,
|
|
1678
|
+
existing: any,
|
|
1679
|
+
template: TemplateRole,
|
|
1680
|
+
field: 'short_description' | 'is_default' | 'is_mandatory' | 'is_human_seat' | 'can_broadcast' | 'receives_all_direct',
|
|
1681
|
+
label: string,
|
|
1682
|
+
cubeId: string,
|
|
1683
|
+
active: ActiveCube,
|
|
1684
|
+
connection: RemoteConnection,
|
|
1685
|
+
): void {
|
|
1686
|
+
const templateValue = template[field];
|
|
1687
|
+
if (templateValue === undefined) return;
|
|
1688
|
+
const key = `role:${template.name}:${field}`;
|
|
1689
|
+
const currentValue = existing[field];
|
|
1690
|
+
const missing = currentValue === undefined || (field === 'short_description' && currentValue === '');
|
|
1691
|
+
const kind = missing ? 'add' : currentValue === templateValue ? 'unchanged' : 'conflict';
|
|
1692
|
+
fragments.push({ key, kind, label, cubeValue: missing ? null : String(currentValue), templateValue: String(templateValue) });
|
|
1693
|
+
if (kind === 'add') additions.push({
|
|
1694
|
+
key,
|
|
1695
|
+
run: async () => { await updateRole(existing.id, { [field]: templateValue } as Parameters<typeof updateRole>[1], cubeId, active, connection); },
|
|
1696
|
+
});
|
|
1697
|
+
if (kind === 'conflict') {
|
|
1698
|
+
conflictKeys.add(key);
|
|
1699
|
+
if (decisions?.[key] === 'accept') additions.push({
|
|
1700
|
+
key,
|
|
1701
|
+
run: async () => { await updateRole(existing.id, { [field]: templateValue } as Parameters<typeof updateRole>[1], cubeId, active, connection); },
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
async function applyMissingRoleFields(existing: any, template: TemplateRole, cubeId: string, active: ActiveCube, connection: RemoteConnection): Promise<boolean> {
|
|
1707
|
+
const updates: Record<string, unknown> = {};
|
|
1708
|
+
if ((existing.short_description === undefined || existing.short_description === '') && template.short_description) {
|
|
1709
|
+
updates.short_description = template.short_description;
|
|
1710
|
+
}
|
|
1711
|
+
for (const field of ['is_default', 'is_mandatory', 'is_human_seat', 'can_broadcast', 'receives_all_direct'] as const) {
|
|
1712
|
+
if (existing[field] === undefined && template[field] !== undefined) updates[field] = template[field];
|
|
1713
|
+
}
|
|
1714
|
+
if (Object.keys(updates).length === 0) return false;
|
|
1715
|
+
await updateRole(existing.id, updates as Parameters<typeof updateRole>[1], cubeId, active, connection);
|
|
1716
|
+
return true;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
async function applyMissingRoleSections(existing: any, template: TemplateRole, cubeId: string, active: ActiveCube, connection: RemoteConnection): Promise<number> {
|
|
1720
|
+
const currentSections = new Map(parseRoleSections(String(existing.detailed_description ?? '')).map((section) => [section.heading, section]));
|
|
1721
|
+
let updated = 0;
|
|
1722
|
+
for (const section of parseRoleSections(template.detailed_description)) {
|
|
1723
|
+
if (section.heading && !currentSections.has(section.heading)) {
|
|
1724
|
+
await patchRoleSection(existing.id, { action: 'insert', heading: section.heading, body: section.body }, cubeId, active, connection);
|
|
1725
|
+
updated++;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
return updated;
|
|
1445
1729
|
}
|
package/src/sync-roles-render.ts
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
export type FragmentKind = 'add' | 'unchanged' | 'conflict';
|
|
14
14
|
|
|
15
|
+
const BIDI_CONTROL_RE = /\p{Bidi_Control}/u;
|
|
16
|
+
|
|
15
17
|
export interface FragmentView {
|
|
16
18
|
key: string;
|
|
17
19
|
kind: FragmentKind;
|
|
@@ -35,10 +37,25 @@ export interface NonClobberSyncResult {
|
|
|
35
37
|
unmatchedDecisions?: string[];
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/** Escape cube-controlled text before it reaches Markdown or a terminal. */
|
|
41
|
+
export function escapeSyncDisplay(value: string): string {
|
|
42
|
+
return [...value].map((char) => {
|
|
43
|
+
const code = char.codePointAt(0)!;
|
|
44
|
+
if (code === 0x0a) return '⏎';
|
|
45
|
+
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) return `\\u{${code.toString(16)}}`;
|
|
46
|
+
if (BIDI_CONTROL_RE.test(char) || code === 0x2028 || code === 0x2029) {
|
|
47
|
+
return `\\u{${code.toString(16)}}`;
|
|
48
|
+
}
|
|
49
|
+
if (char === '`') return '\\u{60}';
|
|
50
|
+
if ('\\*_[]()<>&#|~'.includes(char)) return `\\${char}`;
|
|
51
|
+
return char;
|
|
52
|
+
}).join('');
|
|
53
|
+
}
|
|
54
|
+
|
|
38
55
|
/** Truncate long fragment bodies for at-a-glance diffs. */
|
|
39
56
|
function trunc(s: string | null, n = 200): string {
|
|
40
57
|
if (s == null) return '(absent)';
|
|
41
|
-
const flat = s
|
|
58
|
+
const flat = escapeSyncDisplay(s);
|
|
42
59
|
return flat.length > n ? flat.slice(0, n) + '…' : flat;
|
|
43
60
|
}
|
|
44
61
|
|
|
@@ -75,7 +92,7 @@ export function renderSyncRolesResult(
|
|
|
75
92
|
const mode = result.dryRun
|
|
76
93
|
? '**DRY RUN** (review conflicts below; re-run with `apply: true` + a `decisions` map to commit)'
|
|
77
94
|
: '**APPLIED**';
|
|
78
|
-
const lines: string[] = [`## borg_sync-roles — ${mode}`, `Template: ${templateName}`, ''];
|
|
95
|
+
const lines: string[] = [`## borg_sync-roles — ${mode}`, `Template: ${escapeSyncDisplay(templateName)}`, ''];
|
|
79
96
|
|
|
80
97
|
// Gather all fragments across roles + taxonomy for tallying.
|
|
81
98
|
const allFragments: FragmentView[] = [
|
|
@@ -109,7 +126,7 @@ export function renderSyncRolesResult(
|
|
|
109
126
|
: applied
|
|
110
127
|
? '✓ accepted — template version applied'
|
|
111
128
|
: '↩ kept your version';
|
|
112
|
-
|
|
129
|
+
lines.push(`- **${escapeSyncDisplay(f.label)}** \`${escapeSyncDisplay(f.key)}\` ${status}`);
|
|
113
130
|
lines.push(` - cube (current): "${trunc(f.cubeValue)}"`);
|
|
114
131
|
lines.push(` - template (new): "${trunc(f.templateValue)}"`);
|
|
115
132
|
}
|
|
@@ -127,7 +144,7 @@ export function renderSyncRolesResult(
|
|
|
127
144
|
'(typo or stale key) — their intended accept had NO effect. Check the exact keys against the conflicts above:'
|
|
128
145
|
);
|
|
129
146
|
for (const k of unmatched) {
|
|
130
|
-
|
|
147
|
+
lines.push(`- \`${escapeSyncDisplay(k)}\``);
|
|
131
148
|
}
|
|
132
149
|
lines.push('');
|
|
133
150
|
}
|
|
@@ -137,11 +154,11 @@ export function renderSyncRolesResult(
|
|
|
137
154
|
lines.push(`### Additions (safe — auto-applied, zero clobber risk)`);
|
|
138
155
|
for (const r of newRoles) {
|
|
139
156
|
const note = result.dryRun ? '(new role — would be created)' : '✓ created';
|
|
140
|
-
|
|
157
|
+
lines.push(`- new role **${escapeSyncDisplay(r.name)}** ${note}`);
|
|
141
158
|
}
|
|
142
159
|
for (const f of adds) {
|
|
143
160
|
const note = result.dryRun ? '(would be added)' : '✓ added';
|
|
144
|
-
|
|
161
|
+
lines.push(`- **${escapeSyncDisplay(f.label)}** \`${escapeSyncDisplay(f.key)}\` ${note}`);
|
|
145
162
|
}
|
|
146
163
|
lines.push('');
|
|
147
164
|
}
|
|
@@ -149,7 +166,7 @@ export function renderSyncRolesResult(
|
|
|
149
166
|
// ── Custom roles (never touched) ──
|
|
150
167
|
if (customRoles.length > 0) {
|
|
151
168
|
lines.push(
|
|
152
|
-
|
|
169
|
+
`### Custom roles (untouched): ${customRoles.map((r) => escapeSyncDisplay(r.name)).join(', ')}`
|
|
153
170
|
);
|
|
154
171
|
lines.push('');
|
|
155
172
|
}
|