Skip to content

Commit 17cf29f

Browse files
committed
feat(Executor): Walk node tree and delegate; allow for in-process executors
1 parent ec7dfd3 commit 17cf29f

3 files changed

Lines changed: 219 additions & 23 deletions

File tree

src/base/Client.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import Response from './Response'
55
import { Address } from './Transports'
66

77
/**
8-
* A base client class which acts as a proxy to a remote `Executor`.
8+
* A client to a remote, out of process, `Executor`.
99
*
10-
* Implements aynchronous, proxy methods for `Executor` methods `compile`, `build`, `execute`, etc.
11-
* Those methods send JSON-RPC requests to a `Server` that is serving the remote `Executor`.
10+
* Implements asynchronous, methods for `Executor` methods `compile`, `build`, `execute`, etc.
11+
* which send JSON-RPC requests to a `Server` that is serving the remote `Executor`.
1212
*/
1313
export default abstract class Client implements Interface {
1414
/**

src/base/Executor.test.ts

Lines changed: 144 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import Executor, { Peer, Manifest, Method } from './Executor'
1+
import Executor, { Peer, Manifest, Method, Capabilities } from './Executor'
22
import DirectServer from '../direct/DirectServer'
33
import DirectClient from '../direct/DirectClient'
44
import { ClientType } from './Client'
55
import { Transport } from './Transports'
66
import StdioClient from '../stdio/StdioClient'
7+
import { codeChunk, Node, isA, codeExpression, Article } from '@stencila/schema'
78

89
describe('Peer', () => {
910
test('capable: no capabilities', () => {
@@ -19,7 +20,7 @@ describe('Peer', () => {
1920
expect(peer.capable(Method.execute, {})).toBe(false)
2021
})
2122

22-
test('capable: boolean capabilties', () => {
23+
test('capable: boolean capabilities', () => {
2324
const peer = new Peer(
2425
{
2526
capabilities: {
@@ -142,7 +143,7 @@ describe('Peer', () => {
142143
[]
143144
)
144145

145-
const canDecode = (format: string) =>
146+
const canDecode = (format: string): boolean =>
146147
peer.capable(Method.decode, { content: 'foo', format })
147148
expect(canDecode('julia')).toBe(true)
148149
expect(canDecode('haskell')).toBe(true)
@@ -215,10 +216,148 @@ describe('Peer', () => {
215216

216217
expect(peer1.connect()).toBe(true)
217218
// @ts-ignore
218-
expect(peer1.client instanceof DirectClient).toBe(true)
219+
expect(peer1.interface instanceof DirectClient).toBe(true)
219220

220221
expect(peer2.connect()).toBe(true)
221222
// @ts-ignore
222-
expect(peer2.client instanceof StdioClient).toBe(true)
223+
expect(peer2.interface instanceof StdioClient).toBe(true)
224+
})
225+
})
226+
227+
class DeepThought extends Executor {
228+
public static readonly question: string =
229+
'the answer to life the universe and everything'
230+
231+
public async capabilities(): Promise<Capabilities> {
232+
return {
233+
execute: {
234+
properties: {
235+
node: {
236+
properties: {
237+
type: {
238+
const: 'CodeChunk'
239+
},
240+
text: {
241+
const: DeepThought.question
242+
}
243+
}
244+
}
245+
}
246+
}
247+
}
248+
}
249+
250+
public async execute(node: Node): Promise<Node> {
251+
if (isA('CodeChunk', node) && node.text === DeepThought.question) {
252+
return { ...node, outputs: [42] }
253+
}
254+
return node
255+
}
256+
}
257+
258+
class Calculator extends Executor {
259+
public async capabilities(): Promise<Capabilities> {
260+
return {
261+
execute: {
262+
properties: {
263+
node: {
264+
required: ['type', 'text'],
265+
properties: {
266+
type: {
267+
const: 'CodeExpression'
268+
},
269+
text: {
270+
type: 'string'
271+
}
272+
}
273+
}
274+
}
275+
}
276+
}
277+
}
278+
279+
public async execute(node: Node): Promise<Node> {
280+
if (isA('CodeExpression', node)) {
281+
// eslint-disable-next-line no-eval
282+
return { ...node, output: eval(node.text) }
283+
}
284+
return node
285+
}
286+
}
287+
288+
describe('Executor', () => {
289+
test('peers: in-process', async () => {
290+
const deepThought = new DeepThought()
291+
const calculator = new Calculator()
292+
293+
const manifests: Manifest[] = [
294+
{
295+
executor: deepThought,
296+
capabilities: await deepThought.capabilities()
297+
},
298+
{
299+
executor: calculator,
300+
capabilities: await calculator.capabilities()
301+
}
302+
]
303+
const executor = new Executor(manifests)
304+
305+
// Delegates executable nodes to peers
306+
307+
expect(await executor.execute(codeChunk(DeepThought.question))).toEqual({
308+
type: 'CodeChunk',
309+
text: DeepThought.question,
310+
outputs: [42]
311+
})
312+
313+
expect(await executor.execute(codeExpression('6 * 7'))).toEqual({
314+
type: 'CodeExpression',
315+
text: '6 * 7',
316+
output: 42
317+
})
318+
319+
expect(await executor.execute(codeExpression('2 * Math.PI'))).toEqual({
320+
type: 'CodeExpression',
321+
text: '2 * Math.PI',
322+
output: 2 * Math.PI
323+
})
324+
325+
// Walks node tree and delegates
326+
327+
const article = {
328+
type: 'Article',
329+
content: [
330+
{
331+
type: 'Paragraph',
332+
content: [
333+
'Four times twenty one is: ',
334+
{
335+
type: 'CodeExpression',
336+
text: '4 * 21'
337+
},
338+
'.'
339+
]
340+
},
341+
{
342+
type: 'QuoteBlock',
343+
content: [
344+
{
345+
type: 'Paragraph',
346+
content: ['The answer is:']
347+
},
348+
{
349+
type: 'CodeChunk',
350+
text: DeepThought.question
351+
}
352+
]
353+
}
354+
]
355+
}
356+
const executed = (await executor.execute(article)) as Article
357+
expect(executed.type).toEqual('Article')
358+
// @ts-ignore
359+
expect(executed.content[0].content[1].output).toEqual(84)
360+
// @ts-ignore
361+
expect(executed.content[1].content[1].outputs[0]).toEqual(42)
223362
})
224363
})

src/base/Executor.ts

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { Node } from '@stencila/schema'
1+
import { Node, isPrimitive, nodeType } from '@stencila/schema'
22
import { JSONSchema7Definition } from 'json-schema'
33
import Ajv from 'ajv'
4-
import Client, { ClientType } from './Client'
4+
import { ClientType } from './Client'
55
import Server from './Server'
66
import { Address, Transport } from './Transports'
77
import { getLogger } from '@stencila/logga'
@@ -43,16 +43,22 @@ export interface Addresses {
4343
* etc
4444
*/
4545
export interface Manifest {
46+
/**
47+
* The actual in-process `Executor`, or
48+
* it's id i it is ou-of-process
49+
*/
50+
executor?: Executor | string
51+
4652
/**
4753
* The capabilities of the executor
4854
*/
49-
capabilities: Capabilities
55+
capabilities?: Capabilities
5056

5157
/**
5258
* The addresses of servers that can be used
5359
* to communicate with the executor
5460
*/
55-
addresses: Addresses
61+
addresses?: Addresses
5662
}
5763

5864
/**
@@ -150,12 +156,13 @@ export class Peer {
150156
public readonly clientTypes: ClientType[]
151157

152158
/**
153-
* The client for the peer executor.
159+
* The interface to the peer executor.
154160
*
155-
* The type of client e.g. `StdioClient` vs `WebSocketClient`
161+
* May be an in-process `Executor` or a `Client` to an out-of-process
162+
* `Executor`, in which case it's type e.g. `StdioClient` vs `WebSocketClient`
156163
* will depend upon the available transports in `manifest.transports`.
157164
*/
158-
private client?: Client
165+
private interface?: Interface
159166

160167
/**
161168
* Ajv validation functions for each method.
@@ -184,11 +191,16 @@ export class Peer {
184191
public capable(method: Method, params: { [key: string]: unknown }): boolean {
185192
let validators = this.validators[method]
186193
if (validators === undefined) {
194+
// Peer does not have any capabilities defined
195+
if (this.manifest.capabilities === undefined) return false
196+
187197
let capabilities = this.manifest.capabilities[method]
188-
// Peer does not have any capabilities defined for this method
198+
// Peer does not have any capabilities for this method defined
189199
if (capabilities === undefined) return false
200+
190201
// Peer defines capability as a single JSON Schema definition
191202
if (!Array.isArray(capabilities)) capabilities = [capabilities]
203+
192204
// Compile JSON Schema definitions to validation functions
193205
validators = this.validators[method] = capabilities.map(schema =>
194206
ajv.compile(schema)
@@ -201,14 +213,22 @@ export class Peer {
201213
}
202214

203215
/**
204-
* Connect to the remote `Executor`.
216+
* Connect to the `Executor`.
205217
*
206218
* Finds the first client type that the peer
207219
* executor supports.
208220
*
209221
* @returns A client instance or `undefined` if not able to connect
210222
*/
211223
public connect(): boolean {
224+
// If the executor is in-process, just use it directly
225+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
226+
if (this.manifest.executor instanceof Executor) {
227+
this.interface = this.manifest.executor
228+
return true
229+
}
230+
// Connect to remote executor in order of preference of
231+
// transports
212232
for (const ClientType of this.clientTypes) {
213233
// Get the transport for the client type
214234
// There should be a better way to do this
@@ -224,10 +244,11 @@ export class Peer {
224244
if (transport === undefined)
225245
throw new Error('Wooah! This should not happen!')
226246

227-
// See if the peer has an address the transport
247+
// See if the peer has an address for the transport
248+
if (this.manifest.addresses === undefined) return false
228249
const address = this.manifest.addresses[transport]
229250
if (address !== undefined) {
230-
this.client = new ClientType(address)
251+
this.interface = new ClientType(address)
231252
return true
232253
}
233254
}
@@ -248,9 +269,9 @@ export class Peer {
248269
method: Method,
249270
params: { [key: string]: any } = {}
250271
): Promise<Type> {
251-
if (this.client === undefined)
272+
if (this.interface === undefined)
252273
throw new Error("WTF, no client! You shouldn't be calling this!")
253-
return this.client.call<Type>(method, params)
274+
return this.interface.call<Type>(method, params)
254275
}
255276
}
256277

@@ -348,6 +369,7 @@ export default class Executor implements Interface {
348369
}
349370
for (const peer of this.peers) {
350371
const manifest = peer.manifest
372+
if (manifest.capabilities === undefined) continue
351373
for (const [method, additional] of Object.entries(
352374
manifest.capabilities
353375
)) {
@@ -392,8 +414,23 @@ export default class Executor implements Interface {
392414
return this.delegate(Method.build, { node }, async () => node)
393415
}
394416

417+
/**
418+
* Execute a `Node`.
419+
*
420+
* Walks the node tree and attempts to delegate
421+
* execution of certain types of nodes (currently `CodeChunk` and `CodeExpression`).
422+
*
423+
* @param node The node to execute
424+
*/
395425
public async execute(node: Node): Promise<Node> {
396-
return this.delegate(Method.execute, { node }, async () => node)
426+
return this.walk(node, async node => {
427+
switch (nodeType(node)) {
428+
case 'CodeChunk':
429+
case 'CodeExpression':
430+
return this.delegate(Method.execute, { node }, async () => node)
431+
}
432+
return node
433+
})
397434
}
398435

399436
public async call(
@@ -414,6 +451,26 @@ export default class Executor implements Interface {
414451
}
415452
}
416453

454+
private async walk(
455+
node: Node,
456+
transformer: (node: Node) => Promise<Node>
457+
): Promise<Node> {
458+
return walk(node)
459+
async function walk(node: Node): Promise<Node> {
460+
const transformed = await transformer(node)
461+
if (transformed === undefined || isPrimitive(transformed))
462+
return transformed
463+
if (Array.isArray(transformed)) return Promise.all(transformed.map(walk))
464+
return Object.entries(transformed).reduce(
465+
async (prev, [key, child]) => ({
466+
...(await prev),
467+
...{ [key]: await walk(child) }
468+
}),
469+
Promise.resolve({})
470+
)
471+
}
472+
}
473+
417474
private async delegate<Type>(
418475
method: Method,
419476
params: { [key: string]: any },
@@ -431,7 +488,7 @@ export default class Executor implements Interface {
431488
}
432489

433490
// No peer has necessary capability so resort to fallback
434-
log.debug(`Not able to ${JSON.stringify(params)} `)
491+
log.debug(`Unable to delegate node: ${JSON.stringify(params)} `)
435492
return fallback()
436493
}
437494
}

0 commit comments

Comments
 (0)