@nuanu-ai/agentify 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +2 -0
- package/README.md +135 -0
- package/dist/backoff.d.ts +55 -0
- package/dist/backoff.js +64 -0
- package/dist/check-card.d.ts +33 -0
- package/dist/check-card.js +29 -0
- package/dist/cli.d.ts +21 -0
- package/dist/cli.js +24 -0
- package/dist/client.d.ts +377 -0
- package/dist/client.js +520 -0
- package/dist/contract.d.ts +17 -0
- package/dist/contract.js +20 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +39 -0
- package/dist/schema.d.ts +55 -0
- package/dist/schema.js +48 -0
- package/dist/transport.d.ts +200 -0
- package/dist/transport.js +271 -0
- package/dist/verify.d.ts +75 -0
- package/dist/verify.js +232 -0
- package/dist/worker.d.ts +347 -0
- package/dist/worker.js +614 -0
- package/package.json +52 -0
package/dist/verify.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentify verify` — the check a merchant runs on themselves before they
|
|
3
|
+
* ask us to look.
|
|
4
|
+
*
|
|
5
|
+
* The portal describes two checks. Is the card enough for an agent to assemble
|
|
6
|
+
* a correct purchase, and does the merchant's handler hold idempotency — that
|
|
7
|
+
* is, does a second delivery appear when one and the same order arrives twice.
|
|
8
|
+
*
|
|
9
|
+
* The first is here in full. The second is not, and this file refuses to
|
|
10
|
+
* pretend otherwise: it names what is missing, in the same words anyone can
|
|
11
|
+
* check against the route table, and answers with a code of its own that says
|
|
12
|
+
* "did not run" rather than "passed" or "failed". The reasons are written out
|
|
13
|
+
* in `IDEMPOTENCY_IS_NOT_BUILDABLE` below and are worth reading before anybody
|
|
14
|
+
* tries to add the check, because the gap is not in this package.
|
|
15
|
+
*
|
|
16
|
+
* Which cards are checked is asked for rather than discovered. Nothing in this
|
|
17
|
+
* package, in the contract or in any decision says where a merchant keeps
|
|
18
|
+
* their cards, and a command that went looking would be inventing a
|
|
19
|
+
* convention — a file name, a directory, a manifest — that nobody agreed to
|
|
20
|
+
* and that every merchant would then have to work around.
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { basename } from "node:path";
|
|
24
|
+
import { checkCard } from "./check-card.js";
|
|
25
|
+
import { describeProblems } from "./schema.js";
|
|
26
|
+
/**
|
|
27
|
+
* What the command answers with.
|
|
28
|
+
*
|
|
29
|
+
* Four and not two, because a build that branches on this needs to tell a
|
|
30
|
+
* check that failed from a check that never ran. Collapsing the two would make
|
|
31
|
+
* a missing check look like a passing one on the day somebody decides that
|
|
32
|
+
* anything non-zero is a failure and anything else is fine.
|
|
33
|
+
*/
|
|
34
|
+
export const VERIFY_EXIT = Object.freeze({
|
|
35
|
+
/** Every check ran and every check passed. */
|
|
36
|
+
PASSED: 0,
|
|
37
|
+
/** A check ran and found something. */
|
|
38
|
+
PROBLEMS: 1,
|
|
39
|
+
/** The command was called with something it cannot work from. */
|
|
40
|
+
USAGE: 2,
|
|
41
|
+
/** A check could not be run at all, so nothing is claimed about it. */
|
|
42
|
+
COULD_NOT_RUN: 3,
|
|
43
|
+
});
|
|
44
|
+
/**
|
|
45
|
+
* Why the idempotency run is not in this version, stated so it can be checked
|
|
46
|
+
* rather than taken on trust.
|
|
47
|
+
*
|
|
48
|
+
* The run the portal describes needs a test order to exist: the same order
|
|
49
|
+
* delivered twice through the live subscription, marked `test`, against a card
|
|
50
|
+
* that is published but not yet in any catalog. Four things it needs are
|
|
51
|
+
* absent, and none of them is ours to add here.
|
|
52
|
+
*
|
|
53
|
+
* There is no route that asks for one. The only way an order comes into being
|
|
54
|
+
* on this surface is `purchase_item`, and that is the payment exchange itself
|
|
55
|
+
* — an agent buying — not a request a merchant can make about their own card.
|
|
56
|
+
*
|
|
57
|
+
* `PurchaseRequestSchema` is closed and carries the purchase parameters and
|
|
58
|
+
* nothing else, so there is nowhere in a purchase to say "this one is a test".
|
|
59
|
+
*
|
|
60
|
+
* Nothing says how an order's `test` flag comes to be true. The order carries
|
|
61
|
+
* it, the handler is told to branch on it, and no document in the contract
|
|
62
|
+
* describes who sets it or from what.
|
|
63
|
+
*
|
|
64
|
+
* And a card carries no marker for "published, not yet in catalogs", which is
|
|
65
|
+
* the state the portal says these orders are raised against.
|
|
66
|
+
*
|
|
67
|
+
* What is missing is not an environment to run it in. There are two sites, the
|
|
68
|
+
* test one settles against test funds, and a key says which it belongs to
|
|
69
|
+
* (ADR-0020) — so a merchant already has somewhere to try their integration
|
|
70
|
+
* without spending anything. What is missing is a way to make an order happen
|
|
71
|
+
* there: no route raises one, and the four absences above are the whole of what
|
|
72
|
+
* would have to exist first. Inventing a route or a field here would be
|
|
73
|
+
* designing that, and this is not the place where it is designed.
|
|
74
|
+
*/
|
|
75
|
+
export const IDEMPOTENCY_IS_NOT_BUILDABLE = [
|
|
76
|
+
"The idempotency run needs a test order, and nothing on the surface can ask for one:",
|
|
77
|
+
" - no route raises an order for a merchant's own card; the only way an order",
|
|
78
|
+
" comes into being is purchase_item, which is an agent's payment exchange",
|
|
79
|
+
" - purchase_item's body carries the purchase parameters and nothing else, so",
|
|
80
|
+
" there is nowhere in it to say that a purchase is a test",
|
|
81
|
+
" - nothing in the contract says how an order's test flag comes to be true",
|
|
82
|
+
" - a card carries no marker for published-but-not-yet-in-catalogs, which is",
|
|
83
|
+
" the state the documentation raises these orders against",
|
|
84
|
+
"The test site is not what is missing: it exists, it settles against test",
|
|
85
|
+
"funds, and a csk_test_ key belongs to it. What is missing is a way to make",
|
|
86
|
+
"an order happen there, and inventing a route or a field here would be",
|
|
87
|
+
"designing one.",
|
|
88
|
+
].join("\n");
|
|
89
|
+
/** The code a finding carries when the file held no card to check at all. */
|
|
90
|
+
export const NOT_JSON = "not_json";
|
|
91
|
+
const nameOf = (file, card) => {
|
|
92
|
+
const key = typeof card === "object" && card !== null && "merchant_item_id" in card
|
|
93
|
+
? card.merchant_item_id
|
|
94
|
+
: undefined;
|
|
95
|
+
return typeof key === "string" && key !== "" ? `${file} (${key})` : file;
|
|
96
|
+
};
|
|
97
|
+
const USAGE = [
|
|
98
|
+
"Usage: agentify verify <card.json> [more-cards.json ...]",
|
|
99
|
+
"",
|
|
100
|
+
"Checks each card against the published contract before it is published.",
|
|
101
|
+
"",
|
|
102
|
+
"Answers: 1 a check found something, 2 called with something it cannot work",
|
|
103
|
+
"from, 3 a check could not be run. Zero, which means every check passed, is",
|
|
104
|
+
"not reachable today: the idempotency run cannot be built, and this command",
|
|
105
|
+
"will not report success for a check that never happened.",
|
|
106
|
+
].join("\n");
|
|
107
|
+
/**
|
|
108
|
+
* Why the bare command the documentation shows does not run.
|
|
109
|
+
*
|
|
110
|
+
* `agentify verify` with nothing after it would check the cards the merchant
|
|
111
|
+
* has already published. The binding reason it cannot is the plainest one:
|
|
112
|
+
* this command takes no key and no address, builds no client and asks the
|
|
113
|
+
* gateway nothing, so it has no way to see anything that was published.
|
|
114
|
+
*
|
|
115
|
+
* The call it would have needed does exist, and saying so is the honest half
|
|
116
|
+
* of the answer: `list_merchant_cards` returns published cards whole, each of
|
|
117
|
+
* them the card its author wrote rather than an agent's projection of it. What
|
|
118
|
+
* that route promises is worth repeating exactly, because every part of it is
|
|
119
|
+
* easy to overstate. Whose cards it returns is settled: the merchant whose key
|
|
120
|
+
* the call was made with, and nobody else. A card reaches that list by being accepted at
|
|
121
|
+
* `publish_card`, which parses the same `CardSchema` this package parses, so
|
|
122
|
+
* the cards on it passed this check on the day they went out — which is not
|
|
123
|
+
* the same as passing it now, because the schema can move, nothing parses a
|
|
124
|
+
* stored card again on the way back out, and a command that builds no client
|
|
125
|
+
* can never learn which version of the contract the gateway is speaking. And
|
|
126
|
+
* publishing is more than this check: it can refuse a card for reasons no
|
|
127
|
+
* schema carries, which `checkCard` says in its own words.
|
|
128
|
+
*
|
|
129
|
+
* Taking no key is a choice and not a hole, which is worth saying here because
|
|
130
|
+
* the first line of the message reads like a limitation somebody would set out
|
|
131
|
+
* to remove. A check that read published cards over that route would always
|
|
132
|
+
* pass — they got onto it by passing this very schema — and a green that cannot
|
|
133
|
+
* fail teaches a merchant to stop reading it. What would earn a keyed mode is a
|
|
134
|
+
* dry run of publishing, which can refuse for reasons no schema carries; there
|
|
135
|
+
* is no such route (`docs/research/00-open-questions.md`).
|
|
136
|
+
*
|
|
137
|
+
* So this is a stop and not a scolding, and it is answered with the code that
|
|
138
|
+
* means "did not run". The way through it is to name the card files, which are
|
|
139
|
+
* the copy the merchant can still change — the file is what the next publish
|
|
140
|
+
* carries, whether the card is new or an edit to one already out.
|
|
141
|
+
*/
|
|
142
|
+
const NOTHING_TO_CHECK = [
|
|
143
|
+
"agentify verify was given no card files, and it does not go looking for them:",
|
|
144
|
+
" - this command takes no key and no address and builds no client, so it",
|
|
145
|
+
" cannot ask us anything about what you have published",
|
|
146
|
+
" - the call that would answer such a question does exist, list_merchant_cards,",
|
|
147
|
+
" and it returns published cards whole — but a card reaches that list by",
|
|
148
|
+
" being accepted at publish, which runs this same check, so what comes back",
|
|
149
|
+
" is cards that passed this check on the day they went out",
|
|
150
|
+
" - neither this command nor the contract says where you keep the cards you",
|
|
151
|
+
" publish from, and looking for a file name or a directory would invent a",
|
|
152
|
+
" convention nobody agreed to",
|
|
153
|
+
"Name the card files instead. The file is what your next publish carries,",
|
|
154
|
+
"whether the card is new or an edit to one already out.",
|
|
155
|
+
].join("\n");
|
|
156
|
+
const checkFile = (path) => {
|
|
157
|
+
const name = basename(path);
|
|
158
|
+
let text;
|
|
159
|
+
try {
|
|
160
|
+
text = readFileSync(path, "utf8");
|
|
161
|
+
}
|
|
162
|
+
catch (cause) {
|
|
163
|
+
throw new Error(`${path} could not be read: ${String(cause)}`);
|
|
164
|
+
}
|
|
165
|
+
let card;
|
|
166
|
+
try {
|
|
167
|
+
card = JSON.parse(text);
|
|
168
|
+
}
|
|
169
|
+
catch (cause) {
|
|
170
|
+
return {
|
|
171
|
+
path,
|
|
172
|
+
name,
|
|
173
|
+
problems: [
|
|
174
|
+
{
|
|
175
|
+
path: [],
|
|
176
|
+
code: NOT_JSON,
|
|
177
|
+
message: `${name} is not JSON, so there is no card in it to check: ${String(cause)}`,
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return { path, name: nameOf(name, card), problems: checkCard(card).problems };
|
|
183
|
+
};
|
|
184
|
+
export const runVerify = async (argv, say) => {
|
|
185
|
+
const [command, ...files] = argv;
|
|
186
|
+
if (command !== "verify") {
|
|
187
|
+
say(command === undefined ? USAGE : `agentify does not know "${command}".\n\n${USAGE}`);
|
|
188
|
+
return VERIFY_EXIT.USAGE;
|
|
189
|
+
}
|
|
190
|
+
if (files.length === 0) {
|
|
191
|
+
say(NOTHING_TO_CHECK);
|
|
192
|
+
say("");
|
|
193
|
+
say(USAGE);
|
|
194
|
+
return VERIFY_EXIT.COULD_NOT_RUN;
|
|
195
|
+
}
|
|
196
|
+
const checked = [];
|
|
197
|
+
for (const file of files) {
|
|
198
|
+
try {
|
|
199
|
+
checked.push(checkFile(file));
|
|
200
|
+
}
|
|
201
|
+
catch (cause) {
|
|
202
|
+
say(String(cause instanceof Error ? cause.message : cause));
|
|
203
|
+
return VERIFY_EXIT.USAGE;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
say("Card completeness");
|
|
207
|
+
for (const card of checked) {
|
|
208
|
+
if (card.problems.length === 0) {
|
|
209
|
+
say(` ${card.name}: complete as far as the contract can tell`);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
say(` ${card.name}: ${card.problems.length} finding${card.problems.length === 1 ? "" : "s"}`);
|
|
213
|
+
say(describeProblems(card.problems));
|
|
214
|
+
}
|
|
215
|
+
const faulted = checked.filter((card) => card.problems.length > 0);
|
|
216
|
+
if (faulted.length > 0) {
|
|
217
|
+
// The truncation, said out loud. A card is checked in two stages and the
|
|
218
|
+
// second — the rules that compare one field against another — is only
|
|
219
|
+
// reached when the first passes, so a short list is not a promise that
|
|
220
|
+
// one round of fixes is enough.
|
|
221
|
+
say(" Fix these and run again: a card whose shape is wrong is not checked against the rules that compare one field with another, so there may be more behind them.");
|
|
222
|
+
}
|
|
223
|
+
say("");
|
|
224
|
+
say("Idempotency");
|
|
225
|
+
say(" could not be run.");
|
|
226
|
+
say(IDEMPOTENCY_IS_NOT_BUILDABLE);
|
|
227
|
+
say("");
|
|
228
|
+
say(faulted.length > 0
|
|
229
|
+
? "Verdict: the cards have findings, and the idempotency run did not happen."
|
|
230
|
+
: "Verdict: the cards are complete. Nothing is claimed about idempotency — that check did not run.");
|
|
231
|
+
return faulted.length > 0 ? VERIFY_EXIT.PROBLEMS : VERIFY_EXIT.COULD_NOT_RUN;
|
|
232
|
+
};
|
package/dist/worker.d.ts
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one loop that carries everything the merchant's process receives.
|
|
3
|
+
*
|
|
4
|
+
* The transport is HTTP long polling against the gateway (ADR-0004): the
|
|
5
|
+
* worker asks for the next batch, the gateway holds the request until
|
|
6
|
+
* something arrives or its own window closes, and the answer is a batch of
|
|
7
|
+
* envelopes or an empty one. An empty batch is the ordinary answer to a quiet
|
|
8
|
+
* window and not a failure. There is one loop and not three, because the
|
|
9
|
+
* stream carries three kinds on one connection and the portal promises the
|
|
10
|
+
* merchant one subscription.
|
|
11
|
+
*
|
|
12
|
+
* How each kind is answered is the part worth reading before changing
|
|
13
|
+
* anything, because the three are answered in three different places and none
|
|
14
|
+
* of them is an acknowledgement of the envelope itself.
|
|
15
|
+
*
|
|
16
|
+
* An order is given to the merchant's handler, and whatever the handler
|
|
17
|
+
* returns is posted to the order's answer route — in every mode, without the
|
|
18
|
+
* merchant asking. In the synchronous mode that return is the whole of the
|
|
19
|
+
* merchant's answer; in the asynchronous one it is usually an acceptance, and
|
|
20
|
+
* the goods follow later through the explicit `deliver` call, which is the
|
|
21
|
+
* merchant's to make and not this loop's.
|
|
22
|
+
*
|
|
23
|
+
* A price question is given to the price handler and the answer goes to the
|
|
24
|
+
* quote route against the `price_id` the question carried.
|
|
25
|
+
*
|
|
26
|
+
* An event is handed over and nothing is sent back. An event notifies, it does
|
|
27
|
+
* not ask for work.
|
|
28
|
+
*
|
|
29
|
+
* What happens when a handler throws is the design decision the rest follows
|
|
30
|
+
* from: nothing is sent. An order nobody answered comes back on its own,
|
|
31
|
+
* because the gateway's own order machine is watching for that answer and
|
|
32
|
+
* decides there should be another attempt — that is the machine's meaning of
|
|
33
|
+
* "not delivered". A retry written on this side would be a second machine with
|
|
34
|
+
* a second opinion about how many times a merchant's handler may run, and the
|
|
35
|
+
* two would disagree the first time one of them restarted. The same holds for a
|
|
36
|
+
* handler that answers with something the contract refuses, and for an envelope
|
|
37
|
+
* that arrives with no handler registered for its kind: the merchant is told
|
|
38
|
+
* through the problem channel, and an order or a price question comes back on
|
|
39
|
+
* its own.
|
|
40
|
+
*
|
|
41
|
+
* An event is the exception and it is worth knowing about. It carries no
|
|
42
|
+
* acknowledgement, so nothing here can ask for one again and nothing on the
|
|
43
|
+
* other side is waiting for a reply to notice that it went unanswered. The
|
|
44
|
+
* contract makes no promise that one is ever sent a second time, and whether a
|
|
45
|
+
* particular gateway sends one anyway is that gateway's business. A handler
|
|
46
|
+
* that throws on an event has lost what it carried.
|
|
47
|
+
*
|
|
48
|
+
* The transport failures are the one thing this loop does retry, because there
|
|
49
|
+
* is nothing else to do with them: a poll that did not reach the gateway
|
|
50
|
+
* delivered no envelope to anybody. That retry is the backoff in
|
|
51
|
+
* `backoff.ts` and nothing more.
|
|
52
|
+
*/
|
|
53
|
+
import { type HandlerAnswer, type Order, type OrderEvent, type QuoteRequest, type QuoteResponse } from "@nuanu-ai/agentify-contracts";
|
|
54
|
+
import { type Gateway } from "./transport.js";
|
|
55
|
+
/**
|
|
56
|
+
* What this loop calls with one paid order off the stream.
|
|
57
|
+
*
|
|
58
|
+
* It is not what a merchant writes. The order a merchant's handler receives
|
|
59
|
+
* carries the calls that close it, and putting those on it is `client.ts`'s
|
|
60
|
+
* job: this loop reads the envelope and knows nothing about a gateway address
|
|
61
|
+
* or an API key beyond the one it polls with. So the client registers a
|
|
62
|
+
* function of this shape which wraps the merchant's own.
|
|
63
|
+
*/
|
|
64
|
+
export type OrderDispatch = (order: Order) => HandlerAnswer | Promise<HandlerAnswer>;
|
|
65
|
+
/** The same, for "how much is this and is it there". */
|
|
66
|
+
export type QuoteDispatch = (question: QuoteRequest) => QuoteResponse | Promise<QuoteResponse>;
|
|
67
|
+
/**
|
|
68
|
+
* How this delivery of a message is named, for the one kind that needs it.
|
|
69
|
+
*
|
|
70
|
+
* `id` names this message and `sent_at` names when it went out. What the pair is
|
|
71
|
+
* not is a dependable way to recognise a repeat: a gateway sending an order
|
|
72
|
+
* again builds a fresh envelope around it under a fresh `id`, and what holds
|
|
73
|
+
* still across such a repeat is the order's own identifier in the payload.
|
|
74
|
+
*/
|
|
75
|
+
export interface Delivered {
|
|
76
|
+
readonly id: string;
|
|
77
|
+
readonly sent_at: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* What the merchant's code does with something that happened to an order.
|
|
81
|
+
*
|
|
82
|
+
* The second argument is this delivery's own name, and it is the reason this
|
|
83
|
+
* signature is not just the event. An order is answered against its own
|
|
84
|
+
* identifier and a price question against its `price_id`; an event is answered
|
|
85
|
+
* against nothing at all, so this pair is the only thing naming the message
|
|
86
|
+
* itself rather than the order it is about, which is what a log line or a
|
|
87
|
+
* question to us afterwards needs.
|
|
88
|
+
*
|
|
89
|
+
* It is not there to be deduplicated against. Nothing acknowledges an event, so
|
|
90
|
+
* nothing asks for one again, and the contract makes no promise that one is ever
|
|
91
|
+
* sent twice — which means the risk an event handler is written against is a
|
|
92
|
+
* message that never arrives rather than one that arrives twice.
|
|
93
|
+
*/
|
|
94
|
+
export type EventHandler = (event: OrderEvent, delivered: Delivered) => void | Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* The things the worker has to tell the merchant about, under the names their
|
|
97
|
+
* code can branch on.
|
|
98
|
+
*
|
|
99
|
+
* They are one vocabulary rather than several channels because every one of
|
|
100
|
+
* them has the same shape of consequence — something did not get through — and
|
|
101
|
+
* a merchant who wants to count them wants to count them together.
|
|
102
|
+
*/
|
|
103
|
+
export declare const WORKER_PROBLEM_KINDS: Readonly<{
|
|
104
|
+
/** The gateway speaks another dialect of the contract. The loop stops. */
|
|
105
|
+
readonly CONTRACT_VERSION_MISMATCH: "contract_version_mismatch";
|
|
106
|
+
/** A poll did not reach the gateway or did not come back as an answer. */
|
|
107
|
+
readonly POLL_FAILED: "poll_failed";
|
|
108
|
+
/** The merchant's handler threw. Nothing was answered and the work returns. */
|
|
109
|
+
readonly HANDLER_FAILED: "handler_failed";
|
|
110
|
+
/** The handler answered with something the contract does not accept. */
|
|
111
|
+
readonly HANDLER_ANSWER_REFUSED: "handler_answer_refused";
|
|
112
|
+
/** Something arrived that nobody had registered a handler for. */
|
|
113
|
+
readonly NO_HANDLER: "no_handler";
|
|
114
|
+
/** The answer was produced and could not be delivered to the gateway. */
|
|
115
|
+
readonly ANSWER_FAILED: "answer_failed";
|
|
116
|
+
/** The gateway would not take the answer: it names an order it cannot close. */
|
|
117
|
+
readonly ANSWER_REFUSED: "answer_refused";
|
|
118
|
+
/** A price answer arrived too late to price the purchase it was asked for. */
|
|
119
|
+
readonly QUOTE_ANSWER_UNUSED: "quote_answer_unused";
|
|
120
|
+
/** The worker stopped part-way through a batch and the rest went unread. */
|
|
121
|
+
readonly BATCH_ABANDONED: "batch_abandoned";
|
|
122
|
+
/** The loop itself failed in a way nothing here anticipated. A defect in this SDK. */
|
|
123
|
+
readonly WORKER_FAILED: "worker_failed";
|
|
124
|
+
/** A delivery carried the one field name this contract removes in silence. */
|
|
125
|
+
readonly DELIVERY_FIELD_DROPPED: "delivery_field_dropped";
|
|
126
|
+
}>;
|
|
127
|
+
export type WorkerProblemKind = (typeof WORKER_PROBLEM_KINDS)[keyof typeof WORKER_PROBLEM_KINDS];
|
|
128
|
+
export interface WorkerProblem {
|
|
129
|
+
readonly kind: WorkerProblemKind;
|
|
130
|
+
/** What happened, in one sentence a person can act on. */
|
|
131
|
+
readonly message: string;
|
|
132
|
+
/** Whether the loop stopped because of it. */
|
|
133
|
+
readonly fatal: boolean;
|
|
134
|
+
/** The exception behind it, where there was one. */
|
|
135
|
+
readonly cause?: unknown;
|
|
136
|
+
/** The order or the price question it is about, where it is about one. */
|
|
137
|
+
readonly subject?: string;
|
|
138
|
+
}
|
|
139
|
+
export type ProblemReporter = (problem: WorkerProblem) => void;
|
|
140
|
+
/**
|
|
141
|
+
* Calls a merchant's reporter and lets nothing it does escape.
|
|
142
|
+
*
|
|
143
|
+
* Reporting must not be able to end the loop that reports. A merchant's
|
|
144
|
+
* reporter is their code — a logger over a stream that closed during shutdown,
|
|
145
|
+
* a client that was never configured — and an exception out of it would
|
|
146
|
+
* otherwise unwind the worker and, from the worker's own last-resort handler,
|
|
147
|
+
* escape as an unhandled rejection and take the host process down with it.
|
|
148
|
+
* There is nowhere left to report a reporter that throws, so it is swallowed.
|
|
149
|
+
*
|
|
150
|
+
* Both ways of throwing, and the second is the one that hid. A reporter
|
|
151
|
+
* declared `void` may still be an `async` function — TypeScript allows it, and
|
|
152
|
+
* a merchant shipping problems to an HTTP logger will write one — and such a
|
|
153
|
+
* reporter does not throw, it returns a promise that rejects a turn later, past
|
|
154
|
+
* this `catch`, with nothing holding it. That is the unhandled rejection this
|
|
155
|
+
* comment claims not to allow, arriving by the door the `try` does not cover.
|
|
156
|
+
* So what comes back is settled too, and its failure dropped in the same
|
|
157
|
+
* silence and for the same reason.
|
|
158
|
+
*
|
|
159
|
+
* It is at the top of the file rather than inside the loop because the loop is
|
|
160
|
+
* not the only thing that reports: a merchant calling `deliver` on an order
|
|
161
|
+
* directly has a reporter too, and the same reasoning covers it exactly.
|
|
162
|
+
*/
|
|
163
|
+
export declare const reportSafely: (reporter: ProblemReporter, problem: WorkerProblem) => void;
|
|
164
|
+
/**
|
|
165
|
+
* The one loss in this contract that nobody is told about, said out loud here
|
|
166
|
+
* because this is the place it would reach the agent.
|
|
167
|
+
*
|
|
168
|
+
* A field named `__proto__` is removed while a delivery is parsed, before any
|
|
169
|
+
* check runs — it is neither carried nor refused, and the merchant's handler
|
|
170
|
+
* has no way to know that what it returned is not what went out. The contracts
|
|
171
|
+
* package names the delivery as the place the loss lands in what the agent is
|
|
172
|
+
* handed — `DeliverySchema` in its `handler.ts` — and a delivery is written
|
|
173
|
+
* here, so this file says it too.
|
|
174
|
+
*
|
|
175
|
+
* No card can declare such a field, so nothing legitimate reaches here; a
|
|
176
|
+
* merchant only meets it by delivering a name nobody asked for.
|
|
177
|
+
*
|
|
178
|
+
* It is one function because there are two roads to the same gateway and the
|
|
179
|
+
* loss is the same on both: the worker answering with what a handler returned,
|
|
180
|
+
* and a merchant calling `deliver` on an order himself. Written twice, one of
|
|
181
|
+
* them would drift or, as it did, never be written at all.
|
|
182
|
+
*/
|
|
183
|
+
export declare const droppedFieldWarning: (orderId: string, delivered: Readonly<Record<string, unknown>> | undefined) => WorkerProblem | null;
|
|
184
|
+
/**
|
|
185
|
+
* Which registration each kind on the stream is answered by, in the word a
|
|
186
|
+
* merchant passes to `on`.
|
|
187
|
+
*
|
|
188
|
+
* This table is the vocabulary, and everything that has to know the stream's
|
|
189
|
+
* kinds is derived from it rather than written out again: what a merchant may
|
|
190
|
+
* register, what the registry holds, what `start` counts as an answer, and the
|
|
191
|
+
* sentence that names a registration a merchant is missing. It is written over
|
|
192
|
+
* the contract's own kinds, so a fourth kind added to the envelope — the
|
|
193
|
+
* confirmation request, whose shape the contract does not carry yet — stops
|
|
194
|
+
* this file compiling until it has a word of its own here. The `switch` in
|
|
195
|
+
* `dispatch` below is the second half of that guard, and it is the one that
|
|
196
|
+
* demands the behaviour.
|
|
197
|
+
*/
|
|
198
|
+
export declare const REGISTERED_AS: Readonly<{
|
|
199
|
+
readonly order: "order";
|
|
200
|
+
readonly quote_request: "quote";
|
|
201
|
+
readonly order_event: "event";
|
|
202
|
+
}>;
|
|
203
|
+
/**
|
|
204
|
+
* The words a merchant registers something on the stream under.
|
|
205
|
+
*
|
|
206
|
+
* Indexed by the table's own keys and not by the contract's kinds, which reads
|
|
207
|
+
* the same today and behaves very differently on the day a fourth kind is
|
|
208
|
+
* added. Indexing by a key the table does not have yet collapses this type, and
|
|
209
|
+
* with it `Handlers[Kind]` and the contextual type of every `agentify.on(...)`
|
|
210
|
+
* call — so the first compile after the contract grows reports sixty errors, of
|
|
211
|
+
* which fifty-five are implicit-any at call sites that are not the problem, and
|
|
212
|
+
* the three places that do need a hand are buried among them. Indexed this way
|
|
213
|
+
* that same compile names two places, both real.
|
|
214
|
+
*
|
|
215
|
+
* Completeness is not what was lost: the `satisfies` clause on the table above
|
|
216
|
+
* is what demands a word per contract kind, and it still fires.
|
|
217
|
+
*/
|
|
218
|
+
export type StreamHandlerKind = (typeof REGISTERED_AS)[keyof typeof REGISTERED_AS];
|
|
219
|
+
/** What answers each of them, in the shape this loop calls. */
|
|
220
|
+
export interface StreamDispatch {
|
|
221
|
+
order: OrderDispatch;
|
|
222
|
+
quote: QuoteDispatch;
|
|
223
|
+
event: EventHandler;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The handlers the loop dispatches to, read at the moment an envelope is
|
|
227
|
+
* handled rather than captured when the loop starts.
|
|
228
|
+
*
|
|
229
|
+
* The registry belongs to the client and outlives any one loop. A merchant
|
|
230
|
+
* registers what their process answers, starts, stops, and starts again on the
|
|
231
|
+
* same handlers — and a handler registered while a loop is already running is
|
|
232
|
+
* in place for the next envelope rather than for the next loop.
|
|
233
|
+
*/
|
|
234
|
+
export type HandlerRegistry = {
|
|
235
|
+
[Kind in StreamHandlerKind]?: StreamDispatch[Kind] | undefined;
|
|
236
|
+
} & {
|
|
237
|
+
problem: ProblemReporter;
|
|
238
|
+
};
|
|
239
|
+
/**
|
|
240
|
+
* Time, as the loop sees it.
|
|
241
|
+
*
|
|
242
|
+
* A seam rather than a call to `setTimeout` in the middle of the loop, so that
|
|
243
|
+
* a test can assert which delays were asked for without waiting through them.
|
|
244
|
+
* It is not part of the published surface: a merchant has no business
|
|
245
|
+
* replacing the clock their worker runs on.
|
|
246
|
+
*/
|
|
247
|
+
export interface WorkerClock {
|
|
248
|
+
now(): number;
|
|
249
|
+
sleep(ms: number, signal: AbortSignal): Promise<void>;
|
|
250
|
+
random(): number;
|
|
251
|
+
}
|
|
252
|
+
export declare const systemClock: WorkerClock;
|
|
253
|
+
/**
|
|
254
|
+
* The shortest a quiet poll may take before the next one.
|
|
255
|
+
*
|
|
256
|
+
* The gateway holds a poll open for its own window, so in ordinary work this
|
|
257
|
+
* costs nothing: by the time an empty batch comes back, the floor has long
|
|
258
|
+
* passed and the next poll goes out at once — which is what keeps a waiting
|
|
259
|
+
* agent's price question free of any polling lag (ADR-0004 §4). It exists for
|
|
260
|
+
* the gateway that is not holding requests at all, misconfigured or half
|
|
261
|
+
* broken, where a loop with no floor turns into as many requests a second as
|
|
262
|
+
* the network allows. One second is the whole of the claim: slow enough that
|
|
263
|
+
* nothing is hammered, fast enough that it is invisible against a window
|
|
264
|
+
* measured in tens of seconds.
|
|
265
|
+
*/
|
|
266
|
+
export declare const QUIET_POLL_FLOOR_MS = 1000;
|
|
267
|
+
/**
|
|
268
|
+
* How long the worker asks the gateway to hold a poll open, per ADR-0004 §1.
|
|
269
|
+
*
|
|
270
|
+
* It is a request and not an expectation. The gateway holds it for its own
|
|
271
|
+
* window at most, and the contract says as much, so a shorter answer is
|
|
272
|
+
* ordinary rather than a fault. Asking is what makes the number visible on the
|
|
273
|
+
* wire, where a gateway that wanted to answer sooner can see it.
|
|
274
|
+
*/
|
|
275
|
+
export declare const POLL_WAIT_SECONDS = 25;
|
|
276
|
+
/**
|
|
277
|
+
* How long the worker will wait for a poll before giving up on it.
|
|
278
|
+
*
|
|
279
|
+
* A window asked for is not a promise kept: a connection dropped silently by
|
|
280
|
+
* something in the middle — a load balancer that recycled it, a firewall that
|
|
281
|
+
* forgot it — leaves this side waiting on an answer that will never come, with
|
|
282
|
+
* nothing reported and nothing retried. Without a deadline of our own the only
|
|
283
|
+
* thing that eventually breaks that is whatever timeout the runtime happens to
|
|
284
|
+
* default to, which is a number nobody here chose and which is measured in
|
|
285
|
+
* minutes against a window measured in seconds.
|
|
286
|
+
*
|
|
287
|
+
* Twice the window, so a gateway that holds the full window and answers late is
|
|
288
|
+
* never cut off, and a connection that has gone quiet costs one window of
|
|
289
|
+
* silence rather than several minutes of it.
|
|
290
|
+
*
|
|
291
|
+
* This worker never comes near it against the gateway in this repository, and
|
|
292
|
+
* that is not luck: every poll names the window it wants, and that gateway
|
|
293
|
+
* holds a poll for the smaller of the window asked for and its own, so the wait
|
|
294
|
+
* is bounded by the twenty-five seconds above however the gateway is
|
|
295
|
+
* configured. Naming the window is this side's guarantee. The deadline is for
|
|
296
|
+
* the other case — a connection that died without saying so.
|
|
297
|
+
*
|
|
298
|
+
* A second belt exists on the gateway's side, and it is not for this worker.
|
|
299
|
+
* The window is optional on the wire, and a poll that names none is held for
|
|
300
|
+
* the gateway's whole configured window instead; that window is refused above a
|
|
301
|
+
* ceiling set under this number (`WORKER_POLL_WAIT_CEILING_MS` in
|
|
302
|
+
* `apps/gateway/src/config.ts`), which is where this figure is copied. The
|
|
303
|
+
* gateway depends on the contracts and the core machine rather than on the SDK,
|
|
304
|
+
* so it cannot read it, and nothing checks that the copy still matches:
|
|
305
|
+
* changing what is written here means changing it there in the same breath.
|
|
306
|
+
*
|
|
307
|
+
* It is also the reason two poll failures can be a minute and a half apart,
|
|
308
|
+
* which matters to anybody counting them from outside. A gateway that refuses
|
|
309
|
+
* connections fails a poll at once and the backoff is the whole gap; one that
|
|
310
|
+
* accepts and never answers fails only when this deadline runs out, so the gap
|
|
311
|
+
* is this plus the backoff. `DOUBT_MS` in packages/slice/src/subscription.ts is
|
|
312
|
+
* that sum, and it is named here because raising this number silently narrows
|
|
313
|
+
* it — and a window too narrow reads the quiet between two failures as a
|
|
314
|
+
* recovery.
|
|
315
|
+
*/
|
|
316
|
+
export declare const POLL_DEADLINE_MS: number;
|
|
317
|
+
/** What `startWorker` hands its caller, which is the client and never a merchant. */
|
|
318
|
+
export interface RunningWorker {
|
|
319
|
+
/**
|
|
320
|
+
* Stops the loop and waits for it to finish. Safe to call twice.
|
|
321
|
+
*
|
|
322
|
+
* Two things are abandoned rather than finished, and they are not abandoned
|
|
323
|
+
* on the same terms. A poll parked at the gateway is dropped, and whatever
|
|
324
|
+
* it would have carried was never handed to anybody, so it is redelivered.
|
|
325
|
+
* An answer already on its way — a delivery the handler produced a moment
|
|
326
|
+
* ago — is dropped too, and there the honest thing to say is that nobody on
|
|
327
|
+
* this side knows whether it arrived first: the order may already be closed
|
|
328
|
+
* by it, or may come back. The merchant is told which of their orders that
|
|
329
|
+
* happened to, because on their side the work happened.
|
|
330
|
+
*
|
|
331
|
+
* Stopping does not drain. The contract has a word for a poll that asks for
|
|
332
|
+
* whatever is queued right now and comes straight back, and it is not what a
|
|
333
|
+
* worker shutting down wants: draining pulls more work into a process that
|
|
334
|
+
* is going away.
|
|
335
|
+
*/
|
|
336
|
+
stop(): Promise<void>;
|
|
337
|
+
/** Whether the loop is still going. False once it has stopped for any reason. */
|
|
338
|
+
running(): boolean;
|
|
339
|
+
}
|
|
340
|
+
export declare const startWorker: (gateway: Gateway, handlers: HandlerRegistry, clock?: WorkerClock,
|
|
341
|
+
/**
|
|
342
|
+
* The deadline is a parameter for the same reason the clock is: a test
|
|
343
|
+
* cannot wait out fifty seconds to find out whether the worker gives up on a
|
|
344
|
+
* poll, and a deadline nothing exercises is a deadline nobody has seen work.
|
|
345
|
+
* It is not part of anything a merchant passes.
|
|
346
|
+
*/
|
|
347
|
+
deadlineMs?: number) => RunningWorker;
|