@simantic/cloud-sim 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/README.md +298 -0
- package/dist/catalog.d.ts +32 -0
- package/dist/catalog.js +58 -0
- package/dist/client.d.ts +53 -0
- package/dist/client.js +15 -0
- package/dist/compare.d.ts +26 -0
- package/dist/compare.js +53 -0
- package/dist/errors.d.ts +61 -0
- package/dist/errors.js +111 -0
- package/dist/follow.d.ts +61 -0
- package/dist/follow.js +277 -0
- package/dist/generated/v1.d.ts +1959 -0
- package/dist/generated/v1.js +5 -0
- package/dist/http.d.ts +89 -0
- package/dist/http.js +284 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/node.d.ts +5 -0
- package/dist/node.js +10 -0
- package/dist/runs.d.ts +152 -0
- package/dist/runs.js +284 -0
- package/dist/sse.d.ts +30 -0
- package/dist/sse.js +109 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.js +11 -0
- package/package.json +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# @simantic/cloud-sim
|
|
2
|
+
|
|
3
|
+
TypeScript client for the Cloud Sim API, which simulates MCU firmware in
|
|
4
|
+
the cloud. A run takes an ELF, an MCU and engine-neutral options, runs on
|
|
5
|
+
one of the API version's simulation engines (quartz by default), and hands
|
|
6
|
+
back the UART output and the engine's console, live as it runs and in full
|
|
7
|
+
after.
|
|
8
|
+
|
|
9
|
+
The package is ESM with no dependencies, built on `fetch`, so it runs on
|
|
10
|
+
Node 18 and later, in browsers, and on Deno, Bun and edge runtimes. Its
|
|
11
|
+
wire types are generated from the server's OpenAPI document
|
|
12
|
+
(`openapi/v1.json` in the cloud-sim repo), so they change only when the
|
|
13
|
+
contract does.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install @simantic/cloud-sim
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The package is private to the `simantic` npm organization for now: installing
|
|
22
|
+
it needs an npm token of an org member (`npm login`, or `NPM_TOKEN` on CI and
|
|
23
|
+
Vercel, which writes the `.npmrc` itself). The API it calls is open to any
|
|
24
|
+
simantic account; its contract is at https://cloud.simantic.com/v1/openapi.json.
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
### Node
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { CloudSim } from '@simantic/cloud-sim'
|
|
32
|
+
import { fromPath } from '@simantic/cloud-sim/node'
|
|
33
|
+
|
|
34
|
+
const sim = new CloudSim({ apiKey: process.env.CLOUD_SIM_API_KEY!, version: '1.0' })
|
|
35
|
+
|
|
36
|
+
const run = await sim.runs.create({
|
|
37
|
+
mcu: 'STM32F072RB',
|
|
38
|
+
firmware: await fromPath('build/firmware.elf'),
|
|
39
|
+
simulatedSeconds: 5,
|
|
40
|
+
uartEncoding: 'ascii',
|
|
41
|
+
})
|
|
42
|
+
const done = await sim.runs.wait(run.id)
|
|
43
|
+
console.log(done.status, done.exit_code)
|
|
44
|
+
console.log(await sim.runs.output(run.id))
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`@simantic/cloud-sim/node` holds what needs Node (`fromPath` reads a file
|
|
48
|
+
into a Blob); the main entry needs nothing beyond `fetch`.
|
|
49
|
+
|
|
50
|
+
### Browser
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { CloudSim } from '@simantic/cloud-sim'
|
|
54
|
+
|
|
55
|
+
const sim = new CloudSim({
|
|
56
|
+
// Called before every request, so a refreshed token is picked up.
|
|
57
|
+
apiKey: async () => (await supabase.auth.getSession()).data.session?.access_token ?? '',
|
|
58
|
+
// A 401 calls this once, and the request is sent again.
|
|
59
|
+
onUnauthorized: async () => {
|
|
60
|
+
await supabase.auth.refreshSession()
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const input = document.querySelector<HTMLInputElement>('input[type=file]')!
|
|
65
|
+
const run = await sim.runs.create({ mcu: 'STM32F072RB', firmware: input.files![0]! })
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### The client
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
new CloudSim({
|
|
72
|
+
apiKey, // an smtc_ API key or a dashboard session token, or a function giving one
|
|
73
|
+
version, // Cloud-Sim-Version, such as '1.0'; the server's newest stable without it
|
|
74
|
+
baseUrl, // default https://cloud.simantic.com
|
|
75
|
+
fetch, // default the global fetch
|
|
76
|
+
onUnauthorized, // called once on a 401, before the request is sent again
|
|
77
|
+
maxRetries, // default 3
|
|
78
|
+
timeoutMs, // how long each request has until its answer's headers arrive; default 60000
|
|
79
|
+
onRetry, // ({ attempt, error, delayMs }) => void, told before each retry
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Pin `version` in anything long-lived: a version pins each engine to one
|
|
84
|
+
release, so results do not change under you when the server's default
|
|
85
|
+
moves on. A call that makes runs or reads a catalog can name another
|
|
86
|
+
version for itself (`version`), so one client can serve a version picker.
|
|
87
|
+
|
|
88
|
+
Every call takes an optional `signal`. Aborting it ends the call, its
|
|
89
|
+
retries and the waits between them, and the call throws the signal's
|
|
90
|
+
reason.
|
|
91
|
+
|
|
92
|
+
## Runs
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
sim.runs.create({ mcu, engine?, firmware, simulatedSeconds?, uartEncoding?, uartMessagesOnly?,
|
|
96
|
+
trapUnmapped?, usbCdcConsoles?, idempotencyKey?, version?, timeoutMs?, signal? })
|
|
97
|
+
sim.runs.get(id, { signal? })
|
|
98
|
+
sim.runs.list({ limit?, before?, engine?, signal? })
|
|
99
|
+
sim.runs.result(id, { signal? }) // { exit_code, output, log, truncated } of a finished run
|
|
100
|
+
sim.runs.output(id, { signal? }) // the UART output, as text
|
|
101
|
+
sim.runs.log(id, { signal? }) // the engine's console, as text
|
|
102
|
+
sim.runs.cancel(id, { signal? })
|
|
103
|
+
sim.runs.wait(id, { signal?, intervalMs? })
|
|
104
|
+
sim.runs.follow(id, { signal?, transport?, intervalMs?, from? })
|
|
105
|
+
sim.runs.compare({ engines, mcu, firmware, ...options, version?, timeoutMs?, signal? })
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`firmware` is a `Blob` (a `File` included), a `Uint8Array`, an
|
|
109
|
+
`ArrayBuffer`, `{ url }` or `{ base64 }`. Bytes are uploaded as
|
|
110
|
+
multipart/form-data; a URL is fetched by the server (https only, at most
|
|
111
|
+
24 MiB); base64 goes in JSON.
|
|
112
|
+
|
|
113
|
+
Every create carries an `Idempotency-Key`, generated when you give none, so
|
|
114
|
+
a create the SDK retries after a lost answer gets the run it already made
|
|
115
|
+
rather than a second one. To make a create safe across restarts of your
|
|
116
|
+
own program, pass your own key.
|
|
117
|
+
|
|
118
|
+
The client's `timeoutMs` counts the upload, since the server answers only
|
|
119
|
+
once it has the body. Give a create with a large firmware more:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// A minute, and the upload at 1 Mbit/s (125 bytes a millisecond).
|
|
123
|
+
const timeoutMs = 60_000 + Math.ceil(file.size / 125)
|
|
124
|
+
await sim.runs.create({ mcu, firmware: file, timeoutMs })
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`runs.list()` reads nothing until used. Awaited, it is the first page;
|
|
128
|
+
iterated, it is every run, newest first:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
const { runs, next } = await sim.runs.list({ limit: 10 })
|
|
132
|
+
for await (const run of sim.runs.list({ engine: 'quartz' })) console.log(run.id, run.status)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Following a run
|
|
136
|
+
|
|
137
|
+
`runs.follow` turns a run into events, from wherever it is to its end:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
let output = ''
|
|
141
|
+
const from = { log: 0, output: 0 }
|
|
142
|
+
for await (const event of sim.runs.follow(run.id, { signal })) {
|
|
143
|
+
switch (event.type) {
|
|
144
|
+
case 'status': console.log('now', event.run.status); break
|
|
145
|
+
case 'output': output += event.text; from.output = event.next; break
|
|
146
|
+
case 'log': process.stderr.write(event.text); from.log = event.next; break
|
|
147
|
+
case 'reset': if (event.stream === 'output') output = ''; break
|
|
148
|
+
case 'reconnecting': console.log('reconnecting:', event.error.message); break
|
|
149
|
+
case 'end': console.log('exit code', event.run.exit_code); break
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The `log` and `output` texts concatenate to the run's whole console and
|
|
155
|
+
UART output. A `reset` is rare: it says the run's final result differs from
|
|
156
|
+
what it shipped while it ran, so that stream starts over and what you kept
|
|
157
|
+
of it is to be dropped. Aborting `signal` ends the loop with the signal's
|
|
158
|
+
reason; `break` ends it quietly, and stops whatever request or retry it had
|
|
159
|
+
under way.
|
|
160
|
+
|
|
161
|
+
Each `log` and `output` event's `next` is where that stream has been read
|
|
162
|
+
to. A follow that stopped (the page was left, the retries ran out) picks up
|
|
163
|
+
there with `from`, and passes on only what is new:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
for await (const event of sim.runs.follow(run.id, { from })) { … }
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
While a failure is being retried (a lost connection, a 503, the server's
|
|
170
|
+
`error` event), `follow` says so with a `reconnecting` event, once per
|
|
171
|
+
retry. Once it has recovered, the next event is a `status`, even if the
|
|
172
|
+
run's status is the same, so a UI can show "Reconnecting…" between the two.
|
|
173
|
+
When the retries run out, the loop throws the last failure.
|
|
174
|
+
|
|
175
|
+
`transport` picks how:
|
|
176
|
+
|
|
177
|
+
- `auto` (the default) reads the server-sent event stream, and falls back
|
|
178
|
+
to polling when that request fails before its first event (a server
|
|
179
|
+
without the route, something in between that will not pass an event
|
|
180
|
+
stream, a network failure);
|
|
181
|
+
- `sse` reads `GET /v1/runs/{id}/events`, parsed from `fetch` because
|
|
182
|
+
`EventSource` cannot send `Authorization`. The server closes each stream
|
|
183
|
+
after at most 10 minutes; the SDK reconnects with `Last-Event-ID` and
|
|
184
|
+
picks up after the last whole event. When the server cannot read the run
|
|
185
|
+
(a store outage) it sends an `error` event and closes; the SDK waits as
|
|
186
|
+
that event asks (5 s) and reconnects, and fails with the event's
|
|
187
|
+
`CloudSimError` only after `maxRetries` of them with nothing new between;
|
|
188
|
+
- `poll` pages `GET /v1/runs/{id}/live`, every `intervalMs` (750 ms) while
|
|
189
|
+
nothing is waiting and at once while more is.
|
|
190
|
+
|
|
191
|
+
## Comparing engines
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { CompareError, compareResults } from '@simantic/cloud-sim'
|
|
195
|
+
|
|
196
|
+
const { version, runs } = await sim.runs.compare({
|
|
197
|
+
engines: ['quartz', 'pyrite'],
|
|
198
|
+
mcu: 'STM32F072RB',
|
|
199
|
+
firmware: await fromPath('firmware.elf'),
|
|
200
|
+
simulatedSeconds: 5,
|
|
201
|
+
})
|
|
202
|
+
const [a, b] = await Promise.all(runs.map((r) => sim.runs.wait(r.id)))
|
|
203
|
+
const diff = compareResults(await sim.runs.result(a!.id), await sim.runs.result(b!.id))
|
|
204
|
+
// { exitCodes: [0, 0], sameExitCode: true, outputIdentical: false,
|
|
205
|
+
// firstDifference: { line: 12, a: '…', b: '…' } }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`compare` refuses no engines, or an engine named twice, before asking
|
|
209
|
+
anything (400 `invalid_request`, `param: 'engines'`). It reads the version
|
|
210
|
+
first (its own `version`, the client's, or the server's default) and fails
|
|
211
|
+
with `engine_not_in_version` before uploading anything if an engine is
|
|
212
|
+
missing from it. It then asks for one run per engine in parallel, each with
|
|
213
|
+
its own idempotency key, all pinned to that version. Each run counts
|
|
214
|
+
against your limit of unfinished runs.
|
|
215
|
+
|
|
216
|
+
Every engine's run is asked for even when another is refused. When some
|
|
217
|
+
runs are made and others are not, it fails with a `CompareError`, a
|
|
218
|
+
`CloudSimError` whose status, code, param and request id are the first
|
|
219
|
+
failure's, and which is `transient` only if every failure is. Its `version`
|
|
220
|
+
is the version the comparison used, its `runs` the runs made, by engine,
|
|
221
|
+
which go on on the server, and its `errors` why each other engine's run was
|
|
222
|
+
not, by engine. The Python SDK's `CompareError` has the same fields:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
try {
|
|
226
|
+
await sim.runs.compare({ engines, mcu, firmware })
|
|
227
|
+
} catch (e) {
|
|
228
|
+
if (e instanceof CompareError) {
|
|
229
|
+
for (const run of Object.values(e.runs)) await sim.runs.cancel(run.id) // or follow them
|
|
230
|
+
for (const [engine, error] of Object.entries(e.errors)) console.log(engine, error)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Versions and MCUs
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
await sim.versions.list() // every version, and which is the default
|
|
239
|
+
await sim.versions.get('1.0') // engines, releases, options, limits, changes
|
|
240
|
+
await sim.versions.mcus('1.0', { engine: 'quartz', runnable: true })
|
|
241
|
+
await sim.mcus.list({ runnable: true }) // the client's version
|
|
242
|
+
await sim.mcus.list({ version: '1.1-preview.1' })
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
These need no credential; a client made with `apiKey: ''` can read them.
|
|
246
|
+
|
|
247
|
+
## Errors
|
|
248
|
+
|
|
249
|
+
Every failure is a `CloudSimError`, except an aborted `signal`, which
|
|
250
|
+
throws the signal's reason:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
import { CloudSimError, isTransient } from '@simantic/cloud-sim'
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
await sim.runs.create({ mcu: 'NOPE', firmware })
|
|
257
|
+
} catch (e) {
|
|
258
|
+
if (e instanceof CloudSimError) {
|
|
259
|
+
e.status // the HTTP status, or 0 when no answer arrived
|
|
260
|
+
e.code // stable, such as 'mcu_not_supported', 'network_error', 'timeout'
|
|
261
|
+
e.param // the request field at fault, when there is one
|
|
262
|
+
e.requestId // the request's X-Request-Id, worth quoting in a bug report
|
|
263
|
+
e.transient // whether the same request may work later
|
|
264
|
+
}
|
|
265
|
+
isTransient(e) // the same, for any thrown value: false for anything but a CloudSimError
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Every call is retried on transient failures (no answer, a request past
|
|
270
|
+
`timeoutMs`, 429, 502, 503, 504) up to `maxRetries` times, waiting as
|
|
271
|
+
`Retry-After` says when the server says (a wait of more than a minute is
|
|
272
|
+
not waited out), and backing off from half a second otherwise. That is
|
|
273
|
+
safe for every call: a create carries an `Idempotency-Key`, and the server
|
|
274
|
+
answers a repeated cancel with the run as it is (a run that finished
|
|
275
|
+
meanwhile gets 409 `run_finished`, which is not retried). The client's
|
|
276
|
+
`onRetry` hears of each retry before its wait.
|
|
277
|
+
|
|
278
|
+
## Development
|
|
279
|
+
|
|
280
|
+
This package and the MCP server (`mcp/`) are one npm workspace at the root
|
|
281
|
+
of the cloud-sim repo.
|
|
282
|
+
|
|
283
|
+
```sh
|
|
284
|
+
npm ci # at the repo root
|
|
285
|
+
cd sdk/typescript
|
|
286
|
+
npm test
|
|
287
|
+
npm run typecheck
|
|
288
|
+
npm run build
|
|
289
|
+
npm run generate # after openapi/v1.json changes
|
|
290
|
+
npm run check:generated # fails when src/generated/v1.ts is stale
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Tests run against a fake `fetch`, and every JSON body the fake server
|
|
294
|
+
answers with is checked against the schemas in `openapi/v1.json`.
|
|
295
|
+
|
|
296
|
+
## License
|
|
297
|
+
|
|
298
|
+
Proprietary (`UNLICENSED`), as the API itself is.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Transport } from './http.js';
|
|
2
|
+
import type { CallOptions } from './runs.js';
|
|
3
|
+
import type { McuList, Version, VersionList } from './types.js';
|
|
4
|
+
export interface McuFilter extends CallOptions {
|
|
5
|
+
/** Only this engine's MCUs. An engine the version lacks is 400 `engine_not_in_version`. */
|
|
6
|
+
engine?: string;
|
|
7
|
+
/** Only MCUs that run: on `engine`, or without one on any engine. */
|
|
8
|
+
runnable?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface McuListOptions extends McuFilter {
|
|
11
|
+
/** The API version whose catalog to read, in place of the client's. */
|
|
12
|
+
version?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare class Versions {
|
|
15
|
+
private readonly http;
|
|
16
|
+
constructor(http: Transport);
|
|
17
|
+
/** Every version the server serves, oldest first, and which is the default. */
|
|
18
|
+
list(options?: CallOptions): Promise<VersionList>;
|
|
19
|
+
/** One version: its lifecycle, notes, engines, and changes against the one before. */
|
|
20
|
+
get(version: string, options?: CallOptions): Promise<Version>;
|
|
21
|
+
/** Version `version`'s MCU catalog. */
|
|
22
|
+
mcus(version: string, filter?: McuFilter): Promise<McuList>;
|
|
23
|
+
}
|
|
24
|
+
export declare class Mcus {
|
|
25
|
+
private readonly http;
|
|
26
|
+
constructor(http: Transport);
|
|
27
|
+
/**
|
|
28
|
+
* The MCU catalog of `version`, else of the client's version, else of the
|
|
29
|
+
* server's default.
|
|
30
|
+
*/
|
|
31
|
+
list(options?: McuListOptions): Promise<McuList>;
|
|
32
|
+
}
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// `client.versions` and `client.mcus`: the API versions, the engines each
|
|
2
|
+
// pins, and their MCU catalogs. The server needs no credential for these;
|
|
3
|
+
// the client sends its own anyway, which does no harm.
|
|
4
|
+
import { segment } from './http.js';
|
|
5
|
+
export class Versions {
|
|
6
|
+
http;
|
|
7
|
+
constructor(http) {
|
|
8
|
+
this.http = http;
|
|
9
|
+
}
|
|
10
|
+
/** Every version the server serves, oldest first, and which is the default. */
|
|
11
|
+
list(options = {}) {
|
|
12
|
+
return this.http.json({
|
|
13
|
+
method: 'GET',
|
|
14
|
+
path: '/v1/versions',
|
|
15
|
+
signal: options.signal,
|
|
16
|
+
retry: true,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** One version: its lifecycle, notes, engines, and changes against the one before. */
|
|
20
|
+
get(version, options = {}) {
|
|
21
|
+
return this.http.json({
|
|
22
|
+
method: 'GET',
|
|
23
|
+
path: `/v1/versions/${segment(version)}`,
|
|
24
|
+
signal: options.signal,
|
|
25
|
+
retry: true,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** Version `version`'s MCU catalog. */
|
|
29
|
+
mcus(version, filter = {}) {
|
|
30
|
+
return this.http.json({
|
|
31
|
+
method: 'GET',
|
|
32
|
+
path: `/v1/versions/${segment(version)}/mcus`,
|
|
33
|
+
query: { engine: filter.engine, runnable: filter.runnable },
|
|
34
|
+
signal: filter.signal,
|
|
35
|
+
retry: true,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export class Mcus {
|
|
40
|
+
http;
|
|
41
|
+
constructor(http) {
|
|
42
|
+
this.http = http;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The MCU catalog of `version`, else of the client's version, else of the
|
|
46
|
+
* server's default.
|
|
47
|
+
*/
|
|
48
|
+
list(options = {}) {
|
|
49
|
+
return this.http.json({
|
|
50
|
+
method: 'GET',
|
|
51
|
+
path: '/v1/mcus',
|
|
52
|
+
query: { engine: options.engine, runnable: options.runnable },
|
|
53
|
+
version: options.version,
|
|
54
|
+
signal: options.signal,
|
|
55
|
+
retry: true,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Mcus, Versions } from './catalog.js';
|
|
2
|
+
import { type ApiKey, type RetryInfo } from './http.js';
|
|
3
|
+
import { Runs } from './runs.js';
|
|
4
|
+
export interface CloudSimOptions {
|
|
5
|
+
/**
|
|
6
|
+
* An `smtc_` API key, or a simantic dashboard session's access token. A
|
|
7
|
+
* function is called before every request, so it can hand back a token
|
|
8
|
+
* that has been refreshed since.
|
|
9
|
+
*/
|
|
10
|
+
apiKey: ApiKey;
|
|
11
|
+
/**
|
|
12
|
+
* The API version (`Cloud-Sim-Version`), such as `1.0`: it pins each
|
|
13
|
+
* engine to one release. Without it the server answers with its newest
|
|
14
|
+
* stable version, which can change under a long-lived client.
|
|
15
|
+
*/
|
|
16
|
+
version?: string;
|
|
17
|
+
/** Default `https://cloud.simantic.com`. */
|
|
18
|
+
baseUrl?: string;
|
|
19
|
+
/** Default the global fetch. */
|
|
20
|
+
fetch?: typeof fetch;
|
|
21
|
+
/**
|
|
22
|
+
* Called when the server refuses the credential (401), once per request,
|
|
23
|
+
* which is then sent again with `apiKey` as it is after. The dashboard
|
|
24
|
+
* refreshes its session here.
|
|
25
|
+
*/
|
|
26
|
+
onUnauthorized?: () => Promise<void> | void;
|
|
27
|
+
/**
|
|
28
|
+
* How many times a transient failure (no answer, 429, 502, 503, 504) is
|
|
29
|
+
* retried: every call is safe to repeat (a create carries an
|
|
30
|
+
* Idempotency-Key, and a repeated cancel gets the run as it is). Default 3.
|
|
31
|
+
*/
|
|
32
|
+
maxRetries?: number;
|
|
33
|
+
/**
|
|
34
|
+
* How long each request has until its answer's headers arrive, in
|
|
35
|
+
* milliseconds; past it, the request counts as unanswered (`timeout`,
|
|
36
|
+
* transient) and is retried as one. An upload counts against it, so
|
|
37
|
+
* `runs.create` takes its own. Reading the answer is not timed. 0 for no
|
|
38
|
+
* limit. Default 60000.
|
|
39
|
+
*/
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Told before each retry: which retry, the failure, and how long until it
|
|
43
|
+
* is sent. For showing "Reconnecting…"; it must not throw.
|
|
44
|
+
*/
|
|
45
|
+
onRetry?: (info: RetryInfo) => void;
|
|
46
|
+
}
|
|
47
|
+
/** A client of the Cloud Sim API. */
|
|
48
|
+
export declare class CloudSim {
|
|
49
|
+
readonly runs: Runs;
|
|
50
|
+
readonly versions: Versions;
|
|
51
|
+
readonly mcus: Mcus;
|
|
52
|
+
constructor(options: CloudSimOptions);
|
|
53
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Mcus, Versions } from './catalog.js';
|
|
2
|
+
import { Transport } from './http.js';
|
|
3
|
+
import { Runs } from './runs.js';
|
|
4
|
+
/** A client of the Cloud Sim API. */
|
|
5
|
+
export class CloudSim {
|
|
6
|
+
runs;
|
|
7
|
+
versions;
|
|
8
|
+
mcus;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
const http = new Transport(options);
|
|
11
|
+
this.runs = new Runs(http);
|
|
12
|
+
this.versions = new Versions(http);
|
|
13
|
+
this.mcus = new Mcus(http);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RunResult } from './types.js';
|
|
2
|
+
/** The first UART record two outputs do not share. */
|
|
3
|
+
export interface Difference {
|
|
4
|
+
/** Its line number, from 1. */
|
|
5
|
+
line: number;
|
|
6
|
+
/** The first output's record, or null if that output ended before it. */
|
|
7
|
+
a: string | null;
|
|
8
|
+
/** The second output's record, or null if that output ended before it. */
|
|
9
|
+
b: string | null;
|
|
10
|
+
}
|
|
11
|
+
export interface ResultComparison {
|
|
12
|
+
/** Each result's exit code: 0 ok, 1 the simulation failed, 2 a usage error. */
|
|
13
|
+
exitCodes: [number | null, number | null];
|
|
14
|
+
sameExitCode: boolean;
|
|
15
|
+
/** Whether the UART output is the same, byte for byte. */
|
|
16
|
+
outputIdentical: boolean;
|
|
17
|
+
/** Null when the outputs are identical. */
|
|
18
|
+
firstDifference: Difference | null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* How result `b` differs from result `a`: both exit codes, whether the UART
|
|
22
|
+
* output is identical, and the first record that differs. A record is shown
|
|
23
|
+
* without the newline that ends it, unless that newline is the only
|
|
24
|
+
* difference (`"r2\n"` against `"r2"`). A missing output counts as empty.
|
|
25
|
+
*/
|
|
26
|
+
export declare function compareResults(a: Pick<RunResult, 'exit_code' | 'output'>, b: Pick<RunResult, 'exit_code' | 'output'>): ResultComparison;
|
package/dist/compare.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// `compareResults`: how two runs' results differ, for a comparison of
|
|
2
|
+
// engines (`runs.compare`). Every engine writes the same UART record
|
|
3
|
+
// format: records, each ended by a newline. So the first record that
|
|
4
|
+
// differs is the first line that does, and a trailing newline ends the last
|
|
5
|
+
// record rather than starting an empty one. The Python SDK's
|
|
6
|
+
// `compare_results` reports the same.
|
|
7
|
+
/**
|
|
8
|
+
* How result `b` differs from result `a`: both exit codes, whether the UART
|
|
9
|
+
* output is identical, and the first record that differs. A record is shown
|
|
10
|
+
* without the newline that ends it, unless that newline is the only
|
|
11
|
+
* difference (`"r2\n"` against `"r2"`). A missing output counts as empty.
|
|
12
|
+
*/
|
|
13
|
+
export function compareResults(a, b) {
|
|
14
|
+
const exitCodes = [a.exit_code ?? null, b.exit_code ?? null];
|
|
15
|
+
const left = a.output ?? '';
|
|
16
|
+
const right = b.output ?? '';
|
|
17
|
+
return {
|
|
18
|
+
exitCodes,
|
|
19
|
+
sameExitCode: exitCodes[0] === exitCodes[1],
|
|
20
|
+
outputIdentical: left === right,
|
|
21
|
+
firstDifference: left === right ? null : firstDifference(left, right),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function firstDifference(a, b) {
|
|
25
|
+
const left = records(a);
|
|
26
|
+
const right = records(b);
|
|
27
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
28
|
+
const ra = left[i] ?? null;
|
|
29
|
+
const rb = right[i] ?? null;
|
|
30
|
+
if (ra === rb)
|
|
31
|
+
continue;
|
|
32
|
+
const shown = [bare(ra), bare(rb)];
|
|
33
|
+
// Records that differ only in the newline ending one: show it.
|
|
34
|
+
return shown[0] === shown[1]
|
|
35
|
+
? { line: i + 1, a: ra, b: rb }
|
|
36
|
+
: { line: i + 1, a: shown[0], b: shown[1] };
|
|
37
|
+
}
|
|
38
|
+
// Records join back into their text, so different texts have a record
|
|
39
|
+
// that differs; this is not reached.
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/** `text`'s records, each with the newline that ends it (the last may have none). */
|
|
43
|
+
function records(text) {
|
|
44
|
+
const pieces = text.split('\n');
|
|
45
|
+
const last = pieces.pop();
|
|
46
|
+
const found = pieces.map((piece) => `${piece}\n`);
|
|
47
|
+
if (last)
|
|
48
|
+
found.push(last);
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
51
|
+
function bare(record) {
|
|
52
|
+
return record?.endsWith('\n') ? record.slice(0, -1) : record;
|
|
53
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Run, Version } from './types.js';
|
|
2
|
+
/** The fields of a `CloudSimError`, for building one. */
|
|
3
|
+
export interface CloudSimErrorInit {
|
|
4
|
+
status: number;
|
|
5
|
+
code: string;
|
|
6
|
+
param?: string | null;
|
|
7
|
+
requestId?: string | null;
|
|
8
|
+
/** Whether it may go away on its own; by default, what `status` says. */
|
|
9
|
+
transient?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A request the Cloud Sim API refused or never answered.
|
|
13
|
+
*
|
|
14
|
+
* `status` is the HTTP status, or 0 when no answer arrived (offline, DNS, a
|
|
15
|
+
* CORS refusal, `timeoutMs` passed). `code` is a stable name for programs,
|
|
16
|
+
* such as `mcu_not_supported`, `network_error` or `timeout`; `param` names
|
|
17
|
+
* the request field at fault, when one is; `requestId` is the request's
|
|
18
|
+
* `X-Request-Id`, worth quoting in a bug report. `transient` says whether
|
|
19
|
+
* the same request may work later.
|
|
20
|
+
*/
|
|
21
|
+
export declare class CloudSimError extends Error {
|
|
22
|
+
readonly status: number;
|
|
23
|
+
readonly code: string;
|
|
24
|
+
readonly param: string | null;
|
|
25
|
+
readonly requestId: string | null;
|
|
26
|
+
readonly transient: boolean;
|
|
27
|
+
constructor(message: string, init: CloudSimErrorInit);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* `runs.compare` made some of its runs but not all. The runs it made go on
|
|
31
|
+
* on the server and count against the caller's limit: follow them, or
|
|
32
|
+
* cancel them. Its status, code, param and request id are the first
|
|
33
|
+
* failure's, and it is `transient` only if every failure is. The Python
|
|
34
|
+
* SDK's `CompareError` has the same fields.
|
|
35
|
+
*/
|
|
36
|
+
export declare class CompareError extends CloudSimError {
|
|
37
|
+
/** The version the comparison was made on. */
|
|
38
|
+
readonly version: Version;
|
|
39
|
+
/** The runs made, by engine, in the order the engines were given. */
|
|
40
|
+
readonly runs: Record<string, Run>;
|
|
41
|
+
/**
|
|
42
|
+
* Why each other engine's run was not made, by engine, in the same order:
|
|
43
|
+
* a `CloudSimError`, or whatever else the create threw.
|
|
44
|
+
*/
|
|
45
|
+
readonly errors: Record<string, unknown>;
|
|
46
|
+
constructor(version: Version, runs: Record<string, Run>, errors: Record<string, unknown>);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Whether `error` is a failure that may go away if the same request is made
|
|
50
|
+
* again: a `CloudSimError` that is `transient`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function isTransient(error: unknown): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Whether an answer with `status` may change if the request is made again:
|
|
55
|
+
* none arrived (0), the caller is at their run limit (429), or the server or
|
|
56
|
+
* the edge in front of it could not answer (502, 503, 504). Any other status
|
|
57
|
+
* is the server's answer to this request.
|
|
58
|
+
*/
|
|
59
|
+
export declare function transientStatus(status: number): boolean;
|
|
60
|
+
/** The code the server gives an error of `status` that names none of its own. */
|
|
61
|
+
export declare function generalCode(status: number): string;
|