dsh-labscript 0.0.0-stage → 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/index.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * dsh-labscript -- host half.
3
+ *
4
+ * Registers the labs_* tools, the runtime skill, and a small HTTP API used by
5
+ * the browser configuration page. The browser half is optional: the tools work
6
+ * without it, and the page only edits the four connection settings (backend,
7
+ * python, runmanager host/port, protocolsDir).
8
+ */
9
+ import fs from 'node:fs'
10
+ import { createRuntime, LabError } from './runtime.js'
11
+ import { registerTools } from './tools.js'
12
+ import { registerSkill } from './skill.js'
13
+ import { writeUiConfig } from './store.mjs'
14
+
15
+ export const name = 'dsh-labscript'
16
+ export const inject = ['tools', 'skills']
17
+
18
+ const PACKAGE = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
19
+ const API_PREFIX = '/dsh-labscript/api'
20
+ const MAX_BODY_BYTES = 64 * 1024
21
+
22
+ function sendJson(response, status, body) {
23
+ response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
24
+ response.end(JSON.stringify(body))
25
+ }
26
+
27
+ function readJsonBody(request) {
28
+ return new Promise((resolve, reject) => {
29
+ const chunks = []
30
+ let size = 0
31
+ request.on('data', (chunk) => {
32
+ size += chunk.length
33
+ if (size > MAX_BODY_BYTES) {
34
+ reject(new Error('request body too large'))
35
+ request.destroy()
36
+ return
37
+ }
38
+ chunks.push(chunk)
39
+ })
40
+ request.on('end', () => {
41
+ try {
42
+ resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8')))
43
+ } catch (error) {
44
+ reject(new Error('request body is not JSON'))
45
+ }
46
+ })
47
+ request.on('error', reject)
48
+ })
49
+ }
50
+
51
+ function isTrustedRequest(ctx, request) {
52
+ let connection
53
+ try {
54
+ connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined
55
+ } catch {
56
+ connection = undefined
57
+ }
58
+ if (connection === undefined) return false
59
+ try {
60
+ if (typeof connection.isTrusted === 'function' && connection.isTrusted(request) === true) return true
61
+ if (typeof connection.requestRejection === 'function') {
62
+ const code = connection.requestRejection(request)
63
+ return code === undefined || code === null || code === false
64
+ }
65
+ } catch {
66
+ /* fall through to reject */
67
+ }
68
+ return false
69
+ }
70
+
71
+ function registerRoutes(ctx, runtime) {
72
+ const webServer = typeof ctx.get === 'function' ? ctx.get('webServer') : ctx.webServer
73
+ if (!webServer || typeof webServer.register !== 'function') return
74
+ const handler = async (request, response) => {
75
+ const url = new URL(request.url || '/', 'http://127.0.0.1')
76
+ if (!url.pathname.startsWith(API_PREFIX)) {
77
+ sendJson(response, 404, { ok: false, error: 'not found' })
78
+ return
79
+ }
80
+ if (!isTrustedRequest(ctx, request)) {
81
+ sendJson(response, 403, { ok: false, error: 'untrusted request origin' })
82
+ return
83
+ }
84
+ const route = url.pathname.slice(API_PREFIX.length).replace(/\/+$/, '') || '/'
85
+ try {
86
+ if (route === '/config' && request.method === 'GET') {
87
+ const config = runtime.config
88
+ const doctorInfo = { configFile: runtime.uiConfigPath(), stateFile: runtime.statePath(), python: config.python }
89
+ sendJson(response, 200, {
90
+ ok: true,
91
+ version: runtime.version,
92
+ packageName: PACKAGE.name,
93
+ ...doctorInfo,
94
+ config: {
95
+ backend: config.backend,
96
+ python: config.python,
97
+ runmanager: config.runmanager,
98
+ protocolsDir: config.protocolsDir,
99
+ },
100
+ })
101
+ return
102
+ }
103
+ if (route === '/config' && request.method === 'POST') {
104
+ const body = await readJsonBody(request)
105
+ const stored = writeUiConfig(body, runtime.uiHome)
106
+ sendJson(response, 200, { ok: true, config: stored })
107
+ return
108
+ }
109
+ if (route === '/doctor' && request.method === 'POST') {
110
+ const result = await runtime.doctor()
111
+ sendJson(response, 200, { ok: true, ...result })
112
+ return
113
+ }
114
+ sendJson(response, 404, { ok: false, error: 'not found' })
115
+ } catch (error) {
116
+ sendJson(response, 400, { ok: false, error: error && error.message ? error.message : String(error) })
117
+ }
118
+ }
119
+ ctx.effect(() => webServer.register({ kind: 'prefix', path: API_PREFIX, handler }))
120
+ }
121
+
122
+ export function apply(ctx, config) {
123
+ const runtime = createRuntime(ctx, config)
124
+ registerTools(ctx, runtime)
125
+ registerSkill(ctx)
126
+ registerRoutes(ctx, runtime)
127
+ const logger = ctx.logger
128
+ if (logger && typeof logger.info === 'function') {
129
+ const current = runtime.config
130
+ logger.info('[dsh-labscript] v' + runtime.version + ' backend=' + current.backend + ' python=' + (current.python || '<unset>'))
131
+ if (current.backend === 'labscript' && !current.python) {
132
+ logger.warn('[dsh-labscript] backend is labscript but config.python is empty; every hardware tool will fail')
133
+ }
134
+ }
135
+ return runtime
136
+ }
137
+
138
+ export { LabError }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Protocol files: discovery, YAML parsing, name resolution.
3
+ *
4
+ * A protocol is a YAML mapping. The schema is deliberately small: name,
5
+ * labscript (or joint_script), set, scan, reps, engage, postprocess,
6
+ * finish.restore/panel/history. Unknown keys are reported as warnings; the
7
+ * legacy key "analysis" is accepted as an alias for "postprocess".
8
+ */
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+ import YAML from 'yaml'
12
+
13
+ const KNOWN_KEYS = new Set(['name', 'labscript', 'joint_script', 'set', 'scan', 'reps', 'engage', 'postprocess', 'finish', 'analysis'])
14
+ const MAX_PROTOCOL_BYTES = 256 * 1024
15
+
16
+ function protocolFiles(dir) {
17
+ if (!dir) return []
18
+ let entries = []
19
+ try {
20
+ entries = fs.readdirSync(dir)
21
+ } catch (error) {
22
+ return []
23
+ }
24
+ return entries
25
+ .filter((name) => name.endsWith('.yaml') || name.endsWith('.yml'))
26
+ .sort()
27
+ .map((name) => path.join(dir, name))
28
+ }
29
+
30
+ export function listProtocols(dir) {
31
+ return protocolFiles(dir).map((file) => {
32
+ try {
33
+ const loaded = loadProtocolFile(file)
34
+ return { name: loaded.name, file: path.basename(file), warnings: loaded.warnings }
35
+ } catch (error) {
36
+ return { name: path.basename(file), file: path.basename(file), error: error.message }
37
+ }
38
+ })
39
+ }
40
+
41
+ export function resolveProtocolPath(dir, ref) {
42
+ const value = String(ref || '').trim()
43
+ if (!value) throw new Error('protocol name is required')
44
+ if (path.isAbsolute(value)) {
45
+ if (fs.existsSync(value)) return value
46
+ throw new Error('protocol file not found: ' + value)
47
+ }
48
+ const candidates = [path.join(dir || '', value)]
49
+ if (!value.endsWith('.yaml') && !value.endsWith('.yml')) {
50
+ candidates.push(path.join(dir || '', value + '.yaml'))
51
+ candidates.push(path.join(dir || '', value + '.yml'))
52
+ }
53
+ for (const candidate of candidates) {
54
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate
55
+ }
56
+ throw new Error('protocol not found in protocolsDir: ' + value)
57
+ }
58
+
59
+ export function loadProtocol(dir, ref) {
60
+ return loadProtocolFile(resolveProtocolPath(dir, ref))
61
+ }
62
+
63
+ export function loadProtocolFile(file) {
64
+ const stat = fs.statSync(file)
65
+ if (stat.size > MAX_PROTOCOL_BYTES) throw new Error('protocol file is too large: ' + file)
66
+ const text = fs.readFileSync(file, 'utf8')
67
+ let data
68
+ try {
69
+ data = YAML.parse(text)
70
+ } catch (error) {
71
+ throw new Error('cannot parse protocol YAML ' + path.basename(file) + ': ' + error.message)
72
+ }
73
+ return normalizeProtocol(data, file)
74
+ }
75
+
76
+ export function normalizeProtocol(data, file) {
77
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
78
+ throw new Error('protocol must be a YAML mapping: ' + (file || 'inline'))
79
+ }
80
+ const warnings = []
81
+ for (const key of Object.keys(data)) {
82
+ if (!KNOWN_KEYS.has(key)) warnings.push('unknown protocol key ignored: ' + key)
83
+ }
84
+ const plan = {
85
+ name: data.name ? String(data.name) : path.basename(file || 'protocol').replace(/\.ya?ml$/, ''),
86
+ labscript: data.labscript ? String(data.labscript) : undefined,
87
+ joint_script: data.joint_script ? String(data.joint_script) : undefined,
88
+ set: data.set && typeof data.set === 'object' ? Object.assign({}, data.set) : {},
89
+ scan: data.scan && typeof data.scan === 'object' ? Object.assign({}, data.scan) : {},
90
+ reps: data.reps === undefined ? 1 : data.reps,
91
+ engage: data.engage === undefined ? true : Boolean(data.engage),
92
+ postprocess: null,
93
+ finish: { restore: true, panel: {}, history: {} },
94
+ }
95
+ if (data.analysis !== undefined && data.postprocess === undefined) {
96
+ plan.postprocess = data.analysis ? String(data.analysis) : null
97
+ warnings.push('legacy key "analysis" used as "postprocess"')
98
+ } else if (data.postprocess !== undefined) {
99
+ plan.postprocess = data.postprocess ? String(data.postprocess) : null
100
+ }
101
+ if (data.finish && typeof data.finish === 'object') {
102
+ plan.finish.restore = data.finish.restore === undefined ? true : Boolean(data.finish.restore)
103
+ if (data.finish.panel && typeof data.finish.panel === 'object') plan.finish.panel = Object.assign({}, data.finish.panel)
104
+ if (data.finish.history && typeof data.finish.history === 'object') plan.finish.history = Object.assign({}, data.finish.history)
105
+ for (const key of Object.keys(data.finish)) {
106
+ if (!['restore', 'panel', 'history'].includes(key)) warnings.push('unknown finish key ignored: ' + key)
107
+ }
108
+ }
109
+ if (!plan.labscript && !plan.joint_script) throw new Error('protocol needs "labscript" or "joint_script": ' + (file || 'inline'))
110
+ if (!Number.isInteger(plan.reps) || plan.reps < 1) throw new Error('protocol reps must be a positive integer: ' + (file || 'inline'))
111
+ return { name: plan.name, file: file || null, plan, warnings }
112
+ }
package/lib/runtime.js ADDED
@@ -0,0 +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, 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
+ }
package/lib/skill.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Runtime skill: workflow rules for controlling a labscript-suite experiment.
3
+ * Tool descriptions carry parameter detail; this skill carries the order of
4
+ * operations and the stop rules.
5
+ */
6
+ export const SKILL_CONTENT = [
7
+ '# Labscript experiment control',
8
+ '',
9
+ 'You control a labscript-suite experiment through the labs_* tools. The panel state,',
10
+ 'not your memory, is the truth. Never guess a global value or a scan axis.',
11
+ '',
12
+ '## Before any write',
13
+ '1. Call labs_status. Confirm the loaded labscript, n_shots, error_in_globals, run_shots',
14
+ ' and the keys you are about to touch.',
15
+ '2. Real hardware: the user must approve this turn before panel writes, engage or abort.',
16
+ ' The plugin asks for its own one-shot approval on engage/protocol run in the real',
17
+ ' backend; that approval does not replace the user conversation requirement.',
18
+ '3. Prefer a fixed protocol (labs_protocol) for stable calibration flows. Use labs_set',
19
+ ' and labs_engage only for exploratory measurements.',
20
+ '',
21
+ '## Running',
22
+ '- labs_set writes the panel. It does not run anything. Always pass note: one sentence',
23
+ ' saying what this batch is for (it is written to the event log).',
24
+ '- labs_engage submits a run and returns immediately with n_shots_at_submit and',
25
+ ' expected_n_shots. Do not poll in a loop; when the user wants results, call labs_wait.',
26
+ ' After submitting you may end the turn or do independent work.',
27
+ '- labs_protocol run submits a fixed protocol; labs_wait finalizes it (postprocess,',
28
+ ' finish write-back, panel restore).',
29
+ '- backend defaults to mock-labscript; real hardware is only used when the config says',
30
+ ' backend: labscript explicitly.',
31
+ '- On a new install or when a tool fails with a configuration error, run labs_doctor first.',
32
+ '- labs_abort stops compiling/submitting new shots; shots already queued in BLACS or',
33
+ ' running are not stopped. It is not a hardware interlock. Never call it unless',
34
+ ' something looks wrong or the user asks.',
35
+ '- A panel snapshot is taken before every write. labs_restore restores only the keys that',
36
+ ' write touched (pass full=true for the whole panel), so concurrent edits elsewhere are kept.',
37
+ '',
38
+ '## Reading results',
39
+ '- labs_data reads the run folder HDF5: globals of the first shot and, with scan: a',
40
+ ' per-shot table. HDF5 is authoritative; the panel is only intermediate state.',
41
+ '- Report facts: run folder, expected vs actual shots, postprocess numbers, warnings.',
42
+ ' If a number is missing, say so instead of inventing one.',
43
+ '',
44
+ '## Stop rules',
45
+ '- error_in_globals true: fix or ask; never engage.',
46
+ '- wait timed out or diagnosis=compiled_not_executed: the shots may be compiled but BLACS',
47
+ ' may be paused/not executing. Check BLACS and the panel, then abort or ask; do not resubmit blindly.',
48
+ '- A run is already pending: wait or abort before submitting another.',
49
+ ].join(String.fromCharCode(10))
50
+
51
+ export function registerSkill(ctx) {
52
+ ctx.skills.register({
53
+ name: 'labscript',
54
+ description: 'Control a labscript-suite experiment through the labs_* tools: read the runmanager panel, write globals and scans, submit runs without blocking, wait for completion, run YAML fixed-operation protocols with postprocess write-back, read run HDF5 data, restore panel snapshots.',
55
+ whenToUse: 'When the user asks to inspect or change the labscript panel, run shots or scans, run a fixed experiment protocol, or read results of a labscript run.',
56
+ source: 'runtime',
57
+ content: SKILL_CONTENT,
58
+ invocation: { modelInvocable: true, userInvocable: true },
59
+ })
60
+ }