dsh-labscript 0.2.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.
- package/CHANGELOG.md +30 -19
- package/LICENSE +21 -21
- package/README.md +161 -154
- package/cordis.patch.yml +6 -6
- package/docs/INSTALL.zh.md +64 -54
- package/docs/README.zh.md +57 -57
- package/examples/config.example.yml +33 -33
- package/examples/postprocess_demo.py +19 -19
- package/examples/protocols/demo_scan.yaml +18 -18
- package/lib/client.js +193 -193
- package/lib/index.js +138 -138
- package/lib/protocols.js +112 -112
- package/lib/runtime.js +350 -326
- package/lib/skill.js +60 -60
- package/lib/store.mjs +136 -136
- package/lib/tools.js +456 -432
- package/package.json +69 -69
- package/python/bridge.py +1506 -1445
- package/python/tests/run_tests.py +309 -239
package/lib/runtime.js
CHANGED
|
@@ -1,326 +1,350 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-labscript runtime: effective configuration, python bridge transport and
|
|
3
|
-
* runtime state.
|
|
4
|
-
*
|
|
5
|
-
* Every tool call spawns the bundled python bridge once, writes one JSON
|
|
6
|
-
* request on stdin and reads one JSON envelope from stdout. The bridge is a
|
|
7
|
-
* short-lived process: timeouts and cancellation kill it.
|
|
8
|
-
*
|
|
9
|
-
* Configuration comes from two places and is merged on every read: the profile
|
|
10
|
-
* config (advanced settings) and the small JSON file written by the browser
|
|
11
|
-
* configuration page (backend, python, runmanager, protocolsDir). Pending-run
|
|
12
|
-
* state is persisted under DSH_HOME so a restart does not lose track of a run.
|
|
13
|
-
*/
|
|
14
|
-
import { spawn } from 'node:child_process'
|
|
15
|
-
import fs from 'node:fs'
|
|
16
|
-
import path from 'node:path'
|
|
17
|
-
import { fileURLToPath } from 'node:url'
|
|
18
|
-
import {
|
|
19
|
-
mergeConfig,
|
|
20
|
-
readState,
|
|
21
|
-
readUiConfig,
|
|
22
|
-
statePath,
|
|
23
|
-
uiConfigPath,
|
|
24
|
-
writeState,
|
|
25
|
-
} from './store.mjs'
|
|
26
|
-
|
|
27
|
-
const PACKAGE = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
28
|
-
const VERSION = PACKAGE.version
|
|
29
|
-
const DEFAULT_ENGAGE_TIMEOUT_S = 900
|
|
30
|
-
const DEFAULT_APPROVAL = { engage: true, protocolRun: true }
|
|
31
|
-
const DEFAULT_BACKEND = 'mock-labscript'
|
|
32
|
-
const BACKENDS = ['labscript', 'mock-labscript', 'http-sim']
|
|
33
|
-
|
|
34
|
-
export class LabError extends Error {
|
|
35
|
-
constructor(code, message, detail) {
|
|
36
|
-
super(message)
|
|
37
|
-
this.name = 'LabError'
|
|
38
|
-
this.code = code
|
|
39
|
-
this.detail = detail || null
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function normalizeConfig(raw) {
|
|
44
|
-
const input = raw && typeof raw === 'object' ? raw : {}
|
|
45
|
-
const backend = String(input.backend || DEFAULT_BACKEND).toLowerCase()
|
|
46
|
-
if (!BACKENDS.includes(backend)) {
|
|
47
|
-
throw new LabError('BAD_CONFIG', 'backend must be "labscript", "mock-labscript" or "http-sim", got: ' + backend)
|
|
48
|
-
}
|
|
49
|
-
const approval = Object.assign({}, DEFAULT_APPROVAL, input.approval || {})
|
|
50
|
-
return {
|
|
51
|
-
python: input.python || '',
|
|
52
|
-
bridgeScript: input.bridgeScript || '',
|
|
53
|
-
backend,
|
|
54
|
-
runmanager: input.runmanager || {},
|
|
55
|
-
protocolsDir: input.protocolsDir || '',
|
|
56
|
-
scriptsFile: input.scriptsFile || '',
|
|
57
|
-
hardLimitsFile: input.hardLimitsFile || '',
|
|
58
|
-
calibrationLog: input.calibrationLog || '',
|
|
59
|
-
pathMap: input.pathMap || [],
|
|
60
|
-
reps: input.reps || {},
|
|
61
|
-
postprocess: input.postprocess || {},
|
|
62
|
-
engage: input.engage || {},
|
|
63
|
-
panelHistoryDir: input.panelHistoryDir || '',
|
|
64
|
-
approval: {
|
|
65
|
-
engage: approval.engage !== false,
|
|
66
|
-
protocolRun: approval.protocolRun !== false,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return Promise.reject(
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
clearTimeout(timer)
|
|
205
|
-
if (opts.signal) opts.signal.removeEventListener('abort', onAbort)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-labscript runtime: effective configuration, python bridge transport and
|
|
3
|
+
* runtime state.
|
|
4
|
+
*
|
|
5
|
+
* Every tool call spawns the bundled python bridge once, writes one JSON
|
|
6
|
+
* request on stdin and reads one JSON envelope from stdout. The bridge is a
|
|
7
|
+
* short-lived process: timeouts and cancellation kill it.
|
|
8
|
+
*
|
|
9
|
+
* Configuration comes from two places and is merged on every read: the profile
|
|
10
|
+
* config (advanced settings) and the small JSON file written by the browser
|
|
11
|
+
* configuration page (backend, python, runmanager, protocolsDir). Pending-run
|
|
12
|
+
* state is persisted under DSH_HOME so a restart does not lose track of a run.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from 'node:child_process'
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
import {
|
|
19
|
+
mergeConfig,
|
|
20
|
+
readState,
|
|
21
|
+
readUiConfig,
|
|
22
|
+
statePath,
|
|
23
|
+
uiConfigPath,
|
|
24
|
+
writeState,
|
|
25
|
+
} from './store.mjs'
|
|
26
|
+
|
|
27
|
+
const PACKAGE = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
28
|
+
const VERSION = PACKAGE.version
|
|
29
|
+
const DEFAULT_ENGAGE_TIMEOUT_S = 900
|
|
30
|
+
const DEFAULT_APPROVAL = { engage: true, protocolRun: true, protocolNames: true }
|
|
31
|
+
const DEFAULT_BACKEND = 'mock-labscript'
|
|
32
|
+
const BACKENDS = ['labscript', 'mock-labscript', 'http-sim']
|
|
33
|
+
|
|
34
|
+
export class LabError extends Error {
|
|
35
|
+
constructor(code, message, detail) {
|
|
36
|
+
super(message)
|
|
37
|
+
this.name = 'LabError'
|
|
38
|
+
this.code = code
|
|
39
|
+
this.detail = detail || null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeConfig(raw) {
|
|
44
|
+
const input = raw && typeof raw === 'object' ? raw : {}
|
|
45
|
+
const backend = String(input.backend || DEFAULT_BACKEND).toLowerCase()
|
|
46
|
+
if (!BACKENDS.includes(backend)) {
|
|
47
|
+
throw new LabError('BAD_CONFIG', 'backend must be "labscript", "mock-labscript" or "http-sim", got: ' + backend)
|
|
48
|
+
}
|
|
49
|
+
const approval = Object.assign({}, DEFAULT_APPROVAL, input.approval || {})
|
|
50
|
+
return {
|
|
51
|
+
python: input.python || '',
|
|
52
|
+
bridgeScript: input.bridgeScript || '',
|
|
53
|
+
backend,
|
|
54
|
+
runmanager: input.runmanager || {},
|
|
55
|
+
protocolsDir: input.protocolsDir || '',
|
|
56
|
+
scriptsFile: input.scriptsFile || '',
|
|
57
|
+
hardLimitsFile: input.hardLimitsFile || '',
|
|
58
|
+
calibrationLog: input.calibrationLog || '',
|
|
59
|
+
pathMap: input.pathMap || [],
|
|
60
|
+
reps: input.reps || {},
|
|
61
|
+
postprocess: input.postprocess || {},
|
|
62
|
+
engage: input.engage || {},
|
|
63
|
+
panelHistoryDir: input.panelHistoryDir || '',
|
|
64
|
+
approval: {
|
|
65
|
+
engage: approval.engage !== false,
|
|
66
|
+
protocolRun: approval.protocolRun !== false,
|
|
67
|
+
protocolNames: approval.protocolNames !== false,
|
|
68
|
+
},
|
|
69
|
+
mock: input.mock || {},
|
|
70
|
+
simulator: input.simulator || {},
|
|
71
|
+
dshHome: input.dshHome || '',
|
|
72
|
+
requestTimeoutMs: Number(input.requestTimeoutMs || 60000),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function bridgePayload(config) {
|
|
77
|
+
return {
|
|
78
|
+
backend: config.backend,
|
|
79
|
+
runmanager: config.runmanager,
|
|
80
|
+
scriptsFile: config.scriptsFile,
|
|
81
|
+
hardLimitsFile: config.hardLimitsFile,
|
|
82
|
+
calibrationLog: config.calibrationLog,
|
|
83
|
+
pathMap: config.pathMap,
|
|
84
|
+
reps: config.reps,
|
|
85
|
+
postprocess: config.postprocess,
|
|
86
|
+
engage: Object.assign({ timeoutS: DEFAULT_ENGAGE_TIMEOUT_S }, config.engage || {}),
|
|
87
|
+
panelHistoryDir: config.panelHistoryDir,
|
|
88
|
+
mock: config.mock,
|
|
89
|
+
simulator: config.simulator,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createRuntime(ctx, rawConfig) {
|
|
94
|
+
const profileConfig = rawConfig && typeof rawConfig === 'object' ? rawConfig : {}
|
|
95
|
+
const home = typeof profileConfig.dshHome === 'string' && profileConfig.dshHome !== '' ? profileConfig.dshHome : undefined
|
|
96
|
+
let uiCache = { key: 'unread', config: null, warning: undefined, file: uiConfigPath(home) }
|
|
97
|
+
|
|
98
|
+
function uiState() {
|
|
99
|
+
const file = uiConfigPath(home)
|
|
100
|
+
let stat
|
|
101
|
+
try {
|
|
102
|
+
stat = fs.statSync(file)
|
|
103
|
+
} catch {
|
|
104
|
+
uiCache = { key: 'missing', config: null, warning: undefined, file }
|
|
105
|
+
return uiCache
|
|
106
|
+
}
|
|
107
|
+
const key = String(stat.mtimeMs) + ':' + stat.size
|
|
108
|
+
if (key !== uiCache.key) {
|
|
109
|
+
const read = readUiConfig(home)
|
|
110
|
+
uiCache = { key, config: read.config, warning: read.warning, file }
|
|
111
|
+
}
|
|
112
|
+
return uiCache
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function currentConfig() {
|
|
116
|
+
return normalizeConfig(mergeConfig(profileConfig, uiState().config))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Fail fast on a broken profile config instead of failing on the first tool call.
|
|
120
|
+
currentConfig()
|
|
121
|
+
|
|
122
|
+
const loadedState = readState(home)
|
|
123
|
+
const state = {
|
|
124
|
+
pending: loadedState.pending,
|
|
125
|
+
lastRun: loadedState.lastRun,
|
|
126
|
+
queue: Promise.resolve(),
|
|
127
|
+
active: {},
|
|
128
|
+
waitCancelledByAbort: false,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function saveState() {
|
|
132
|
+
try {
|
|
133
|
+
writeState(state, VERSION, home)
|
|
134
|
+
} catch {
|
|
135
|
+
/* state persistence is best effort */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function setPending(pending) {
|
|
140
|
+
state.pending = pending || null
|
|
141
|
+
saveState()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function setLastRun(lastRun) {
|
|
145
|
+
state.lastRun = lastRun || null
|
|
146
|
+
saveState()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function clearPending() {
|
|
150
|
+
state.pending = null
|
|
151
|
+
saveState()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function runBridge(command, args, options) {
|
|
155
|
+
const opts = options || {}
|
|
156
|
+
let config
|
|
157
|
+
try {
|
|
158
|
+
config = currentConfig()
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return Promise.reject(error)
|
|
161
|
+
}
|
|
162
|
+
if (!config.python) {
|
|
163
|
+
return Promise.reject(new LabError('BAD_CONFIG', 'config.python is required (path to a python interpreter with numpy and h5py; use the labscript-suite environment for real hardware)'))
|
|
164
|
+
}
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
let settled = false
|
|
167
|
+
let child
|
|
168
|
+
const finish = (error, value) => {
|
|
169
|
+
if (settled) return
|
|
170
|
+
settled = true
|
|
171
|
+
if (error) reject(error)
|
|
172
|
+
else resolve(value)
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
child = spawn(config.python, [bridgeScriptOf(config)], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
|
176
|
+
state.active[command] = child
|
|
177
|
+
} catch (error) {
|
|
178
|
+
finish(new LabError('SPAWN_FAILED', 'cannot spawn python bridge: ' + error.message))
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
const stdout = []
|
|
182
|
+
const stderr = []
|
|
183
|
+
const timeoutMs = Number(opts.timeoutMs || config.requestTimeoutMs)
|
|
184
|
+
const timer = setTimeout(() => {
|
|
185
|
+
try { child.kill() } catch { /* ignore */ }
|
|
186
|
+
finish(new LabError('TIMEOUT', 'bridge call timed out after ' + timeoutMs + ' ms: ' + command, {
|
|
187
|
+
hint: command === 'wait' ? 'the run may still be executing; use labs_wait with run_folder or labs_abort' : undefined,
|
|
188
|
+
}))
|
|
189
|
+
}, timeoutMs)
|
|
190
|
+
const onAbort = () => {
|
|
191
|
+
try { child.kill() } catch { /* ignore */ }
|
|
192
|
+
finish(new LabError('ABORTED', 'bridge call cancelled: ' + command, {
|
|
193
|
+
hint: command === 'wait' ? 'the run may still be executing; use labs_wait with run_folder or labs_abort' : undefined,
|
|
194
|
+
}))
|
|
195
|
+
}
|
|
196
|
+
if (opts.signal) {
|
|
197
|
+
if (opts.signal.aborted) onAbort()
|
|
198
|
+
else opts.signal.addEventListener('abort', onAbort, { once: true })
|
|
199
|
+
}
|
|
200
|
+
child.stdout.on('data', (chunk) => stdout.push(chunk))
|
|
201
|
+
child.stderr.on('data', (chunk) => stderr.push(chunk))
|
|
202
|
+
child.on('error', (error) => {
|
|
203
|
+
if (state.active[command] === child) delete state.active[command]
|
|
204
|
+
clearTimeout(timer)
|
|
205
|
+
if (opts.signal) opts.signal.removeEventListener('abort', onAbort)
|
|
206
|
+
finish(new LabError('SPAWN_FAILED', 'python bridge failed: ' + error.message))
|
|
207
|
+
})
|
|
208
|
+
child.on('close', (code) => {
|
|
209
|
+
if (state.active[command] === child) delete state.active[command]
|
|
210
|
+
clearTimeout(timer)
|
|
211
|
+
if (opts.signal) opts.signal.removeEventListener('abort', onAbort)
|
|
212
|
+
const cancelledByAbort = command === 'wait' && state.waitCancelledByAbort
|
|
213
|
+
const outText = Buffer.concat(stdout).toString('utf8').trim()
|
|
214
|
+
const errText = Buffer.concat(stderr).toString('utf8').trim()
|
|
215
|
+
if (!outText) {
|
|
216
|
+
if (cancelledByAbort) {
|
|
217
|
+
finish(new LabError('ABORTED', 'wait cancelled by labs_abort'))
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
finish(new LabError('BRIDGE_EMPTY_OUTPUT', 'bridge produced no JSON (exit ' + code + '): ' + tail(errText)))
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
let envelope
|
|
224
|
+
try {
|
|
225
|
+
envelope = JSON.parse(outText)
|
|
226
|
+
} catch {
|
|
227
|
+
finish(new LabError('BRIDGE_BAD_JSON', 'bridge output is not JSON: ' + tail(outText)))
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
if (!envelope || envelope.ok !== true) {
|
|
231
|
+
const info = envelope && envelope.error ? envelope.error : {}
|
|
232
|
+
finish(new LabError(info.code || 'BRIDGE_ERROR', info.message || 'bridge error', info.detail || null))
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
finish(null, envelope.data)
|
|
236
|
+
})
|
|
237
|
+
try {
|
|
238
|
+
child.stdin.write(JSON.stringify({ cmd: command, args: args || {}, config: bridgePayload(config) }))
|
|
239
|
+
child.stdin.end()
|
|
240
|
+
} catch (error) {
|
|
241
|
+
try { child.kill() } catch { /* ignore */ }
|
|
242
|
+
finish(new LabError('SPAWN_FAILED', 'cannot write to python bridge: ' + error.message))
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function bridgeScriptOf(config) {
|
|
248
|
+
return config.bridgeScript || fileURLToPath(new URL('../python/bridge.py', import.meta.url))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function cancelWait() {
|
|
252
|
+
const child = state.active.wait
|
|
253
|
+
if (!child) return false
|
|
254
|
+
state.waitCancelledByAbort = true
|
|
255
|
+
try {
|
|
256
|
+
child.kill()
|
|
257
|
+
} catch {
|
|
258
|
+
/* ignore */
|
|
259
|
+
}
|
|
260
|
+
return true
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function serial(task) {
|
|
264
|
+
const run = state.queue.then(task, task)
|
|
265
|
+
state.queue = run.then(() => undefined, () => undefined)
|
|
266
|
+
return run
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function doctor() {
|
|
270
|
+
const checks = []
|
|
271
|
+
const add = (name, status, detail, hint) => checks.push({ name, status, detail: detail || '', hint: hint || '' })
|
|
272
|
+
let config
|
|
273
|
+
try {
|
|
274
|
+
config = currentConfig()
|
|
275
|
+
add('config', 'ok', 'profile config loaded')
|
|
276
|
+
} catch (error) {
|
|
277
|
+
add('config', 'fail', error.message, 'fix the profile cordis.patch.yml config')
|
|
278
|
+
return { version: VERSION, backend: 'unknown', checks, configFile: uiState().file, stateFile: statePath(home) }
|
|
279
|
+
}
|
|
280
|
+
add('version', 'ok', 'dsh-labscript ' + VERSION)
|
|
281
|
+
add('backend', config.backend === 'labscript' ? 'warn' : 'ok', config.backend, config.backend === 'labscript' ? 'REAL HARDWARE mode: engage writes to the live panel' : 'simulator mode')
|
|
282
|
+
const pythonSet = config.python !== ''
|
|
283
|
+
const pythonIsFile = pythonSet && fs.existsSync(config.python)
|
|
284
|
+
const pythonHasSeparator = pythonSet && (config.python.includes('/') || config.python.includes('\\'))
|
|
285
|
+
const pythonStatus = !pythonSet ? 'fail' : pythonIsFile ? 'ok' : pythonHasSeparator ? 'fail' : 'warn'
|
|
286
|
+
add('python-path', pythonStatus, config.python || '(not configured)',
|
|
287
|
+
!pythonSet ? 'set the labscript-suite python interpreter (or any python with numpy+h5py for mock)'
|
|
288
|
+
: pythonStatus === 'fail' ? 'path does not exist'
|
|
289
|
+
: pythonStatus === 'warn' ? 'not an absolute file path; will be resolved through PATH'
|
|
290
|
+
: '')
|
|
291
|
+
let approval = null
|
|
292
|
+
try { approval = typeof ctx.get === 'function' ? ctx.get('approval') : undefined } catch { approval = undefined }
|
|
293
|
+
add('approval', approval ? 'ok' : 'warn', approval ? 'approval service available' : 'no approval service', approval ? '' : 'real-hardware engage/protocol run will be refused')
|
|
294
|
+
try {
|
|
295
|
+
fs.mkdirSync(path.dirname(statePath(home)), { recursive: true })
|
|
296
|
+
fs.accessSync(path.dirname(statePath(home)), fs.constants.W_OK)
|
|
297
|
+
add('state-dir', 'ok', path.dirname(statePath(home)))
|
|
298
|
+
} catch (error) {
|
|
299
|
+
add('state-dir', 'fail', path.dirname(statePath(home)), 'cannot write runtime state: ' + error.message)
|
|
300
|
+
}
|
|
301
|
+
if (config.protocolsDir) {
|
|
302
|
+
add('protocols-dir', fs.existsSync(config.protocolsDir) ? 'ok' : 'warn', config.protocolsDir, fs.existsSync(config.protocolsDir) ? '' : 'directory does not exist (lists will be empty)')
|
|
303
|
+
}
|
|
304
|
+
if (config.python) {
|
|
305
|
+
try {
|
|
306
|
+
const data = await runBridge('doctor', {}, { timeoutMs: 60000 })
|
|
307
|
+
for (const check of data.checks || []) checks.push(check)
|
|
308
|
+
} catch (error) {
|
|
309
|
+
add('python-bridge', 'fail', error.message, error.detail && error.detail.hint ? error.detail.hint : 'run the bridge manually to see the traceback')
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
version: VERSION,
|
|
314
|
+
backend: config.backend,
|
|
315
|
+
checks,
|
|
316
|
+
configFile: uiState().file,
|
|
317
|
+
stateFile: statePath(home),
|
|
318
|
+
python: config.python,
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
ctx,
|
|
324
|
+
version: VERSION,
|
|
325
|
+
uiHome: home,
|
|
326
|
+
statePath: () => statePath(home),
|
|
327
|
+
uiConfigPath: () => uiConfigPath(home),
|
|
328
|
+
get config() {
|
|
329
|
+
return currentConfig()
|
|
330
|
+
},
|
|
331
|
+
state,
|
|
332
|
+
setPending,
|
|
333
|
+
setLastRun,
|
|
334
|
+
clearPending,
|
|
335
|
+
saveState,
|
|
336
|
+
cancelWait,
|
|
337
|
+
runBridge,
|
|
338
|
+
serial,
|
|
339
|
+
doctor,
|
|
340
|
+
isMock() {
|
|
341
|
+
return currentConfig().backend === 'mock-labscript'
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function tail(text) {
|
|
347
|
+
if (!text) return ''
|
|
348
|
+
const lines = text.split(String.fromCharCode(10))
|
|
349
|
+
return lines.slice(-8).join('; ')
|
|
350
|
+
}
|