macp-sdk-typescript 0.11.0 → 0.13.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 +383 -0
- package/README.md +1 -1
- 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/projections/base.d.ts +71 -14
- package/dist/projections/base.js +83 -12
- package/dist/projections/decision.js +23 -3
- package/dist/projections/handoff.js +39 -16
- package/dist/projections/proposal.js +16 -2
- package/dist/projections/quorum.js +13 -0
- package/dist/projections/task.d.ts +5 -5
- package/dist/projections/task.js +35 -13
- package/dist/proposal.d.ts +1 -0
- package/dist/proposal.js +2 -2
- package/dist/proto-registry.d.ts +69 -3
- package/dist/proto-registry.js +83 -5
- 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 +3 -2
package/dist/projections/base.js
CHANGED
|
@@ -1,13 +1,63 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.BaseProjection = void 0;
|
|
3
|
+
exports.BaseProjection = exports.PROJECTION_ANOMALY_FIELD_ORDER = exports.ANOMALY_SETTLED_HANDOFF = exports.ANOMALY_DUPLICATE_TASK_ACCEPT = exports.ANOMALY_DUPLICATE_BALLOT = exports.ANOMALY_DUPLICATE_VOTE = void 0;
|
|
4
4
|
const logging_1 = require("../logging");
|
|
5
|
+
/**
|
|
6
|
+
* The four `ProjectionAnomalyKind` values as named constants — all four are
|
|
7
|
+
* cross-SDK contract, literally pinned by the spec repo's
|
|
8
|
+
* `schemas/parity/contract.json` (`projection_anomaly.kinds`, `contract_version`
|
|
9
|
+
* 1.2.0 — `ANOMALY_DUPLICATE_TASK_ACCEPT`/`ANOMALY_SETTLED_HANDOFF` landed there
|
|
10
|
+
* under issue #126/#128, after this SDK's PR #134 completed the agreement
|
|
11
|
+
* `macp-sdk-python`'s PR #95 started). `satisfies` links each constant to the
|
|
12
|
+
* union above at compile time, so a drifted value fails `npm run check` before
|
|
13
|
+
* it can ever reach `make verify-parity`.
|
|
14
|
+
*/
|
|
15
|
+
exports.ANOMALY_DUPLICATE_VOTE = 'duplicate_vote';
|
|
16
|
+
exports.ANOMALY_DUPLICATE_BALLOT = 'duplicate_ballot';
|
|
17
|
+
exports.ANOMALY_DUPLICATE_TASK_ACCEPT = 'duplicate_task_accept';
|
|
18
|
+
exports.ANOMALY_SETTLED_HANDOFF = 'settled_handoff';
|
|
19
|
+
/**
|
|
20
|
+
* Runtime field order for `ProjectionAnomaly`, with the same frozen-set
|
|
21
|
+
* guarantee as `_ProjectionAnomalyFieldSetIsFrozen` above, expressed as a
|
|
22
|
+
* value rather than only a type. The spec repo's
|
|
23
|
+
* `schemas/parity/contract.json` (`projection_anomaly.fields`) pins this
|
|
24
|
+
* field order as a runtime JSON array, so this SDK needs its own runtime
|
|
25
|
+
* array to assert against it (see `tests/parity/contract.test.ts`).
|
|
26
|
+
*
|
|
27
|
+
* **Public, not hidden — corrected during Phase 2's own verification round.**
|
|
28
|
+
* An earlier revision of this docblock claimed this constant was
|
|
29
|
+
* "deliberately NOT added to the public barrel," on the (wrong) assumption
|
|
30
|
+
* that it would follow `canonicalizeCommitmentPayload`'s precedent in
|
|
31
|
+
* `commitment-hash.ts`. That precedent doesn't transfer: `commitment-hash.ts`
|
|
32
|
+
* is barrel-exported via a *named* line in `src/index.ts`
|
|
33
|
+
* (`export { commitmentHash, isCanonicalCommitmentHash } from
|
|
34
|
+
* './commitment-hash'`), which can omit a name on purpose. This module is
|
|
35
|
+
* barrel-exported via `src/projections.ts`'s `export * from
|
|
36
|
+
* './projections/base'` → `src/index.ts`'s `export * from './projections'`
|
|
37
|
+
* — a wildcard re-export chain has no way to omit one name, so this constant
|
|
38
|
+
* reaches `dist/index.d.ts` and the runtime `require('macp-sdk-typescript')`
|
|
39
|
+
* surface regardless of intent (verified: `node -e "require('./dist/index.js')
|
|
40
|
+
* .PROJECTION_ANOMALY_FIELD_ORDER"` prints the array). This is harmless —
|
|
41
|
+
* a small readonly string tuple is no more a surface-area risk than
|
|
42
|
+
* `STANDARD_MODES` — so the fix is documenting reality, not restructuring
|
|
43
|
+
* the export chain to force exclusion. Phase 3's public-API snapshot
|
|
44
|
+
* correctly captures this symbol as part of today's real surface.
|
|
45
|
+
*/
|
|
46
|
+
exports.PROJECTION_ANOMALY_FIELD_ORDER = [
|
|
47
|
+
'kind',
|
|
48
|
+
'mode',
|
|
49
|
+
'messageType',
|
|
50
|
+
'messageId',
|
|
51
|
+
'sender',
|
|
52
|
+
'subjectId',
|
|
53
|
+
'detail',
|
|
54
|
+
];
|
|
5
55
|
/**
|
|
6
56
|
* Abstract base for in-process mode-state tracking — parity with python-sdk's
|
|
7
57
|
* `macp_sdk.base_projection.BaseProjection`. Maintains a shared transcript,
|
|
8
58
|
* phase string, and commitment payload; subclasses override `applyMode`
|
|
9
|
-
* to handle mode-specific envelopes. `
|
|
10
|
-
*
|
|
59
|
+
* to handle mode-specific envelopes. `Commitment` is handled here so custom
|
|
60
|
+
* modes get the common lifecycle for free.
|
|
11
61
|
*/
|
|
12
62
|
class BaseProjection {
|
|
13
63
|
/**
|
|
@@ -38,18 +88,27 @@ class BaseProjection {
|
|
|
38
88
|
/**
|
|
39
89
|
* Cardinality anomalies recorded while replaying this projection's
|
|
40
90
|
* accepted transcript (e.g. a duplicate vote or ballot from the same
|
|
41
|
-
* sender
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* `BaseProjection`
|
|
91
|
+
* sender, a duplicate `TaskAccept` competing for an already-held assignee
|
|
92
|
+
* slot, or a `HandoffAccept`/`HandoffDecline` for an already-settled
|
|
93
|
+
* handoff). See `ProjectionAnomaly`. Empty unless a subclass calls
|
|
94
|
+
* `recordAnomaly`. **Corrected 2026-09-25** (this note previously said
|
|
95
|
+
* nothing calls `recordAnomaly` because `DecisionProjection`/
|
|
96
|
+
* `QuorumProjection` didn't extend `BaseProjection` — that was true before
|
|
97
|
+
* issue #91's refactor, and is stale now): both `DecisionProjection`
|
|
98
|
+
* (`decision.ts`) and `QuorumProjection` (`quorum.ts`) extend
|
|
99
|
+
* `BaseProjection` today and DO call the inherited `recordAnomaly` when
|
|
100
|
+
* they discard a duplicate vote/ballot — they just pass a literal
|
|
101
|
+
* `'duplicate_vote'`/`'duplicate_ballot'` string for `kind` rather than
|
|
102
|
+
* this file's `ANOMALY_DUPLICATE_VOTE`/`ANOMALY_DUPLICATE_BALLOT`
|
|
103
|
+
* constants (referencing those constants instead is optional cleanup, not
|
|
104
|
+
* required by anything in this repo). This field and `recordAnomaly` are
|
|
105
|
+
* no longer ext-mode-only — any subclass, built-in or ext-mode, uses them.
|
|
47
106
|
*/
|
|
48
107
|
anomalies = [];
|
|
49
108
|
/**
|
|
50
109
|
* True once at least one `ProjectionAnomaly` has been recorded — i.e. this
|
|
51
|
-
* projection observed and discarded a
|
|
52
|
-
*
|
|
110
|
+
* projection observed and discarded a cardinality anomaly (see `anomalies`
|
|
111
|
+
* above for the full set of kinds). This getter is exactly
|
|
53
112
|
* `anomalies.length > 0`; it never asserts "this transcript violates the
|
|
54
113
|
* spec" (see `ProjectionAnomaly`'s docblock, and `applyEnvelope`'s "Input
|
|
55
114
|
* contract: accepted-only" section above, for why a projection cannot make
|
|
@@ -157,7 +216,19 @@ class BaseProjection {
|
|
|
157
216
|
this.applyMode(envelope, protoRegistry);
|
|
158
217
|
}
|
|
159
218
|
catch (err) {
|
|
160
|
-
|
|
219
|
+
// Identity (`===`), not deep equality: guards against popping an
|
|
220
|
+
// unrelated entry if a subclass's `applyMode` pushed something onto
|
|
221
|
+
// `transcript` itself before throwing (issue #106.4) — two distinct
|
|
222
|
+
// envelopes can be structurally equal, but only the one this call
|
|
223
|
+
// itself pushed should ever be popped here. No current subclass does
|
|
224
|
+
// this (nothing pushes before the fallible decode above), so this
|
|
225
|
+
// guard is latent, but `BaseProjection` is documented subclassable
|
|
226
|
+
// API and the failure mode it prevents is worse than the bug the
|
|
227
|
+
// rollback itself fixes. The dedup-set delete below needs no such
|
|
228
|
+
// guard — `seenIdAdded` already keys it to this envelope's own id —
|
|
229
|
+
// and must still run even when the pop is skipped.
|
|
230
|
+
if (this.transcript.at(-1) === envelope)
|
|
231
|
+
this.transcript.pop();
|
|
161
232
|
if (seenIdAdded)
|
|
162
233
|
this.seenMessageIds.delete(envelope.messageId);
|
|
163
234
|
throw err;
|
|
@@ -37,6 +37,23 @@ class DecisionProjection extends base_1.BaseProjection {
|
|
|
37
37
|
}
|
|
38
38
|
case 'Vote': {
|
|
39
39
|
const record = payload;
|
|
40
|
+
// RFC-MACP-0007 §5 rule 2: "Evaluation, Objection, and Vote MUST
|
|
41
|
+
// reference an existing proposal_id." A conforming runtime never
|
|
42
|
+
// admits a Vote for an unknown proposal_id into accepted history, but
|
|
43
|
+
// per the accepted-only input contract (`BaseProjection.applyEnvelope`)
|
|
44
|
+
// this guards the same class of unfiltered-transcript input that
|
|
45
|
+
// `HandoffAccept`/`HandoffDecline` already guard against (issue #119,
|
|
46
|
+
// mirrors issue #111's task.ts fix). Evaluation/Objection need no
|
|
47
|
+
// matching guard: they only append to audit arrays (see
|
|
48
|
+
// `docs/api/projections.md` "List accumulators and logical
|
|
49
|
+
// duplicates"), so an unknown-proposalId record there fabricates no
|
|
50
|
+
// Map state and moves no phase. Vote is different: without this
|
|
51
|
+
// guard, `this.votes.get(...) ?? new Map()` below would fabricate a
|
|
52
|
+
// new `votes` entry for an unknown proposalId, inflating
|
|
53
|
+
// `voteTotals()`/`majorityWinner()`'s denominators even though no
|
|
54
|
+
// known proposal exists.
|
|
55
|
+
if (!this.proposals.has(record.proposalId))
|
|
56
|
+
break;
|
|
40
57
|
const bySender = this.votes.get(record.proposalId) ?? new Map();
|
|
41
58
|
const kept = bySender.get(envelope.sender);
|
|
42
59
|
if (kept !== undefined) {
|
|
@@ -68,9 +85,12 @@ class DecisionProjection extends base_1.BaseProjection {
|
|
|
68
85
|
// not a normative MACP term (it appears once, in passing, at
|
|
69
86
|
// RFC-MACP-0012 `:211`); this guard is justified by session
|
|
70
87
|
// terminality, not by a phase specification. An anomaly would be
|
|
71
|
-
// recorded here too, but
|
|
72
|
-
//
|
|
73
|
-
//
|
|
88
|
+
// recorded here too, but unlike five of the six sites that were
|
|
89
|
+
// "frozen pending cross-SDK agreement" (issue #126/#128, now
|
|
90
|
+
// resolved for `duplicate_task_accept`/`settled_handoff` — see
|
|
91
|
+
// `base.ts`), this one is a session-terminality phase-regression
|
|
92
|
+
// guard, not a settlement discard, and remains genuinely undecided
|
|
93
|
+
// per #128's own explicit scoping. No behavior change here.
|
|
74
94
|
if (this.phase !== 'Committed') {
|
|
75
95
|
this.phase = 'Voting';
|
|
76
96
|
}
|
|
@@ -27,6 +27,11 @@ class HandoffProjection extends base_1.BaseProjection {
|
|
|
27
27
|
case 'HandoffContext': {
|
|
28
28
|
const record = payload;
|
|
29
29
|
const handoff = this.handoffs.get(record.handoffId);
|
|
30
|
+
// RFC-MACP-0010 §5 rule 2: HandoffContext MUST reference an existing
|
|
31
|
+
// handoff_id, so the phase transition belongs inside this guard too
|
|
32
|
+
// (issue #119, same shape as issue #111) — a HandoffContext for an
|
|
33
|
+
// unknown handoff_id must not advance `phase` past `'OfferPending'`
|
|
34
|
+
// while a different, real handoff is still pending.
|
|
30
35
|
if (handoff) {
|
|
31
36
|
// Per RFC-MACP-0010 §2.1: context after accept is permitted as supplementary docs.
|
|
32
37
|
// Only update status if not already accepted/declined.
|
|
@@ -34,9 +39,9 @@ class HandoffProjection extends base_1.BaseProjection {
|
|
|
34
39
|
handoff.status = 'context_sent';
|
|
35
40
|
}
|
|
36
41
|
handoff.contextContentType = record.contentType;
|
|
42
|
+
if (this.phase === 'OfferPending')
|
|
43
|
+
this.phase = 'ContextSharing';
|
|
37
44
|
}
|
|
38
|
-
if (this.phase === 'OfferPending')
|
|
39
|
-
this.phase = 'ContextSharing';
|
|
40
45
|
break;
|
|
41
46
|
}
|
|
42
47
|
case 'HandoffAccept': {
|
|
@@ -47,9 +52,10 @@ class HandoffProjection extends base_1.BaseProjection {
|
|
|
47
52
|
// and HandoffDecline MUST reference an existing handoff_id." An
|
|
48
53
|
// accept for an unknown/never-offered handoff_id is invalid input
|
|
49
54
|
// (e.g. an unfiltered transcript) and MUST NOT mutate `phase` or
|
|
50
|
-
// fabricate a handoff record.
|
|
51
|
-
//
|
|
52
|
-
//
|
|
55
|
+
// fabricate a handoff record. Confirmed (issue #126/#128): no
|
|
56
|
+
// anomaly is recorded for this case — an unknown `handoff_id` is
|
|
57
|
+
// not caller misuse (a projection that joined mid-session may never
|
|
58
|
+
// have observed the original offer).
|
|
53
59
|
break;
|
|
54
60
|
}
|
|
55
61
|
// RFC-MACP-0010 §5 rule 4 (`:68`): "Once an offer has been
|
|
@@ -58,10 +64,7 @@ class HandoffProjection extends base_1.BaseProjection {
|
|
|
58
64
|
// direction too: a `handoff_id` transitions
|
|
59
65
|
// offered -> accepted | declined exactly once. Only settle if this
|
|
60
66
|
// handoff hasn't already resolved — the same shape as the
|
|
61
|
-
// HandoffContext guard just above.
|
|
62
|
-
// this guard discards a competing accept, but `ProjectionAnomalyKind`
|
|
63
|
-
// (`base.ts:8-9`) is deliberately frozen pending cross-SDK agreement
|
|
64
|
-
// with macp-sdk-python.
|
|
67
|
+
// HandoffContext guard just above.
|
|
65
68
|
if (handoff.status === 'offered' || handoff.status === 'context_sent') {
|
|
66
69
|
handoff.status = 'accepted';
|
|
67
70
|
handoff.acceptedBy = record.acceptedBy;
|
|
@@ -71,6 +74,17 @@ class HandoffProjection extends base_1.BaseProjection {
|
|
|
71
74
|
handoff.implicit = record.implicit ?? false;
|
|
72
75
|
this.phase = 'Accepted';
|
|
73
76
|
}
|
|
77
|
+
else {
|
|
78
|
+
this.recordAnomaly({
|
|
79
|
+
kind: 'settled_handoff',
|
|
80
|
+
mode: envelope.mode,
|
|
81
|
+
messageType: envelope.messageType,
|
|
82
|
+
messageId: envelope.messageId,
|
|
83
|
+
sender: envelope.sender,
|
|
84
|
+
subjectId: record.handoffId,
|
|
85
|
+
detail: `sender ${envelope.sender} attempted HandoffAccept for ${record.handoffId}, but it already settled as '${handoff.status}'`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
74
88
|
break;
|
|
75
89
|
}
|
|
76
90
|
case 'HandoffDecline': {
|
|
@@ -81,22 +95,31 @@ class HandoffProjection extends base_1.BaseProjection {
|
|
|
81
95
|
// and HandoffDecline MUST reference an existing handoff_id." A
|
|
82
96
|
// decline for an unknown/never-offered handoff_id is invalid input
|
|
83
97
|
// (e.g. an unfiltered transcript) and MUST NOT mutate `phase` or
|
|
84
|
-
// fabricate a handoff record.
|
|
85
|
-
//
|
|
86
|
-
//
|
|
98
|
+
// fabricate a handoff record. Confirmed (issue #126/#128): no
|
|
99
|
+
// anomaly is recorded for this case — an unknown `handoff_id` is
|
|
100
|
+
// not caller misuse (a projection that joined mid-session may never
|
|
101
|
+
// have observed the original offer).
|
|
87
102
|
break;
|
|
88
103
|
}
|
|
89
104
|
// RFC-MACP-0010 §5 rule 4 (`:68`) + §5.1(4) (`:113-116`): a
|
|
90
105
|
// `handoff_id` settles once. A decline after the handoff already
|
|
91
|
-
// settled (accepted or declined) is invalid and ignored.
|
|
92
|
-
// would be recorded here too, but `ProjectionAnomalyKind`
|
|
93
|
-
// (`base.ts:8-9`) is deliberately frozen pending cross-SDK agreement
|
|
94
|
-
// with macp-sdk-python.
|
|
106
|
+
// settled (accepted or declined) is invalid and ignored.
|
|
95
107
|
if (handoff.status === 'offered' || handoff.status === 'context_sent') {
|
|
96
108
|
handoff.status = 'declined';
|
|
97
109
|
handoff.declinedBy = record.declinedBy;
|
|
98
110
|
this.phase = 'Declined';
|
|
99
111
|
}
|
|
112
|
+
else {
|
|
113
|
+
this.recordAnomaly({
|
|
114
|
+
kind: 'settled_handoff',
|
|
115
|
+
mode: envelope.mode,
|
|
116
|
+
messageType: envelope.messageType,
|
|
117
|
+
messageId: envelope.messageId,
|
|
118
|
+
sender: envelope.sender,
|
|
119
|
+
subjectId: record.handoffId,
|
|
120
|
+
detail: `sender ${envelope.sender} attempted HandoffDecline for ${record.handoffId}, but it already settled as '${handoff.status}'`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
100
123
|
break;
|
|
101
124
|
}
|
|
102
125
|
default:
|
|
@@ -69,11 +69,25 @@ class ProposalProjection extends base_1.BaseProjection {
|
|
|
69
69
|
reason: record.reason,
|
|
70
70
|
sender: envelope.sender,
|
|
71
71
|
});
|
|
72
|
+
// RFC-MACP-0008 §5 rule 3 requires `Reject` to reference an existing
|
|
73
|
+
// proposal_id, so `this.phase` must only move to the terminal state
|
|
74
|
+
// when a proposal record for it actually exists here (issue #119,
|
|
75
|
+
// same shape as issue #111's TaskComplete/TaskFail fix) — otherwise
|
|
76
|
+
// an unfiltered transcript could flip `phase` to `'TerminalRejected'`
|
|
77
|
+
// (a TERMINAL_PHASES member, see `src/agent/participant.ts`) on a
|
|
78
|
+
// never-seen proposalId, ending a live Participant's run() loop for a
|
|
79
|
+
// session that never actually terminated. `this.rejections.push(...)`
|
|
80
|
+
// above stays unconditional — it's an append-only audit log (see
|
|
81
|
+
// `docs/api/projections.md` "List accumulators and logical
|
|
82
|
+
// duplicates"), so `isTerminallyRejected`/`hasTerminalRejection` can
|
|
83
|
+
// still report `true` for an unknown id even though `phase` does not
|
|
84
|
+
// move; that split is intentional, not a residual bug.
|
|
72
85
|
if (terminal) {
|
|
73
86
|
const proposal = this.proposals.get(record.proposalId);
|
|
74
|
-
if (proposal)
|
|
87
|
+
if (proposal) {
|
|
75
88
|
proposal.status = 'rejected';
|
|
76
|
-
|
|
89
|
+
this.phase = 'TerminalRejected';
|
|
90
|
+
}
|
|
77
91
|
}
|
|
78
92
|
break;
|
|
79
93
|
}
|
|
@@ -38,6 +38,19 @@ class QuorumProjection extends base_1.BaseProjection {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
setBallot(envelope, requestId, vote, reason) {
|
|
41
|
+
// RFC-MACP-0011 §5 rule 3 (`rfcs/RFC-MACP-0011-quorum-mode.md:69`): a ballot
|
|
42
|
+
// MUST reference the Session's accepted request_id. `applyEnvelope`'s input
|
|
43
|
+
// contract is accepted-only (see accepted-only-contract.test.ts), not a
|
|
44
|
+
// guarantee every field inside an accepted envelope is itself valid, so an
|
|
45
|
+
// unfiltered/multi-session transcript could still carry a ballot for a
|
|
46
|
+
// request_id this projection never saw an ApprovalRequest for. Without this
|
|
47
|
+
// guard that would fabricate a `ballots` Map entry: votedSenders() and
|
|
48
|
+
// approvalCount()/rejectionCount()/abstentionCount() would report on a
|
|
49
|
+
// request that doesn't exist, and a second such ballot from the same sender
|
|
50
|
+
// would fire a spurious duplicate_ballot anomaly against it (issue #121,
|
|
51
|
+
// same bug shape as issue #119's proposal.ts/handoff.ts/decision.ts sites).
|
|
52
|
+
if (!this.requests.has(requestId))
|
|
53
|
+
return;
|
|
41
54
|
const senderMap = this.ballots.get(requestId) ?? new Map();
|
|
42
55
|
const kept = senderMap.get(envelope.sender);
|
|
43
56
|
if (kept !== undefined) {
|
|
@@ -19,13 +19,13 @@ export interface TaskUpdateRecord {
|
|
|
19
19
|
message?: string;
|
|
20
20
|
sender: string;
|
|
21
21
|
}
|
|
22
|
-
export interface
|
|
22
|
+
export interface TaskCompleteRecord {
|
|
23
23
|
taskId: string;
|
|
24
24
|
assignee: string;
|
|
25
25
|
summary?: string;
|
|
26
26
|
sender: string;
|
|
27
27
|
}
|
|
28
|
-
export interface
|
|
28
|
+
export interface TaskFailRecord {
|
|
29
29
|
taskId: string;
|
|
30
30
|
assignee: string;
|
|
31
31
|
errorCode?: string;
|
|
@@ -37,8 +37,8 @@ export declare class TaskProjection extends BaseProjection {
|
|
|
37
37
|
protected readonly mode = "macp.mode.task.v1";
|
|
38
38
|
readonly tasks: Map<string, TaskRecord>;
|
|
39
39
|
readonly updates: TaskUpdateRecord[];
|
|
40
|
-
readonly completions:
|
|
41
|
-
readonly failures:
|
|
40
|
+
readonly completions: TaskCompleteRecord[];
|
|
41
|
+
readonly failures: TaskFailRecord[];
|
|
42
42
|
phase: 'Pending' | 'Requested' | 'InProgress' | 'Completed' | 'Failed' | 'Committed';
|
|
43
43
|
/**
|
|
44
44
|
* The **session-level** active assignment: who currently holds the session's
|
|
@@ -63,7 +63,7 @@ export declare class TaskProjection extends BaseProjection {
|
|
|
63
63
|
/** Handle a Task-mode envelope. See `BaseProjection.applyEnvelope` for the accepted-only input contract and redelivery/rollback semantics shared by all built-in modes. */
|
|
64
64
|
protected applyMode(envelope: Envelope, protoRegistry: ProtoRegistry): void;
|
|
65
65
|
getTask(taskId: string): TaskRecord | undefined;
|
|
66
|
-
|
|
66
|
+
isCompleted(taskId: string): boolean;
|
|
67
67
|
isFailed(taskId: string): boolean;
|
|
68
68
|
isRetryable(taskId: string): boolean;
|
|
69
69
|
progressOf(taskId: string): number;
|
package/dist/projections/task.js
CHANGED
|
@@ -74,16 +74,37 @@ class TaskProjection extends base_1.BaseProjection {
|
|
|
74
74
|
// below, which frees the slot — see its comment for why that needs no
|
|
75
75
|
// policy input.
|
|
76
76
|
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
77
|
+
// A discarded second `TaskAccept` records a `duplicate_task_accept`
|
|
78
|
+
// anomaly (issue #126/#128) only when this projection actually has a
|
|
79
|
+
// `TaskRequest` on file for the losing `taskId` (`task` is defined) —
|
|
80
|
+
// an accept for a `taskId` this projection never saw a `TaskRequest`
|
|
81
|
+
// for stays a silent no-op below, since a projection that joined
|
|
82
|
+
// mid-session may never have observed the original request, and that
|
|
83
|
+
// is not caller misuse. Note this guard's `activeAssignment` slot is
|
|
84
|
+
// session-scoped (see the comment above), so the `taskId` that loses
|
|
85
|
+
// here need not be the same one the slot is already held for — it is
|
|
86
|
+
// simply a second, separately-requested task competing for the one
|
|
87
|
+
// slot the session allows.
|
|
88
|
+
if (task) {
|
|
89
|
+
if (this.activeAssignment === undefined) {
|
|
90
|
+
task.assignee = record.assignee;
|
|
91
|
+
task.status = 'accepted';
|
|
92
|
+
this.activeAssignment = { sender: envelope.sender, taskId: record.taskId };
|
|
93
|
+
this.phase = 'InProgress';
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
this.recordAnomaly({
|
|
97
|
+
kind: 'duplicate_task_accept',
|
|
98
|
+
mode: envelope.mode,
|
|
99
|
+
messageType: envelope.messageType,
|
|
100
|
+
messageId: envelope.messageId,
|
|
101
|
+
sender: envelope.sender,
|
|
102
|
+
subjectId: record.taskId,
|
|
103
|
+
detail: `sender ${envelope.sender} attempted TaskAccept for ${record.taskId}, but ${this.activeAssignment.sender} already holds the session's one assignee slot with task ${this.activeAssignment.taskId}`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
86
106
|
}
|
|
107
|
+
// unknown taskId (task undefined): stays a silent no-op.
|
|
87
108
|
break;
|
|
88
109
|
}
|
|
89
110
|
case 'TaskReject': {
|
|
@@ -137,17 +158,18 @@ class TaskProjection extends base_1.BaseProjection {
|
|
|
137
158
|
if (task) {
|
|
138
159
|
task.status = 'completed';
|
|
139
160
|
task.progress = 1;
|
|
161
|
+
this.phase = 'Completed';
|
|
140
162
|
}
|
|
141
|
-
this.phase = 'Completed';
|
|
142
163
|
break;
|
|
143
164
|
}
|
|
144
165
|
case 'TaskFail': {
|
|
145
166
|
const record = payload;
|
|
146
167
|
this.failures.push({ ...record, retryable: record.retryable ?? false, sender: envelope.sender });
|
|
147
168
|
const task = this.tasks.get(record.taskId);
|
|
148
|
-
if (task)
|
|
169
|
+
if (task) {
|
|
149
170
|
task.status = 'failed';
|
|
150
|
-
|
|
171
|
+
this.phase = 'Failed';
|
|
172
|
+
}
|
|
151
173
|
break;
|
|
152
174
|
}
|
|
153
175
|
default:
|
|
@@ -157,7 +179,7 @@ class TaskProjection extends base_1.BaseProjection {
|
|
|
157
179
|
getTask(taskId) {
|
|
158
180
|
return this.tasks.get(taskId);
|
|
159
181
|
}
|
|
160
|
-
|
|
182
|
+
isCompleted(taskId) {
|
|
161
183
|
return this.tasks.get(taskId)?.status === 'completed';
|
|
162
184
|
}
|
|
163
185
|
isFailed(taskId) {
|
package/dist/proposal.d.ts
CHANGED
package/dist/proposal.js
CHANGED
|
@@ -60,10 +60,10 @@ class ProposalSession {
|
|
|
60
60
|
mode: constants_1.MODE_PROPOSAL,
|
|
61
61
|
messageType: 'SessionStart',
|
|
62
62
|
sessionId: this.sessionId,
|
|
63
|
-
sender: this.senderFor(input.sender),
|
|
63
|
+
sender: this.senderFor(input.sender, input.auth),
|
|
64
64
|
payload: this.client.protoRegistry.encodeKnownPayload(constants_1.MODE_PROPOSAL, 'SessionStart', (0, envelope_1.toProtoPayload)(payload)),
|
|
65
65
|
});
|
|
66
|
-
return this.sendAndTrack(envelope, this.auth);
|
|
66
|
+
return this.sendAndTrack(envelope, input.auth ?? this.auth);
|
|
67
67
|
}
|
|
68
68
|
async propose(input) {
|
|
69
69
|
(0, validation_1.validateRequiredField)('proposalId', input.proposalId);
|
package/dist/proto-registry.d.ts
CHANGED
|
@@ -11,7 +11,13 @@ export declare class ProtoRegistry {
|
|
|
11
11
|
* Decode an `ext.multi_round.v1` `Contribute` payload, accepting both wire
|
|
12
12
|
* formats: legacy JSON (`{"value":"..."}`, still replayed verbatim from
|
|
13
13
|
* pre-proto histories) and canonical protobuf (`ContributePayload`). Both
|
|
14
|
-
* normalize to `{ value
|
|
14
|
+
* normalize to `{ value }`, but only the protobuf path's `value` is always
|
|
15
|
+
* a `string` — the legacy-JSON path passes `parsed.value` through exactly
|
|
16
|
+
* as `JSON.parse` produced it (issue #124: no `String(...)` coercion, no
|
|
17
|
+
* `?? ''` fallback for an absent key), so `value` may be `undefined`,
|
|
18
|
+
* `null`, a number, a boolean, or an object/array for malformed-but-valid
|
|
19
|
+
* legacy JSON. This method's own contract is observational, not a gate —
|
|
20
|
+
* see the `contribute_acceptance` note below.
|
|
15
21
|
*
|
|
16
22
|
* Parse-then-fallback (issue #93), not a first-byte shortcut: JSON is always
|
|
17
23
|
* attempted first, and only a `JSON.parse` failure falls through to
|
|
@@ -19,8 +25,27 @@ export declare class ProtoRegistry {
|
|
|
19
25
|
* not generalize — e.g. legacy JSON with leading whitespace has neither byte
|
|
20
26
|
* first, and used to reach `decodeMessage` and throw. Parity with
|
|
21
27
|
* `macp-sdk-python`'s `_decode_json_first_then_proto`, which has always
|
|
22
|
-
* worked this way.
|
|
23
|
-
*
|
|
28
|
+
* worked this way.
|
|
29
|
+
*
|
|
30
|
+
* **A successful `JSON.parse` is not proof the bytes are legacy JSON**
|
|
31
|
+
* (issue #104 — this method's prior "essentially never also syntactically
|
|
32
|
+
* valid JSON" claim was false, not merely imprecise): the canonical proto
|
|
33
|
+
* tag byte for field 1 (`0x0A`) is itself insignificant JSON whitespace, so
|
|
34
|
+
* for specific value byte-lengths the length varint — or, when the varint
|
|
35
|
+
* is itself whitespace, the value's own leading byte — becomes the first
|
|
36
|
+
* significant character `JSON.parse` sees, and a genuine `ContributePayload`
|
|
37
|
+
* silently misreads as a JSON number/string/object. Verified directly
|
|
38
|
+
* (encode-then-decode sweep, value lengths 1-127, seven value shapes): every
|
|
39
|
+
* one of lengths 9, 10, 13, 32, 34, 45, 48, 49-57, and 123 corrupted before
|
|
40
|
+
* `isCanonicalProto` below, most to total silent data loss
|
|
41
|
+
* (`String(undefined ?? '') === ''`), zero corrupt after.
|
|
42
|
+
*
|
|
43
|
+
* `isCanonicalProto` resolves the ambiguity by tie-break, not by refusing
|
|
44
|
+
* to try JSON first: bytes that parse as JSON *and* are the exact canonical
|
|
45
|
+
* proto encoding are treated as proto. This has a narrow, priced cost on
|
|
46
|
+
* the reverse direction (legacy JSON misread as proto) that it introduces
|
|
47
|
+
* rather than merely fails to close — see `isCanonicalProto`'s own
|
|
48
|
+
* docblock.
|
|
24
49
|
*
|
|
25
50
|
* An empty payload falls through the same path: `JSON.parse('')` throws, so
|
|
26
51
|
* it reaches `decodeMessage` on zero bytes, which yields `{}` (proto3
|
|
@@ -31,6 +56,47 @@ export declare class ProtoRegistry {
|
|
|
31
56
|
* currently `macp-runtime`-only), not something this method decides.
|
|
32
57
|
*/
|
|
33
58
|
private decodeMultiRoundContribute;
|
|
59
|
+
/**
|
|
60
|
+
* True iff `payload` is the exact canonical protobuf encoding of
|
|
61
|
+
* `typeName` — used to break the JSON/proto ambiguity in
|
|
62
|
+
* `decodeMultiRoundContribute` (issue #104): bytes that both parse as JSON
|
|
63
|
+
* *and* round-trip byte-identically through the proto message are treated
|
|
64
|
+
* as proto, not JSON.
|
|
65
|
+
*
|
|
66
|
+
* `macp-sdk-python`'s equivalent (`_is_canonical_proto`) needs an explicit
|
|
67
|
+
* `DiscardUnknownFields()` step before comparing, because Python's protobuf
|
|
68
|
+
* runtime preserves unknown fields verbatim through
|
|
69
|
+
* `ParseFromString`/`SerializeToString` by default — without it, a plain
|
|
70
|
+
* round-trip check misclassified several whitespace-prefixed legacy-JSON
|
|
71
|
+
* payloads as canonical proto. **`protobufjs` needs no equivalent step**:
|
|
72
|
+
* verified directly (a synthetic payload with a declared field-1 string
|
|
73
|
+
* plus an undeclared field 2) that `Type.decode()` silently drops fields
|
|
74
|
+
* not in the schema rather than preserving them, so `Type.encode()` can
|
|
75
|
+
* never reproduce bytes it never captured — a byte-exact round-trip here
|
|
76
|
+
* is already proof the payload is *exactly* the single-field encoding, no
|
|
77
|
+
* unknown fields possible. Confirmed this closes the same
|
|
78
|
+
* whitespace-prefixed false-positive class `DiscardUnknownFields` closes
|
|
79
|
+
* in Python (tab/space-prefixed legacy JSON at several lengths).
|
|
80
|
+
*
|
|
81
|
+
* One narrow, symmetric residual survives regardless — genuinely
|
|
82
|
+
* irreducible, not a gap this check merely fails to close: a payload whose
|
|
83
|
+
* first byte is a literal `0x0A` (`\n`) and whose remainder forms a
|
|
84
|
+
* complete, well-formed proto field-1 string with nothing left over has no
|
|
85
|
+
* unknown field for either implementation to expose a mismatch on, because
|
|
86
|
+
* it *is*, byte for byte, both a legal JSON reading and the canonical
|
|
87
|
+
* proto encoding of some (possibly nonsensical) string. Verified instances
|
|
88
|
+
* (this SDK's own `JSON.stringify`, which — unlike Python's `json.dumps`
|
|
89
|
+
* — omits the space after `:`, shifting the colliding lengths by one byte
|
|
90
|
+
* from `macp-sdk-python`'s pinned cases): `'\n' + JSON.stringify({value:
|
|
91
|
+
* 'z'.repeat(112)})`, and with one extra whitespace byte inserted after the
|
|
92
|
+
* `\n`, `'\r'` at value length 1 and `' '` at value length 20. A real
|
|
93
|
+
* legacy-JSON sender would need to deliberately prefix with a literal
|
|
94
|
+
* newline byte for this to matter — this SDK's own JSON producers never
|
|
95
|
+
* emit one — so the trade (fixing the ordinary, naturally-reachable
|
|
96
|
+
* forward-direction corruption above at the cost of this adversarial,
|
|
97
|
+
* unreachable-by-any-known-encoder reverse case) is the right one.
|
|
98
|
+
*/
|
|
99
|
+
private isCanonicalProto;
|
|
34
100
|
private lookupType;
|
|
35
101
|
private tryDecodeUtf8;
|
|
36
102
|
}
|