macp-sdk-typescript 0.10.0 → 0.12.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/CHANGELOG.md +465 -0
- package/README.md +2 -2
- package/dist/agent/index.d.ts +1 -1
- package/dist/agent/index.js +2 -1
- package/dist/agent/participant.d.ts +47 -4
- package/dist/agent/participant.js +88 -10
- package/dist/agent/strategies.js +23 -7
- package/dist/agent/transports.d.ts +19 -1
- package/dist/agent/transports.js +108 -66
- package/dist/auth.d.ts +15 -4
- package/dist/auth.js +23 -7
- package/dist/base-session.js +13 -3
- package/dist/client.js +58 -15
- package/dist/commitment-hash.d.ts +33 -3
- package/dist/commitment-hash.js +39 -5
- package/dist/decision.d.ts +1 -0
- package/dist/decision.js +11 -9
- package/dist/envelope.js +9 -1
- package/dist/handoff.d.ts +1 -0
- package/dist/handoff.js +19 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +18 -7
- package/dist/policy.d.ts +54 -7
- package/dist/policy.js +143 -19
- package/dist/projections/base.d.ts +71 -14
- package/dist/projections/base.js +106 -16
- package/dist/projections/decision.d.ts +5 -49
- package/dist/projections/decision.js +30 -85
- package/dist/projections/handoff.d.ts +5 -48
- package/dist/projections/handoff.js +44 -93
- package/dist/projections/proposal.d.ts +5 -48
- package/dist/projections/proposal.js +21 -79
- package/dist/projections/quorum.d.ts +5 -49
- package/dist/projections/quorum.js +20 -82
- package/dist/projections/task.d.ts +31 -51
- package/dist/projections/task.js +49 -90
- package/dist/proposal.d.ts +1 -0
- package/dist/proposal.js +2 -2
- package/dist/proto-registry.d.ts +85 -5
- package/dist/proto-registry.js +101 -15
- package/dist/quorum.d.ts +1 -0
- package/dist/quorum.js +4 -4
- package/dist/task.d.ts +1 -0
- package/dist/task.js +24 -11
- package/dist/types.d.ts +14 -7
- package/dist/validation.d.ts +14 -0
- package/dist/validation.js +39 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -5
|
@@ -29,7 +29,23 @@ class Participant {
|
|
|
29
29
|
modeVersion;
|
|
30
30
|
configurationVersion;
|
|
31
31
|
policyVersion;
|
|
32
|
+
/** Whether the event loop is currently inside {@link run}. */
|
|
32
33
|
running = false;
|
|
34
|
+
/**
|
|
35
|
+
* Latching: set once a terminal result is dispatched (a terminal phase,
|
|
36
|
+
* or the {@code SessionCancel} fallback in {@link processMessage}). A
|
|
37
|
+
* terminal `Participant` is single-use — {@link run} refuses to
|
|
38
|
+
* re-enter, because the projection is resolved and the runtime will
|
|
39
|
+
* accept nothing further for this session.
|
|
40
|
+
*/
|
|
41
|
+
terminal = false;
|
|
42
|
+
/**
|
|
43
|
+
* The cooperative cancel flag {@link stop} sets and {@link run} clears
|
|
44
|
+
* on entry — the loop's break predicate. Distinct from {@link terminal}:
|
|
45
|
+
* a `stop()`-exited, non-terminal `Participant` is a resumable pause
|
|
46
|
+
* (see {@link isStopped}), not single-use.
|
|
47
|
+
*/
|
|
48
|
+
stopRequested = false;
|
|
33
49
|
lastPhase;
|
|
34
50
|
cancelCallbackServer;
|
|
35
51
|
cancelCallbackConfig;
|
|
@@ -170,13 +186,19 @@ class Participant {
|
|
|
170
186
|
this.dispatcher.onTerminal(handler);
|
|
171
187
|
return this;
|
|
172
188
|
}
|
|
173
|
-
/**
|
|
189
|
+
/**
|
|
190
|
+
* `true` after a terminal outcome (single-use from here on), or while a
|
|
191
|
+
* `stop()` request is pending/has taken effect on a `Participant` whose
|
|
192
|
+
* loop has not (yet) been resumed by a later {@link run}. A `stop()`
|
|
193
|
+
* exit is resumable — see {@link run} and {@link terminal}'s own doc.
|
|
194
|
+
*/
|
|
174
195
|
get isStopped() {
|
|
175
|
-
return
|
|
196
|
+
return this.terminal || this.stopRequested;
|
|
176
197
|
}
|
|
177
198
|
async run() {
|
|
178
|
-
if (this.running)
|
|
199
|
+
if (this.running || this.terminal)
|
|
179
200
|
return;
|
|
201
|
+
this.stopRequested = false;
|
|
180
202
|
this.running = true;
|
|
181
203
|
if (this.cancelCallbackConfig && !this.cancelCallbackServer) {
|
|
182
204
|
this.cancelCallbackServer = await (0, cancel_callback_1.startCancelCallbackServer)({
|
|
@@ -193,7 +215,7 @@ class Participant {
|
|
|
193
215
|
}
|
|
194
216
|
try {
|
|
195
217
|
for await (const message of this.transport.start()) {
|
|
196
|
-
if (
|
|
218
|
+
if (this.stopRequested)
|
|
197
219
|
break;
|
|
198
220
|
const reachedTerminal = await this.processMessage(message);
|
|
199
221
|
if (reachedTerminal)
|
|
@@ -202,6 +224,7 @@ class Participant {
|
|
|
202
224
|
}
|
|
203
225
|
finally {
|
|
204
226
|
this.running = false;
|
|
227
|
+
await this.teardown();
|
|
205
228
|
}
|
|
206
229
|
}
|
|
207
230
|
/**
|
|
@@ -244,6 +267,7 @@ class Participant {
|
|
|
244
267
|
/** Returns true if a terminal state was reached. */
|
|
245
268
|
async processMessage(message) {
|
|
246
269
|
const ctx = this.buildHandlerContext();
|
|
270
|
+
const alreadyTerminal = this.terminal;
|
|
247
271
|
if (this.session && message.raw) {
|
|
248
272
|
const applyMethod = this.session.projection
|
|
249
273
|
.applyEnvelope;
|
|
@@ -252,24 +276,64 @@ class Participant {
|
|
|
252
276
|
}
|
|
253
277
|
}
|
|
254
278
|
await this.dispatcher.dispatch(message, ctx);
|
|
279
|
+
let firedTerminal = false;
|
|
255
280
|
const currentPhase = this.projection.phase;
|
|
256
281
|
if (currentPhase !== this.lastPhase) {
|
|
257
282
|
this.lastPhase = currentPhase;
|
|
258
283
|
await this.dispatcher.dispatchPhaseChange(currentPhase, ctx);
|
|
259
284
|
if (TERMINAL_PHASES.has(currentPhase)) {
|
|
285
|
+
this.terminal = true;
|
|
286
|
+
firedTerminal = true;
|
|
260
287
|
const terminalResult = {
|
|
261
288
|
state: currentPhase,
|
|
262
289
|
commitment: this.projection.commitment,
|
|
263
290
|
};
|
|
264
291
|
await this.dispatcher.dispatchTerminal(terminalResult);
|
|
265
|
-
return true;
|
|
266
292
|
}
|
|
267
293
|
}
|
|
268
|
-
|
|
294
|
+
// Fallback for envelopes no projection maps to a phase (issue #106.1):
|
|
295
|
+
// no built-in projection maps `SessionCancel` to a terminal phase, so the
|
|
296
|
+
// phase-driven path above never fires for it. Against the current
|
|
297
|
+
// macp-runtime, `run()`'s streamed path never even observes this branch —
|
|
298
|
+
// `cancel_session` stores `SessionCancel` as `EntryKind::Internal`
|
|
299
|
+
// (runtime.rs `make_internal_entry`/`cancel_session`), and
|
|
300
|
+
// `get_incoming_after` (macp-storage `log_store.rs`) filters strictly to
|
|
301
|
+
// `EntryKind::Incoming`, so `StreamSession`/`GrpcTransportAdapter` never
|
|
302
|
+
// delivers it; cancellation surfaces via the separate
|
|
303
|
+
// `session_lifecycle_bus`/`WatchSessions` mechanism instead. This
|
|
304
|
+
// fallback's real, currently-reachable value is `processEvent()` callers
|
|
305
|
+
// driving their own foreign message loop (where a `SessionCancel` can be
|
|
306
|
+
// handed in directly) and cross-SDK parity with `macp-sdk-python`'s
|
|
307
|
+
// identical fallback — not the streamed `run()` path today. Gated on
|
|
308
|
+
// `!alreadyTerminal` (captured before this call's own phase-driven
|
|
309
|
+
// branch could have just set it) so a `SessionCancel` arriving on an
|
|
310
|
+
// already-terminal session — one whose own terminal dispatch already
|
|
311
|
+
// ran, this call or an earlier one — cannot double-dispatch
|
|
312
|
+
// `onTerminal`.
|
|
313
|
+
if (!firedTerminal && !alreadyTerminal && message.messageType === 'SessionCancel') {
|
|
314
|
+
this.terminal = true;
|
|
315
|
+
this.lastPhase = 'Cancelled';
|
|
316
|
+
await this.dispatcher.dispatchTerminal({ state: 'Cancelled' });
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
return firedTerminal;
|
|
269
320
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Idempotent teardown of everything {@link run} started: the transport
|
|
323
|
+
* subscription and the cancel-callback HTTP listener, if any. Called
|
|
324
|
+
* from both {@link stop} and every exit from {@link run}'s loop (issue
|
|
325
|
+
* #106.2) — a normal terminal exit or a transport running out of
|
|
326
|
+
* messages used to leak both. A rejecting `transport.stop()` is caught
|
|
327
|
+
* rather than propagated, so a transport fault on teardown cannot mask
|
|
328
|
+
* a clean session outcome as a thrown error from {@link run}.
|
|
329
|
+
*/
|
|
330
|
+
async teardown() {
|
|
331
|
+
try {
|
|
332
|
+
await this.transport.stop();
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
logging_1.logger.debug('transport stop failed', err);
|
|
336
|
+
}
|
|
273
337
|
if (this.cancelCallbackServer) {
|
|
274
338
|
const srv = this.cancelCallbackServer;
|
|
275
339
|
this.cancelCallbackServer = undefined;
|
|
@@ -281,9 +345,23 @@ class Participant {
|
|
|
281
345
|
}
|
|
282
346
|
}
|
|
283
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* Request that {@link run}'s loop stop. This SDK's {@link TransportAdapter}
|
|
350
|
+
* has no `cancel()` — `stop()` on the transport is the only thing that can
|
|
351
|
+
* wake a loop suspended awaiting the next envelope, so teardown (including
|
|
352
|
+
* the transport) runs here unconditionally, not just the flag. Safe to
|
|
353
|
+
* call whether or not `run()` is currently active; does not itself flip
|
|
354
|
+
* {@link running} — only `run()`'s own loop exit does that, since it is
|
|
355
|
+
* the only place that actually knows the loop has left.
|
|
356
|
+
*/
|
|
357
|
+
async stop() {
|
|
358
|
+
this.stopRequested = true;
|
|
359
|
+
await this.teardown();
|
|
360
|
+
}
|
|
284
361
|
/**
|
|
285
362
|
* Attach a running cancel-callback HTTP server to this participant.
|
|
286
|
-
* The server is closed automatically
|
|
363
|
+
* The server is closed automatically on any exit from {@link run} — see
|
|
364
|
+
* {@link teardown}.
|
|
287
365
|
* Parity with python-sdk's `Participant.attach_cancel_callback_server`.
|
|
288
366
|
*/
|
|
289
367
|
attachCancelCallbackServer(server) {
|
package/dist/agent/strategies.js
CHANGED
|
@@ -9,15 +9,23 @@ exports.commitmentHandler = commitmentHandler;
|
|
|
9
9
|
exports.functionCommitter = functionCommitter;
|
|
10
10
|
exports.majorityCommitter = majorityCommitter;
|
|
11
11
|
const envelope_1 = require("../envelope");
|
|
12
|
+
const validation_1 = require("../validation");
|
|
12
13
|
function evaluationHandler(strategy) {
|
|
13
14
|
return async (event, ctx) => {
|
|
14
15
|
if (event.messageType !== 'Proposal')
|
|
15
16
|
return;
|
|
16
17
|
const result = await strategy.evaluate(event.payload, ctx.session);
|
|
18
|
+
// Issue #108.7/4g: validate the strategy's own output — reusing this
|
|
19
|
+
// repo's own validators (they throw MacpSessionError, the documented
|
|
20
|
+
// error contract) rather than porting Python's inline ValueError checks
|
|
21
|
+
// — and dispatch the normalized value, so a bad strategy fails loudly at
|
|
22
|
+
// the handler naming the strategy, not later from the session.
|
|
23
|
+
const recommendation = (0, validation_1.validateRecommendation)(result.recommendation);
|
|
24
|
+
(0, validation_1.validateConfidence)(result.confidence);
|
|
17
25
|
if (ctx.actions.evaluate) {
|
|
18
26
|
await ctx.actions.evaluate({
|
|
19
27
|
proposalId: event.proposalId ?? event.payload.proposalId ?? event.payload.proposal_id,
|
|
20
|
-
recommendation
|
|
28
|
+
recommendation,
|
|
21
29
|
confidence: result.confidence,
|
|
22
30
|
reason: result.reason,
|
|
23
31
|
});
|
|
@@ -35,11 +43,12 @@ function votingHandler(strategy) {
|
|
|
35
43
|
if (!strategy.shouldVote(projection))
|
|
36
44
|
return;
|
|
37
45
|
const result = await strategy.decideVote(projection);
|
|
46
|
+
const vote = (0, validation_1.validateVote)(result.vote);
|
|
38
47
|
if (ctx.actions.vote) {
|
|
39
48
|
const proposalId = event.proposalId ?? event.payload.proposalId ?? event.payload.proposal_id;
|
|
40
49
|
await ctx.actions.vote({
|
|
41
50
|
proposalId,
|
|
42
|
-
vote
|
|
51
|
+
vote,
|
|
43
52
|
reason: result.reason,
|
|
44
53
|
});
|
|
45
54
|
}
|
|
@@ -52,17 +61,24 @@ function majorityVoter(options) {
|
|
|
52
61
|
const threshold = options?.positiveThreshold ?? 0.5;
|
|
53
62
|
return {
|
|
54
63
|
shouldVote(projection) {
|
|
55
|
-
|
|
64
|
+
// RFC-MACP-0007 §4 (`rfcs/RFC-MACP-0007-decision-mode.md:73`): REVIEW
|
|
65
|
+
// evaluations "do not block or approve a proposal; they serve as
|
|
66
|
+
// informational analysis records only." A set of only REVIEWs has no
|
|
67
|
+
// decisive evaluation to vote on -- mirroring RFC-MACP-0012:137's
|
|
68
|
+
// denominator rule ("the denominator is the decisive votes ...
|
|
69
|
+
// Abstentions are excluded") for votes (issue #124 item 2).
|
|
70
|
+
return projection.evaluations.some((e) => e.recommendation.toLowerCase() !== 'review');
|
|
56
71
|
},
|
|
57
72
|
async decideVote(projection) {
|
|
58
|
-
const
|
|
59
|
-
const
|
|
73
|
+
const decisive = projection.evaluations.filter((e) => e.recommendation.toLowerCase() !== 'review');
|
|
74
|
+
const positive = decisive.filter((e) => ['approve', 'accept', 'yes'].includes(e.recommendation.toLowerCase()));
|
|
75
|
+
const ratio = decisive.length > 0 ? positive.length / decisive.length : 0;
|
|
60
76
|
if (ratio >= threshold) {
|
|
61
|
-
return { vote: 'APPROVE', reason: `${positive.length}/${
|
|
77
|
+
return { vote: 'APPROVE', reason: `${positive.length}/${decisive.length} evaluations positive` };
|
|
62
78
|
}
|
|
63
79
|
return {
|
|
64
80
|
vote: 'REJECT',
|
|
65
|
-
reason: `Only ${positive.length}/${
|
|
81
|
+
reason: `Only ${positive.length}/${decisive.length} evaluations positive`,
|
|
66
82
|
};
|
|
67
83
|
},
|
|
68
84
|
};
|
|
@@ -5,6 +5,23 @@ export interface TransportAdapter {
|
|
|
5
5
|
start(): AsyncIterable<IncomingMessage>;
|
|
6
6
|
stop(): Promise<void>;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Bounded retry policy for the startup subscribe race (issue #100, ported
|
|
10
|
+
* from macp-sdk-python#75): the runtime replies `NOT_FOUND` to `StreamSession`/`sendSubscribe` when a
|
|
11
|
+
* sibling participant hasn't finished the initiator's SessionStart yet, so
|
|
12
|
+
* the session doesn't exist *yet* rather than not existing at all. Distinct
|
|
13
|
+
* from `src/retry.ts`'s `RetryPolicy`, which is scoped to retryable **ack**
|
|
14
|
+
* (NACK) codes on `Send` — this policy is a gRPC **stream**-open retry and
|
|
15
|
+
* has no `retryableCodes` set: the one retryable condition (transient
|
|
16
|
+
* `NOT_FOUND` before any envelope has been delivered) is hardcoded in
|
|
17
|
+
* `GrpcTransportAdapter.start()`, not configurable per-code.
|
|
18
|
+
*/
|
|
19
|
+
export interface SubscribeRetryPolicy {
|
|
20
|
+
maxRetries: number;
|
|
21
|
+
backoffBase: number;
|
|
22
|
+
backoffMax: number;
|
|
23
|
+
}
|
|
24
|
+
export declare const DEFAULT_SUBSCRIBE_RETRY_POLICY: SubscribeRetryPolicy;
|
|
8
25
|
export declare class GrpcTransportAdapter implements TransportAdapter {
|
|
9
26
|
private readonly client;
|
|
10
27
|
private readonly sessionId;
|
|
@@ -12,6 +29,7 @@ export declare class GrpcTransportAdapter implements TransportAdapter {
|
|
|
12
29
|
private stream;
|
|
13
30
|
private seq;
|
|
14
31
|
private delivered;
|
|
32
|
+
private readonly subscribeRetryPolicy;
|
|
15
33
|
/**
|
|
16
34
|
* `message_id`s already counted into `delivered`, so the resume cursor
|
|
17
35
|
* advances once per distinct accepted envelope rather than once per raw
|
|
@@ -28,7 +46,7 @@ export declare class GrpcTransportAdapter implements TransportAdapter {
|
|
|
28
46
|
* TTL-bounded by protocol.
|
|
29
47
|
*/
|
|
30
48
|
private readonly seenMessageIds;
|
|
31
|
-
constructor(client: MacpClient, sessionId: string, auth?: AuthConfig | undefined);
|
|
49
|
+
constructor(client: MacpClient, sessionId: string, auth?: AuthConfig | undefined, subscribeRetryPolicy?: Partial<SubscribeRetryPolicy>);
|
|
32
50
|
/**
|
|
33
51
|
* The server passive-subscribe ordinal of the last delivered envelope (the
|
|
34
52
|
* 1-based count of *distinct* accepted envelopes delivered on this stream,
|
package/dist/agent/transports.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.HttpTransportAdapter = exports.GrpcTransportAdapter = void 0;
|
|
3
|
+
exports.HttpTransportAdapter = exports.GrpcTransportAdapter = exports.DEFAULT_SUBSCRIBE_RETRY_POLICY = void 0;
|
|
4
|
+
const errors_1 = require("../errors");
|
|
4
5
|
function normalizeEnvelope(envelope, decodePayload, seq) {
|
|
5
6
|
const payload = decodePayload(envelope.mode, envelope.messageType, envelope.payload) ?? {};
|
|
6
7
|
return {
|
|
@@ -12,6 +13,14 @@ function normalizeEnvelope(envelope, decodePayload, seq) {
|
|
|
12
13
|
seq,
|
|
13
14
|
};
|
|
14
15
|
}
|
|
16
|
+
exports.DEFAULT_SUBSCRIBE_RETRY_POLICY = {
|
|
17
|
+
maxRetries: 4,
|
|
18
|
+
backoffBase: 0.1,
|
|
19
|
+
backoffMax: 2.0,
|
|
20
|
+
};
|
|
21
|
+
function sleep(seconds) {
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
|
23
|
+
}
|
|
15
24
|
class GrpcTransportAdapter {
|
|
16
25
|
client;
|
|
17
26
|
sessionId;
|
|
@@ -19,6 +28,7 @@ class GrpcTransportAdapter {
|
|
|
19
28
|
stream = null;
|
|
20
29
|
seq = 0;
|
|
21
30
|
delivered = 0;
|
|
31
|
+
subscribeRetryPolicy;
|
|
22
32
|
/**
|
|
23
33
|
* `message_id`s already counted into `delivered`, so the resume cursor
|
|
24
34
|
* advances once per distinct accepted envelope rather than once per raw
|
|
@@ -35,10 +45,11 @@ class GrpcTransportAdapter {
|
|
|
35
45
|
* TTL-bounded by protocol.
|
|
36
46
|
*/
|
|
37
47
|
seenMessageIds = new Set();
|
|
38
|
-
constructor(client, sessionId, auth) {
|
|
48
|
+
constructor(client, sessionId, auth, subscribeRetryPolicy) {
|
|
39
49
|
this.client = client;
|
|
40
50
|
this.sessionId = sessionId;
|
|
41
51
|
this.auth = auth;
|
|
52
|
+
this.subscribeRetryPolicy = { ...exports.DEFAULT_SUBSCRIBE_RETRY_POLICY, ...subscribeRetryPolicy };
|
|
42
53
|
}
|
|
43
54
|
/**
|
|
44
55
|
* The server passive-subscribe ordinal of the last delivered envelope (the
|
|
@@ -61,72 +72,103 @@ class GrpcTransportAdapter {
|
|
|
61
72
|
return this.delivered;
|
|
62
73
|
}
|
|
63
74
|
async *start() {
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// a
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
// (
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
75
|
+
// Startup subscribe race (issue #100, ported from macp-sdk-python#75): a
|
|
76
|
+
// sibling participant may not have observed the initiator's SessionStart
|
|
77
|
+
// yet, so the runtime's very first reply to sendSubscribe() can be a
|
|
78
|
+
// transient NOT_FOUND for a session that simply doesn't exist *yet* —
|
|
79
|
+
// not a real error. `receivedAny` seeds `true` when `this.delivered > 0`
|
|
80
|
+
// (a prior start() on this same adapter already proved the session
|
|
81
|
+
// exists), so a stop()+start() reconnect never re-enters the retry
|
|
82
|
+
// budget it doesn't need. Once any envelope for this session has been
|
|
83
|
+
// delivered in this call, the session demonstrably exists and a later
|
|
84
|
+
// NOT_FOUND (e.g. the session expired mid-stream) must propagate
|
|
85
|
+
// immediately, not retry.
|
|
86
|
+
let receivedAny = this.delivered > 0;
|
|
87
|
+
let attempt = 0;
|
|
88
|
+
while (true) {
|
|
89
|
+
// A prior stream from an earlier start() (without an intervening stop())
|
|
90
|
+
// must not keep feeding this adapter's counter — two live generators
|
|
91
|
+
// would both advance `delivered` from two differently-positioned
|
|
92
|
+
// replays. `MacpStream.close()` is `this.call.end()` (`src/client.ts:223-227`),
|
|
93
|
+
// a write-side half-close — it does not itself force the server to stop
|
|
94
|
+
// pushing. It is enough here because macp-runtime's StreamSession loop
|
|
95
|
+
// reads the resulting end-of-request-stream as `StreamAction::ClientDone`,
|
|
96
|
+
// whose arm drains any already-buffered envelopes once and then breaks,
|
|
97
|
+
// ending that stream's response side too
|
|
98
|
+
// (macp-runtime/src/server.rs:654-666). Close it before opening the new
|
|
99
|
+
// one so re-entrant start() and stop()-then-start() (including a
|
|
100
|
+
// subscribe-retry reconnect) behave identically.
|
|
101
|
+
if (this.stream) {
|
|
102
|
+
this.stream.close();
|
|
103
|
+
this.stream = null;
|
|
104
|
+
}
|
|
105
|
+
this.stream = this.client.openStream({ auth: this.auth });
|
|
106
|
+
// RFC-MACP-0006 §3.2: subscribe to the session, resuming from this
|
|
107
|
+
// adapter's own cursor. `this.delivered` is 0 before anything has been
|
|
108
|
+
// delivered, by construction, and `afterSequence = 0` is normatively
|
|
109
|
+
// "replay from the session's first accepted envelope" — so the first
|
|
110
|
+
// subscribe and every later one (e.g. after stop()+start(), or a
|
|
111
|
+
// subscribe retry below) are the same expression. There is no separate
|
|
112
|
+
// first-subscribe/reconnect branch to keep in sync with the counter.
|
|
113
|
+
await this.stream.sendSubscribe(this.sessionId, this.delivered);
|
|
114
|
+
try {
|
|
115
|
+
for await (const envelope of this.stream.responses()) {
|
|
116
|
+
if (envelope.sessionId !== this.sessionId)
|
|
117
|
+
continue;
|
|
118
|
+
receivedAny = true;
|
|
119
|
+
// Count this envelope into the resume cursor only *after* the
|
|
120
|
+
// consumer has come back for more, not when it is handed off. `yield`
|
|
121
|
+
// suspends here until the consumer's `for await` either calls
|
|
122
|
+
// `.next()` again (it took this envelope and kept iterating — the
|
|
123
|
+
// code below runs) or closes the iterator via `break`/`return`/throw
|
|
124
|
+
// (e.g. `Participant.run()` observing its `stopRequested` flag and
|
|
125
|
+
// breaking out of its loop without processing this envelope). In the latter
|
|
126
|
+
// case `.return()` on this generator resumes at this `yield` as if a
|
|
127
|
+
// `return` had been written here, so nothing below it runs: the
|
|
128
|
+
// cursor is never advanced past an envelope the consumer never
|
|
129
|
+
// actually took past the loop boundary, and the next `start()`
|
|
130
|
+
// redelivers it instead of silently skipping it. See #66 — counting
|
|
131
|
+
// before yielding let a stop-mid-stream permanently drop an envelope
|
|
132
|
+
// once #65 made the resume cursor authoritative.
|
|
133
|
+
const message = normalizeEnvelope(envelope, (mode, mt, p) => this.client.protoRegistry.decodeKnownPayload(mode, mt, p), this.seq++);
|
|
134
|
+
yield message;
|
|
135
|
+
// RFC-MACP-0006 §3.2 Redelivery: a redelivery MUST NOT advance the
|
|
136
|
+
// resume cursor, and a consumer that accumulates state per envelope
|
|
137
|
+
// MUST be idempotent w.r.t. `message_id`. An empty/absent messageId
|
|
138
|
+
// has no identity to dedup on and increments unconditionally — the
|
|
139
|
+
// same carve-out the projection guard documents at
|
|
140
|
+
// `src/projections/base.ts:224-227`, so the two dedup sites read the
|
|
141
|
+
// same way. In practice the `else` below can never run against a
|
|
142
|
+
// conformant runtime: `validate_envelope_shape` rejects any envelope
|
|
143
|
+
// with an empty `message_id` as `InvalidEnvelope` before it can be
|
|
144
|
+
// accepted (`macp-runtime/src/server.rs:118`), and RFC-MACP-0001 §8.2
|
|
145
|
+
// makes `message_id` the runtime's dedup identity, so no accepted
|
|
146
|
+
// envelope in any session history can carry one. The branch stays
|
|
147
|
+
// anyway — it costs nothing and errs toward still counting the
|
|
148
|
+
// envelope instead of silently dropping it if a non-conformant server
|
|
149
|
+
// ever sends one.
|
|
150
|
+
if (envelope.messageId) {
|
|
151
|
+
if (!this.seenMessageIds.has(envelope.messageId)) {
|
|
152
|
+
this.seenMessageIds.add(envelope.messageId);
|
|
153
|
+
this.delivered++;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
this.delivered++;
|
|
158
|
+
}
|
|
126
159
|
}
|
|
160
|
+
return;
|
|
127
161
|
}
|
|
128
|
-
|
|
129
|
-
|
|
162
|
+
catch (err) {
|
|
163
|
+
const isTransientNotFound = !receivedAny &&
|
|
164
|
+
err instanceof errors_1.MacpTransportError &&
|
|
165
|
+
err.code === 'NOT_FOUND' &&
|
|
166
|
+
attempt < this.subscribeRetryPolicy.maxRetries;
|
|
167
|
+
if (!isTransientNotFound)
|
|
168
|
+
throw err;
|
|
169
|
+
attempt++;
|
|
170
|
+
const delay = Math.min(this.subscribeRetryPolicy.backoffBase * 2 ** (attempt - 1), this.subscribeRetryPolicy.backoffMax);
|
|
171
|
+
await sleep(delay);
|
|
130
172
|
}
|
|
131
173
|
}
|
|
132
174
|
}
|
package/dist/auth.d.ts
CHANGED
|
@@ -29,8 +29,15 @@ export declare const Auth: {
|
|
|
29
29
|
* otherwise). Not for production — use {@link Auth.bearer}.
|
|
30
30
|
*
|
|
31
31
|
* The legacy `x-macp-agent-id` header was removed in 0.5.0: no supported
|
|
32
|
-
* runtime reads it.
|
|
33
|
-
*
|
|
32
|
+
* runtime reads it. Sets {@link AuthConfig.expectedSender} to `agentId`
|
|
33
|
+
* (issue #124): the runtime already refuses a mismatched `sender`
|
|
34
|
+
* unconditionally for dev credentials (`macp-runtime/src/server.rs:229-231`,
|
|
35
|
+
* no insecure/dev exemption), so this SDK now enforces the same guarantee
|
|
36
|
+
* client-side instead of surfacing the runtime's rejection late and
|
|
37
|
+
* confusingly. A caller that genuinely wants the pre-0.2 permissive
|
|
38
|
+
* behavior — reusing one credential across multiple senders — should use
|
|
39
|
+
* `Auth.bearer(token, senderHint)` (the legacy two-arg form) instead, which
|
|
40
|
+
* never sets `expectedSender`.
|
|
34
41
|
*/
|
|
35
42
|
devAgent(agentId: string): AuthConfig;
|
|
36
43
|
/**
|
|
@@ -50,8 +57,12 @@ export declare function validateAuth(auth: AuthConfig): void;
|
|
|
50
57
|
export declare function authSender(auth?: AuthConfig): string | undefined;
|
|
51
58
|
/**
|
|
52
59
|
* Throw {@link MacpIdentityMismatchError} when a caller-supplied `sender`
|
|
53
|
-
* conflicts with `auth.expectedSender`. Silent when
|
|
54
|
-
*
|
|
60
|
+
* conflicts with `auth.expectedSender`. Silent when `auth.expectedSender` is
|
|
61
|
+
* undefined, or when `sender` is undefined or `''` — an empty `sender` is
|
|
62
|
+
* treated the same as absent, matching `macp-runtime/src/server.rs:229`'s
|
|
63
|
+
* `!env.sender.is_empty() && ...` guard (issue #124): the runtime always
|
|
64
|
+
* accepts an empty `sender`, so this client-side guard must too, or it would
|
|
65
|
+
* reject a payload the runtime would accept.
|
|
55
66
|
*/
|
|
56
67
|
export declare function assertSenderMatchesIdentity(auth: AuthConfig | undefined, sender: string | undefined): void;
|
|
57
68
|
export declare function metadataFromAuth(auth: AuthConfig): grpc.Metadata;
|
package/dist/auth.js
CHANGED
|
@@ -50,11 +50,18 @@ exports.Auth = {
|
|
|
50
50
|
* otherwise). Not for production — use {@link Auth.bearer}.
|
|
51
51
|
*
|
|
52
52
|
* The legacy `x-macp-agent-id` header was removed in 0.5.0: no supported
|
|
53
|
-
* runtime reads it.
|
|
54
|
-
*
|
|
53
|
+
* runtime reads it. Sets {@link AuthConfig.expectedSender} to `agentId`
|
|
54
|
+
* (issue #124): the runtime already refuses a mismatched `sender`
|
|
55
|
+
* unconditionally for dev credentials (`macp-runtime/src/server.rs:229-231`,
|
|
56
|
+
* no insecure/dev exemption), so this SDK now enforces the same guarantee
|
|
57
|
+
* client-side instead of surfacing the runtime's rejection late and
|
|
58
|
+
* confusingly. A caller that genuinely wants the pre-0.2 permissive
|
|
59
|
+
* behavior — reusing one credential across multiple senders — should use
|
|
60
|
+
* `Auth.bearer(token, senderHint)` (the legacy two-arg form) instead, which
|
|
61
|
+
* never sets `expectedSender`.
|
|
55
62
|
*/
|
|
56
63
|
devAgent(agentId) {
|
|
57
|
-
return { bearerToken: agentId, senderHint: agentId };
|
|
64
|
+
return { bearerToken: agentId, senderHint: agentId, expectedSender: agentId };
|
|
58
65
|
},
|
|
59
66
|
/**
|
|
60
67
|
* Production bearer-token credential. Pass `{ expectedSender }` to have the
|
|
@@ -82,7 +89,12 @@ exports.Auth = {
|
|
|
82
89
|
};
|
|
83
90
|
function validateAuth(auth) {
|
|
84
91
|
if (!auth.bearerToken) {
|
|
85
|
-
|
|
92
|
+
// MacpSdkError (issue #108.8), not a bare Error: the documented pattern
|
|
93
|
+
// in both SDKs' docs is `catch (e) { if (e instanceof MacpSdkError) ... }`,
|
|
94
|
+
// so a plain Error escaped it. MacpSdkError, not MacpSessionError — this
|
|
95
|
+
// is a client configuration fault, not a session-payload fault. Message
|
|
96
|
+
// text unchanged so any existing message-matching caller still matches.
|
|
97
|
+
throw new errors_1.MacpSdkError('bearerToken is required');
|
|
86
98
|
}
|
|
87
99
|
}
|
|
88
100
|
function authSender(auth) {
|
|
@@ -92,13 +104,17 @@ function authSender(auth) {
|
|
|
92
104
|
}
|
|
93
105
|
/**
|
|
94
106
|
* Throw {@link MacpIdentityMismatchError} when a caller-supplied `sender`
|
|
95
|
-
* conflicts with `auth.expectedSender`. Silent when
|
|
96
|
-
*
|
|
107
|
+
* conflicts with `auth.expectedSender`. Silent when `auth.expectedSender` is
|
|
108
|
+
* undefined, or when `sender` is undefined or `''` — an empty `sender` is
|
|
109
|
+
* treated the same as absent, matching `macp-runtime/src/server.rs:229`'s
|
|
110
|
+
* `!env.sender.is_empty() && ...` guard (issue #124): the runtime always
|
|
111
|
+
* accepts an empty `sender`, so this client-side guard must too, or it would
|
|
112
|
+
* reject a payload the runtime would accept.
|
|
97
113
|
*/
|
|
98
114
|
function assertSenderMatchesIdentity(auth, sender) {
|
|
99
115
|
if (!auth?.expectedSender)
|
|
100
116
|
return;
|
|
101
|
-
if (sender === undefined)
|
|
117
|
+
if (sender === undefined || sender === '')
|
|
102
118
|
return;
|
|
103
119
|
if (sender !== auth.expectedSender) {
|
|
104
120
|
throw new errors_1.MacpIdentityMismatchError(auth.expectedSender, sender);
|
package/dist/base-session.js
CHANGED
|
@@ -57,9 +57,19 @@ class BaseSession {
|
|
|
57
57
|
}
|
|
58
58
|
/** Send SessionStart and begin tracking via the projection. */
|
|
59
59
|
async start(input) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
// Bring this extension point's validation up to parity with the five
|
|
61
|
+
// built-in mode sessions (issue #108.4/4d): previously only participant
|
|
62
|
+
// count + maxSuspendMs were checked, so an ext-mode built on this
|
|
63
|
+
// documented extension point was the weakest-validated mode — an empty
|
|
64
|
+
// intent or an out-of-range ttlMs reached the wire unvalidated.
|
|
65
|
+
(0, validation_1.validateSessionStart)({
|
|
66
|
+
intent: input.intent,
|
|
67
|
+
participants: input.participants,
|
|
68
|
+
ttlMs: input.ttlMs,
|
|
69
|
+
maxSuspendMs: input.maxSuspendMs,
|
|
70
|
+
modeVersion: this.modeVersion,
|
|
71
|
+
configurationVersion: this.configurationVersion,
|
|
72
|
+
});
|
|
63
73
|
const payload = (0, envelope_1.buildSessionStartPayload)({
|
|
64
74
|
intent: input.intent,
|
|
65
75
|
participants: input.participants,
|