@worker-protocol/client 0.1.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/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Rowing Tech, S.A.
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,9 @@
1
+ worker-protocol
2
+ Copyright 2026 Rowing Tech, S.A.
3
+
4
+ This product includes software developed at Rowing Tech, S.A. (https://rowing.tech).
5
+
6
+ Licensed under the Apache License, Version 2.0. See LICENSE for the full text.
7
+
8
+ "worker-protocol" and any conformance claim made in its name are not licensed under Apache-2.0;
9
+ see section 6 of the License.
package/dist/call.d.ts ADDED
@@ -0,0 +1,110 @@
1
+ import type * as z from "zod";
2
+ /**
3
+ * One call to a Worker, and every rule in `spec/` that binds the party making it.
4
+ *
5
+ * Nine rules in the specification oblige a consumer rather than a Worker, and until this package
6
+ * existed none of them had a subject: `conformance/verifiability.md` classes them `P` and a report
7
+ * says *other subject*, because a tool pointed at a base URL never contacted whoever they bind.
8
+ * They are all here, each cited where it is obeyed, and `__tests__/consumer-rules.test.ts` is what
9
+ * holds this to them.
10
+ *
11
+ * DESC-13, DESC-30, ENDP-13, ENDP-14, ENDP-21, ENDP-27, ENDP-28, ENDP-30, ENDP-31 — that is the
12
+ * whole list, and a line below cites each. It was eleven until the Claim lifecycle was withdrawn:
13
+ * TASK-18 bound a holder not to do arithmetic on a lease it no longer has, and TASK-20 had it name
14
+ * that lease on the Action.
15
+ */
16
+ /** A Worker refused, and the refusal is the Worker's own statement about itself (ENDP-25). */
17
+ export declare class Refused extends Error {
18
+ readonly code: string;
19
+ readonly status: number;
20
+ /** ENDP-25. `reject` will be wrong again; `retry` may not be. */
21
+ readonly kind: "reject" | "retry";
22
+ readonly url: string;
23
+ constructor(code: string, status: number,
24
+ /** ENDP-25. `reject` will be wrong again; `retry` may not be. */
25
+ kind: "reject" | "retry", url: string, message: string);
26
+ }
27
+ /**
28
+ * A Worker answered something this protocol does not admit.
29
+ *
30
+ * It names the rule, because a consumer told *the response was invalid* cannot act on it and two
31
+ * very different faults read identically — which is the whole reason the ids exist. What is
32
+ * checked is only what `schemas/` fixes; a Task's payload, an Action's result and an event's data
33
+ * are the Worker's own and this never looks inside them.
34
+ */
35
+ export declare class Malformed extends Error {
36
+ readonly rule: string;
37
+ readonly url: string;
38
+ readonly detail: string;
39
+ constructor(rule: string, url: string, detail: string);
40
+ }
41
+ /** A declared address that serves nothing, which DESC-30 makes a contract error and not a retry. */
42
+ export declare class Unserved extends Error {
43
+ readonly url: string;
44
+ constructor(url: string);
45
+ }
46
+ export type Fetch = typeof globalThis.fetch;
47
+ export type CallerOptions = {
48
+ /** REG-3. Presented as `Authorization: Bearer <token>`, and nowhere else. */
49
+ credential?: string;
50
+ /** ENDP-30 (recommended). How many times a `retry` is repeated before the caller gives up. */
51
+ retries?: number;
52
+ /** ENDP-30. The first wait, doubled each time. */
53
+ backoffMs?: number;
54
+ fetch?: Fetch;
55
+ /** For a test that needs the clock still. Defaults to a real wait. */
56
+ wait?: (ms: number) => Promise<void>;
57
+ };
58
+ export type Call = {
59
+ url: string;
60
+ method?: "GET" | "POST";
61
+ body?: string;
62
+ /** ENDP-15. Where the Action declares it reads a key from the header. */
63
+ idempotencyKey?: string;
64
+ /**
65
+ * Whether a `404` here means the ADDRESS serves nothing (DESC-30) or that a resource is gone.
66
+ *
67
+ * The division is the one DESC-30 was narrowed to: a read of a Capability's own address asks
68
+ * whether the Worker answers there at all, and a call naming a metric, an Action or a Task asks
69
+ * about a thing — the first is a Descriptor that lied, the second is an ordinary refusal.
70
+ */
71
+ addressLevel?: boolean;
72
+ };
73
+ export declare function caller(descriptorUrl: string, options?: CallerOptions): {
74
+ call: (spec: Call) => Promise<{
75
+ status: number;
76
+ body: string;
77
+ json: unknown;
78
+ }>;
79
+ validated: <T>(spec: Call, schema: z.ZodType<T>, rule: string) => Promise<T>;
80
+ /** DESC-12. Every address is resolved against the URL the Descriptor was read FROM. */
81
+ resolve: (address: string) => string;
82
+ edition: () => string | undefined;
83
+ };
84
+ export type Caller = ReturnType<typeof caller>;
85
+ /**
86
+ * Every page of a collection, read the way ENDP-20 and ENDP-21 say.
87
+ *
88
+ * ENDP-31 is the rule this exists to obey and it is the one a caller breaks without noticing: it
89
+ * reads how many items it RECEIVED, never how many it asked for. A caller that assumed a full page
90
+ * meant more to come, or that a short page meant the end, silently loses the rest of a collection
91
+ * — so the only thing that ends this loop is the cursor being absent.
92
+ *
93
+ * ENDP-21: the cursor is opaque, is produced only by the Worker, and is never constructed here. It
94
+ * goes back exactly as it arrived.
95
+ */
96
+ /**
97
+ * Every page of a collection, drained into one array.
98
+ *
99
+ * `pages` yields page by page so that a consumer may stop early; every caller in this package
100
+ * wants the whole thing, which is this. ENDP-20's envelope, ENDP-21's cursor and the bound on a
101
+ * Worker whose cursor never advances are all `pages`' — nothing is added here but the array.
102
+ */
103
+ export declare function collect<T>(caller: Caller, url: string, schema: z.ZodType<{
104
+ items: T[];
105
+ nextCursor?: string;
106
+ }>, rule: string, parameters?: Record<string, string>): Promise<T[]>;
107
+ export declare function pages<T>(caller: Caller, url: string, schema: z.ZodType<{
108
+ items: T[];
109
+ nextCursor?: string;
110
+ }>, rule: string, parameters?: Record<string, string>): AsyncGenerator<T[]>;
package/dist/call.js ADDED
@@ -0,0 +1,216 @@
1
+ import { error as errorSchema } from "@worker-protocol/schemas";
2
+ /**
3
+ * One call to a Worker, and every rule in `spec/` that binds the party making it.
4
+ *
5
+ * Nine rules in the specification oblige a consumer rather than a Worker, and until this package
6
+ * existed none of them had a subject: `conformance/verifiability.md` classes them `P` and a report
7
+ * says *other subject*, because a tool pointed at a base URL never contacted whoever they bind.
8
+ * They are all here, each cited where it is obeyed, and `__tests__/consumer-rules.test.ts` is what
9
+ * holds this to them.
10
+ *
11
+ * DESC-13, DESC-30, ENDP-13, ENDP-14, ENDP-21, ENDP-27, ENDP-28, ENDP-30, ENDP-31 — that is the
12
+ * whole list, and a line below cites each. It was eleven until the Claim lifecycle was withdrawn:
13
+ * TASK-18 bound a holder not to do arithmetic on a lease it no longer has, and TASK-20 had it name
14
+ * that lease on the Action.
15
+ */
16
+ /** A Worker refused, and the refusal is the Worker's own statement about itself (ENDP-25). */
17
+ export class Refused extends Error {
18
+ code;
19
+ status;
20
+ kind;
21
+ url;
22
+ constructor(code, status,
23
+ /** ENDP-25. `reject` will be wrong again; `retry` may not be. */
24
+ kind, url, message) {
25
+ super(`${code} (${status}) at ${url}: ${message}`);
26
+ this.code = code;
27
+ this.status = status;
28
+ this.kind = kind;
29
+ this.url = url;
30
+ this.name = "Refused";
31
+ }
32
+ }
33
+ /**
34
+ * A Worker answered something this protocol does not admit.
35
+ *
36
+ * It names the rule, because a consumer told *the response was invalid* cannot act on it and two
37
+ * very different faults read identically — which is the whole reason the ids exist. What is
38
+ * checked is only what `schemas/` fixes; a Task's payload, an Action's result and an event's data
39
+ * are the Worker's own and this never looks inside them.
40
+ */
41
+ export class Malformed extends Error {
42
+ rule;
43
+ url;
44
+ detail;
45
+ constructor(rule, url, detail) {
46
+ super(`${rule}: ${detail} — from ${url}`);
47
+ this.rule = rule;
48
+ this.url = url;
49
+ this.detail = detail;
50
+ this.name = "Malformed";
51
+ }
52
+ }
53
+ /** A declared address that serves nothing, which DESC-30 makes a contract error and not a retry. */
54
+ export class Unserved extends Error {
55
+ url;
56
+ constructor(url) {
57
+ super(`DESC-30: ${url} is declared in the Descriptor and serves nothing. This is a contract error: the Descriptor says the Worker answers here and it does not.`);
58
+ this.url = url;
59
+ this.name = "Unserved";
60
+ }
61
+ }
62
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
63
+ export function caller(descriptorUrl, options = {}) {
64
+ const send = options.fetch ?? globalThis.fetch;
65
+ const wait = options.wait ?? sleep;
66
+ const retries = options.retries ?? 3;
67
+ const backoff = options.backoffMs ?? 200;
68
+ // DESC-13: a credential granted for this Worker is not presented to an address on an origin the
69
+ // operator did not record as the Worker's own. An address may point away — a Worker whose Tasks
70
+ // are held by one deployment and whose health is answered by another is a placement decision —
71
+ // and the Descriptor is a document the Worker controls, so an address in it is an instruction to
72
+ // send a request somewhere. Without this, a Worker could name any host and be handed the token.
73
+ const ownOrigin = new URL(descriptorUrl).origin;
74
+ /** DESC-30: addresses this consumer has found to serve nothing. It does not call them again. */
75
+ const unserved = new Set();
76
+ /** ENDP-5. What the Worker last said produced an answer, for a caller that wants to notice. */
77
+ let lastEdition;
78
+ const once = async (call) => {
79
+ const headers = new Headers();
80
+ if (options.credential !== undefined) {
81
+ if (new URL(call.url).origin === ownOrigin) {
82
+ headers.set("authorization", `Bearer ${options.credential}`);
83
+ }
84
+ // Off-origin: the request still goes, and without the credential. DESC-13 forbids presenting
85
+ // it, not calling the address — which the Worker declared and may well serve openly.
86
+ }
87
+ if (call.body !== undefined)
88
+ headers.set("content-type", "application/json");
89
+ if (call.idempotencyKey !== undefined) {
90
+ headers.set("idempotency-key", call.idempotencyKey);
91
+ }
92
+ return send(call.url, {
93
+ method: call.method ?? "GET",
94
+ headers,
95
+ ...(call.body === undefined ? {} : { body: call.body }),
96
+ redirect: "manual",
97
+ });
98
+ };
99
+ /**
100
+ * One call, classified, retried where the Worker said it may be, and never where it said not.
101
+ *
102
+ * ENDP-28 is the rule that costs the most to get wrong and it binds here: a caller that retries
103
+ * a `reject` hammers a Worker with a request that will never succeed and buries the failure. So
104
+ * a `reject` throws on the first answer, and only a `retry` comes round again.
105
+ */
106
+ const call = async (spec) => {
107
+ if (unserved.has(spec.url))
108
+ throw new Unserved(spec.url);
109
+ let waited = backoff;
110
+ for (let attempt = 0;; attempt++) {
111
+ const response = await once(spec);
112
+ const text = await response.text();
113
+ let json = null;
114
+ try {
115
+ json = JSON.parse(text);
116
+ }
117
+ catch {
118
+ json = null;
119
+ }
120
+ // ENDP-5: a caller that sees an edition it did not expect re-reads the Descriptor rather
121
+ // than parsing the body. What it does about it is the caller's; noticing is this line.
122
+ lastEdition = response.headers.get("worker-protocol-edition") ?? lastEdition;
123
+ if (response.status < 400)
124
+ return { status: response.status, body: text, json };
125
+ // DESC-30: an address the Descriptor declares and that serves nothing is a contract error,
126
+ // and a consumer stops. It is recorded so that nothing here calls it again, which is what
127
+ // *does not retry* means when the same consumer keeps running.
128
+ if (response.status === 404 && spec.addressLevel === true) {
129
+ unserved.add(spec.url);
130
+ throw new Unserved(spec.url);
131
+ }
132
+ // ENDP-14, ENDP-27: where the class and the status disagree, the class in the envelope wins
133
+ // when an envelope is present and parses, and the status wins when it is not. ENDP-13: an
134
+ // answer that can be classified by neither is `reject` — stopping loudly on something that
135
+ // would have succeeded costs an alert, and retrying on something that never will costs the
136
+ // work, silently.
137
+ const envelope = errorSchema.safeParse(json);
138
+ const kind = envelope.success
139
+ ? envelope.data.class
140
+ : RETRY_STATUS.has(response.status)
141
+ ? "retry"
142
+ : "reject";
143
+ const code = envelope.success ? envelope.data.code : `http_${response.status}`;
144
+ const message = envelope.success ? envelope.data.message : text.slice(0, 200);
145
+ // ENDP-28: a caller does not retry a reject. The request is wrong and will be wrong again.
146
+ if (kind === "reject" || attempt >= retries) {
147
+ throw new Refused(code, response.status, kind, spec.url, message);
148
+ }
149
+ // ENDP-30 (recommended): a caller backs off and repeats a `retry` unchanged. Unchanged is
150
+ // the load-bearing word — the same request, under the same key if it had one.
151
+ await wait(waited);
152
+ waited *= 2;
153
+ }
154
+ };
155
+ /** A call whose answer is a document this protocol fixes the shape of. */
156
+ const validated = async (spec, schema, rule) => {
157
+ const answer = await call(spec);
158
+ const parsed = schema.safeParse(answer.json);
159
+ if (!parsed.success) {
160
+ const issue = parsed.error.issues[0];
161
+ const where = issue?.path.join(".") || "(root)";
162
+ throw new Malformed(rule, spec.url, `${where}: ${issue?.message ?? "did not validate"}`);
163
+ }
164
+ return parsed.data;
165
+ };
166
+ return {
167
+ call,
168
+ validated,
169
+ /** DESC-12. Every address is resolved against the URL the Descriptor was read FROM. */
170
+ resolve: (address) => new URL(address, descriptorUrl).toString(),
171
+ edition: () => lastEdition,
172
+ };
173
+ }
174
+ /** ENDP-29's `retry` rows, for an answer that carries no envelope to read a class off. */
175
+ const RETRY_STATUS = new Set([408, 429, 500, 502, 503, 504]);
176
+ /**
177
+ * Every page of a collection, read the way ENDP-20 and ENDP-21 say.
178
+ *
179
+ * ENDP-31 is the rule this exists to obey and it is the one a caller breaks without noticing: it
180
+ * reads how many items it RECEIVED, never how many it asked for. A caller that assumed a full page
181
+ * meant more to come, or that a short page meant the end, silently loses the rest of a collection
182
+ * — so the only thing that ends this loop is the cursor being absent.
183
+ *
184
+ * ENDP-21: the cursor is opaque, is produced only by the Worker, and is never constructed here. It
185
+ * goes back exactly as it arrived.
186
+ */
187
+ /**
188
+ * Every page of a collection, drained into one array.
189
+ *
190
+ * `pages` yields page by page so that a consumer may stop early; every caller in this package
191
+ * wants the whole thing, which is this. ENDP-20's envelope, ENDP-21's cursor and the bound on a
192
+ * Worker whose cursor never advances are all `pages`' — nothing is added here but the array.
193
+ */
194
+ export async function collect(caller, url, schema, rule, parameters = {}) {
195
+ const held = [];
196
+ for await (const page of pages(caller, url, schema, rule, parameters))
197
+ held.push(...page);
198
+ return held;
199
+ }
200
+ export async function* pages(caller, url, schema, rule, parameters = {}) {
201
+ let cursor;
202
+ // A bound, because a Worker whose cursor never advances would otherwise spin a consumer forever.
203
+ for (let page = 0; page < 10_000; page++) {
204
+ const target = new URL(url);
205
+ for (const [key, value] of Object.entries(parameters))
206
+ target.searchParams.set(key, value);
207
+ if (cursor !== undefined)
208
+ target.searchParams.set("cursor", cursor);
209
+ const answered = await caller.validated({ url: target.toString(), addressLevel: Object.keys(parameters).length === 0 }, schema, rule);
210
+ yield answered.items;
211
+ // ENDP-20: absent at the end of the collection — absent, not null, and not an empty page.
212
+ if (answered.nextCursor === undefined)
213
+ return;
214
+ cursor = answered.nextCursor;
215
+ }
216
+ }
@@ -0,0 +1,109 @@
1
+ import { type activity as activitySchema, type alert, descriptor as descriptorSchema, health as healthSchema, type metricBucket, type task as taskSchema } from "@worker-protocol/schemas";
2
+ import type * as z from "zod";
3
+ import { type CallerOptions } from "./call.ts";
4
+ /**
5
+ * `@worker-protocol/client` — read a Worker, and take work from it.
6
+ *
7
+ * `consume(url)` reads a Descriptor once, resolves every address the Worker declared (DESC-12) and
8
+ * answers an object with one member per Capability that Worker implements — and nothing for the
9
+ * ones it does not, because DESC-2 admits any combination including none.
10
+ *
11
+ * **It is the other half of `mount()` and it carries the same kind of thing.** A consumer that
12
+ * wrote this itself would write the address resolution, the paging and its cursor, the retry that
13
+ * must not happen on a `reject`, and the classification of an answer it cannot read. All of that
14
+ * is fixed by rules — nine of which oblige a consumer rather than a Worker — and `call.ts` cites
15
+ * every one.
16
+ *
17
+ * It depends on `@worker-protocol/schemas` and on `fetch`, and on nothing else. A Tower, a teams
18
+ * app or a Worker that consumes another Worker installs no web framework to do it.
19
+ *
20
+ * **Strict about what this protocol fixes, and blind to what it does not.** Every document
21
+ * `schemas/` describes is validated, and a Worker that answers something else raises `Malformed`
22
+ * naming the rule. A Task's payload, an Action's input and result, an event's data are the
23
+ * Worker's own — this protocol has no data model — and nothing here looks inside them.
24
+ */
25
+ export { type Call, type Caller, type CallerOptions, Malformed, Refused, Unserved, } from "./call.ts";
26
+ type Descriptor = z.infer<typeof descriptorSchema>;
27
+ type Task = z.infer<typeof taskSchema>;
28
+ type Alert = z.infer<typeof alert>;
29
+ type Activity = z.infer<typeof activitySchema>;
30
+ type Bucket = z.infer<typeof metricBucket>;
31
+ /** What one Worker offers, read from its Descriptor and never guessed. */
32
+ export type Consumed = {
33
+ /** The document itself, validated. Everything below was read out of it. */
34
+ descriptor: Descriptor;
35
+ /** DESC-23. The edition this Worker declares it speaks. */
36
+ edition: string;
37
+ health?: () => Promise<z.infer<typeof healthSchema>>;
38
+ metrics?: {
39
+ /** MET-8. One metric, every bucket in the interval, paged through (ENDP-20, ENDP-31). */
40
+ read: (metric: string, options?: MetricRead) => Promise<Bucket[]>;
41
+ };
42
+ actions?: {
43
+ /** ACT-5. The body is the input and carries nothing else. */
44
+ perform: (name: string, input: unknown, options?: PerformOptions) => Promise<unknown>;
45
+ /** ACT-15. The document `configure` would accept, where this Worker exposes one. */
46
+ settings?: () => Promise<unknown>;
47
+ };
48
+ alerts?: () => Promise<Alert[]>;
49
+ /** ACTV-2. What the Worker is doing and has undertaken to do. Read, never written. */
50
+ activity?: () => Promise<Activity[]>;
51
+ /**
52
+ * NDG-2. Tell this Worker there is work of a Task type it answers.
53
+ *
54
+ * The one write in this package that is not an Action, and the one whose body this protocol
55
+ * fixes rather than the Worker: a type, and nothing else. It buys latency and nothing else —
56
+ * TASK-19 recommends it and binds nobody, because a consumer reading on its own schedule is
57
+ * slower and never wrong, while one that reads only when told is a single dropped request away
58
+ * from stalling silently. So this answers nothing and is safe to lose.
59
+ *
60
+ * NDG-3: a type this Worker declares no Skill for is refused, and `Refused` carries the code.
61
+ * `skills.canAnswer` in this package is how a caller knows before sending one.
62
+ */
63
+ nudges?: (type: string) => Promise<void>;
64
+ tasks?: {
65
+ /**
66
+ * TASK-5. Every Task whose condition holds that this credential covers.
67
+ *
68
+ * Answering one is `actions.perform` with an Action the Task type names, and there is nothing
69
+ * to claim and nothing to close: the condition stops holding and the Task is gone.
70
+ */
71
+ list: (type?: string) => Promise<Task[]>;
72
+ /**
73
+ * How to answer a Task of this type: the Action to post, and the shape it takes.
74
+ *
75
+ * A Task carries its id, its type, its payload and when its condition began — and nothing about
76
+ * how to answer it, because that belongs to the Worker that raised it and is declared twice
77
+ * over in its Descriptor: the Task type names the Action that answers it (TASK-32), and that
78
+ * Action declares the JSON Schema of its input (ACT-2). Reading both is two walks down a
79
+ * document a consumer already holds, and every consumer was doing them by hand.
80
+ *
81
+ * The schema is handed back as it travels, so a console can render a form from it and an agent
82
+ * can build the document, neither having been told anything about this Worker. Where the Task
83
+ * can end several ways, that schema is a discriminated union and each ending is a variant.
84
+ *
85
+ * `undefined` where this Worker does not raise the type, or names an Action its own `actions`
86
+ * entry does not accept — a Descriptor disagreeing with itself is the verifier's to report
87
+ * against that Worker, and handing back a call that would answer `404` is not a consumer's job.
88
+ */
89
+ answers: (type: string) => {
90
+ action: string;
91
+ input: unknown;
92
+ } | undefined;
93
+ };
94
+ };
95
+ export type MetricRead = {
96
+ granularity?: string;
97
+ from?: Date;
98
+ to?: Date;
99
+ /** MET-19. Dimensions to break down by, each one that declared its set of values. */
100
+ by?: string[];
101
+ /** MET-16. Dimensions to fix, each spelled as a parameter of its own name. */
102
+ fixed?: Record<string, string>;
103
+ };
104
+ export type PerformOptions = {
105
+ /** ENDP-15. Where the Action declares it reads a key from the header. */
106
+ idempotencyKey?: string;
107
+ };
108
+ export { type Compatibility, canAnswer } from "./skills.ts";
109
+ export declare function consume(baseUrl: string, options?: CallerOptions): Promise<Consumed>;
package/dist/index.js ADDED
@@ -0,0 +1,134 @@
1
+ import { activityPage, alertPage, descriptor as descriptorSchema, health as healthSchema, metricPage, taskPage, } from "@worker-protocol/schemas";
2
+ import { caller, collect } from "./call.js";
3
+ /**
4
+ * `@worker-protocol/client` — read a Worker, and take work from it.
5
+ *
6
+ * `consume(url)` reads a Descriptor once, resolves every address the Worker declared (DESC-12) and
7
+ * answers an object with one member per Capability that Worker implements — and nothing for the
8
+ * ones it does not, because DESC-2 admits any combination including none.
9
+ *
10
+ * **It is the other half of `mount()` and it carries the same kind of thing.** A consumer that
11
+ * wrote this itself would write the address resolution, the paging and its cursor, the retry that
12
+ * must not happen on a `reject`, and the classification of an answer it cannot read. All of that
13
+ * is fixed by rules — nine of which oblige a consumer rather than a Worker — and `call.ts` cites
14
+ * every one.
15
+ *
16
+ * It depends on `@worker-protocol/schemas` and on `fetch`, and on nothing else. A Tower, a teams
17
+ * app or a Worker that consumes another Worker installs no web framework to do it.
18
+ *
19
+ * **Strict about what this protocol fixes, and blind to what it does not.** Every document
20
+ * `schemas/` describes is validated, and a Worker that answers something else raises `Malformed`
21
+ * naming the rule. A Task's payload, an Action's input and result, an event's data are the
22
+ * Worker's own — this protocol has no data model — and nothing here looks inside them.
23
+ */
24
+ export { Malformed, Refused, Unserved, } from "./call.js";
25
+ const rfc3339 = (at) => at.toISOString().replace(/\.\d{3}Z$/, "Z");
26
+ export { canAnswer } from "./skills.js";
27
+ export async function consume(baseUrl, options = {}) {
28
+ // DESC-3: the one route this protocol fixes, and the only address a consumer ever assembles.
29
+ // Everything else is declared, which is what ENDP-1 buys and why nothing below concatenates.
30
+ const descriptorUrl = new URL(".well-known/worker-protocol", baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
31
+ const call = caller(descriptorUrl, options);
32
+ const descriptor = await call.validated({ url: descriptorUrl, addressLevel: true }, descriptorSchema, "DESC-1");
33
+ const entry = (name) => descriptor.capabilities[name];
34
+ const addressOf = (name) => {
35
+ const declared = entry(name)?.address;
36
+ return typeof declared === "string" ? call.resolve(declared) : undefined;
37
+ };
38
+ const consumed = { descriptor, edition: descriptor.edition };
39
+ const healthAddress = addressOf("health");
40
+ if (healthAddress !== undefined) {
41
+ consumed.health = () => call.validated({ url: healthAddress, addressLevel: true }, healthSchema, "HLTH-2");
42
+ }
43
+ const metricsAddress = addressOf("metrics");
44
+ if (metricsAddress !== undefined) {
45
+ consumed.metrics = {
46
+ read: async (metric, read = {}) => {
47
+ // MET-19 travels as a repeated parameter — `?by=a&by=b` — which is why it goes on the URL
48
+ // here rather than into the flat record of single-valued parameters below.
49
+ const url = new URL(metricsAddress);
50
+ for (const dimension of read.by ?? [])
51
+ url.searchParams.append("by", dimension);
52
+ const parameters = { metric };
53
+ if (read.granularity !== undefined)
54
+ parameters.granularity = read.granularity;
55
+ // MET-11: RFC 3339 instants carrying an offset, and the interval is half-open.
56
+ if (read.from !== undefined)
57
+ parameters.from = rfc3339(read.from);
58
+ if (read.to !== undefined)
59
+ parameters.to = rfc3339(read.to);
60
+ // MET-16: a dimension is fixed with a parameter named exactly as the dimension.
61
+ for (const [name, value] of Object.entries(read.fixed ?? {}))
62
+ parameters[name] = value;
63
+ return collect(call, url.toString(), metricPage, "MET-14", parameters);
64
+ },
65
+ };
66
+ }
67
+ const actionsAddress = addressOf("actions");
68
+ if (actionsAddress !== undefined) {
69
+ const perform = async (name, input, perform = {}) => {
70
+ // ACT-5: a POST to the declared address, naming the Action in the query parameter, with the
71
+ // body the input and nothing else. A resource-level 404 is ACT-6 and not DESC-30's.
72
+ const url = new URL(actionsAddress);
73
+ url.searchParams.set("action", name);
74
+ const answered = await call.call({
75
+ url: url.toString(),
76
+ method: "POST",
77
+ body: JSON.stringify(input),
78
+ idempotencyKey: perform.idempotencyKey,
79
+ });
80
+ // ACT-10, ACT-11: `200` with the Action's own result, `204` with none, `202` where it does
81
+ // not complete within the call. The result is the Worker's shape and is not validated.
82
+ return answered.status === 200 ? answered.json : undefined;
83
+ };
84
+ consumed.actions = { perform };
85
+ const configure = entry("actions")?.accepts
86
+ ?.configure;
87
+ if (typeof configure?.readAddress === "string") {
88
+ const settingsUrl = call.resolve(configure.readAddress);
89
+ // ACT-15: a GET answers a document `configure` would accept. Its shape is the Worker's own,
90
+ // so this reads it and does not judge it.
91
+ consumed.actions.settings = async () => (await call.call({ url: settingsUrl, addressLevel: true })).json;
92
+ }
93
+ }
94
+ const alertsAddress = addressOf("alerts");
95
+ if (alertsAddress !== undefined) {
96
+ consumed.alerts = () => collect(call, alertsAddress, alertPage, "ALRT-2");
97
+ }
98
+ const activityAddress = addressOf("activity");
99
+ if (activityAddress !== undefined) {
100
+ consumed.activity = () => collect(call, activityAddress, activityPage, "ACTV-2");
101
+ }
102
+ const nudgesAddress = addressOf("nudges");
103
+ if (nudgesAddress !== undefined) {
104
+ // NDG-2: a POST carrying the type and nothing else, answered `204`. Nothing comes back, so
105
+ // nothing is parsed — a body here would be the receiver holding state about work it has not
106
+ // looked at, which is the lease `spec/tasks.md` withdrew arriving through another door.
107
+ consumed.nudges = async (type) => {
108
+ await call.call({ url: nudgesAddress, method: "POST", body: JSON.stringify({ type }) });
109
+ };
110
+ }
111
+ const tasksAddress = addressOf("tasks");
112
+ if (tasksAddress !== undefined) {
113
+ consumed.tasks = {
114
+ list: (type) =>
115
+ // TASK-8 filters by type where one is asked for; absent, the read is unfiltered and a
116
+ // `404` from it would be DESC-30's rather than a resource's, which `pages` works out.
117
+ collect(call, tasksAddress, taskPage, "TASK-5", type === undefined ? {} : { type }),
118
+ // TASK-32 names the Action; ACT-2 declares its input. Both are already in the document this
119
+ // consumer read, so this walks it rather than calling anything.
120
+ answers: (type) => {
121
+ const raises = entry("tasks")
122
+ ?.raises;
123
+ const accepts = entry("actions")
124
+ ?.accepts;
125
+ const action = raises?.[type]?.answeredBy;
126
+ if (action === undefined)
127
+ return undefined;
128
+ const taken = accepts?.[action];
129
+ return taken === undefined ? undefined : { action, input: taken.input };
130
+ },
131
+ };
132
+ }
133
+ return consumed;
134
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Whether one Worker can answer another's Tasks, decided from what both of them declared.
3
+ *
4
+ * This is the question an operator asks when enrolling a Worker — *can it take that one's work?* —
5
+ * and `spec/tasks.md` is what answers it. TASK-32 has the owner declare the payload it sends under
6
+ * `raises`; TASK-31 has the answerer declare what it requires under `skills`. NAME-6 fixes which
7
+ * way to judge the two: a document a Worker receives is judged against the party that sends it.
8
+ *
9
+ * **It compares declarations and calls nothing.** Both Descriptors are already in hand — a Tower
10
+ * holds a dated copy of each (DESC-20) — so the answer arrives at enrollment, before any Task
11
+ * exists and before any work changes hands, which is the whole point of asking. Sampling the
12
+ * Tasks an owner happens to have open answers a weaker question: at enrollment there are usually
13
+ * none, and *nothing was open to check* is not *this pairing works*.
14
+ *
15
+ * It lives here rather than in a Tower because a Tower is a role and not a product, and this is an
16
+ * algorithm `spec/` states rather than a policy anybody chooses. `packages/README.md` puts the
17
+ * rules that bind a consumer in this package for the same reason: the alternative is every Tower,
18
+ * teams app and proxy deriving it again, and disagreeing about the cases below.
19
+ */
20
+ import type { descriptor } from "@worker-protocol/schemas";
21
+ import type * as z from "zod";
22
+ type Descriptor = z.infer<typeof descriptor>;
23
+ /**
24
+ * What was decided, and why — so that a console can say it rather than showing a boolean.
25
+ *
26
+ * `unknown` is not `false`. An answerer that declares the Skill and states no requirement has
27
+ * claimed the capability and said nothing about what it needs, which TASK-31 admits; a Tower that
28
+ * reported that as a refusal would be inventing an obligation the specification does not carry.
29
+ */
30
+ export type Compatibility = {
31
+ verdict: "compatible" | "incompatible" | "unknown";
32
+ why: string;
33
+ };
34
+ /**
35
+ * Can `answerer` answer `owner`'s Tasks of this type?
36
+ *
37
+ * Two documents travel, one each way, so there are two halves to judge and both must hold. The
38
+ * Task goes from owner to answerer: what the answerer REQUIRES (TASK-31 `payload`) must be covered
39
+ * by what the owner SENDS (TASK-32 `payload`). The answer goes back: what the owner's answering
40
+ * Action TAKES (ACT-2 `input`, named by TASK-32 `answeredBy`) must be covered by what the answerer
41
+ * PRODUCES (TASK-31 `produces`). Either half the answerer left undeclared is `unknown` — a claim
42
+ * with nothing to check, which TASK-31 admits and a Tower must not report as a refusal.
43
+ */
44
+ export declare function canAnswer(owner: Descriptor, answerer: Descriptor, type: string): Compatibility;
45
+ export {};
package/dist/skills.js ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Whether one Worker can answer another's Tasks, decided from what both of them declared.
3
+ *
4
+ * This is the question an operator asks when enrolling a Worker — *can it take that one's work?* —
5
+ * and `spec/tasks.md` is what answers it. TASK-32 has the owner declare the payload it sends under
6
+ * `raises`; TASK-31 has the answerer declare what it requires under `skills`. NAME-6 fixes which
7
+ * way to judge the two: a document a Worker receives is judged against the party that sends it.
8
+ *
9
+ * **It compares declarations and calls nothing.** Both Descriptors are already in hand — a Tower
10
+ * holds a dated copy of each (DESC-20) — so the answer arrives at enrollment, before any Task
11
+ * exists and before any work changes hands, which is the whole point of asking. Sampling the
12
+ * Tasks an owner happens to have open answers a weaker question: at enrollment there are usually
13
+ * none, and *nothing was open to check* is not *this pairing works*.
14
+ *
15
+ * It lives here rather than in a Tower because a Tower is a role and not a product, and this is an
16
+ * algorithm `spec/` states rather than a policy anybody chooses. `packages/README.md` puts the
17
+ * rules that bind a consumer in this package for the same reason: the alternative is every Tower,
18
+ * teams app and proxy deriving it again, and disagreeing about the cases below.
19
+ */
20
+ /** The variants of a union schema, or the schema itself where it is not one. */
21
+ const variantsOf = (schema) => schema.anyOf ?? schema.oneOf ?? [schema];
22
+ /**
23
+ * The member that tells a union's variants apart, where one does — TASK-32's discriminator.
24
+ *
25
+ * It is a member every variant fixes to a different constant, which is what `z.discriminatedUnion`
26
+ * writes and what TASK-32 requires of a Task with several endings.
27
+ */
28
+ function discriminator(variants) {
29
+ if (variants.length < 2)
30
+ return undefined;
31
+ const first = variants[0];
32
+ if (first === undefined)
33
+ return undefined;
34
+ return Object.keys(first.properties ?? {}).find((member) => {
35
+ const fixed = variants.map((one) => one.properties?.[member]?.const);
36
+ return fixed.every((one) => one !== undefined) && new Set(fixed).size === variants.length;
37
+ });
38
+ }
39
+ /** The names a JSON Schema requires, or none where it names no `required` array. */
40
+ const requiredOf = (schema) => Array.isArray(schema.required) ? schema.required.filter((one) => typeof one === "string") : [];
41
+ /** The `type` a schema fixes for one member, where it fixes one. */
42
+ const typeOf = (schema, member) => schema.properties?.[member]?.type;
43
+ /**
44
+ * Whether what one party requires is covered by what the other provides — NAME-6, one direction.
45
+ *
46
+ * The tractable part of comparing two JSON Schemas, and the part `spec/tasks.md` states in TASK-31:
47
+ * a receiver may ask for less than the sender produces and may not ask for more. So every member
48
+ * the receiver requires must be one the sender declares, and where both fix a `type` for it the two
49
+ * must agree. Full subsumption is undecidable in general, and a Tower that attempted it would refuse
50
+ * pairings nobody could explain; this decides what the rule claims and no more. It is assignability
51
+ * and it reads the same in both directions, which is why it is stated here once rather than derived
52
+ * from either half's rule.
53
+ *
54
+ * **The discriminator is the owner's word and is not charged to the answerer.** TASK-32 puts a
55
+ * Task's endings in a union told apart by a member the OWNER mints — `outcome: "found"` — and an
56
+ * answerer writing its own Descriptor cannot know that word, because it serves owners it has never
57
+ * read. Counting it as coverage the answerer owes would refuse every honest answerer and would put
58
+ * the per-owner mapping back exactly where withdrawing TASK-2's list took it from. So it is skipped
59
+ * where the answerer says nothing about it, and used to pick the variant where it says something.
60
+ */
61
+ function covered(requires, provides, noun) {
62
+ const takes = variantsOf(requires);
63
+ const told = discriminator(takes);
64
+ for (const produced of variantsOf(provides)) {
65
+ // Where the answerer names the ending, that variant is the one it is answering. Where it does
66
+ // not, any variant it satisfies will do — it produces a subtype, which is assignable.
67
+ const said = told === undefined ? undefined : produced.properties?.[told]?.const;
68
+ const against = takes
69
+ .filter((taken) => said === undefined || taken.properties?.[told]?.const === said)
70
+ .map((taken) => accepts(taken, produced, told));
71
+ if (against.length === 0) {
72
+ return { verdict: "incompatible", why: `${noun} no ending it names` };
73
+ }
74
+ if (!against.some((one) => one.length === 0)) {
75
+ return { verdict: "incompatible", why: `${noun} ${against[0].join(", ")}` };
76
+ }
77
+ }
78
+ return { verdict: "compatible", why: "" };
79
+ }
80
+ /**
81
+ * One variant against one: the members it is missing and the ones it disagrees about, or none.
82
+ *
83
+ * `told` is skipped, for the reason `covered` gives. The complaints come back as names rather than
84
+ * a sentence so that the direction is written once, by the caller that knows which way this is.
85
+ */
86
+ function accepts(requires, provides, told) {
87
+ const offered = Object.keys(provides.properties ?? {});
88
+ const owed = requiredOf(requires).filter((member) => member !== told);
89
+ const missing = owed.filter((member) => !offered.includes(member));
90
+ const disagreeing = owed.filter((member) => {
91
+ const wanted = typeOf(requires, member);
92
+ const given = typeOf(provides, member);
93
+ return wanted !== undefined && given !== undefined && wanted !== given;
94
+ });
95
+ return [...missing, ...disagreeing];
96
+ }
97
+ /**
98
+ * Can `answerer` answer `owner`'s Tasks of this type?
99
+ *
100
+ * Two documents travel, one each way, so there are two halves to judge and both must hold. The
101
+ * Task goes from owner to answerer: what the answerer REQUIRES (TASK-31 `payload`) must be covered
102
+ * by what the owner SENDS (TASK-32 `payload`). The answer goes back: what the owner's answering
103
+ * Action TAKES (ACT-2 `input`, named by TASK-32 `answeredBy`) must be covered by what the answerer
104
+ * PRODUCES (TASK-31 `produces`). Either half the answerer left undeclared is `unknown` — a claim
105
+ * with nothing to check, which TASK-31 admits and a Tower must not report as a refusal.
106
+ */
107
+ export function canAnswer(owner, answerer, type) {
108
+ const skill = answerer.skills?.[type];
109
+ if (skill === undefined) {
110
+ return { verdict: "incompatible", why: `it declares no Skill for ${type}` };
111
+ }
112
+ const tasks = owner.capabilities.tasks;
113
+ const raised = tasks?.raises?.[type];
114
+ if (raised === undefined) {
115
+ return { verdict: "incompatible", why: `the owner raises no ${type}` };
116
+ }
117
+ // Receiving: the Task the owner sends, against what the answerer needs to be handed.
118
+ const requires = skill.payload;
119
+ const receiving = requires === undefined
120
+ ? { verdict: "unknown", why: "it states no requirement for what it receives" }
121
+ : covered(requires, raised.payload, "it requires what the owner does not send:");
122
+ // Sending: what the answerer produces, against what the one Action the owner names will take.
123
+ // Where the Task has several endings they are variants of that input, and producing some of them
124
+ // is producing a subtype — assignable, and `covered` looks inside the union to say so.
125
+ const declared = owner.capabilities.actions
126
+ ?.accepts;
127
+ const produces = skill.produces;
128
+ const takes = declared?.[raised.answeredBy]?.input;
129
+ let sending;
130
+ if (produces === undefined) {
131
+ sending = { verdict: "unknown", why: "it states nothing about what it produces" };
132
+ }
133
+ else if (takes === undefined) {
134
+ sending = { verdict: "unknown", why: "the owner's answering Action is not one it accepts" };
135
+ }
136
+ else {
137
+ sending = covered(takes, produces, "the owner's Action requires what it does not produce:");
138
+ }
139
+ const halves = [receiving, sending];
140
+ const refused = halves.find((one) => one.verdict === "incompatible");
141
+ if (refused !== undefined)
142
+ return refused;
143
+ const open = halves.filter((one) => one.verdict === "unknown");
144
+ if (open.length > 0)
145
+ return { verdict: "unknown", why: open.map((one) => one.why).join("; ") };
146
+ return {
147
+ verdict: "compatible",
148
+ why: "it can read what the owner sends and produce what it takes",
149
+ };
150
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@worker-protocol/client",
3
+ "version": "0.1.0",
4
+ "workerProtocolEdition": "0.1",
5
+ "description": "Read a Worker, and take work from it. The consumer half of worker-protocol",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/rowing-tech/worker-protocol.git",
10
+ "directory": "packages/client"
11
+ },
12
+ "homepage": "https://github.com/rowing-tech/worker-protocol#readme",
13
+ "bugs": "https://github.com/rowing-tech/worker-protocol/issues",
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "type": "module",
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./dist/index.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "LICENSE",
29
+ "NOTICE"
30
+ ],
31
+ "dependencies": {
32
+ "@worker-protocol/schemas": "0.1.0"
33
+ },
34
+ "peerDependencies": {
35
+ "zod": "^4.5.4"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "26.5.1",
39
+ "@worker-protocol/hono": "0.1.0",
40
+ "hono": "4.13.7",
41
+ "typescript": "7.0.2",
42
+ "vitest": "5.0.0",
43
+ "zod": "4.5.4"
44
+ },
45
+ "scripts": {
46
+ "typecheck": "tsc -p tsconfig.json",
47
+ "build": "tsc -p tsconfig.build.json",
48
+ "test": "vitest run"
49
+ }
50
+ }