@zhin.js/harness 0.0.0-stage → 0.0.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/LICENSE +21 -0
- package/README.md +8 -2
- package/lib/authority.d.ts +37 -0
- package/lib/authority.js +161 -0
- package/lib/budget.d.ts +48 -0
- package/lib/budget.js +154 -0
- package/lib/contracts.d.ts +643 -0
- package/lib/contracts.js +86 -0
- package/lib/guard.d.ts +25 -0
- package/lib/guard.js +213 -0
- package/lib/harness.d.ts +197 -0
- package/lib/harness.js +731 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +8 -0
- package/lib/journal.d.ts +86 -0
- package/lib/journal.js +712 -0
- package/lib/mailbox.d.ts +6 -0
- package/lib/mailbox.js +422 -0
- package/lib/policies/admission.d.ts +48 -0
- package/lib/policies/admission.js +118 -0
- package/lib/policies/groups.d.ts +47 -0
- package/lib/policies/groups.js +91 -0
- package/lib/policies/index.d.ts +5 -0
- package/lib/policies/index.js +5 -0
- package/lib/policies/live.d.ts +28 -0
- package/lib/policies/live.js +44 -0
- package/lib/policies/markers.d.ts +23 -0
- package/lib/policies/markers.js +42 -0
- package/lib/policies/merge.d.ts +49 -0
- package/lib/policies/merge.js +101 -0
- package/package.json +39 -3
- package/src/authority.ts +194 -0
- package/src/budget.ts +181 -0
- package/src/contracts.ts +762 -0
- package/src/guard.ts +305 -0
- package/src/harness.ts +975 -0
- package/src/index.ts +8 -0
- package/src/journal.ts +833 -0
- package/src/mailbox.ts +467 -0
- package/src/policies/admission.ts +164 -0
- package/src/policies/groups.ts +137 -0
- package/src/policies/index.ts +5 -0
- package/src/policies/live.ts +68 -0
- package/src/policies/markers.ts +58 -0
- package/src/policies/merge.ts +121 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn groups of a lane's queue (ADR 0048 H3; semantics of the former
|
|
3
|
+
* `TurnAdmissionGate`, ADR 0038 §1/§5 and #725).
|
|
4
|
+
*
|
|
5
|
+
* The queue holds single mails; a group is the set of queued mails one turn
|
|
6
|
+
* will answer. Groups are recomputed from the queue on every decision, so
|
|
7
|
+
* the policy stays pure. Replaying the queue in order:
|
|
8
|
+
*
|
|
9
|
+
* - **live** (danmaku, when enabled): joins the lane's last live group, no
|
|
10
|
+
* sender limit.
|
|
11
|
+
* - **merge** (mergeable plain text, when a merge window is enabled): joins
|
|
12
|
+
* the last merge group that already has the sender (burst), else the last
|
|
13
|
+
* merge group with fewer than `maxSenders` senders, else opens a new one.
|
|
14
|
+
* - **single** (everything else): joins the sender's own queued group when
|
|
15
|
+
* that is a single plain-text group and this mail is plain text too
|
|
16
|
+
* (burst coalescing); otherwise opens a new group. A member never joins
|
|
17
|
+
* another member's single group.
|
|
18
|
+
* - Mails requeued by a stale discard (`carriedInto`) belong to the group of
|
|
19
|
+
* their target mail, ahead of it, regardless of `maxSenders`; when the
|
|
20
|
+
* target left the queue they form their own group.
|
|
21
|
+
*
|
|
22
|
+
* Groups are ordered by their first mail in the queue; the head group always
|
|
23
|
+
* contains the queue's first mail.
|
|
24
|
+
*/
|
|
25
|
+
import type { InboxEntry, Mail, MailId } from '../contracts.js';
|
|
26
|
+
import { inboxMarkers } from './markers.js';
|
|
27
|
+
|
|
28
|
+
export type InboxGroupKind = 'single' | 'merge' | 'live';
|
|
29
|
+
|
|
30
|
+
export interface InboxGroup {
|
|
31
|
+
readonly kind: InboxGroupKind;
|
|
32
|
+
/** In queue order. */
|
|
33
|
+
readonly entries: readonly InboxEntry[];
|
|
34
|
+
/** Distinct senders in queue order. */
|
|
35
|
+
readonly senders: readonly string[];
|
|
36
|
+
readonly plainText: boolean;
|
|
37
|
+
/** Earliest / latest (re)queue time of the group's own (not carried) mails. */
|
|
38
|
+
readonly openedAt: number;
|
|
39
|
+
readonly lastJoinedAt: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface InboxGroupingOptions {
|
|
43
|
+
/** Merge windows enabled; omitted = mergeable is ignored. */
|
|
44
|
+
readonly merge?: { readonly maxSenders: number };
|
|
45
|
+
/** Danmaku windows enabled. */
|
|
46
|
+
readonly live?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function classifyMail(mail: Mail, options: InboxGroupingOptions): InboxGroupKind {
|
|
50
|
+
const m = inboxMarkers(mail);
|
|
51
|
+
if (options.live && m.live) return 'live';
|
|
52
|
+
if (options.merge && m.mergeable && m.plainText) return 'merge';
|
|
53
|
+
return 'single';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface Draft {
|
|
57
|
+
readonly kind: InboxGroupKind;
|
|
58
|
+
readonly own: Set<string>;
|
|
59
|
+
plainText: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function groupInbox(entries: readonly InboxEntry[], options: InboxGroupingOptions): InboxGroup[] {
|
|
63
|
+
const maxSenders = Math.max(1, Math.floor(options.merge?.maxSenders ?? 1));
|
|
64
|
+
const drafts: Draft[] = [];
|
|
65
|
+
const draftOf = new Map<InboxEntry, Draft>();
|
|
66
|
+
const byMail = new Map<MailId, Draft>();
|
|
67
|
+
const findLast = (predicate: (d: Draft) => boolean): Draft | undefined => {
|
|
68
|
+
for (let i = drafts.length - 1; i >= 0; i -= 1) if (predicate(drafts[i]!)) return drafts[i];
|
|
69
|
+
return undefined;
|
|
70
|
+
};
|
|
71
|
+
const open = (kind: InboxGroupKind, plainText: boolean): Draft => {
|
|
72
|
+
const d: Draft = { kind, own: new Set(), plainText };
|
|
73
|
+
drafts.push(d);
|
|
74
|
+
return d;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (entry.carriedInto !== undefined) continue;
|
|
79
|
+
const m = inboxMarkers(entry.mail);
|
|
80
|
+
const kind = classifyMail(entry.mail, options);
|
|
81
|
+
let d: Draft;
|
|
82
|
+
if (kind === 'live') {
|
|
83
|
+
d = findLast((g) => g.kind === 'live') ?? open('live', true);
|
|
84
|
+
} else if (kind === 'merge') {
|
|
85
|
+
d = findLast((g) => g.kind === 'merge' && g.own.has(m.senderId))
|
|
86
|
+
?? findLast((g) => g.kind === 'merge' && g.own.size < maxSenders)
|
|
87
|
+
?? open('merge', true);
|
|
88
|
+
} else {
|
|
89
|
+
const own = findLast((g) => g.own.has(m.senderId));
|
|
90
|
+
d = own && own.kind === 'single' && own.plainText && m.plainText ? own : open('single', m.plainText);
|
|
91
|
+
}
|
|
92
|
+
d.own.add(m.senderId);
|
|
93
|
+
draftOf.set(entry, d);
|
|
94
|
+
byMail.set(entry.mail.id, d);
|
|
95
|
+
}
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
if (entry.carriedInto === undefined) continue;
|
|
98
|
+
const target = byMail.get(entry.carriedInto);
|
|
99
|
+
if (target) {
|
|
100
|
+
draftOf.set(entry, target);
|
|
101
|
+
} else {
|
|
102
|
+
const d: Draft = { kind: 'single', own: new Set([inboxMarkers(entry.mail).senderId]), plainText: inboxMarkers(entry.mail).plainText };
|
|
103
|
+
draftOf.set(entry, d);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const order: Draft[] = [];
|
|
108
|
+
const members = new Map<Draft, InboxEntry[]>();
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
const d = draftOf.get(entry)!;
|
|
111
|
+
let list = members.get(d);
|
|
112
|
+
if (!list) {
|
|
113
|
+
list = [];
|
|
114
|
+
members.set(d, list);
|
|
115
|
+
order.push(d);
|
|
116
|
+
}
|
|
117
|
+
list.push(entry);
|
|
118
|
+
}
|
|
119
|
+
return order.map((d) => {
|
|
120
|
+
const list = members.get(d)!;
|
|
121
|
+
const own = list.filter((e) => e.carriedInto === undefined);
|
|
122
|
+
const times = (own.length > 0 ? own : list).map((e) => e.postedAt);
|
|
123
|
+
const senders: string[] = [];
|
|
124
|
+
for (const e of list) {
|
|
125
|
+
const s = inboxMarkers(e.mail).senderId;
|
|
126
|
+
if (!senders.includes(s)) senders.push(s);
|
|
127
|
+
}
|
|
128
|
+
return Object.freeze({
|
|
129
|
+
kind: d.kind,
|
|
130
|
+
entries: Object.freeze(list),
|
|
131
|
+
senders: Object.freeze(senders),
|
|
132
|
+
plainText: d.plainText,
|
|
133
|
+
openedAt: Math.min(...times),
|
|
134
|
+
lastJoinedAt: Math.max(...times),
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Danmaku window (ADR 0038 §5) as an inbox policy part.
|
|
3
|
+
*
|
|
4
|
+
* Every danmaku of a lane waits in one window that closes `windowMs` after
|
|
5
|
+
* its first mail, but never sooner than `minIntervalMs` after the lane's
|
|
6
|
+
* previous danmaku turn ended. No sender limit; the window keeps the newest
|
|
7
|
+
* `LIVE_WINDOW_MAX_MESSAGES` mails (older ones are evicted, counted in
|
|
8
|
+
* `InboxTurnFacts.evicted`), and one turn answers it.
|
|
9
|
+
*/
|
|
10
|
+
import type { InboxEntry, MailId } from '../contracts.js';
|
|
11
|
+
import type { InboxGroup } from './groups.js';
|
|
12
|
+
import { inboxMarkers } from './markers.js';
|
|
13
|
+
|
|
14
|
+
export interface LiveWindowOptions {
|
|
15
|
+
/** Answer the window this long after its first danmaku. */
|
|
16
|
+
readonly windowMs: number;
|
|
17
|
+
/** Never start a danmaku turn sooner than this after the lane's previous one ended. */
|
|
18
|
+
readonly minIntervalMs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Danmaku one window keeps for the model; older ones are evicted. */
|
|
22
|
+
export const LIVE_WINDOW_MAX_MESSAGES = 100;
|
|
23
|
+
|
|
24
|
+
export function normalizeLiveWindow(options: LiveWindowOptions): LiveWindowOptions {
|
|
25
|
+
return Object.freeze({
|
|
26
|
+
windowMs: Math.max(0, options.windowMs),
|
|
27
|
+
minIntervalMs: Math.max(0, options.minIntervalMs),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** When a live group may be answered; `lastLiveEndedAt` = end of the lane's previous danmaku turn. */
|
|
32
|
+
export function liveWindowDueAt(
|
|
33
|
+
group: Pick<InboxGroup, 'openedAt'>,
|
|
34
|
+
lastLiveEndedAt: number | undefined,
|
|
35
|
+
options: LiveWindowOptions,
|
|
36
|
+
): number {
|
|
37
|
+
return Math.max(group.openedAt + options.windowMs, lastLiveEndedAt === undefined ? 0 : lastLiveEndedAt + options.minIntervalMs);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Mails to evict so a live group keeps at most `max` mails: the earliest
|
|
42
|
+
* sender's oldest mail goes first (as the former gate did), so memory stays
|
|
43
|
+
* bounded however busy the room is.
|
|
44
|
+
*/
|
|
45
|
+
export function liveEvictions(entries: readonly InboxEntry[], max: number = LIVE_WINDOW_MAX_MESSAGES): readonly MailId[] {
|
|
46
|
+
const excess = entries.length - Math.max(0, max);
|
|
47
|
+
if (excess <= 0) return [];
|
|
48
|
+
const order: string[] = [];
|
|
49
|
+
const by = new Map<string, MailId[]>();
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
const s = inboxMarkers(e.mail).senderId;
|
|
52
|
+
let list = by.get(s);
|
|
53
|
+
if (!list) {
|
|
54
|
+
list = [];
|
|
55
|
+
by.set(s, list);
|
|
56
|
+
order.push(s);
|
|
57
|
+
}
|
|
58
|
+
list.push(e.mail.id);
|
|
59
|
+
}
|
|
60
|
+
const out: MailId[] = [];
|
|
61
|
+
for (const s of order) {
|
|
62
|
+
for (const id of by.get(s)!) {
|
|
63
|
+
if (out.length >= excess) return out;
|
|
64
|
+
out.push(id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the inbox-policy markers of a mail (ADR 0048 H3).
|
|
3
|
+
*
|
|
4
|
+
* Only `human` mail carries markers (`MailBody.human`). Every other kind is
|
|
5
|
+
* an ordinary, non-plain, non-mergeable mail from its transport sender, so
|
|
6
|
+
* actor / timer / event mail is never merged into, superseded by, or used to
|
|
7
|
+
* stop a human's turn.
|
|
8
|
+
*/
|
|
9
|
+
import { senderOf, type Mail } from '../contracts.js';
|
|
10
|
+
|
|
11
|
+
export interface InboxMarkers {
|
|
12
|
+
readonly senderId: string;
|
|
13
|
+
readonly senderName?: string;
|
|
14
|
+
readonly text: string;
|
|
15
|
+
readonly messageId?: string;
|
|
16
|
+
readonly human: boolean;
|
|
17
|
+
readonly plainText: boolean;
|
|
18
|
+
readonly mergeable: boolean;
|
|
19
|
+
readonly live: boolean;
|
|
20
|
+
readonly isMaster: boolean;
|
|
21
|
+
readonly isStop: boolean;
|
|
22
|
+
readonly unaddressed: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function textOf(mail: Mail): string {
|
|
26
|
+
const body = mail.body;
|
|
27
|
+
switch (body.kind) {
|
|
28
|
+
case 'human': return body.text;
|
|
29
|
+
case 'ask': return body.question;
|
|
30
|
+
case 'delegate': return body.brief;
|
|
31
|
+
case 'tell': return body.content;
|
|
32
|
+
case 'result': return body.content;
|
|
33
|
+
case 'timer': return body.content;
|
|
34
|
+
default: return '';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function inboxMarkers(mail: Mail): InboxMarkers {
|
|
39
|
+
const senderId = senderOf(mail);
|
|
40
|
+
const text = textOf(mail);
|
|
41
|
+
const body = mail.body;
|
|
42
|
+
if (body.kind !== 'human') {
|
|
43
|
+
return { senderId, text, human: false, plainText: false, mergeable: false, live: false, isMaster: false, isStop: false, unaddressed: false };
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
senderId,
|
|
47
|
+
...(body.senderName ? { senderName: body.senderName } : {}),
|
|
48
|
+
text,
|
|
49
|
+
...(body.messageId ? { messageId: body.messageId } : {}),
|
|
50
|
+
human: true,
|
|
51
|
+
plainText: body.plainText === true,
|
|
52
|
+
mergeable: body.mergeable === true,
|
|
53
|
+
live: body.live === true,
|
|
54
|
+
isMaster: body.isMaster === true,
|
|
55
|
+
isStop: body.isStop === true,
|
|
56
|
+
unaddressed: body.unaddressed === true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge window (ADR 0038 §1) as an inbox policy part, plus the helpers a
|
|
3
|
+
* merged turn needs (ADR 0048 H3, Q2).
|
|
4
|
+
*
|
|
5
|
+
* A merge group is answered once it went quiet for `quietMs` or, at the
|
|
6
|
+
* latest, `maxWaitMs` after it opened. Q2 (accepted): a merged batch runs in
|
|
7
|
+
* its first mail's engagement; its principal is the first human sender; its
|
|
8
|
+
* authority is the intersection of every sender's authority. These helpers
|
|
9
|
+
* are exposed now; wiring them into the IM turn is PR-2.
|
|
10
|
+
*/
|
|
11
|
+
import { intersectScopes, type Authority, type Mail, type Principal } from '../contracts.js';
|
|
12
|
+
import { minTrust } from '../authority.js';
|
|
13
|
+
import type { InboxGroup } from './groups.js';
|
|
14
|
+
import { inboxMarkers } from './markers.js';
|
|
15
|
+
|
|
16
|
+
export interface MergeWindowOptions {
|
|
17
|
+
/** Flush once no new mail joined the window for this long. */
|
|
18
|
+
readonly quietMs: number;
|
|
19
|
+
/** Flush at the latest this long after the window opened. */
|
|
20
|
+
readonly maxWaitMs: number;
|
|
21
|
+
/** Senders one window may hold; further senders open the next window. */
|
|
22
|
+
readonly maxSenders: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function normalizeMergeWindow(options: MergeWindowOptions): MergeWindowOptions {
|
|
26
|
+
return Object.freeze({
|
|
27
|
+
quietMs: Math.max(0, options.quietMs),
|
|
28
|
+
maxWaitMs: Math.max(0, options.maxWaitMs),
|
|
29
|
+
maxSenders: Math.max(1, Math.floor(options.maxSenders)),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** When a merge group may be answered. */
|
|
34
|
+
export function mergeWindowDueAt(group: Pick<InboxGroup, 'openedAt' | 'lastJoinedAt'>, options: MergeWindowOptions): number {
|
|
35
|
+
return Math.min(group.lastJoinedAt + options.quietMs, group.openedAt + options.maxWaitMs);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One sender's mails in a batch, in arrival order. */
|
|
39
|
+
export interface InboxParticipant {
|
|
40
|
+
readonly senderId: string;
|
|
41
|
+
readonly senderName?: string;
|
|
42
|
+
readonly texts: readonly string[];
|
|
43
|
+
/** Platform message ids behind `texts`, when known. */
|
|
44
|
+
readonly messageIds?: readonly string[];
|
|
45
|
+
readonly mails: readonly Mail[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Groups a batch by sender, senders in order of their first mail. */
|
|
49
|
+
export function participantsOf(batch: readonly Mail[]): readonly InboxParticipant[] {
|
|
50
|
+
const order: string[] = [];
|
|
51
|
+
const by = new Map<string, { senderName?: string; texts: string[]; messageIds: string[]; mails: Mail[] }>();
|
|
52
|
+
for (const mail of batch) {
|
|
53
|
+
const m = inboxMarkers(mail);
|
|
54
|
+
let p = by.get(m.senderId);
|
|
55
|
+
if (!p) {
|
|
56
|
+
p = { ...(m.senderName ? { senderName: m.senderName } : {}), texts: [], messageIds: [], mails: [] };
|
|
57
|
+
by.set(m.senderId, p);
|
|
58
|
+
order.push(m.senderId);
|
|
59
|
+
}
|
|
60
|
+
if (!p.senderName && m.senderName) p.senderName = m.senderName;
|
|
61
|
+
p.texts.push(m.text);
|
|
62
|
+
if (m.messageId) p.messageIds.push(m.messageId);
|
|
63
|
+
p.mails.push(mail);
|
|
64
|
+
}
|
|
65
|
+
return Object.freeze(order.map((senderId) => {
|
|
66
|
+
const p = by.get(senderId)!;
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
senderId,
|
|
69
|
+
...(p.senderName ? { senderName: p.senderName } : {}),
|
|
70
|
+
texts: Object.freeze(p.texts),
|
|
71
|
+
...(p.messageIds.length > 0 ? { messageIds: Object.freeze(p.messageIds) } : {}),
|
|
72
|
+
mails: Object.freeze(p.mails),
|
|
73
|
+
});
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One sender: their texts joined by newlines. Several senders: one
|
|
79
|
+
* `name: text` line per sender so the model can tell who asked what.
|
|
80
|
+
*/
|
|
81
|
+
export function renderInboxText(participants: readonly InboxParticipant[]): string {
|
|
82
|
+
const lines = (p: InboxParticipant) => p.texts.filter((text) => text.trim());
|
|
83
|
+
if (participants.length === 1) return lines(participants[0]!).join('\n');
|
|
84
|
+
return participants
|
|
85
|
+
.map((p) => ({ name: p.senderName?.trim() || p.senderId, texts: lines(p) }))
|
|
86
|
+
.filter((p) => p.texts.length > 0)
|
|
87
|
+
.map((p) => `${p.name}: ${p.texts.join('\n')}`)
|
|
88
|
+
.join('\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Q2: the principal of a merged batch is its first human sender. */
|
|
92
|
+
export function firstHumanPrincipal(batch: readonly Mail[]): Principal | undefined {
|
|
93
|
+
for (const mail of batch) if (mail.from.kind === 'human') return mail.from.principal;
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Q2: the authority of a merged batch is the intersection of its mails'
|
|
99
|
+
* authorities (trust = lowest, tools / contacts / egress hosts = common,
|
|
100
|
+
* network only when everyone has it). Principal, approvers and chain stay
|
|
101
|
+
* those of the first human mail (approvals go to that principal), so the
|
|
102
|
+
* result never exceeds any sender's authority.
|
|
103
|
+
*/
|
|
104
|
+
export function mergedAuthority(batch: readonly Mail[]): Authority | undefined {
|
|
105
|
+
if (batch.length === 0) return undefined;
|
|
106
|
+
const lead = batch.find((mail) => mail.from.kind === 'human') ?? batch[0]!;
|
|
107
|
+
const all = batch.map((mail) => mail.authority);
|
|
108
|
+
const base = lead.authority;
|
|
109
|
+
return Object.freeze({
|
|
110
|
+
principal: base.principal,
|
|
111
|
+
trust: minTrust(...all.map((a) => a.trust)),
|
|
112
|
+
tools: all.reduce((scope, a) => intersectScopes(scope, a.tools), base.tools),
|
|
113
|
+
contacts: all.reduce((scope, a) => intersectScopes(scope, a.contacts), base.contacts),
|
|
114
|
+
egress: Object.freeze({
|
|
115
|
+
network: all.every((a) => a.egress.network),
|
|
116
|
+
hosts: all.reduce((scope, a) => intersectScopes(scope, a.egress.hosts), base.egress.hosts),
|
|
117
|
+
}),
|
|
118
|
+
approvers: base.approvers,
|
|
119
|
+
chain: base.chain,
|
|
120
|
+
});
|
|
121
|
+
}
|