borgmcp 5.1.1 → 5.2.0
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/dist/assimilate-cmd.d.ts +1 -0
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +52 -7
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +2 -1
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +24 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/regen-format.d.ts +3 -1
- package/dist/regen-format.d.ts.map +1 -1
- package/dist/regen-format.js +24 -4
- package/dist/regen-format.js.map +1 -1
- package/dist/remote-client.d.ts +4 -3
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +9 -3
- package/dist/remote-client.js.map +1 -1
- package/docs/RELEASING.md +2 -2
- package/package.json +2 -2
- package/src/assimilate-cmd.ts +59 -9
- package/src/assimilate-deps.ts +2 -1
- package/src/config.ts +25 -0
- package/src/index.ts +3 -1
- package/src/regen-format.ts +28 -6
- package/src/remote-client.ts +14 -6
package/src/assimilate-cmd.ts
CHANGED
|
@@ -331,6 +331,7 @@ export interface AssimilateDeps {
|
|
|
331
331
|
trustIdentity: string;
|
|
332
332
|
serverCapabilities?: readonly string[];
|
|
333
333
|
}>;
|
|
334
|
+
listServerCredentialOrigins?: (origin: string) => Promise<string[]>;
|
|
334
335
|
resumeServerEnrollment: (
|
|
335
336
|
apiUrl: string,
|
|
336
337
|
onPending?: () => void,
|
|
@@ -533,13 +534,26 @@ function localAssimilateCliCommand(apiUrl: string, cli: BorgCli): string {
|
|
|
533
534
|
return `\`borg assimilate --host ${apiUrl} --cli ${cli}\``;
|
|
534
535
|
}
|
|
535
536
|
|
|
536
|
-
function
|
|
537
|
-
|
|
537
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
538
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function isSiblingEndpoint(reachedOrigin: string, enrolledOrigin: string): boolean {
|
|
542
|
+
const reached = new URL(reachedOrigin);
|
|
543
|
+
const enrolled = new URL(enrolledOrigin);
|
|
544
|
+
return reached.hostname === enrolled.hostname
|
|
545
|
+
|| (reached.port === enrolled.port
|
|
546
|
+
&& isLoopbackHostname(reached.hostname)
|
|
547
|
+
&& isLoopbackHostname(enrolled.hostname));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function reportServerFailure(
|
|
551
|
+
deps: Pick<AssimilateDeps, 'stderr' | 'listServerCredentialOrigins'>,
|
|
538
552
|
apiUrl: string,
|
|
539
553
|
error: unknown,
|
|
540
554
|
enroll = false,
|
|
541
555
|
mode: 'assimilate' | 'cube-init' = 'assimilate',
|
|
542
|
-
): number {
|
|
556
|
+
): Promise<number> {
|
|
543
557
|
const message = error instanceof Error ? error.message : String(error);
|
|
544
558
|
const retryCommand = localAssimilateCommand(apiUrl, enroll, mode);
|
|
545
559
|
if (error instanceof BorgServerError && error.code === 'CREATE_CUBE_DENIED') {
|
|
@@ -551,6 +565,42 @@ function reportServerFailure(
|
|
|
551
565
|
return 1;
|
|
552
566
|
}
|
|
553
567
|
if (error instanceof BorgServerError && error.code === 'NOT_ENROLLED') {
|
|
568
|
+
let enrolledOrigins: string[] = [];
|
|
569
|
+
try {
|
|
570
|
+
enrolledOrigins = (await deps.listServerCredentialOrigins?.(apiUrl) ?? []).filter((origin) => {
|
|
571
|
+
try {
|
|
572
|
+
const parsed = new URL(origin);
|
|
573
|
+
return parsed.protocol === 'https:' && parsed.origin === origin && origin !== apiUrl;
|
|
574
|
+
} catch {
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
} catch {
|
|
579
|
+
// Preserve the existing recovery when the optional diagnostic lookup is unavailable.
|
|
580
|
+
}
|
|
581
|
+
const siblingOrigins = enrolledOrigins.filter((origin) => isSiblingEndpoint(apiUrl, origin));
|
|
582
|
+
if (siblingOrigins.length > 0) {
|
|
583
|
+
const enrollmentLabel = siblingOrigins.length === 1 ? 'a saved enrollment' : 'saved enrollments';
|
|
584
|
+
const commandLabel = siblingOrigins.length === 1 ? 'the enrolled endpoint' : 'one of the enrolled endpoints';
|
|
585
|
+
const commands = siblingOrigins
|
|
586
|
+
.map((origin) => localAssimilateCommand(origin, false, mode))
|
|
587
|
+
.join(' or ');
|
|
588
|
+
deps.stderr(
|
|
589
|
+
`Borg found ${enrollmentLabel} for ${siblingOrigins.join(', ')}, but this command is reaching ${apiUrl}. ` +
|
|
590
|
+
`Use ${commandLabel} instead: ${commands}.\n`,
|
|
591
|
+
);
|
|
592
|
+
return 1;
|
|
593
|
+
}
|
|
594
|
+
if (enrolledOrigins.length > 0) {
|
|
595
|
+
deps.stderr(
|
|
596
|
+
`This client is enrolled against ${enrolledOrigins.join(', ')}, not ${apiUrl}. ` +
|
|
597
|
+
`Confirm that the host, port, and IPv4 or IPv6 loopback ` +
|
|
598
|
+
`form in ${apiUrl} match the endpoint used during enrollment. If this client has never ` +
|
|
599
|
+
`enrolled with that server, run ${localAssimilateCommand(apiUrl, true, mode)} from the ` +
|
|
600
|
+
`operator’s terminal.\n`,
|
|
601
|
+
);
|
|
602
|
+
return 1;
|
|
603
|
+
}
|
|
554
604
|
deps.stderr(
|
|
555
605
|
`Borg could not find a saved enrollment for ${apiUrl}. ` +
|
|
556
606
|
`This can mean that this client has not enrolled with the server, or that its enrollment ` +
|
|
@@ -1267,7 +1317,7 @@ export async function prepareAssimilationSeat(
|
|
|
1267
1317
|
return { kind: 'stop', code: diagnoseSessionTermination(deps, apiUrl, 'superseded') };
|
|
1268
1318
|
}
|
|
1269
1319
|
}
|
|
1270
|
-
return { kind: 'stop', code: reportServerFailure(deps, apiUrl, error) };
|
|
1320
|
+
return { kind: 'stop', code: await reportServerFailure(deps, apiUrl, error) };
|
|
1271
1321
|
}
|
|
1272
1322
|
|
|
1273
1323
|
if (result.prepareAborted) {
|
|
@@ -1281,7 +1331,7 @@ export async function prepareAssimilationSeat(
|
|
|
1281
1331
|
if (result.local_session === undefined) {
|
|
1282
1332
|
return {
|
|
1283
1333
|
kind: 'stop',
|
|
1284
|
-
code: reportServerFailure(deps, apiUrl, new Error('Borg server did not return compatible secure session metadata')),
|
|
1334
|
+
code: await reportServerFailure(deps, apiUrl, new Error('Borg server did not return compatible secure session metadata')),
|
|
1285
1335
|
};
|
|
1286
1336
|
}
|
|
1287
1337
|
const assignedRole = cubeDetail.roles.find((role) => role.id === result.role_id) ?? resolvedRole;
|
|
@@ -1592,7 +1642,7 @@ export async function resolveAssimilationAuthority(
|
|
|
1592
1642
|
hasPersistedIdentity = existing !== null || await deps.hasPersistedActiveCube();
|
|
1593
1643
|
} catch (error) {
|
|
1594
1644
|
if (error instanceof LegacySessionCredentialCollisionError) {
|
|
1595
|
-
return { kind: 'stop', code: reportServerFailure(deps, error.origin, error, false, mode) };
|
|
1645
|
+
return { kind: 'stop', code: await reportServerFailure(deps, error.origin, error, false, mode) };
|
|
1596
1646
|
}
|
|
1597
1647
|
localSeatReadError = error;
|
|
1598
1648
|
}
|
|
@@ -1601,7 +1651,7 @@ export async function resolveAssimilationAuthority(
|
|
|
1601
1651
|
if (!selectedAuthority) return { kind: 'stop', code: 1 };
|
|
1602
1652
|
let authority = selectedAuthority;
|
|
1603
1653
|
if (localSeatReadError !== undefined) {
|
|
1604
|
-
return { kind: 'stop', code: reportServerFailure(deps, authority.apiUrl, localSeatReadError, false, mode) };
|
|
1654
|
+
return { kind: 'stop', code: await reportServerFailure(deps, authority.apiUrl, localSeatReadError, false, mode) };
|
|
1605
1655
|
}
|
|
1606
1656
|
|
|
1607
1657
|
const projectRoot = repositoryContext.root;
|
|
@@ -1704,7 +1754,7 @@ export async function resolveAssimilationAuthority(
|
|
|
1704
1754
|
} catch (error) {
|
|
1705
1755
|
return {
|
|
1706
1756
|
kind: 'stop',
|
|
1707
|
-
code: reportServerFailure(deps, authority.apiUrl, error, args.flags.enroll === true, mode),
|
|
1757
|
+
code: await reportServerFailure(deps, authority.apiUrl, error, args.flags.enroll === true, mode),
|
|
1708
1758
|
};
|
|
1709
1759
|
}
|
|
1710
1760
|
|
|
@@ -1861,7 +1911,7 @@ export async function runAssimilate(
|
|
|
1861
1911
|
return 1;
|
|
1862
1912
|
}
|
|
1863
1913
|
if (error instanceof BorgServerError) {
|
|
1864
|
-
return reportServerFailure(deps, auth.apiUrl, error, false, mode);
|
|
1914
|
+
return await reportServerFailure(deps, auth.apiUrl, error, false, mode);
|
|
1865
1915
|
}
|
|
1866
1916
|
deps.stderr(
|
|
1867
1917
|
'Repository cube initialization failed.\n' +
|
package/src/assimilate-deps.ts
CHANGED
|
@@ -67,7 +67,7 @@ import {
|
|
|
67
67
|
addProjectSessionStartHook,
|
|
68
68
|
provisionLaunchAccess,
|
|
69
69
|
} from './config-utils.js';
|
|
70
|
-
import { findPendingServerEnrollment } from './config.js';
|
|
70
|
+
import { findPendingServerEnrollment, listServerCredentialOrigins } from './config.js';
|
|
71
71
|
import { setTerminalTitle as setTitle } from './terminal-title.js';
|
|
72
72
|
import { defaultCliChoiceDeps, resolveCliChoice } from './cli-platform.js';
|
|
73
73
|
import { prepareCodexRemoteLaunch, defaultCodexRemoteDeps } from './codex-remote.js';
|
|
@@ -268,6 +268,7 @@ export function buildDefaultAssimilateDeps(
|
|
|
268
268
|
}
|
|
269
269
|
return connectLocalBorgServer(apiUrl);
|
|
270
270
|
},
|
|
271
|
+
listServerCredentialOrigins,
|
|
271
272
|
resumeServerEnrollment: async (apiUrl, onPending) =>
|
|
272
273
|
resumeLocalBorgServerEnrollment(apiUrl, {
|
|
273
274
|
...(onPending === undefined ? {} : { onPending }),
|
package/src/config.ts
CHANGED
|
@@ -566,6 +566,31 @@ export async function hasServerCredentialForOrigin(origin: string): Promise<bool
|
|
|
566
566
|
}), { processShared: processSharedEnrollmentLock() });
|
|
567
567
|
}
|
|
568
568
|
|
|
569
|
+
export async function listServerCredentialOrigins(origin: string): Promise<string[]> {
|
|
570
|
+
const backend = await getServerCredentialBackend();
|
|
571
|
+
if (!backend.entries) return [];
|
|
572
|
+
return withEnrollmentOriginLock(origin, () => withCredentialStoreLock(async () => {
|
|
573
|
+
const accounts = await backend.entries!();
|
|
574
|
+
const origins = new Set<string>();
|
|
575
|
+
for (const [account, value] of Object.entries(accounts)) {
|
|
576
|
+
try {
|
|
577
|
+
const candidate = JSON.parse(value) as { origin?: unknown; trustIdentity?: unknown };
|
|
578
|
+
if (typeof candidate.origin !== 'string' || typeof candidate.trustIdentity !== 'string') continue;
|
|
579
|
+
const record = decodeActiveServerCredentialRecord(
|
|
580
|
+
value,
|
|
581
|
+
candidate.origin,
|
|
582
|
+
candidate.trustIdentity,
|
|
583
|
+
);
|
|
584
|
+
if (account !== serverCredentialAccount(record.origin, record.trustIdentity)) continue;
|
|
585
|
+
origins.add(record.origin);
|
|
586
|
+
} catch {
|
|
587
|
+
// Ignore unrelated or malformed backend entries.
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return [...origins].sort();
|
|
591
|
+
}), { processShared: processSharedEnrollmentLock() });
|
|
592
|
+
}
|
|
593
|
+
|
|
569
594
|
function decodePendingServerEnrollment(
|
|
570
595
|
stored: string,
|
|
571
596
|
origin: string,
|
package/src/index.ts
CHANGED
|
@@ -606,7 +606,9 @@ export async function main() {
|
|
|
606
606
|
drone: displayedResult.drone,
|
|
607
607
|
role: displayedResult.role,
|
|
608
608
|
behind_by: typeof displayedResult.behind_by === 'number' ? displayedResult.behind_by : null,
|
|
609
|
-
|
|
609
|
+
...(Array.isArray(displayedResult.decisions)
|
|
610
|
+
? { decision_topics: displayedResult.decisions.map((d) => d.topic) }
|
|
611
|
+
: {}),
|
|
610
612
|
running_version: getPackageVersion(),
|
|
611
613
|
on_disk_version: onDiskVersion,
|
|
612
614
|
wake_path_healthy: inboxMonitorHealthy,
|
package/src/regen-format.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { shellEscape } from './shell-escape.js';
|
|
|
22
22
|
import { OPENCODE_WAKE_PATH_GUIDANCE } from './opencode-wake-copy.js';
|
|
23
23
|
import { isBorgSession } from './launch-gate.js';
|
|
24
24
|
import type { AgentKind } from './agent-runtime.js';
|
|
25
|
+
import type { Decision } from 'borgmcp-shared/protocol';
|
|
25
26
|
|
|
26
27
|
export type HandoverMode = 'origin' | 'local';
|
|
27
28
|
|
|
@@ -385,6 +386,14 @@ Any time you make a factual claim that could be verified — "this shipped as ve
|
|
|
385
386
|
- Cube log state → \`borg_read-log unread_only=true\` for wake triage, draining until \`behind_by=0\`; don't cite from memory or from another drone's summary
|
|
386
387
|
- Ratified cube decision → \`borg_decisions {topic}\` — cite the registry's active decision by topic; NEVER restate a ratified decision from memory (a memory restatement drifts on the axis). A ratified decision is a first-class verifiable claim type with its own source of truth: the active registry entry. Recording one is \`borg_decide\`: Coordinator/Queen are workflow-eligible to ratify, but role labels grant no server permission; the selected local client needs a live cube-manage grant.
|
|
387
388
|
|
|
389
|
+
**Durable layers:**
|
|
390
|
+
1. Decision registry (\`borg_decide\` / \`borg_decisions\`): choices between alternatives that could be revisited, cited by topic, served into every drone's context, capped at 16,384 active bytes per cube.
|
|
391
|
+
2. Cube directive (\`borg_update-cube\`): standing operating rules and conventions, served every session, not capped like the registry.
|
|
392
|
+
3. Cube documents (\`borg_put-document\` / \`borg_get-document\`): large or detailed material — contracts, designs, evidence — cited by id, never inlined.
|
|
393
|
+
4. Repository \`AGENTS.md\`: rules specific to one repository, read only by seats working there.
|
|
394
|
+
|
|
395
|
+
Rules: a registry entry that records a rule rather than a choice belongs in the directive — move it and remove the registry copy; on a cap refusal the order is relocate rules → supersede stale choices → remove obsolete; never archive playbook prose in the registry; detail goes to a document and is cited.
|
|
396
|
+
|
|
388
397
|
**The discipline is universal to reviewer-class actions** (Code Reviewer formal gates + Security Auditor SR gates + PM-courtesy verifications + UX-courtesy reviews + any drone making a verification-worthy factual claim in their cube-log post). It lives in this universal playbook rather than any one role's text because it applies to ALL reviewers.
|
|
389
398
|
|
|
390
399
|
**Four-surface propagation:**
|
|
@@ -554,7 +563,8 @@ export function formatRegenMarkdown(
|
|
|
554
563
|
// gh#740: active ratified decisions for the cube. Rendered in the
|
|
555
564
|
// always-shown band (lite + full) so the source of truth is in context at
|
|
556
565
|
// mid-session restatement moments. Absent on a pre-gh#740 worker → omitted.
|
|
557
|
-
decisions?:
|
|
566
|
+
decisions?: Decision[];
|
|
567
|
+
decisions_error?: string;
|
|
558
568
|
},
|
|
559
569
|
opts: { mode?: RegenMode; handoverMode?: HandoverMode } = {}
|
|
560
570
|
): string {
|
|
@@ -612,16 +622,28 @@ export function formatRegenMarkdown(
|
|
|
612
622
|
const taxonomyTip = nullTaxonomyTip(result.cube.message_taxonomy);
|
|
613
623
|
|
|
614
624
|
// gh#740: render active ratified decisions concisely (one line each), capped
|
|
615
|
-
// with an elision footer.
|
|
616
|
-
//
|
|
625
|
+
// with an elision footer. Empty and failed reads are explicit and distinct;
|
|
626
|
+
// an absent field from a pre-gh#740 worker is omitted. Lives in the
|
|
617
627
|
// always-shown band below so it surfaces on LITE wakes (the mid-session
|
|
618
628
|
// restatement moment), not just the session-start full regen (PM F1).
|
|
619
629
|
const RATIFIED_DECISIONS_CAP = 12;
|
|
620
|
-
const activeDecisions = Array.isArray(result.decisions) ? result.decisions : [];
|
|
621
630
|
const decisionsSection = (() => {
|
|
622
|
-
if (
|
|
631
|
+
if (result.decisions_error !== undefined) {
|
|
632
|
+
const errorClass = /^[A-Za-z][A-Za-z0-9]*Error$/.test(result.decisions_error)
|
|
633
|
+
? result.decisions_error
|
|
634
|
+
: 'UnknownError';
|
|
635
|
+
return [
|
|
636
|
+
'## Ratified decisions',
|
|
637
|
+
`The decision registry could not be read (${errorClass}). Active decisions are unavailable.`,
|
|
638
|
+
].join('\n');
|
|
639
|
+
}
|
|
640
|
+
if (!Array.isArray(result.decisions)) return '';
|
|
641
|
+
const activeDecisions = result.decisions;
|
|
642
|
+
if (activeDecisions.length === 0) {
|
|
643
|
+
return ['## Ratified decisions', 'No active decisions are recorded.'].join('\n');
|
|
644
|
+
}
|
|
623
645
|
const shown = activeDecisions.slice(0, RATIFIED_DECISIONS_CAP);
|
|
624
|
-
const lines = shown.map((d
|
|
646
|
+
const lines = shown.map((d) => `- **${d.topic}:** ${d.decision}`);
|
|
625
647
|
const remaining = activeDecisions.length - shown.length;
|
|
626
648
|
if (remaining > 0) lines.push(`- _+${remaining} more — \`borg_decisions\`_`);
|
|
627
649
|
return ['## Ratified decisions', 'Cite these by topic — do NOT restate a ratified decision from memory.', ...lines].join('\n');
|
package/src/remote-client.ts
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
type ListDocumentsResult,
|
|
58
58
|
type RemoveDocumentResult,
|
|
59
59
|
type CreateCubeRepository,
|
|
60
|
+
type Decision,
|
|
60
61
|
} from 'borgmcp-shared/protocol';
|
|
61
62
|
import { Buffer } from 'node:buffer';
|
|
62
63
|
import { canonicalizeWorkingRepoIdentity } from './working-repo.js';
|
|
@@ -1261,9 +1262,9 @@ export async function listDecisions(
|
|
|
1261
1262
|
apiUrl: string,
|
|
1262
1263
|
topic?: string,
|
|
1263
1264
|
serverTrustIdentity?: string,
|
|
1264
|
-
): Promise<{ decisions:
|
|
1265
|
+
): Promise<{ decisions: Decision[] }> {
|
|
1265
1266
|
const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
|
|
1266
|
-
const payload = await localServerRequest<{ decisions:
|
|
1267
|
+
const payload = await localServerRequest<{ decisions: Decision[] }>(
|
|
1267
1268
|
local,
|
|
1268
1269
|
`/api/cubes/${local.cubeId}/decisions`,
|
|
1269
1270
|
'PUT',
|
|
@@ -1352,7 +1353,8 @@ export async function regen(
|
|
|
1352
1353
|
behind_by?: number;
|
|
1353
1354
|
// gh#740: active ratified decisions for the cube, rendered by regen-format.
|
|
1354
1355
|
// Local regen composes these via listDecisions.
|
|
1355
|
-
decisions?:
|
|
1356
|
+
decisions?: Decision[];
|
|
1357
|
+
decisions_error?: string;
|
|
1356
1358
|
}> {
|
|
1357
1359
|
const local = await localAuthorityContext(
|
|
1358
1360
|
sessionToken,
|
|
@@ -1382,7 +1384,8 @@ export async function regen(
|
|
|
1382
1384
|
? await getLocalServerCursor(localCursorBinding(local))
|
|
1383
1385
|
: await resolveLocalLogCursor(local, opts.since);
|
|
1384
1386
|
const page = await localReadLogPage(local, { cursor, limit: 1 });
|
|
1385
|
-
let decisions:
|
|
1387
|
+
let decisions: Decision[] | undefined;
|
|
1388
|
+
let decisionsError: string | undefined;
|
|
1386
1389
|
try {
|
|
1387
1390
|
decisions = (await listDecisions(
|
|
1388
1391
|
sessionToken,
|
|
@@ -1391,8 +1394,12 @@ export async function regen(
|
|
|
1391
1394
|
opts.serverTrustIdentity,
|
|
1392
1395
|
)).decisions;
|
|
1393
1396
|
} catch (error) {
|
|
1397
|
+
const constructorName = error instanceof Error ? error.constructor.name : '';
|
|
1398
|
+
decisionsError = /^[A-Za-z][A-Za-z0-9]*Error$/.test(constructorName)
|
|
1399
|
+
? constructorName
|
|
1400
|
+
: 'UnknownError';
|
|
1394
1401
|
console.warn(
|
|
1395
|
-
`Local regen: failed to fetch ratified decisions (${
|
|
1402
|
+
`Local regen: failed to fetch ratified decisions (${decisionsError}); continuing without them.`,
|
|
1396
1403
|
);
|
|
1397
1404
|
}
|
|
1398
1405
|
return {
|
|
@@ -1403,7 +1410,8 @@ export async function regen(
|
|
|
1403
1410
|
drones: composed.drones,
|
|
1404
1411
|
recentLog: [],
|
|
1405
1412
|
behind_by: page.entries.length + page.behind_by,
|
|
1406
|
-
decisions,
|
|
1413
|
+
...(decisions === undefined ? {} : { decisions }),
|
|
1414
|
+
...(decisionsError === undefined ? {} : { decisions_error: decisionsError }),
|
|
1407
1415
|
};
|
|
1408
1416
|
}
|
|
1409
1417
|
|