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/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
- mock: input.mock || {},
69
- simulator: input.simulator || {},
70
- dshHome: input.dshHome || '',
71
- requestTimeoutMs: Number(input.requestTimeoutMs || 60000),
72
- }
73
- }
74
-
75
- function bridgePayload(config) {
76
- return {
77
- backend: config.backend,
78
- runmanager: config.runmanager,
79
- scriptsFile: config.scriptsFile,
80
- hardLimitsFile: config.hardLimitsFile,
81
- calibrationLog: config.calibrationLog,
82
- pathMap: config.pathMap,
83
- reps: config.reps,
84
- postprocess: config.postprocess,
85
- engage: Object.assign({ timeoutS: DEFAULT_ENGAGE_TIMEOUT_S }, config.engage || {}),
86
- panelHistoryDir: config.panelHistoryDir,
87
- mock: config.mock,
88
- simulator: config.simulator,
89
- }
90
- }
91
-
92
- export function createRuntime(ctx, rawConfig) {
93
- const profileConfig = rawConfig && typeof rawConfig === 'object' ? rawConfig : {}
94
- const home = typeof profileConfig.dshHome === 'string' && profileConfig.dshHome !== '' ? profileConfig.dshHome : undefined
95
- let uiCache = { key: 'unread', config: null, warning: undefined, file: uiConfigPath(home) }
96
-
97
- function uiState() {
98
- const file = uiConfigPath(home)
99
- let stat
100
- try {
101
- stat = fs.statSync(file)
102
- } catch {
103
- uiCache = { key: 'missing', config: null, warning: undefined, file }
104
- return uiCache
105
- }
106
- const key = String(stat.mtimeMs) + ':' + stat.size
107
- if (key !== uiCache.key) {
108
- const read = readUiConfig(home)
109
- uiCache = { key, config: read.config, warning: read.warning, file }
110
- }
111
- return uiCache
112
- }
113
-
114
- function currentConfig() {
115
- return normalizeConfig(mergeConfig(profileConfig, uiState().config))
116
- }
117
-
118
- // Fail fast on a broken profile config instead of failing on the first tool call.
119
- currentConfig()
120
-
121
- const loadedState = readState(home)
122
- const state = {
123
- pending: loadedState.pending,
124
- lastRun: loadedState.lastRun,
125
- queue: Promise.resolve(),
126
- }
127
-
128
- function saveState() {
129
- try {
130
- writeState(state, VERSION, home)
131
- } catch {
132
- /* state persistence is best effort */
133
- }
134
- }
135
-
136
- function setPending(pending) {
137
- state.pending = pending || null
138
- saveState()
139
- }
140
-
141
- function setLastRun(lastRun) {
142
- state.lastRun = lastRun || null
143
- saveState()
144
- }
145
-
146
- function clearPending() {
147
- state.pending = null
148
- saveState()
149
- }
150
-
151
- function runBridge(command, args, options) {
152
- const opts = options || {}
153
- let config
154
- try {
155
- config = currentConfig()
156
- } catch (error) {
157
- return Promise.reject(error)
158
- }
159
- if (!config.python) {
160
- 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)'))
161
- }
162
- return new Promise((resolve, reject) => {
163
- let settled = false
164
- let child
165
- const finish = (error, value) => {
166
- if (settled) return
167
- settled = true
168
- if (error) reject(error)
169
- else resolve(value)
170
- }
171
- try {
172
- child = spawn(config.python, [bridgeScriptOf(config)], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
173
- } catch (error) {
174
- finish(new LabError('SPAWN_FAILED', 'cannot spawn python bridge: ' + error.message))
175
- return
176
- }
177
- const stdout = []
178
- const stderr = []
179
- const timeoutMs = Number(opts.timeoutMs || config.requestTimeoutMs)
180
- const timer = setTimeout(() => {
181
- try { child.kill() } catch { /* ignore */ }
182
- finish(new LabError('TIMEOUT', 'bridge call timed out after ' + timeoutMs + ' ms: ' + command, {
183
- hint: command === 'wait' ? 'the run may still be executing; use labs_wait with run_folder or labs_abort' : undefined,
184
- }))
185
- }, timeoutMs)
186
- const onAbort = () => {
187
- try { child.kill() } catch { /* ignore */ }
188
- finish(new LabError('ABORTED', 'bridge call cancelled: ' + command, {
189
- hint: command === 'wait' ? 'the run may still be executing; use labs_wait with run_folder or labs_abort' : undefined,
190
- }))
191
- }
192
- if (opts.signal) {
193
- if (opts.signal.aborted) onAbort()
194
- else opts.signal.addEventListener('abort', onAbort, { once: true })
195
- }
196
- child.stdout.on('data', (chunk) => stdout.push(chunk))
197
- child.stderr.on('data', (chunk) => stderr.push(chunk))
198
- child.on('error', (error) => {
199
- clearTimeout(timer)
200
- if (opts.signal) opts.signal.removeEventListener('abort', onAbort)
201
- finish(new LabError('SPAWN_FAILED', 'python bridge failed: ' + error.message))
202
- })
203
- child.on('close', (code) => {
204
- clearTimeout(timer)
205
- if (opts.signal) opts.signal.removeEventListener('abort', onAbort)
206
- const outText = Buffer.concat(stdout).toString('utf8').trim()
207
- const errText = Buffer.concat(stderr).toString('utf8').trim()
208
- if (!outText) {
209
- finish(new LabError('BRIDGE_EMPTY_OUTPUT', 'bridge produced no JSON (exit ' + code + '): ' + tail(errText)))
210
- return
211
- }
212
- let envelope
213
- try {
214
- envelope = JSON.parse(outText)
215
- } catch {
216
- finish(new LabError('BRIDGE_BAD_JSON', 'bridge output is not JSON: ' + tail(outText)))
217
- return
218
- }
219
- if (!envelope || envelope.ok !== true) {
220
- const info = envelope && envelope.error ? envelope.error : {}
221
- finish(new LabError(info.code || 'BRIDGE_ERROR', info.message || 'bridge error', info.detail || null))
222
- return
223
- }
224
- finish(null, envelope.data)
225
- })
226
- try {
227
- child.stdin.write(JSON.stringify({ cmd: command, args: args || {}, config: bridgePayload(config) }))
228
- child.stdin.end()
229
- } catch (error) {
230
- try { child.kill() } catch { /* ignore */ }
231
- finish(new LabError('SPAWN_FAILED', 'cannot write to python bridge: ' + error.message))
232
- }
233
- })
234
- }
235
-
236
- function bridgeScriptOf(config) {
237
- return config.bridgeScript || fileURLToPath(new URL('../python/bridge.py', import.meta.url))
238
- }
239
-
240
- function serial(task) {
241
- const run = state.queue.then(task, task)
242
- state.queue = run.then(() => undefined, () => undefined)
243
- return run
244
- }
245
-
246
- async function doctor() {
247
- const checks = []
248
- const add = (name, status, detail, hint) => checks.push({ name, status, detail: detail || '', hint: hint || '' })
249
- let config
250
- try {
251
- config = currentConfig()
252
- add('config', 'ok', 'profile config loaded')
253
- } catch (error) {
254
- add('config', 'fail', error.message, 'fix the profile cordis.patch.yml config')
255
- return { version: VERSION, backend: 'unknown', checks, configFile: uiState().file, stateFile: statePath(home) }
256
- }
257
- add('version', 'ok', 'dsh-labscript ' + VERSION)
258
- add('backend', config.backend === 'labscript' ? 'warn' : 'ok', config.backend, config.backend === 'labscript' ? 'REAL HARDWARE mode: engage writes to the live panel' : 'simulator mode')
259
- const pythonSet = config.python !== ''
260
- const pythonIsFile = pythonSet && fs.existsSync(config.python)
261
- const pythonHasSeparator = pythonSet && (config.python.includes('/') || config.python.includes('\\'))
262
- const pythonStatus = !pythonSet ? 'fail' : pythonIsFile ? 'ok' : pythonHasSeparator ? 'fail' : 'warn'
263
- add('python-path', pythonStatus, config.python || '(not configured)',
264
- !pythonSet ? 'set the labscript-suite python interpreter (or any python with numpy+h5py for mock)'
265
- : pythonStatus === 'fail' ? 'path does not exist'
266
- : pythonStatus === 'warn' ? 'not an absolute file path; will be resolved through PATH'
267
- : '')
268
- let approval = null
269
- try { approval = typeof ctx.get === 'function' ? ctx.get('approval') : undefined } catch { approval = undefined }
270
- add('approval', approval ? 'ok' : 'warn', approval ? 'approval service available' : 'no approval service', approval ? '' : 'real-hardware engage/protocol run will be refused')
271
- try {
272
- fs.mkdirSync(path.dirname(statePath(home)), { recursive: true })
273
- fs.accessSync(path.dirname(statePath(home)), fs.constants.W_OK)
274
- add('state-dir', 'ok', path.dirname(statePath(home)))
275
- } catch (error) {
276
- add('state-dir', 'fail', path.dirname(statePath(home)), 'cannot write runtime state: ' + error.message)
277
- }
278
- if (config.protocolsDir) {
279
- add('protocols-dir', fs.existsSync(config.protocolsDir) ? 'ok' : 'warn', config.protocolsDir, fs.existsSync(config.protocolsDir) ? '' : 'directory does not exist (lists will be empty)')
280
- }
281
- if (config.python) {
282
- try {
283
- const data = await runBridge('doctor', {}, { timeoutMs: 60000 })
284
- for (const check of data.checks || []) checks.push(check)
285
- } catch (error) {
286
- add('python-bridge', 'fail', error.message, error.detail && error.detail.hint ? error.detail.hint : 'run the bridge manually to see the traceback')
287
- }
288
- }
289
- return {
290
- version: VERSION,
291
- backend: config.backend,
292
- checks,
293
- configFile: uiState().file,
294
- stateFile: statePath(home),
295
- python: config.python,
296
- }
297
- }
298
-
299
- return {
300
- ctx,
301
- version: VERSION,
302
- uiHome: home,
303
- statePath: () => statePath(home),
304
- uiConfigPath: () => uiConfigPath(home),
305
- get config() {
306
- return currentConfig()
307
- },
308
- state,
309
- setPending,
310
- setLastRun,
311
- clearPending,
312
- saveState,
313
- runBridge,
314
- serial,
315
- doctor,
316
- isMock() {
317
- return currentConfig().backend === 'mock-labscript'
318
- },
319
- }
320
- }
321
-
322
- function tail(text) {
323
- if (!text) return ''
324
- const lines = text.split(String.fromCharCode(10))
325
- return lines.slice(-8).join('; ')
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
+ }