@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.
@@ -0,0 +1,643 @@
1
+ /**
2
+ * ADR 0048 Agent Harness — shared contracts (H1).
3
+ *
4
+ * Everything here is plain, serializable data plus the interfaces that the
5
+ * harness components implement. Components (authority, budget, guard,
6
+ * journal, mailbox) depend on this file only, never on each other, so they
7
+ * can be built and tested independently and composed in `harness.ts`.
8
+ *
9
+ * Rules that hold across all components:
10
+ * - No platform (IM) types. Venues are opaque references.
11
+ * - Every value that crosses a mailbox is JSON-serializable (A2A in H5).
12
+ * - Pure functions never mutate their inputs; returned objects are frozen.
13
+ */
14
+ /** Agent definition name (`agents/<name>/agent.json`). */
15
+ export type AgentName = string;
16
+ /**
17
+ * Actor identity: `<agent>` for a named, long-lived actor, or
18
+ * `<agent>#<instance>` for an ephemeral one (e.g. a one-off delegation).
19
+ */
20
+ export type ActorId = string;
21
+ export type EngagementId = string;
22
+ export type MailId = string;
23
+ /** Opaque reference to where an engagement is shown (a group, a DM, Console, …). */
24
+ export interface VenueRef {
25
+ readonly kind: string;
26
+ readonly id: string;
27
+ }
28
+ /**
29
+ * Trust levels, lowest first. Comparisons use this order only; never compare
30
+ * strings any other way.
31
+ */
32
+ export declare const TRUST_LEVELS: readonly ["stranger", "group_member", "trusted", "master"];
33
+ export type TrustLevel = (typeof TRUST_LEVELS)[number];
34
+ /** The human (or system trigger) an engagement ultimately acts for. */
35
+ export interface Principal {
36
+ readonly kind: 'human' | 'system';
37
+ /** Stable id: platform user id for humans, trigger id (e.g. `timer:<id>`) for system. */
38
+ readonly id: string;
39
+ readonly trust: TrustLevel;
40
+ /** Where the principal spoke, when known; approvals are asked there. */
41
+ readonly venue?: VenueRef;
42
+ }
43
+ /**
44
+ * A set of names. `all` means unrestricted; `only` is an exact finite set.
45
+ * There are no globs at this layer: callers expand patterns before building
46
+ * a scope, so intersection stays exact and auditable.
47
+ */
48
+ export type NameScope = {
49
+ readonly kind: 'all';
50
+ } | {
51
+ readonly kind: 'only';
52
+ readonly names: readonly string[];
53
+ };
54
+ export declare const ALL: NameScope;
55
+ export declare const NONE: NameScope;
56
+ export declare function only(names: Iterable<string>): NameScope;
57
+ export declare function scopeHas(scope: NameScope, name: string): boolean;
58
+ /** Set intersection. `all ∩ x = x`. Result names are sorted and unique. */
59
+ export declare function intersectScopes(a: NameScope, b: NameScope): NameScope;
60
+ /** `true` when every name `inner` allows is also allowed by `outer`. */
61
+ export declare function scopeWithin(inner: NameScope, outer: NameScope): boolean;
62
+ export interface EgressScope {
63
+ readonly network: boolean;
64
+ /** Hosts allowed when `network` is true; `all` = no host restriction. */
65
+ readonly hosts: NameScope;
66
+ }
67
+ export interface Authority {
68
+ readonly principal: Principal;
69
+ /** Effective trust; never above `principal.trust`. */
70
+ readonly trust: TrustLevel;
71
+ /** Tools this authority may call. */
72
+ readonly tools: NameScope;
73
+ /** Agents this authority may contact (ask / delegate / tell). */
74
+ readonly contacts: NameScope;
75
+ readonly egress: EgressScope;
76
+ /**
77
+ * Who answers approvals. Always humans (invariant 4): the principal by
78
+ * default, plus configured approver ids. Never an actor id.
79
+ */
80
+ readonly approvers: readonly string[];
81
+ /** Actor ids that derived this authority, root first (audit trail). */
82
+ readonly chain: readonly ActorId[];
83
+ }
84
+ /** What a delegator asks to narrow when it hands work on. Omitted = keep. */
85
+ export interface AuthorityNarrowing {
86
+ readonly tools?: NameScope;
87
+ readonly contacts?: NameScope;
88
+ readonly trust?: TrustLevel;
89
+ readonly egress?: Partial<EgressScope>;
90
+ }
91
+ /** An agent definition's fixed capability ceiling (from agent.json). */
92
+ export interface AgentCeiling {
93
+ readonly agent: AgentName;
94
+ readonly tools: NameScope;
95
+ readonly contacts: NameScope;
96
+ readonly maxTrust: TrustLevel;
97
+ readonly egress: EgressScope;
98
+ /** Who may hand this agent work: agent names, plus `human` for people. */
99
+ readonly acceptsFrom: NameScope;
100
+ /** Default delegation budget when handing work to this agent (ADR 0048 §2, §5). */
101
+ readonly defaultBudget?: Partial<BudgetAmounts>;
102
+ }
103
+ export interface BudgetAmounts {
104
+ readonly modelCalls: number;
105
+ readonly tokens: number;
106
+ readonly wallMs: number;
107
+ /** Tool calls with side effects (writes, sends, shell, config). */
108
+ readonly sideEffects: number;
109
+ }
110
+ export type BudgetKey = keyof BudgetAmounts;
111
+ export declare const BUDGET_KEYS: readonly BudgetKey[];
112
+ export type GrantId = string;
113
+ export interface BudgetSnapshot {
114
+ readonly grantId: GrantId;
115
+ readonly parentId?: GrantId;
116
+ readonly granted: BudgetAmounts;
117
+ /**
118
+ * Consumed by this grant plus its closed children. A closed child adds at
119
+ * most its own granted amount (overspend stays visible on the child's
120
+ * snapshot and in the journal, but never pushes an ancestor past its grant).
121
+ */
122
+ readonly spent: BudgetAmounts;
123
+ /** Currently reserved by open child grants. */
124
+ readonly reservedForChildren: BudgetAmounts;
125
+ readonly remaining: BudgetAmounts;
126
+ readonly closed: boolean;
127
+ }
128
+ export type BudgetResult<T> = {
129
+ readonly ok: true;
130
+ readonly value: T;
131
+ } | {
132
+ readonly ok: false;
133
+ readonly code: 'budget_exceeded' | 'grant_closed' | 'unknown_grant' | 'invalid_amount';
134
+ readonly exhausted?: readonly BudgetKey[];
135
+ readonly message: string;
136
+ };
137
+ export interface BudgetLedger {
138
+ /** Opens a root grant for a new engagement. */
139
+ openRoot(amounts: BudgetAmounts): GrantId;
140
+ /**
141
+ * Carves a child grant out of the parent's remaining amounts. A requested
142
+ * key above the parent's remaining fails (never silently clamps); omitted
143
+ * keys default to an equal share policy decided by the implementation and
144
+ * documented there, but never more than the parent's remaining.
145
+ */
146
+ grant(parent: GrantId, request: Partial<BudgetAmounts>): BudgetResult<GrantId>;
147
+ /** Records usage. Fails with `budget_exceeded` (listing keys) once any key would go below zero; the charge is still recorded so overspend is visible. */
148
+ charge(grant: GrantId, usage: Partial<BudgetAmounts>): BudgetResult<BudgetSnapshot>;
149
+ /** Closes a grant; its unspent amount returns to the parent's remaining. Idempotent. */
150
+ close(grant: GrantId): BudgetResult<BudgetSnapshot>;
151
+ snapshot(grant: GrantId): BudgetSnapshot | undefined;
152
+ }
153
+ export type MailKind = 'human' | 'ask' | 'delegate' | 'tell' | 'result' | 'signal' | 'timer' | 'event';
154
+ export type MailSender = {
155
+ readonly kind: 'human';
156
+ readonly principal: Principal;
157
+ } | {
158
+ readonly kind: 'actor';
159
+ readonly actor: ActorId;
160
+ } | {
161
+ readonly kind: 'system';
162
+ readonly source: 'timer' | 'event' | 'harness';
163
+ };
164
+ export type MailBody = {
165
+ readonly kind: 'human';
166
+ readonly text: string;
167
+ readonly venue: VenueRef;
168
+ readonly messageId?: string;
169
+ /** Platform sender id; defaults to the sender principal's id. */
170
+ readonly senderId?: string;
171
+ /** Display name used when several senders share one turn. */
172
+ readonly senderName?: string;
173
+ /** Plain text without media, quotes or forwards (only those may be merged / superseded). */
174
+ readonly plainText?: boolean;
175
+ /** May join a merge window (ADR 0038 §1): plain group text from a non-master, not a command, stop or approval. */
176
+ readonly mergeable?: boolean;
177
+ /** A danmaku of a live room (ADR 0038 §5): joins the lane's danmaku window. */
178
+ readonly live?: boolean;
179
+ /** Sender is the bot master (may stop anyone's turn). */
180
+ readonly isMaster?: boolean;
181
+ /** Explicit stop request: consumed by the inbox, never run as a turn. */
182
+ readonly isStop?: boolean;
183
+ /**
184
+ * The transport saw neither an @ / reply to the bot nor a private chat
185
+ * (an ordinary group message). The admission policy does not run it as a
186
+ * turn (`not_addressed`).
187
+ */
188
+ readonly unaddressed?: boolean;
189
+ } | {
190
+ readonly kind: 'ask';
191
+ readonly question: string;
192
+ } | {
193
+ readonly kind: 'delegate';
194
+ readonly brief: string;
195
+ readonly deliverable?: string;
196
+ } | {
197
+ readonly kind: 'tell';
198
+ readonly content: string;
199
+ } | {
200
+ readonly kind: 'result';
201
+ /** The `ask` / `delegate` mail this answers. */
202
+ readonly inReplyTo: MailId;
203
+ readonly status: 'completed' | 'failed' | 'cancelled' | 'budget_exceeded' | 'denied';
204
+ readonly content: string;
205
+ } | {
206
+ readonly kind: 'signal';
207
+ readonly signal: 'cancel';
208
+ readonly target: MailId;
209
+ readonly reason?: string;
210
+ } | {
211
+ readonly kind: 'timer';
212
+ readonly timerId: string;
213
+ readonly content: string;
214
+ } | {
215
+ readonly kind: 'event';
216
+ readonly event: string;
217
+ readonly data: unknown;
218
+ };
219
+ export interface Mail {
220
+ readonly id: MailId;
221
+ readonly kind: MailKind;
222
+ readonly engagementId: EngagementId;
223
+ /** Mail that caused this one; absent only for the engagement's root mail (invariant 3). */
224
+ readonly causationId?: MailId;
225
+ /** Filled by the harness from the transport, never by the sender (invariant 6). */
226
+ readonly from: MailSender;
227
+ readonly to: ActorId;
228
+ /**
229
+ * Serial lane inside the target's inbox (ADR 0048 H3, D1). Mails of one
230
+ * actor in the same lane run one turn at a time and may be merged by the
231
+ * inbox policy; different lanes run in parallel. Defaults to the engagement
232
+ * (`engagement:<id>`); an IM venue session uses one lane across the
233
+ * engagements its messages open. See `laneOf`.
234
+ */
235
+ readonly lane?: string;
236
+ readonly authority: Authority;
237
+ readonly grantId: GrantId;
238
+ readonly body: MailBody;
239
+ readonly createdAt: number;
240
+ }
241
+ export interface Engagement {
242
+ readonly id: EngagementId;
243
+ readonly principal: Principal;
244
+ readonly rootMailId: MailId;
245
+ readonly rootGrantId: GrantId;
246
+ readonly venue?: VenueRef;
247
+ readonly openedAt: number;
248
+ }
249
+ export interface GuardLimits {
250
+ /** Max actor-to-actor hops from the root mail (root = depth 0). */
251
+ readonly maxDepth: number;
252
+ /** Max ask + delegate mails in one engagement. */
253
+ readonly maxFanout: number;
254
+ /** Max ask/delegate round trips between the same unordered pair of actors. */
255
+ readonly maxPairRoundTrips: number;
256
+ /** Max times the same (from, to, normalized content) may be sent in one engagement. */
257
+ readonly maxDuplicates: number;
258
+ }
259
+ export declare const DEFAULT_GUARD_LIMITS: GuardLimits;
260
+ export type GuardDenyCode = 'missing_causation' | 'unknown_engagement' | 'authority_not_attenuated' | 'contact_not_allowed' | 'not_accepted_by_target' | 'depth_exceeded' | 'fanout_exceeded' | 'pair_round_trips_exceeded' | 'duplicate_mail';
261
+ export type GuardDecision = {
262
+ readonly allow: true;
263
+ } | {
264
+ readonly allow: false;
265
+ readonly code: GuardDenyCode;
266
+ readonly reason: string;
267
+ /** When true the harness freezes the branch and escalates to the principal. */
268
+ readonly escalate: boolean;
269
+ };
270
+ /** Read-only view of an engagement's mail graph the guard decides on. */
271
+ export interface EngagementGraphView {
272
+ readonly engagement: Engagement;
273
+ /** All admitted mails of this engagement, in admission order. */
274
+ readonly mails: readonly Mail[];
275
+ /** Mail by id within this engagement. */
276
+ get(id: MailId): Mail | undefined;
277
+ }
278
+ export interface GuardContext {
279
+ readonly graph: EngagementGraphView | undefined;
280
+ /** Ceiling of the target actor's agent definition. */
281
+ readonly targetCeiling: AgentCeiling;
282
+ /** Authority of the sender when the sender is an actor (its current turn's authority). */
283
+ readonly senderAuthority?: Authority;
284
+ readonly limits: GuardLimits;
285
+ }
286
+ export interface GuardKernel {
287
+ /** Decides whether `mail` may be delivered. Pure: no side effects. */
288
+ admitMail(mail: Mail, context: GuardContext): GuardDecision;
289
+ }
290
+ export type BoardEntryKind = 'artifact' | 'note' | 'todo';
291
+ export type BoardTodoStatus = 'pending' | 'in_progress' | 'done';
292
+ export interface BoardEntry {
293
+ readonly key: string;
294
+ readonly kind: BoardEntryKind;
295
+ readonly content: string;
296
+ readonly author: ActorId;
297
+ readonly updatedAt: number;
298
+ readonly version: number;
299
+ readonly status?: BoardTodoStatus;
300
+ }
301
+ export interface BoardWriteInput {
302
+ readonly key: string;
303
+ readonly kind?: BoardEntryKind;
304
+ readonly content: string;
305
+ readonly author: ActorId;
306
+ readonly status?: BoardTodoStatus;
307
+ readonly mailId?: MailId;
308
+ }
309
+ export interface BoardReadFilter {
310
+ readonly key?: string;
311
+ readonly kind?: BoardEntryKind;
312
+ }
313
+ export interface RemoteMailResult {
314
+ readonly status: 'completed' | 'cancelled' | 'rejected';
315
+ readonly content: string;
316
+ readonly consumed?: Partial<BudgetAmounts>;
317
+ }
318
+ export interface RemoteMailTransport {
319
+ canRoute(target: ActorId): boolean;
320
+ send(mail: Mail, grantedBudget: BudgetAmounts): Promise<RemoteMailResult>;
321
+ }
322
+ export interface GuardBlockReason {
323
+ readonly code: GuardDenyCode | string;
324
+ readonly detail?: string;
325
+ }
326
+ export interface FollowUpInput {
327
+ readonly mailId: MailId;
328
+ readonly from: ActorId;
329
+ readonly content: string;
330
+ readonly budget?: Partial<BudgetAmounts>;
331
+ readonly timeoutMs?: number;
332
+ }
333
+ export interface SendAck {
334
+ readonly ok: boolean;
335
+ readonly accepted: boolean;
336
+ readonly mailId: MailId;
337
+ readonly mail?: Mail;
338
+ readonly reply?: {
339
+ readonly status: TurnStatus;
340
+ readonly content: string;
341
+ };
342
+ readonly result?: {
343
+ readonly status: TurnStatus | 'rejected';
344
+ readonly content: string;
345
+ };
346
+ readonly code?: string;
347
+ readonly reason?: GuardBlockReason;
348
+ readonly escalated?: boolean;
349
+ }
350
+ export type EngagementEventType = 'engagement_opened' | 'mail_admitted' | 'mail_denied' | 'mail_sent' | 'mail_delivered' | 'mail_completed' | 'mail_cancelled' | 'turn_started' | 'turn_finished' | 'budget_granted' | 'budget_charged' | 'budget_exceeded' | 'tool_called' | 'board_written' | 'venue_projected' | 'guard_blocked' | 'escalated' | 'engagement_closed';
351
+ export type JournalEvent = {
352
+ readonly type: 'board_written';
353
+ readonly actor: ActorId;
354
+ readonly key: string;
355
+ readonly kind: 'artifact' | 'note' | 'todo';
356
+ readonly content?: string;
357
+ readonly status?: BoardTodoStatus;
358
+ readonly version?: number;
359
+ } | {
360
+ readonly type: EngagementEventType;
361
+ readonly [key: string]: unknown;
362
+ };
363
+ export interface EngagementEvent {
364
+ readonly engagementId: EngagementId;
365
+ /** 0-based, contiguous per engagement; assigned by the journal. */
366
+ readonly seq: number;
367
+ readonly at: number;
368
+ readonly type: EngagementEventType;
369
+ /** JSON-serializable payload; shape is per event type and documented at the call site. */
370
+ readonly data: Readonly<Record<string, unknown>>;
371
+ }
372
+ export type JournalRecord = EngagementEvent & {
373
+ readonly event?: JournalEvent;
374
+ };
375
+ export interface EngagementHopSummary {
376
+ readonly mailId: MailId;
377
+ readonly kind: MailKind;
378
+ readonly from: string;
379
+ readonly to: ActorId;
380
+ readonly causationId?: MailId;
381
+ readonly status: 'sent' | 'delivered' | 'completed' | 'cancelled';
382
+ }
383
+ export interface EngagementSummary {
384
+ readonly engagementId: EngagementId;
385
+ readonly initiator?: Principal;
386
+ readonly venue?: VenueRef;
387
+ readonly startedAt: number;
388
+ readonly endedAt?: number;
389
+ readonly status: 'open' | 'completed' | 'escalated' | 'cancelled' | 'blocked';
390
+ readonly reason?: string;
391
+ readonly actors: readonly ActorId[];
392
+ readonly mailCount: number;
393
+ readonly toolCallCount: number;
394
+ readonly boardEntries: readonly BoardEntry[];
395
+ readonly guardBlocks: readonly {
396
+ readonly from: ActorId;
397
+ readonly to?: ActorId;
398
+ readonly reason: GuardBlockReason;
399
+ readonly at: number;
400
+ }[];
401
+ readonly escalations: readonly {
402
+ readonly by: ActorId;
403
+ readonly reason: string;
404
+ readonly at: number;
405
+ }[];
406
+ readonly projections: readonly {
407
+ readonly venue: VenueRef;
408
+ readonly actor: ActorId;
409
+ readonly mailId: MailId;
410
+ readonly mode: 'visible' | 'summary';
411
+ readonly at: number;
412
+ }[];
413
+ readonly consumed: BudgetAmounts;
414
+ readonly hops: readonly EngagementHopSummary[];
415
+ }
416
+ export interface JournalRetention {
417
+ /** Engagements whose last event is older than this are pruned. */
418
+ readonly maxAgeMs: number;
419
+ /** Events per engagement above this are rejected (`journal_full`). */
420
+ readonly maxEventsPerEngagement: number;
421
+ }
422
+ export declare const DEFAULT_JOURNAL_RETENTION: JournalRetention;
423
+ export declare class JournalFullError extends Error {
424
+ readonly engagementId: EngagementId;
425
+ readonly limit: number;
426
+ readonly code = "journal_full";
427
+ constructor(engagementId: EngagementId, limit: number);
428
+ }
429
+ export interface EngagementJournal {
430
+ append(event: Omit<EngagementEvent, 'seq'>): Promise<EngagementEvent>;
431
+ read(engagementId: EngagementId): Promise<readonly EngagementEvent[]>;
432
+ /** Deletes engagements past retention; returns how many engagements were removed. */
433
+ prune(now: number): Promise<number>;
434
+ /** Returns most recent engagement IDs ordered by latest activity descending. */
435
+ listEngagements(limit?: number): Promise<readonly EngagementId[]>;
436
+ /** Summarizes an engagement's events into a collaboration graph and stats. */
437
+ summarize(engagementId: EngagementId): Promise<EngagementSummary | undefined>;
438
+ /** Compacts intermediate events of a normally completed engagement. */
439
+ compact(engagementId: EngagementId): Promise<void>;
440
+ }
441
+ /**
442
+ * Serial key: one actor runs one turn at a time per lane (D1). `lane`
443
+ * defaults to the mail's engagement, so without explicit lanes this is the
444
+ * H1 rule "one actor, one engagement, one turn at a time".
445
+ */
446
+ export interface MailboxKey {
447
+ readonly actor: ActorId;
448
+ readonly lane: string;
449
+ /**
450
+ * Engagement of the batch's first mail. A merged batch may span several
451
+ * engagements (one per human message); the first one carries the turn (Q2).
452
+ */
453
+ readonly engagementId: EngagementId;
454
+ }
455
+ /** Lane of a mail: `mail.lane`, or its engagement. */
456
+ export declare function laneOf(mail: Pick<Mail, 'lane' | 'engagementId'>): string;
457
+ export declare function mailboxKeyOf(mail: Pick<Mail, 'to' | 'lane' | 'engagementId'>): MailboxKey;
458
+ /**
459
+ * Stable sender id the inbox policies compare: the human body's `senderId`,
460
+ * else the principal's id; the actor id for actor mail; `system:<source>`.
461
+ * Always transport-derived (invariant 6), never read from free text.
462
+ */
463
+ export declare function senderOf(mail: Pick<Mail, 'from' | 'body'>): string;
464
+ export type TurnStatus = 'completed' | 'failed' | 'cancelled' | 'budget_exceeded';
465
+ /** Why the inbox finished a mail without running it (status `cancelled`). */
466
+ export type InboxDropReason = 'cancelled' | 'expired' | 'stopped' | 'evicted' | 'closed';
467
+ export interface ActorTurnResult {
468
+ readonly status: TurnStatus;
469
+ /** Text handed back to the asker / delegator when the batch contains ask / delegate mail. */
470
+ readonly content?: string;
471
+ /** Set when the inbox dropped the mail(s) instead of running them. */
472
+ readonly reason?: InboxDropReason;
473
+ }
474
+ /** Pre-send verdict for a running turn (ADR 0038 §4). */
475
+ export type InboxStaleVerdict = {
476
+ readonly kind: 'fresh';
477
+ } | {
478
+ readonly kind: 'discard';
479
+ readonly senders: readonly string[];
480
+ } | {
481
+ readonly kind: 'send';
482
+ readonly senders: readonly string[];
483
+ readonly reason: 'acted' | 'starvation';
484
+ };
485
+ /** Facts about how the inbox admitted a batch (recorded on the turn trace by the agent layer). */
486
+ export interface InboxTurnFacts {
487
+ /** Milliseconds between the batch's earliest (re)queue time and its start. */
488
+ readonly queuedMs: number;
489
+ /** Mails in the batch. */
490
+ readonly mergedMessages: number;
491
+ /** The batch replaces the same sender's own unanswered turn. */
492
+ readonly supersededPrevious: boolean;
493
+ /** The batch carries mails whose previous reply was discarded as stale. */
494
+ readonly carried: boolean;
495
+ /** Mails of this lane evicted (danmaku window cap) since the previous turn started. */
496
+ readonly evicted: number;
497
+ }
498
+ /** Handed to the runner with each batch; lets the turn talk back to its inbox. */
499
+ export interface ActorTurnControl {
500
+ readonly facts: InboxTurnFacts;
501
+ /** A reply went out: later same-sender mails queue instead of superseding, and the reply is never cut. */
502
+ markDelivered(): void;
503
+ /** The turn did something that must be reported (tool call, hard gate): a stale reply still goes out. */
504
+ markActed(): void;
505
+ /**
506
+ * Ask before each send. The first `discard` moves this batch back into the
507
+ * follow-up's group; later calls keep answering `discard`.
508
+ */
509
+ staleCheck(): InboxStaleVerdict;
510
+ }
511
+ /** Runs one actor turn over a batch of mails. Supplied by the agent layer. */
512
+ export type ActorTurnRunner = (actor: ActorId, batch: readonly Mail[], signal: AbortSignal, control?: ActorTurnControl) => Promise<ActorTurnResult>;
513
+ /** One queued (or running) mail as the inbox policy sees it. */
514
+ export interface InboxEntry {
515
+ readonly mail: Mail;
516
+ /** Global arrival order (kept when a mail is requeued). */
517
+ readonly seq: number;
518
+ /** Mailbox clock when the mail was (re)queued. */
519
+ readonly postedAt: number;
520
+ /** Requeued because the same sender superseded its running turn. */
521
+ readonly superseded?: boolean;
522
+ /** Requeued because its reply went stale; belongs to the group of this mail. */
523
+ readonly carriedInto?: MailId;
524
+ }
525
+ export interface InboxRunning {
526
+ readonly batch: readonly InboxEntry[];
527
+ readonly startedAt: number;
528
+ /** Entries with `seq >= startSeq` arrived while this batch ran. */
529
+ readonly startSeq: number;
530
+ readonly delivered: boolean;
531
+ readonly acted: boolean;
532
+ }
533
+ /** Read-only view of one lane's inbox handed to the policy. */
534
+ export interface InboxView {
535
+ readonly key: MailboxKey;
536
+ readonly now: number;
537
+ /** Queued mails in queue order (not necessarily `seq` order). */
538
+ readonly pending: readonly InboxEntry[];
539
+ readonly running?: InboxRunning;
540
+ /** Last finished batch of this lane, kept for `InboxPolicy.memoryMs`. */
541
+ readonly lastTurn?: {
542
+ readonly batch: readonly Mail[];
543
+ readonly endedAt: number;
544
+ };
545
+ }
546
+ export type InboxRejectCode = 'queue_full' | 'forbidden' | 'not_running' | 'not_addressed';
547
+ /** What happens to a mail being posted (decided before it is queued). */
548
+ export type InboxPostDecision =
549
+ /** Queue it. `merged`: it joined an existing group. `evict`: queued mails to drop (danmaku cap). */
550
+ {
551
+ readonly kind: 'enqueue';
552
+ readonly merged?: boolean;
553
+ readonly evict?: readonly MailId[];
554
+ }
555
+ /** Abort the running batch and requeue it in front, followed by this mail. */
556
+ | {
557
+ readonly kind: 'supersede';
558
+ }
559
+ /** Consume this mail: abort the running batch and drop these senders' queued mails. */
560
+ | {
561
+ readonly kind: 'stop';
562
+ readonly cancelSenders: readonly string[];
563
+ } | {
564
+ readonly kind: 'reject';
565
+ readonly code: InboxRejectCode;
566
+ readonly message: string;
567
+ };
568
+ /** When an idle lane starts its next turn. */
569
+ export type InboxReadyDecision =
570
+ /** Run these mails: an in-order subsequence of `pending` that contains `pending[0]`. */
571
+ {
572
+ readonly kind: 'run';
573
+ readonly batch: readonly Mail[];
574
+ }
575
+ /** Not yet: re-check at `until` (or on the next post when omitted). */
576
+ | {
577
+ readonly kind: 'wait';
578
+ readonly until?: number;
579
+ };
580
+ export type InboxStaleDecision = Exclude<InboxStaleVerdict, {
581
+ readonly kind: 'discard';
582
+ }>
583
+ /** Discard the reply and requeue the running batch right before `carryInto`. */
584
+ | {
585
+ readonly kind: 'discard';
586
+ readonly senders: readonly string[];
587
+ readonly carryInto: MailId;
588
+ };
589
+ /**
590
+ * Inbox policy (D2): pure decisions over an `InboxView`; the mailbox applies
591
+ * them. Omitted members fall back to plain FIFO: enqueue, run the first
592
+ * mail, always fresh. See `createInboxPolicy` for the IM admission policy.
593
+ */
594
+ export interface InboxPolicy {
595
+ onPost?(entry: InboxEntry, view: InboxView): InboxPostDecision;
596
+ ready?(view: InboxView): InboxReadyDecision;
597
+ staleOf?(view: InboxView & {
598
+ readonly running: InboxRunning;
599
+ }): InboxStaleDecision;
600
+ /** How long a lane's last finished batch stays visible as `lastTurn`. Default 0. */
601
+ readonly memoryMs?: number;
602
+ }
603
+ /** Schedules `callback` after `delayMs`; returns a cancel function. */
604
+ export type InboxSetTimer = (callback: () => void, delayMs: number) => () => void;
605
+ export interface MailboxOptions {
606
+ readonly run: ActorTurnRunner;
607
+ readonly policy?: InboxPolicy;
608
+ /** Max turns running at once across all keys. */
609
+ readonly maxConcurrentTurns: number;
610
+ /**
611
+ * Hard cap on mails queued per key; posting beyond it is rejected. With a
612
+ * merging policy this is a memory ceiling, the policy bounds turns.
613
+ */
614
+ readonly maxQueuedPerKey: number;
615
+ /** A queued mail older than this when its lane would start is dropped (`expired`). `0`/omitted = never. */
616
+ readonly queueTtlMs?: number;
617
+ readonly now?: () => number;
618
+ /** Defaults to an unref'd `setTimeout`. */
619
+ readonly setTimer?: InboxSetTimer;
620
+ /** Called once per finished batch (also on failure / cancel / drop). */
621
+ readonly onTurnFinished?: (key: MailboxKey, batch: readonly Mail[], result: ActorTurnResult) => void | Promise<void>;
622
+ }
623
+ export type PostResult = {
624
+ readonly accepted: true;
625
+ /** 1-based queue position; 0 when the mail was consumed (`stopped`). */
626
+ readonly position: number;
627
+ readonly disposition: 'queued' | 'merged' | 'superseded' | 'stopped';
628
+ } | {
629
+ readonly accepted: false;
630
+ readonly code: InboxRejectCode | 'mailbox_closed';
631
+ readonly message: string;
632
+ };
633
+ export interface Mailbox {
634
+ post(mail: Mail): PostResult;
635
+ /** Cancels the running turn for `mailId` (via its AbortSignal) or drops it if still queued. */
636
+ cancel(mailId: MailId, reason?: string): boolean;
637
+ /** Drops every queued mail of `senderId` in one lane; returns how many were dropped. */
638
+ cancelSender(key: Pick<MailboxKey, 'actor' | 'lane'>, senderId: string, reason?: InboxDropReason): number;
639
+ /** Resolves when no turn is running or queued. */
640
+ idle(): Promise<void>;
641
+ /** Stops accepting mail, aborts running turns, drops the queue. */
642
+ close(reason?: string): Promise<void>;
643
+ }