@zhin.js/harness 0.0.0-stage → 1.0.1
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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 凉菜
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zhin.js/harness
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
ADR 0048 Agent Harness (published as a dependency of `@zhin.js/agent`): actors exchange **mail** inside an **engagement**; every mail carries an **authority** that can only be attenuated and a **budget grant** carved from its parent; a **guard kernel** decides delivery; an **engagement journal** records everything.
|
|
4
|
+
|
|
5
|
+
See `docs/adr/0048-agent-harness.md`. The shared contracts live in `src/contracts.ts`; each component (authority, budget, guard, journal, mailbox) depends only on them.
|
|
6
|
+
|
|
7
|
+
## Inbox policies (ADR 0048 H3)
|
|
8
|
+
|
|
9
|
+
The mailbox serializes turns per `(actor, lane)`; a lane defaults to the engagement, and an IM session can use one lane for every human message it receives. An `InboxPolicy` (`onPost` / `ready` / `staleOf`) decides how queued mails become turns. `createInboxPolicy` (`src/policies/`) carries the IM admission rules that `TurnAdmissionGate` used to own: stop, same-sender supersede, burst coalescing, FIFO between members, merge windows, stale-reply discard and danmaku windows. Clock and timers can be injected.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR 0048 §3.4 invariant 1 — authority is only ever attenuated.
|
|
3
|
+
*
|
|
4
|
+
* Effective authority of a delegatee = delegator authority ∩ the narrowing
|
|
5
|
+
* declared at hand-off ∩ the delegatee agent's ceiling. Every function here is
|
|
6
|
+
* pure: inputs are never mutated and every returned object is frozen.
|
|
7
|
+
*/
|
|
8
|
+
import { type ActorId, type AgentCeiling, type AgentName, type Authority, type AuthorityNarrowing, type Principal, type TrustLevel } from './contracts.js';
|
|
9
|
+
/** Negative when `a` is lower than `b`, 0 when equal, positive when higher. Throws on unknown levels. */
|
|
10
|
+
export declare function compareTrust(a: TrustLevel, b: TrustLevel): number;
|
|
11
|
+
/** The lowest of the given levels. Throws when called with none. */
|
|
12
|
+
export declare function minTrust(...levels: readonly TrustLevel[]): TrustLevel;
|
|
13
|
+
/**
|
|
14
|
+
* Authority of an engagement's root mail: what the principal may do through
|
|
15
|
+
* this agent. Approvers are the human principal first, then configured ids.
|
|
16
|
+
*/
|
|
17
|
+
export declare function rootAuthority(principal: Principal, ceiling: AgentCeiling, options?: {
|
|
18
|
+
readonly approvers?: readonly string[];
|
|
19
|
+
}): Authority;
|
|
20
|
+
/**
|
|
21
|
+
* Derives the authority handed to `target` by actor `via`. Never widens: a
|
|
22
|
+
* narrowing asking for more than the parent just gets the intersection.
|
|
23
|
+
* Principal and approvers are carried over unchanged.
|
|
24
|
+
*/
|
|
25
|
+
export declare function attenuate(parent: Authority, narrowing: AuthorityNarrowing, target: AgentCeiling, via: ActorId): Authority;
|
|
26
|
+
/**
|
|
27
|
+
* `true` iff `child` is a single, valid attenuation step from `parent`:
|
|
28
|
+
* same principal and approvers, no wider trust / tools / contacts / egress,
|
|
29
|
+
* and a chain that extends the parent's by exactly one actor.
|
|
30
|
+
*/
|
|
31
|
+
export declare function isAttenuationOf(child: Authority, parent: Authority): boolean;
|
|
32
|
+
/** Agent name of an actor id: the part before `#`. */
|
|
33
|
+
export declare function agentOf(actor: ActorId): AgentName;
|
|
34
|
+
export declare function canCall(authority: Authority, tool: string): boolean;
|
|
35
|
+
/** Takes an agent name; use `agentOf(actor)` for actor ids. */
|
|
36
|
+
export declare function canContact(authority: Authority, agentName: AgentName): boolean;
|
|
37
|
+
export declare function allowsHost(authority: Authority, host: string): boolean;
|
package/lib/authority.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR 0048 §3.4 invariant 1 — authority is only ever attenuated.
|
|
3
|
+
*
|
|
4
|
+
* Effective authority of a delegatee = delegator authority ∩ the narrowing
|
|
5
|
+
* declared at hand-off ∩ the delegatee agent's ceiling. Every function here is
|
|
6
|
+
* pure: inputs are never mutated and every returned object is frozen.
|
|
7
|
+
*/
|
|
8
|
+
import { ALL, TRUST_LEVELS, intersectScopes, scopeHas, scopeWithin, } from './contracts.js';
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Trust
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
function trustRank(level) {
|
|
13
|
+
const rank = TRUST_LEVELS.indexOf(level);
|
|
14
|
+
if (rank < 0)
|
|
15
|
+
throw new TypeError(`Unknown trust level: ${String(level)}`);
|
|
16
|
+
return rank;
|
|
17
|
+
}
|
|
18
|
+
function isTrustLevel(value) {
|
|
19
|
+
return typeof value === 'string' && TRUST_LEVELS.includes(value);
|
|
20
|
+
}
|
|
21
|
+
/** Negative when `a` is lower than `b`, 0 when equal, positive when higher. Throws on unknown levels. */
|
|
22
|
+
export function compareTrust(a, b) {
|
|
23
|
+
return trustRank(a) - trustRank(b);
|
|
24
|
+
}
|
|
25
|
+
/** The lowest of the given levels. Throws when called with none. */
|
|
26
|
+
export function minTrust(...levels) {
|
|
27
|
+
if (levels.length === 0)
|
|
28
|
+
throw new TypeError('minTrust requires at least one level');
|
|
29
|
+
let lowest = levels[0];
|
|
30
|
+
for (const level of levels) {
|
|
31
|
+
if (compareTrust(level, lowest) < 0)
|
|
32
|
+
lowest = level;
|
|
33
|
+
}
|
|
34
|
+
trustRank(lowest);
|
|
35
|
+
return lowest;
|
|
36
|
+
}
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Construction
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
function freezeScope(scope) {
|
|
41
|
+
return intersectScopes(scope, ALL);
|
|
42
|
+
}
|
|
43
|
+
function freezePrincipal(principal) {
|
|
44
|
+
return Object.freeze({
|
|
45
|
+
kind: principal.kind,
|
|
46
|
+
id: principal.id,
|
|
47
|
+
trust: principal.trust,
|
|
48
|
+
...(principal.venue ? { venue: Object.freeze({ kind: principal.venue.kind, id: principal.venue.id }) } : {}),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function freezeEgress(network, hosts) {
|
|
52
|
+
return Object.freeze({ network, hosts: freezeScope(hosts) });
|
|
53
|
+
}
|
|
54
|
+
function dedupe(ids) {
|
|
55
|
+
return Object.freeze([...new Set(ids)]);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Authority of an engagement's root mail: what the principal may do through
|
|
59
|
+
* this agent. Approvers are the human principal first, then configured ids.
|
|
60
|
+
*/
|
|
61
|
+
export function rootAuthority(principal, ceiling, options = {}) {
|
|
62
|
+
const approvers = [...(principal.kind === 'human' ? [principal.id] : []), ...(options.approvers ?? [])];
|
|
63
|
+
return Object.freeze({
|
|
64
|
+
principal: freezePrincipal(principal),
|
|
65
|
+
trust: minTrust(principal.trust, ceiling.maxTrust),
|
|
66
|
+
tools: freezeScope(ceiling.tools),
|
|
67
|
+
contacts: freezeScope(ceiling.contacts),
|
|
68
|
+
egress: freezeEgress(ceiling.egress.network, ceiling.egress.hosts),
|
|
69
|
+
approvers: dedupe(approvers),
|
|
70
|
+
chain: Object.freeze([]),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Derives the authority handed to `target` by actor `via`. Never widens: a
|
|
75
|
+
* narrowing asking for more than the parent just gets the intersection.
|
|
76
|
+
* Principal and approvers are carried over unchanged.
|
|
77
|
+
*/
|
|
78
|
+
export function attenuate(parent, narrowing, target, via) {
|
|
79
|
+
const tools = intersectScopes(intersectScopes(parent.tools, narrowing.tools ?? ALL), target.tools);
|
|
80
|
+
const contacts = intersectScopes(intersectScopes(parent.contacts, narrowing.contacts ?? ALL), target.contacts);
|
|
81
|
+
const trust = minTrust(parent.trust, narrowing.trust ?? parent.trust, target.maxTrust);
|
|
82
|
+
const network = parent.egress.network && (narrowing.egress?.network ?? true) && target.egress.network;
|
|
83
|
+
const hosts = intersectScopes(intersectScopes(parent.egress.hosts, narrowing.egress?.hosts ?? ALL), target.egress.hosts);
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
principal: freezePrincipal(parent.principal),
|
|
86
|
+
trust,
|
|
87
|
+
tools,
|
|
88
|
+
contacts,
|
|
89
|
+
egress: freezeEgress(network, hosts),
|
|
90
|
+
approvers: Object.freeze([...parent.approvers]),
|
|
91
|
+
chain: Object.freeze([...parent.chain, via]),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Verification (called by the guard kernel)
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
function sameJson(a, b) {
|
|
98
|
+
if (a === b)
|
|
99
|
+
return true;
|
|
100
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
|
|
101
|
+
return false;
|
|
102
|
+
if (Array.isArray(a) !== Array.isArray(b))
|
|
103
|
+
return false;
|
|
104
|
+
const aKeys = Object.keys(a).filter((key) => a[key] !== undefined);
|
|
105
|
+
const bKeys = Object.keys(b).filter((key) => b[key] !== undefined);
|
|
106
|
+
if (aKeys.length !== bKeys.length)
|
|
107
|
+
return false;
|
|
108
|
+
return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) &&
|
|
109
|
+
sameJson(a[key], b[key]));
|
|
110
|
+
}
|
|
111
|
+
function sameList(a, b) {
|
|
112
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* `true` iff `child` is a single, valid attenuation step from `parent`:
|
|
116
|
+
* same principal and approvers, no wider trust / tools / contacts / egress,
|
|
117
|
+
* and a chain that extends the parent's by exactly one actor.
|
|
118
|
+
*/
|
|
119
|
+
export function isAttenuationOf(child, parent) {
|
|
120
|
+
if (!isTrustLevel(child.trust) || !isTrustLevel(parent.trust))
|
|
121
|
+
return false;
|
|
122
|
+
if (!isTrustLevel(child.principal.trust))
|
|
123
|
+
return false;
|
|
124
|
+
if (!sameJson(child.principal, parent.principal))
|
|
125
|
+
return false;
|
|
126
|
+
if (!sameList(child.approvers, parent.approvers))
|
|
127
|
+
return false;
|
|
128
|
+
if (compareTrust(child.trust, parent.trust) > 0)
|
|
129
|
+
return false;
|
|
130
|
+
if (compareTrust(child.trust, child.principal.trust) > 0)
|
|
131
|
+
return false;
|
|
132
|
+
if (!scopeWithin(child.tools, parent.tools))
|
|
133
|
+
return false;
|
|
134
|
+
if (!scopeWithin(child.contacts, parent.contacts))
|
|
135
|
+
return false;
|
|
136
|
+
if (child.egress.network && !parent.egress.network)
|
|
137
|
+
return false;
|
|
138
|
+
if (!scopeWithin(child.egress.hosts, parent.egress.hosts))
|
|
139
|
+
return false;
|
|
140
|
+
if (child.chain.length !== parent.chain.length + 1)
|
|
141
|
+
return false;
|
|
142
|
+
return parent.chain.every((actor, index) => child.chain[index] === actor);
|
|
143
|
+
}
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Queries
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
/** Agent name of an actor id: the part before `#`. */
|
|
148
|
+
export function agentOf(actor) {
|
|
149
|
+
const hash = actor.indexOf('#');
|
|
150
|
+
return hash < 0 ? actor : actor.slice(0, hash);
|
|
151
|
+
}
|
|
152
|
+
export function canCall(authority, tool) {
|
|
153
|
+
return scopeHas(authority.tools, tool);
|
|
154
|
+
}
|
|
155
|
+
/** Takes an agent name; use `agentOf(actor)` for actor ids. */
|
|
156
|
+
export function canContact(authority, agentName) {
|
|
157
|
+
return scopeHas(authority.contacts, agentName);
|
|
158
|
+
}
|
|
159
|
+
export function allowsHost(authority, host) {
|
|
160
|
+
return authority.egress.network && scopeHas(authority.egress.hosts, host);
|
|
161
|
+
}
|
package/lib/budget.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory conserved budget ledger (ADR 0048 §3.4 invariant 2).
|
|
3
|
+
*
|
|
4
|
+
* Semantics, per budget key:
|
|
5
|
+
* - consumed(g) = g.ownSpent + Σ rolledUp(closed child), where
|
|
6
|
+
* rolledUp(c) = min(consumed(c), granted(c)).
|
|
7
|
+
* Overspend beyond a grant stays visible on that grant's own
|
|
8
|
+
* snapshot but never inflates the parent's accounting, so
|
|
9
|
+
* a subtree can never consume more than it was granted
|
|
10
|
+
* from above (conservation).
|
|
11
|
+
* - reserved(g) = Σ granted(c) over OPEN children c.
|
|
12
|
+
* - remaining(g) = granted(g) - consumed(g) - reserved(g) (may go negative
|
|
13
|
+
* after an overspending charge).
|
|
14
|
+
* - Snapshot: `spent` = consumed(g) (own + closed children), `reservedForChildren`
|
|
15
|
+
* = reserved(g), `remaining` as above.
|
|
16
|
+
*
|
|
17
|
+
* grant(): requested keys above the parent's remaining fail whole with
|
|
18
|
+
* `budget_exceeded` (no partial grant, no clamping). Omitted keys default to
|
|
19
|
+
* floor(parent remaining / 2), never below 0. The child's granted amount is
|
|
20
|
+
* reserved on the parent immediately.
|
|
21
|
+
*
|
|
22
|
+
* charge(): always records the usage on an open grant; reports
|
|
23
|
+
* `budget_exceeded` (with the exhausted keys) when any remaining < 0.
|
|
24
|
+
*
|
|
25
|
+
* close(): closes open descendants first (deepest first), then the grant.
|
|
26
|
+
* The parent's reservation is released and the child's consumed amount is
|
|
27
|
+
* rolled up, i.e. unspent returns to the parent. Idempotent.
|
|
28
|
+
*/
|
|
29
|
+
import { type BudgetAmounts, type BudgetLedger, type BudgetResult, type BudgetSnapshot, type GrantId } from './contracts.js';
|
|
30
|
+
export interface InMemoryBudgetLedgerOptions {
|
|
31
|
+
readonly idFactory?: () => GrantId;
|
|
32
|
+
}
|
|
33
|
+
export declare class InMemoryBudgetLedger implements BudgetLedger {
|
|
34
|
+
private readonly nodes;
|
|
35
|
+
private readonly idFactory;
|
|
36
|
+
constructor(options?: InMemoryBudgetLedgerOptions);
|
|
37
|
+
openRoot(amounts: BudgetAmounts): GrantId;
|
|
38
|
+
grant(parentId: GrantId, request: Partial<BudgetAmounts>): BudgetResult<GrantId>;
|
|
39
|
+
charge(grantId: GrantId, usage: Partial<BudgetAmounts>): BudgetResult<BudgetSnapshot>;
|
|
40
|
+
close(grantId: GrantId): BudgetResult<BudgetSnapshot>;
|
|
41
|
+
snapshot(grantId: GrantId): BudgetSnapshot | undefined;
|
|
42
|
+
private create;
|
|
43
|
+
private closeNode;
|
|
44
|
+
private consumed;
|
|
45
|
+
private reserved;
|
|
46
|
+
private remaining;
|
|
47
|
+
private snap;
|
|
48
|
+
}
|
package/lib/budget.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory conserved budget ledger (ADR 0048 §3.4 invariant 2).
|
|
3
|
+
*
|
|
4
|
+
* Semantics, per budget key:
|
|
5
|
+
* - consumed(g) = g.ownSpent + Σ rolledUp(closed child), where
|
|
6
|
+
* rolledUp(c) = min(consumed(c), granted(c)).
|
|
7
|
+
* Overspend beyond a grant stays visible on that grant's own
|
|
8
|
+
* snapshot but never inflates the parent's accounting, so
|
|
9
|
+
* a subtree can never consume more than it was granted
|
|
10
|
+
* from above (conservation).
|
|
11
|
+
* - reserved(g) = Σ granted(c) over OPEN children c.
|
|
12
|
+
* - remaining(g) = granted(g) - consumed(g) - reserved(g) (may go negative
|
|
13
|
+
* after an overspending charge).
|
|
14
|
+
* - Snapshot: `spent` = consumed(g) (own + closed children), `reservedForChildren`
|
|
15
|
+
* = reserved(g), `remaining` as above.
|
|
16
|
+
*
|
|
17
|
+
* grant(): requested keys above the parent's remaining fail whole with
|
|
18
|
+
* `budget_exceeded` (no partial grant, no clamping). Omitted keys default to
|
|
19
|
+
* floor(parent remaining / 2), never below 0. The child's granted amount is
|
|
20
|
+
* reserved on the parent immediately.
|
|
21
|
+
*
|
|
22
|
+
* charge(): always records the usage on an open grant; reports
|
|
23
|
+
* `budget_exceeded` (with the exhausted keys) when any remaining < 0.
|
|
24
|
+
*
|
|
25
|
+
* close(): closes open descendants first (deepest first), then the grant.
|
|
26
|
+
* The parent's reservation is released and the child's consumed amount is
|
|
27
|
+
* rolled up, i.e. unspent returns to the parent. Idempotent.
|
|
28
|
+
*/
|
|
29
|
+
import { BUDGET_KEYS, } from './contracts.js';
|
|
30
|
+
const zero = () => ({ modelCalls: 0, tokens: 0, wallMs: 0, sideEffects: 0 });
|
|
31
|
+
const valid = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0;
|
|
32
|
+
export class InMemoryBudgetLedger {
|
|
33
|
+
nodes = new Map();
|
|
34
|
+
idFactory;
|
|
35
|
+
constructor(options = {}) {
|
|
36
|
+
let n = 0;
|
|
37
|
+
this.idFactory = options.idFactory ?? (() => `g${++n}`);
|
|
38
|
+
}
|
|
39
|
+
openRoot(amounts) {
|
|
40
|
+
for (const k of BUDGET_KEYS) {
|
|
41
|
+
if (!valid(amounts?.[k]))
|
|
42
|
+
throw new TypeError(`invalid budget amount for ${k}: ${String(amounts?.[k])}`);
|
|
43
|
+
}
|
|
44
|
+
return this.create(undefined, { ...amounts }).id;
|
|
45
|
+
}
|
|
46
|
+
grant(parentId, request) {
|
|
47
|
+
const parent = this.nodes.get(parentId);
|
|
48
|
+
if (!parent)
|
|
49
|
+
return fail('unknown_grant', `unknown grant ${parentId}`);
|
|
50
|
+
if (parent.closed)
|
|
51
|
+
return fail('grant_closed', `grant ${parentId} is closed`);
|
|
52
|
+
const bad = invalidKey(request);
|
|
53
|
+
if (bad)
|
|
54
|
+
return fail('invalid_amount', `invalid amount for ${bad}`);
|
|
55
|
+
const rem = this.remaining(parent);
|
|
56
|
+
const exhausted = BUDGET_KEYS.filter((k) => request[k] !== undefined && request[k] > rem[k]);
|
|
57
|
+
if (exhausted.length > 0) {
|
|
58
|
+
return fail('budget_exceeded', `requested more than parent remaining for: ${exhausted.join(', ')}`, exhausted);
|
|
59
|
+
}
|
|
60
|
+
const amounts = zero();
|
|
61
|
+
for (const k of BUDGET_KEYS)
|
|
62
|
+
amounts[k] = request[k] ?? Math.max(0, Math.floor(rem[k] / 2));
|
|
63
|
+
return { ok: true, value: this.create(parent, amounts).id };
|
|
64
|
+
}
|
|
65
|
+
charge(grantId, usage) {
|
|
66
|
+
const node = this.nodes.get(grantId);
|
|
67
|
+
if (!node)
|
|
68
|
+
return fail('unknown_grant', `unknown grant ${grantId}`);
|
|
69
|
+
const bad = invalidKey(usage);
|
|
70
|
+
if (bad)
|
|
71
|
+
return fail('invalid_amount', `invalid amount for ${bad}`);
|
|
72
|
+
if (node.closed)
|
|
73
|
+
return fail('grant_closed', `grant ${grantId} is closed`);
|
|
74
|
+
for (const k of BUDGET_KEYS)
|
|
75
|
+
node.ownSpent[k] += usage[k] ?? 0;
|
|
76
|
+
const rem = this.remaining(node);
|
|
77
|
+
const exhausted = BUDGET_KEYS.filter((k) => rem[k] < 0);
|
|
78
|
+
if (exhausted.length > 0) {
|
|
79
|
+
return fail('budget_exceeded', `budget exceeded for: ${exhausted.join(', ')}`, exhausted);
|
|
80
|
+
}
|
|
81
|
+
return { ok: true, value: this.snap(node) };
|
|
82
|
+
}
|
|
83
|
+
close(grantId) {
|
|
84
|
+
const node = this.nodes.get(grantId);
|
|
85
|
+
if (!node)
|
|
86
|
+
return fail('unknown_grant', `unknown grant ${grantId}`);
|
|
87
|
+
this.closeNode(node);
|
|
88
|
+
return { ok: true, value: this.snap(node) };
|
|
89
|
+
}
|
|
90
|
+
snapshot(grantId) {
|
|
91
|
+
const node = this.nodes.get(grantId);
|
|
92
|
+
return node ? this.snap(node) : undefined;
|
|
93
|
+
}
|
|
94
|
+
create(parent, granted) {
|
|
95
|
+
const id = this.idFactory();
|
|
96
|
+
const node = { id, parent, granted, ownSpent: zero(), rolledUp: zero(), children: [], closed: false };
|
|
97
|
+
this.nodes.set(id, node);
|
|
98
|
+
parent?.children.push(node);
|
|
99
|
+
return node;
|
|
100
|
+
}
|
|
101
|
+
closeNode(node) {
|
|
102
|
+
if (node.closed)
|
|
103
|
+
return;
|
|
104
|
+
for (const child of node.children)
|
|
105
|
+
this.closeNode(child);
|
|
106
|
+
node.closed = true;
|
|
107
|
+
if (node.parent) {
|
|
108
|
+
const consumed = this.consumed(node);
|
|
109
|
+
for (const k of BUDGET_KEYS)
|
|
110
|
+
node.parent.rolledUp[k] += Math.min(consumed[k], node.granted[k]);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
consumed(node) {
|
|
114
|
+
const out = zero();
|
|
115
|
+
for (const k of BUDGET_KEYS)
|
|
116
|
+
out[k] = node.ownSpent[k] + node.rolledUp[k];
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
reserved(node) {
|
|
120
|
+
const out = zero();
|
|
121
|
+
for (const c of node.children)
|
|
122
|
+
if (!c.closed)
|
|
123
|
+
for (const k of BUDGET_KEYS)
|
|
124
|
+
out[k] += c.granted[k];
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
remaining(node) {
|
|
128
|
+
const c = this.consumed(node);
|
|
129
|
+
const r = this.reserved(node);
|
|
130
|
+
const out = zero();
|
|
131
|
+
for (const k of BUDGET_KEYS)
|
|
132
|
+
out[k] = node.granted[k] - c[k] - r[k];
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
snap(node) {
|
|
136
|
+
return Object.freeze({
|
|
137
|
+
grantId: node.id,
|
|
138
|
+
...(node.parent ? { parentId: node.parent.id } : {}),
|
|
139
|
+
granted: Object.freeze({ ...node.granted }),
|
|
140
|
+
spent: Object.freeze(this.consumed(node)),
|
|
141
|
+
reservedForChildren: Object.freeze(this.reserved(node)),
|
|
142
|
+
remaining: Object.freeze(this.remaining(node)),
|
|
143
|
+
closed: node.closed,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function invalidKey(amounts) {
|
|
148
|
+
if (amounts === null || typeof amounts !== 'object')
|
|
149
|
+
return BUDGET_KEYS[0];
|
|
150
|
+
return BUDGET_KEYS.find((k) => amounts[k] !== undefined && !valid(amounts[k]));
|
|
151
|
+
}
|
|
152
|
+
function fail(code, message, exhausted) {
|
|
153
|
+
return exhausted ? { ok: false, code, exhausted, message } : { ok: false, code, message };
|
|
154
|
+
}
|