@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 +201 -0
- package/NOTICE +9 -0
- package/dist/call.d.ts +110 -0
- package/dist/call.js +216 -0
- package/dist/index.d.ts +109 -0
- package/dist/index.js +134 -0
- package/dist/skills.d.ts +45 -0
- package/dist/skills.js +150 -0
- package/package.json +50 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/skills.d.ts
ADDED
|
@@ -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
|
+
}
|