dsh-labscript 0.2.0 → 0.2.2

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 CHANGED
@@ -1,138 +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 }
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 CHANGED
@@ -1,112 +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
- }
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
+ }