dsh-labscript 0.0.0-stage → 0.2.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/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +154 -3
- package/cordis.patch.yml +6 -0
- package/docs/INSTALL.zh.md +54 -0
- package/docs/README.zh.md +57 -0
- package/examples/config.example.yml +33 -0
- package/examples/postprocess_demo.py +19 -0
- package/examples/protocols/demo_scan.yaml +18 -0
- package/lib/client.js +193 -0
- package/lib/index.js +138 -0
- package/lib/protocols.js +112 -0
- package/lib/runtime.js +326 -0
- package/lib/skill.js +60 -0
- package/lib/store.mjs +136 -0
- package/lib/tools.js +432 -0
- package/package.json +69 -6
- package/python/bridge.py +1445 -0
- package/python/tests/run_tests.py +239 -0
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 }
|
package/lib/protocols.js
ADDED
|
@@ -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,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 }
|
|
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
|
+
}
|
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
|
+
}
|