@monosmith/plugin-sdk 0.1.0 → 0.2.1

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.
Files changed (39) hide show
  1. package/README.md +3 -2
  2. package/codec/index.d.ts +1 -1
  3. package/codec/index.js +1 -1
  4. package/codec/method-codec.algorithm.js +4 -4
  5. package/codec/wire-message.mapper.d.ts +3 -1
  6. package/codec/wire-message.mapper.js +5 -1
  7. package/concurrency/concurrency.policy.d.ts +21 -0
  8. package/concurrency/concurrency.policy.js +30 -0
  9. package/concurrency/index.d.ts +1 -0
  10. package/concurrency/index.js +1 -0
  11. package/conformance/check-plugin.use-case.d.ts +2 -1
  12. package/conformance/check-plugin.use-case.js +4 -1
  13. package/conformance/check-scheduling.use-case.d.ts +11 -0
  14. package/conformance/check-scheduling.use-case.js +98 -0
  15. package/host/call-interrupted.error.d.ts +16 -0
  16. package/host/call-interrupted.error.js +20 -0
  17. package/host/host-options.contract.d.ts +6 -0
  18. package/host/host-options.contract.js +2 -0
  19. package/host/index.d.ts +3 -1
  20. package/host/index.js +2 -1
  21. package/host/plugin-client.client.d.ts +26 -7
  22. package/host/plugin-client.client.js +180 -49
  23. package/index.d.ts +1 -0
  24. package/index.js +1 -0
  25. package/package.json +1 -1
  26. package/protocol/protocol-schema.contract.js +76 -17
  27. package/protocol/protocol-types.contract.d.ts +52 -0
  28. package/protocol/protocol-types.contract.js +7 -0
  29. package/protocol/schema-document.contract.d.ts +4 -1
  30. package/serve/capabilities-of.algorithm.d.ts +2 -1
  31. package/serve/capabilities-of.algorithm.js +4 -1
  32. package/serve/dispatch-request.use-case.d.ts +3 -2
  33. package/serve/dispatch-request.use-case.js +4 -3
  34. package/serve/index.d.ts +1 -1
  35. package/serve/plugin.contract.d.ts +31 -16
  36. package/serve/request-pool.store.d.ts +49 -0
  37. package/serve/request-pool.store.js +110 -0
  38. package/serve/serve.use-case.d.ts +8 -4
  39. package/serve/serve.use-case.js +109 -22
@@ -1,7 +1,9 @@
1
- import { decodeResult, encodeParams, parseResponse, writeRequest } from '../codec/index.js';
1
+ import { decodeResult, encodeParams, parseResponse, writeNotification, writeRequest } from '../codec/index.js';
2
+ import { inFlightLimit } from '../concurrency/index.js';
2
3
  import { FrameReader, frameMessage } from '../framing/index.js';
3
4
  import { PROTOCOL_VERSION, RpcError, protocolSchema } from '../protocol/index.js';
4
- import { DEFAULT_CALL_TIMEOUT_MS } from './host-options.contract.js';
5
+ import { CallInterruptedError } from './call-interrupted.error.js';
6
+ import { DEFAULT_CALL_TIMEOUT_MS, DEFAULT_CANCEL_GRACE_MS } from './host-options.contract.js';
5
7
  function writeFrame(output, frame) {
6
8
  return new Promise((resolve, reject) => {
7
9
  output.write(frame, (error) => {
@@ -17,15 +19,18 @@ function messageOf(thrown) {
17
19
  }
18
20
  /**
19
21
  * The host's side of a connection to one initialized plugin, for a plugin
20
- * author's tests (smith itself is the host in real use). Calls are serialised,
21
- * as smith serialises them, each with a timeout. Open one with {@link startPlugin}
22
- * (a process), {@link connectPlugin} (streams) or {@link connectInProcess}
23
- * (a plugin object served in this process).
22
+ * author's tests (smith itself is the host in real use). It never has more
23
+ * requests in flight than the plugin declared in `concurrency`, as smith does:
24
+ * with the default of one, calls are serialised, each waiting for the one
25
+ * before it to be answered. Each call has a timeout and may be cancelled with
26
+ * an `AbortSignal`. Open one with {@link startPlugin} (a process),
27
+ * {@link connectPlugin} (streams) or {@link connectInProcess} (a plugin object
28
+ * served in this process).
24
29
  */
25
30
  export class PluginClient {
26
31
  /** Performs the handshake on a connection. */
27
32
  static async open(connection, options) {
28
- const client = new PluginClient(connection);
33
+ const client = new PluginClient(connection, options.cancelGraceMs ?? DEFAULT_CANCEL_GRACE_MS);
29
34
  try {
30
35
  client.info = await client.call('initialize', {
31
36
  protocolVersions: [PROTOCOL_VERSION],
@@ -41,61 +46,97 @@ export class PluginClient {
41
46
  connection.kill();
42
47
  throw new Error(`plugin ${client.info.plugin.name} chose protocol version ${client.info.protocolVersion}, host speaks ${PROTOCOL_VERSION}`);
43
48
  }
49
+ client.#limit = inFlightLimit(client.info.concurrency);
44
50
  return client;
45
51
  }
46
52
  #connection;
47
- #waiter;
53
+ #waiters = new Map();
54
+ #queue = [];
55
+ #graceMs;
48
56
  #lost;
49
- #tail = Promise.resolve();
50
57
  #nextId = 0;
58
+ #limit = 1;
59
+ #inFlight = 0;
60
+ #peak = 0;
51
61
  /** What the plugin answered to `initialize`: identity, kind, roles, capabilities. */
52
62
  info;
53
- constructor(connection) {
63
+ constructor(connection, graceMs) {
54
64
  this.#connection = connection;
65
+ this.#graceMs = graceMs;
55
66
  void this.#readResponses();
56
67
  }
57
- /** Runs a task after the ones before it, whatever became of them. */
58
- async #serialise(task) {
59
- const previous = this.#tail;
60
- const current = (async () => {
61
- await previous;
62
- return await task();
63
- })();
64
- this.#tail = (async () => {
65
- try {
66
- await current;
67
- }
68
- catch {
69
- // The caller of this task sees the failure; the next one only waits.
70
- }
71
- })();
72
- return await current;
68
+ /** Waits for a place among the requests that may be in flight; rejects if `signal` aborts first. */
69
+ #acquire(signal) {
70
+ if (signal?.aborted === true)
71
+ return Promise.reject(new CallInterruptedError('cancelled before it was sent', 'cancelled', false));
72
+ if (this.#inFlight < this.#limit) {
73
+ this.#take();
74
+ return Promise.resolve();
75
+ }
76
+ return new Promise((resolve, reject) => {
77
+ const entry = {
78
+ grant: () => {
79
+ signal?.removeEventListener('abort', onAbort);
80
+ this.#take();
81
+ resolve();
82
+ },
83
+ };
84
+ const onAbort = () => {
85
+ const at = this.#queue.indexOf(entry);
86
+ if (at !== -1)
87
+ this.#queue.splice(at, 1);
88
+ reject(new CallInterruptedError('cancelled before it was sent', 'cancelled', false));
89
+ };
90
+ signal?.addEventListener('abort', onAbort, { once: true });
91
+ this.#queue.push(entry);
92
+ });
93
+ }
94
+ #take() {
95
+ this.#inFlight++;
96
+ this.#peak = Math.max(this.#peak, this.#inFlight);
73
97
  }
74
- async #send(method, params, timeoutMs) {
98
+ #release() {
99
+ this.#inFlight--;
100
+ this.#queue.shift()?.grant();
101
+ }
102
+ async #send(method, params, timeoutMs, signal) {
75
103
  if (this.#lost !== undefined)
76
104
  throw new Error(`${method}: plugin stopped answering: ${this.#lost.message}`, { cause: this.#lost });
77
105
  const id = ++this.#nextId;
78
106
  const body = writeRequest(id, method, params === undefined ? undefined : encodeParams(method, params));
79
- const answer = new Promise((resolve) => { this.#waiter = { id, settle: resolve }; });
107
+ const answer = new Promise((resolve) => { this.#waiters.set(id, { settle: resolve }); });
80
108
  try {
81
109
  await writeFrame(this.#connection.output, frameMessage(body));
82
110
  }
83
111
  catch (error) {
84
- this.#waiter = undefined;
112
+ this.#waiters.delete(id);
85
113
  throw new Error(`${method}: plugin is not accepting requests: ${messageOf(error)}`, { cause: error });
86
114
  }
87
115
  let timer;
88
- const timeout = new Promise((resolve) => {
89
- timer = setTimeout(() => {
90
- this.#connection.kill();
91
- resolve({ timedOut: true });
92
- }, timeoutMs);
116
+ let onAbort;
117
+ const interrupted = new Promise((resolve) => {
118
+ timer = setTimeout(() => { resolve('timeout'); }, timeoutMs);
119
+ onAbort = () => { resolve('cancelled'); };
120
+ if (signal?.aborted === true)
121
+ resolve('cancelled');
122
+ else
123
+ signal?.addEventListener('abort', onAbort, { once: true });
93
124
  });
94
- const outcome = await Promise.race([answer, timeout]);
95
- clearTimeout(timer);
96
- this.#waiter = undefined;
97
- if ('timedOut' in outcome)
98
- throw new Error(`${method}: plugin did not answer within ${timeoutMs}ms`);
125
+ try {
126
+ const winner = await Promise.race([answer, interrupted]);
127
+ if (typeof winner !== 'string') {
128
+ this.#waiters.delete(id);
129
+ return this.#decode(method, winner);
130
+ }
131
+ return await this.#giveUp(method, id, answer, winner, timeoutMs);
132
+ }
133
+ finally {
134
+ clearTimeout(timer);
135
+ if (onAbort !== undefined)
136
+ signal?.removeEventListener('abort', onAbort);
137
+ }
138
+ }
139
+ #decode(method, outcome) {
99
140
  if ('error' in outcome)
100
141
  throw new Error(`${method}: plugin stopped answering: ${outcome.error.message}`, { cause: outcome.error });
101
142
  try {
@@ -110,6 +151,51 @@ export class PluginClient {
110
151
  throw new Error(`${method}: plugin stopped answering: ${messageOf(error)}`, { cause: error });
111
152
  }
112
153
  }
154
+ /**
155
+ * The end of a call whose signal aborted or whose time ran out. A plugin that
156
+ * advertised cancel is sent the notification and has the grace period to
157
+ * answer; one that cannot be asked is killed at once when the time ran out,
158
+ * and after the grace period when the caller cancelled. A plugin that does not
159
+ * answer in time is killed, and every other call in flight to it fails.
160
+ */
161
+ async #giveUp(method, id, answer, reason, timeoutMs) {
162
+ const asked = this.supports('cancel');
163
+ if (asked) {
164
+ try {
165
+ await this.notify('cancel', { id });
166
+ }
167
+ catch {
168
+ // The plugin is gone; waiting for the answer below reports how it went.
169
+ }
170
+ }
171
+ if (asked || reason === 'cancelled') {
172
+ let timer;
173
+ const grace = new Promise((resolve) => {
174
+ timer = setTimeout(() => { resolve('expired'); }, this.#graceMs);
175
+ });
176
+ const winner = await Promise.race([answer, grace]);
177
+ clearTimeout(timer);
178
+ if (winner !== 'expired') {
179
+ this.#waiters.delete(id);
180
+ return this.#answeredAfterStopping(method, winner, reason, timeoutMs);
181
+ }
182
+ }
183
+ this.#waiters.delete(id);
184
+ this.#connection.kill();
185
+ if (reason === 'timeout')
186
+ throw new CallInterruptedError(`${method}: plugin did not answer within ${timeoutMs}ms`, reason, true);
187
+ throw new CallInterruptedError(`${method}: cancelled; the plugin did not stop within ${this.#graceMs}ms and was killed`, reason, true);
188
+ }
189
+ /** The plugin answered while the host was giving up: a result is the work finished in time, and is the call's result. */
190
+ #answeredAfterStopping(method, outcome, reason, timeoutMs) {
191
+ try {
192
+ return this.#decode(method, outcome);
193
+ }
194
+ catch (error) {
195
+ const what = reason === 'timeout' ? `did not answer within ${timeoutMs}ms` : 'cancelled';
196
+ throw new CallInterruptedError(`${method}: ${what}: ${messageOf(error)}`, reason, false, error instanceof RpcError ? error : undefined);
197
+ }
198
+ }
113
199
  async #readResponses() {
114
200
  const reader = new FrameReader();
115
201
  try {
@@ -124,31 +210,76 @@ export class PluginClient {
124
210
  }
125
211
  }
126
212
  #deliver(body) {
127
- const waiter = this.#waiter;
128
- if (waiter === undefined)
129
- return;
213
+ let id;
130
214
  try {
131
- if (parseResponse(body).id === waiter.id)
132
- waiter.settle({ body });
133
- // Responses to other ids cannot happen with serialised calls; skip them.
215
+ id = parseResponse(body).id;
134
216
  }
135
217
  catch (error) {
136
- waiter.settle({ error: new Error(`malformed response: ${messageOf(error)}`, { cause: error }) });
218
+ // A malformed response cannot be attributed to a call: the stream is lost.
219
+ this.#lose(new Error(`malformed response: ${messageOf(error)}`, { cause: error }));
220
+ return;
137
221
  }
222
+ if (id === null)
223
+ return;
224
+ const waiter = this.#waiters.get(id);
225
+ if (waiter === undefined)
226
+ return;
227
+ this.#waiters.delete(id);
228
+ waiter.settle({ body });
138
229
  }
139
230
  #lose(error) {
140
231
  this.#lost = error;
141
- this.#waiter?.settle({ error });
232
+ const waiting = this.#waiters;
233
+ this.#waiters = new Map();
234
+ for (const waiter of waiting.values())
235
+ waiter.settle({ error });
142
236
  }
143
237
  async call(method, params, options = {}) {
144
- return await this.#serialise(() => this.#send(method, params, options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS));
238
+ const timeoutMs = options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
239
+ if (method === 'shutdown')
240
+ return await this.#send(method, params, timeoutMs, options.signal); // it must reach a plugin that is busy
241
+ try {
242
+ await this.#acquire(options.signal);
243
+ }
244
+ catch (error) {
245
+ if (error instanceof CallInterruptedError)
246
+ throw new CallInterruptedError(`${method}: ${error.message}`, error.reason, false);
247
+ throw error;
248
+ }
249
+ try {
250
+ return await this.#send(method, params, timeoutMs, options.signal);
251
+ }
252
+ finally {
253
+ this.#release();
254
+ }
255
+ }
256
+ /** How long the plugin has to answer after it was asked to stop. */
257
+ get cancelGraceMs() {
258
+ return this.#graceMs;
259
+ }
260
+ /** The most requests this client has had in flight at once. */
261
+ get peakInFlight() {
262
+ return this.#peak;
263
+ }
264
+ /** Sends a message that gets no answer, such as `cancel`. */
265
+ async notify(method, params) {
266
+ if (this.#lost !== undefined)
267
+ throw new Error(`${method}: plugin stopped answering: ${this.#lost.message}`, { cause: this.#lost });
268
+ try {
269
+ const body = writeNotification(method, encodeParams(method, params));
270
+ await writeFrame(this.#connection.output, frameMessage(body));
271
+ }
272
+ catch (error) {
273
+ throw new Error(`${method}: plugin is not accepting requests: ${messageOf(error)}`, { cause: error });
274
+ }
145
275
  }
146
276
  /** Whether the plugin advertised a method: `initialize` and `shutdown` always, the others by their capability. */
147
277
  supports(method) {
148
278
  if (method === 'initialize' || method === 'shutdown')
149
279
  return true;
150
280
  const capability = Object.hasOwn(protocolSchema['x-methods'], method) ? protocolSchema['x-methods'][method].capability : null;
151
- return capability !== null && this.info.capabilities[capability] === true;
281
+ const advertised = this.info?.capabilities;
282
+ return capability !== null && advertised?.[capability] === true;
152
283
  }
153
284
  /** Asks the plugin to shut down and waits briefly for it to go. Rejects if it did not go quietly. */
154
285
  async close() {
package/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './protocol/index.ts';
2
2
  export * from './serve/index.ts';
3
+ export * from './concurrency/index.ts';
3
4
  export * from './tasks/index.ts';
4
5
  export * from './scaffold/index.ts';
5
6
  export * from './ci/index.ts';
package/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  export * from './protocol/index.js';
4
4
  // Writing a plugin.
5
5
  export * from './serve/index.js';
6
+ export * from './concurrency/index.js';
6
7
  export * from './tasks/index.js';
7
8
  export * from './scaffold/index.js';
8
9
  export * from './ci/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monosmith/plugin-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "The TypeScript SDK for writing smith plugins: the protocol's types, the serve loop, a test host, the conformance checks, templating and key-order-keeping JSON editing. No runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -3,7 +3,7 @@ export const protocolSchema = {
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "$id": "https://raw.githubusercontent.com/monosmith-app/monosmith/main/packages/plugin-sdk/schema/protocol.schema.json",
5
5
  "title": "smith plugin protocol",
6
- "description": "The messages of the smith plugin protocol: JSON-RPC 2.0 between the smith host and a plugin, a separate process. This file is generated from the Go types of packages/plugin-sdk/protocol, which are the source of truth; do not edit it. Regenerate it with `go run ./schemagen` in packages/plugin-sdk.\n\nx-methods maps each method name to the definitions of its params and result and to the capability a plugin advertises to be asked it (null: always available). x-errorCodes, x-roles and x-transport complete the contract. A definition's required members are the ones the Go SDK writes without omitempty.\n\nFraming: each message is the header Content-Length: <bytes of the body>, a blank line (CRLF CRLF) and the body, UTF-8 JSON, as the Language Server Protocol frames. The length counts bytes, not characters. The host sends initialize first, calls one plugin at a time, and a request with an id gets exactly one response with the same id. A request without an id is a notification and gets none. Stdout carries only frames: a plugin logs to stderr. The host sets SMITH_PLUGIN=1 in the plugin's environment.\n\nReading: a receiver ignores members it does not know (the protocol grows by adding members), and treats a null array or object as an empty one, because the Go SDK writes a nil slice or map as null when the member is required. A member that is absent and a member with its zero value are the same for every optional member.\n\nPaths in messages are relative to the workspace and use forward slashes; only workspaceRoot is absolute and uses the host's separators.",
6
+ "description": "The messages of the smith plugin protocol: JSON-RPC 2.0 between the smith host and a plugin, a separate process. This file is generated from the Go types of packages/plugin-sdk/protocol, which are the source of truth; do not edit it. Regenerate it with `go run ./schemagen` in packages/plugin-sdk.\n\nx-methods maps each method name to the definitions of its params and result and to the capability a plugin advertises to be asked it (null: always available). x-errorCodes, x-roles and x-transport complete the contract. A definition's required members are the ones the Go SDK writes without omitempty.\n\nFraming: each message is the header Content-Length: <bytes of the body>, a blank line (CRLF CRLF) and the body, UTF-8 JSON, as the Language Server Protocol frames. The length counts bytes, not characters. The host sends initialize first, and a request with an id gets exactly one response with the same id. A request without an id is a notification and gets none. The host has at most as many requests in flight to a plugin as its initialize result declares in concurrency (absent: 1, the next request is sent after the previous is answered); responses may come in any order, matched by id. A plugin that advertised the cancel capability is sent the cancel notification for a request the host gives up on, and answers that request with its result or the request-cancelled error; a plugin without it is never sent cancel. Stdout carries only frames: a plugin logs to stderr. The host sets SMITH_PLUGIN=1 in the plugin's environment.\n\nReading: a receiver ignores members it does not know (the protocol grows by adding members), and treats a null array or object as an empty one, because the Go SDK writes a nil slice or map as null when the member is required. A member that is absent and a member with its zero value are the same for every optional member.\n\nPaths in messages are relative to the workspace and use forward slashes; only workspaceRoot is absolute and uses the host's separators.",
7
7
  "x-protocolVersion": 1,
8
8
  "x-transport": {
9
9
  "framing": "content-length",
@@ -20,7 +20,8 @@ export const protocolSchema = {
20
20
  "result": {
21
21
  "$ref": "#/$defs/InitializeResult"
22
22
  },
23
- "capability": null
23
+ "capability": null,
24
+ "notification": false
24
25
  },
25
26
  "shutdown": {
26
27
  "description": "The last request. The plugin answers an empty object and exits.",
@@ -30,7 +31,17 @@ export const protocolSchema = {
30
31
  "result": {
31
32
  "$ref": "#/$defs/Empty"
32
33
  },
33
- "capability": null
34
+ "capability": null,
35
+ "notification": false
36
+ },
37
+ "cancel": {
38
+ "description": "A notification, so it gets no response: the host asks the plugin to stop the in-flight request with the given id. The plugin then answers that request, with its result if the work was already done or with the request-cancelled error. An id that is unknown or already answered is ignored. The host sends it only to a plugin that advertised the capability.",
39
+ "params": {
40
+ "$ref": "#/$defs/CancelParams"
41
+ },
42
+ "result": null,
43
+ "capability": "cancel",
44
+ "notification": true
34
45
  },
35
46
  "discover": {
36
47
  "description": "Describes the projects behind manifests the host found; a language plugin's way into the workspace.",
@@ -40,7 +51,8 @@ export const protocolSchema = {
40
51
  "result": {
41
52
  "$ref": "#/$defs/DiscoverResult"
42
53
  },
43
- "capability": "discover"
54
+ "capability": "discover",
55
+ "notification": false
44
56
  },
45
57
  "generate": {
46
58
  "description": "Writes the starter files of a new project.",
@@ -50,7 +62,8 @@ export const protocolSchema = {
50
62
  "result": {
51
63
  "$ref": "#/$defs/GenerateResult"
52
64
  },
53
- "capability": "generate"
65
+ "capability": "generate",
66
+ "notification": false
54
67
  },
55
68
  "version/write": {
56
69
  "description": "Records a release version in a project's manifest.",
@@ -60,7 +73,8 @@ export const protocolSchema = {
60
73
  "result": {
61
74
  "$ref": "#/$defs/VersionResult"
62
75
  },
63
- "capability": "version"
76
+ "capability": "version",
77
+ "notification": false
64
78
  },
65
79
  "publish": {
66
80
  "description": "Publishes one project at one version, or builds the files attached to a release.",
@@ -70,7 +84,8 @@ export const protocolSchema = {
70
84
  "result": {
71
85
  "$ref": "#/$defs/PublishResult"
72
86
  },
73
- "capability": "publish"
87
+ "capability": "publish",
88
+ "notification": false
74
89
  },
75
90
  "ci": {
76
91
  "description": "Describes what a generated pipeline needs for the plugin's projects.",
@@ -80,7 +95,8 @@ export const protocolSchema = {
80
95
  "result": {
81
96
  "$ref": "#/$defs/CIResult"
82
97
  },
83
- "capability": "ci"
98
+ "capability": "ci",
99
+ "notification": false
84
100
  },
85
101
  "doctor": {
86
102
  "description": "Checks the plugin's projects and reports findings, each with its remedy and, optionally, a fix.",
@@ -90,7 +106,8 @@ export const protocolSchema = {
90
106
  "result": {
91
107
  "$ref": "#/$defs/DoctorResult"
92
108
  },
93
- "capability": "doctor"
109
+ "capability": "doctor",
110
+ "notification": false
94
111
  },
95
112
  "contribute": {
96
113
  "description": "Asks a convention (toolchain, structure, deploy) for the tasks and dependencies it adds to projects other plugins discovered.",
@@ -100,7 +117,8 @@ export const protocolSchema = {
100
117
  "result": {
101
118
  "$ref": "#/$defs/ContributeResult"
102
119
  },
103
- "capability": "contribute"
120
+ "capability": "contribute",
121
+ "notification": false
104
122
  },
105
123
  "remove": {
106
124
  "description": "Lets a plugin clean up what it owns outside a project's directory after the project is removed.",
@@ -110,7 +128,8 @@ export const protocolSchema = {
110
128
  "result": {
111
129
  "$ref": "#/$defs/RemoveResult"
112
130
  },
113
- "capability": "remove"
131
+ "capability": "remove",
132
+ "notification": false
114
133
  },
115
134
  "published": {
116
135
  "description": "Asks which version of a project its registry has, so a release can check its tags against it.",
@@ -120,7 +139,8 @@ export const protocolSchema = {
120
139
  "result": {
121
140
  "$ref": "#/$defs/PublishedResult"
122
141
  },
123
- "capability": "published"
142
+ "capability": "published",
143
+ "notification": false
124
144
  },
125
145
  "dependencies/install": {
126
146
  "description": "Adds packages to a project with the ecosystem's own toolchain, or, with no project, installs every dependency of the projects the plugin serves.",
@@ -130,7 +150,8 @@ export const protocolSchema = {
130
150
  "result": {
131
151
  "$ref": "#/$defs/DependencyResult"
132
152
  },
133
- "capability": "dependencies"
153
+ "capability": "dependencies",
154
+ "notification": false
134
155
  },
135
156
  "dependencies/uninstall": {
136
157
  "description": "Removes packages from a project.",
@@ -140,7 +161,8 @@ export const protocolSchema = {
140
161
  "result": {
141
162
  "$ref": "#/$defs/DependencyResult"
142
163
  },
143
- "capability": "dependencies"
164
+ "capability": "dependencies",
165
+ "notification": false
144
166
  },
145
167
  "dependencies/declared": {
146
168
  "description": "Lists the dependencies the manifests of the plugin's projects declare.",
@@ -150,7 +172,8 @@ export const protocolSchema = {
150
172
  "result": {
151
173
  "$ref": "#/$defs/DeclaredResult"
152
174
  },
153
- "capability": "declarations"
175
+ "capability": "declarations",
176
+ "notification": false
154
177
  },
155
178
  "dependencies/rewrite": {
156
179
  "description": "Changes declared dependency specs in place.",
@@ -160,7 +183,8 @@ export const protocolSchema = {
160
183
  "result": {
161
184
  "$ref": "#/$defs/DependencyResult"
162
185
  },
163
- "capability": "declarations"
186
+ "capability": "declarations",
187
+ "notification": false
164
188
  },
165
189
  "dependencies/latest": {
166
190
  "description": "Asks registries for the newest releases of dependencies, and how the tools the plugin pins compare with theirs.",
@@ -170,7 +194,8 @@ export const protocolSchema = {
170
194
  "result": {
171
195
  "$ref": "#/$defs/LatestResult"
172
196
  },
173
- "capability": "latest"
197
+ "capability": "latest",
198
+ "notification": false
174
199
  }
175
200
  },
176
201
  "x-errorCodes": [
@@ -203,6 +228,11 @@ export const protocolSchema = {
203
228
  "name": "PluginFailure",
204
229
  "code": -32000,
205
230
  "description": "The method ran and failed for a reason the user must act on (a missing toolchain, a bad manifest). The message is shown to the user as it is, so it should say what to do. An error a method of the Go SDK returns becomes this code."
231
+ },
232
+ {
233
+ "name": "RequestCancelled",
234
+ "code": -32800,
235
+ "description": "The plugin stopped the request because the host sent a cancel notification for its id. A plugin that had finished answers with the result instead. Only a plugin that advertised the cancel capability sends it; when a cancelled request fails with any error, the SDKs answer with this code."
206
236
  }
207
237
  ],
208
238
  "x-roles": [
@@ -424,6 +454,19 @@ export const protocolSchema = {
424
454
  }
425
455
  }
426
456
  },
457
+ "CancelParams": {
458
+ "description": "CancelParams are the params of the cancel notification.",
459
+ "type": "object",
460
+ "properties": {
461
+ "id": {
462
+ "type": "integer",
463
+ "description": "ID is the id of the in-flight request to stop. An id the plugin does not know, or whose request is already answered, is ignored."
464
+ }
465
+ },
466
+ "required": [
467
+ "id"
468
+ ]
469
+ },
427
470
  "Capabilities": {
428
471
  "description": "Capabilities are the optional methods a plugin implements.",
429
472
  "type": "object",
@@ -469,6 +512,10 @@ export const protocolSchema = {
469
512
  "latest": {
470
513
  "type": "boolean",
471
514
  "description": "Latest reports the newest releases of dependencies and pinned tools."
515
+ },
516
+ "cancel": {
517
+ "type": "boolean",
518
+ "description": "Cancel says the plugin stops the work of a request when the host sends a cancel notification with its id, then answers that request with its result (if the work was already done) or the request-cancelled error. A host never sends the notification to a plugin without it."
472
519
  }
473
520
  }
474
521
  },
@@ -943,6 +990,10 @@ export const protocolSchema = {
943
990
  "type": "string"
944
991
  },
945
992
  "description": "Notes are printed to the user after generation."
993
+ },
994
+ "installPending": {
995
+ "type": "boolean",
996
+ "description": "InstallPending is true when the plugin changed what the workspace depends on and left installing it to the host. A project is made by several plugins in turn; the host asks each plugin that reported this to install once (dependencies/install with Quiet set) after the last of them has generated, instead of once per plugin."
946
997
  }
947
998
  },
948
999
  "required": [
@@ -991,6 +1042,10 @@ export const protocolSchema = {
991
1042
  "capabilities": {
992
1043
  "$ref": "#/$defs/Capabilities"
993
1044
  },
1045
+ "concurrency": {
1046
+ "type": "integer",
1047
+ "description": "Concurrency is how many requests the plugin can work on at the same time; the host never has more in flight. Absent or 0 means 1, which is what every plugin that says nothing does: the host sends the next request only after the previous one is answered. N above 1 allows that many, and ConcurrencyUnlimited (-1) leaves the number to the host. Any other negative value is invalid and read as 1."
1048
+ },
994
1049
  "languages": {
995
1050
  "type": "array",
996
1051
  "items": {
@@ -1089,6 +1144,10 @@ export const protocolSchema = {
1089
1144
  "dev": {
1090
1145
  "type": "boolean",
1091
1146
  "description": "Dev adds them as development dependencies where the ecosystem has such a thing; a plugin without one says so in a note."
1147
+ },
1148
+ "quiet": {
1149
+ "type": "boolean",
1150
+ "description": "Quiet keeps the package manager's output unless it fails: the host sets it when it installs as one step of a larger job (smith add)."
1092
1151
  }
1093
1152
  }
1094
1153
  },