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/store.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-labscript local state: UI config + runtime pending state.
|
|
3
|
+
*
|
|
4
|
+
* The browser configuration page stores a small JSON file under DSH_HOME. It is
|
|
5
|
+
* merged over the profile config (UI values win for the keys it manages); the
|
|
6
|
+
* other profile keys (scripts, hard limits, postprocess, approval, ...) stay in
|
|
7
|
+
* the profile's cordis.patch.yml. No credentials are stored here.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import os from 'node:os'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
|
|
13
|
+
export function dshHome() {
|
|
14
|
+
const fromEnv = process.env.DSH_HOME
|
|
15
|
+
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return fromEnv
|
|
16
|
+
return path.join(os.homedir(), '.dsh')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function uiConfigPath(home) {
|
|
20
|
+
return path.join(home || dshHome(), 'labscript.config.json')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function statePath(home) {
|
|
24
|
+
return path.join(home || dshHome(), 'labscript-state.json')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const UI_KEYS = ['backend', 'python', 'runmanager', 'protocolsDir']
|
|
28
|
+
const BACKENDS = ['labscript', 'mock-labscript', 'http-sim']
|
|
29
|
+
|
|
30
|
+
export function normalizeUiConfig(input) {
|
|
31
|
+
const source = input && typeof input === 'object' ? input : {}
|
|
32
|
+
const backend = String(source.backend || 'mock-labscript').toLowerCase()
|
|
33
|
+
if (!BACKENDS.includes(backend)) {
|
|
34
|
+
throw new Error('backend must be one of ' + BACKENDS.join(', ') + ', got: ' + backend)
|
|
35
|
+
}
|
|
36
|
+
const runmanager = source.runmanager && typeof source.runmanager === 'object' ? source.runmanager : {}
|
|
37
|
+
const port = Number(runmanager.port || 42523)
|
|
38
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('runmanager.port must be 1-65535')
|
|
39
|
+
const timeoutS = runmanager.timeoutS === undefined || runmanager.timeoutS === null || runmanager.timeoutS === ''
|
|
40
|
+
? undefined
|
|
41
|
+
: Number(runmanager.timeoutS)
|
|
42
|
+
if (timeoutS !== undefined && (!Number.isFinite(timeoutS) || timeoutS <= 0 || timeoutS > 600)) {
|
|
43
|
+
throw new Error('runmanager.timeoutS must be 1-600')
|
|
44
|
+
}
|
|
45
|
+
const out = {
|
|
46
|
+
backend,
|
|
47
|
+
python: String(source.python || '').trim(),
|
|
48
|
+
runmanager: {
|
|
49
|
+
host: String(runmanager.host || '127.0.0.1').trim(),
|
|
50
|
+
port,
|
|
51
|
+
},
|
|
52
|
+
protocolsDir: String(source.protocolsDir || '').trim(),
|
|
53
|
+
}
|
|
54
|
+
if (timeoutS !== undefined) out.runmanager.timeoutS = timeoutS
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Read the UI config. Returns null when the file does not exist. */
|
|
59
|
+
export function readUiConfig(home) {
|
|
60
|
+
const file = uiConfigPath(home)
|
|
61
|
+
let raw
|
|
62
|
+
try {
|
|
63
|
+
raw = fs.readFileSync(file, 'utf8')
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error && error.code === 'ENOENT') return { config: null, warning: undefined, file }
|
|
66
|
+
return { config: null, warning: 'cannot read ' + file + ': ' + error.message, file }
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
return { config: normalizeUiConfig(JSON.parse(raw)), warning: undefined, file }
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { config: null, warning: 'ignoring corrupt ' + file + ': ' + error.message, file }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function writeUiConfig(input, home) {
|
|
76
|
+
const previous = readUiConfig(home).config || {}
|
|
77
|
+
const patch = input && typeof input === 'object' ? input : {}
|
|
78
|
+
const config = normalizeUiConfig(Object.assign({}, previous, patch, {
|
|
79
|
+
runmanager: Object.assign({}, previous.runmanager || {}, patch.runmanager || {}),
|
|
80
|
+
}))
|
|
81
|
+
const file = uiConfigPath(home)
|
|
82
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
83
|
+
const temp = file + '.' + process.pid + '.' + Date.now() + '.tmp'
|
|
84
|
+
try {
|
|
85
|
+
fs.writeFileSync(temp, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
86
|
+
fs.renameSync(temp, file)
|
|
87
|
+
} finally {
|
|
88
|
+
try { if (fs.existsSync(temp)) fs.unlinkSync(temp) } catch { /* best effort */ }
|
|
89
|
+
}
|
|
90
|
+
return config
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Merge the UI-managed keys over the profile config (UI wins where present). */
|
|
94
|
+
export function mergeConfig(profile, ui) {
|
|
95
|
+
const base = profile && typeof profile === 'object' ? profile : {}
|
|
96
|
+
if (!ui || typeof ui !== 'object') return base
|
|
97
|
+
const out = Object.assign({}, base)
|
|
98
|
+
for (const key of UI_KEYS) {
|
|
99
|
+
if (ui[key] === undefined) continue
|
|
100
|
+
if (key === 'runmanager') out.runmanager = Object.assign({}, base.runmanager || {}, ui.runmanager || {})
|
|
101
|
+
else out[key] = ui[key]
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function readState(home) {
|
|
107
|
+
const file = statePath(home)
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
110
|
+
return {
|
|
111
|
+
pending: parsed && typeof parsed.pending === 'object' ? parsed.pending : null,
|
|
112
|
+
lastRun: parsed && typeof parsed.lastRun === 'object' ? parsed.lastRun : null,
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
return { pending: null, lastRun: null }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function writeState(state, version, home) {
|
|
120
|
+
const file = statePath(home)
|
|
121
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
122
|
+
const temp = file + '.' + process.pid + '.' + Date.now() + '.tmp'
|
|
123
|
+
const payload = {
|
|
124
|
+
version: version || null,
|
|
125
|
+
updated_at: new Date().toISOString(),
|
|
126
|
+
pending: state.pending || null,
|
|
127
|
+
lastRun: state.lastRun || null,
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
fs.writeFileSync(temp, JSON.stringify(payload, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
131
|
+
fs.renameSync(temp, file)
|
|
132
|
+
} finally {
|
|
133
|
+
try { if (fs.existsSync(temp)) fs.unlinkSync(temp) } catch { /* best effort */ }
|
|
134
|
+
}
|
|
135
|
+
return payload
|
|
136
|
+
}
|
package/lib/tools.js
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The eight labs_* tools. Each one is a thin wrapper over one python bridge
|
|
3
|
+
* command plus the DSH-side concerns: approval, serialization, pending-run
|
|
4
|
+
* bookkeeping and compact rendering.
|
|
5
|
+
*/
|
|
6
|
+
import { LabError } from './runtime.js'
|
|
7
|
+
import { listProtocols, loadProtocol } from './protocols.js'
|
|
8
|
+
|
|
9
|
+
const NEWLINE = String.fromCharCode(10)
|
|
10
|
+
|
|
11
|
+
function text(value) {
|
|
12
|
+
return [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function lines(values) {
|
|
16
|
+
return values.filter((value) => value !== undefined && value !== null && value !== '').join(NEWLINE)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function looseOutput(render) {
|
|
20
|
+
return { schema: { type: 'object', additionalProperties: true }, render: render }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function number(value) {
|
|
24
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function progressLine(run) {
|
|
28
|
+
if (!run) return 'run: none pending'
|
|
29
|
+
const parts = ['run: ' + (run.run_folder || '<submitted>')]
|
|
30
|
+
if (run.kind) parts.push('kind=' + run.kind)
|
|
31
|
+
if (run.n_h5 !== undefined) parts.push('h5=' + run.n_h5 + '/' + (run.expected_n_shots || '?'))
|
|
32
|
+
else if (run.expected_n_shots) parts.push('expected=' + run.expected_n_shots)
|
|
33
|
+
if (run.complete !== undefined) parts.push('complete=' + run.complete)
|
|
34
|
+
if (run.elapsed_s !== undefined) parts.push('elapsed=' + run.elapsed_s + 's')
|
|
35
|
+
return parts.join(' ')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function pendingProgress(runtime, exec) {
|
|
39
|
+
const pending = runtime.state.pending
|
|
40
|
+
if (!pending) return null
|
|
41
|
+
const base = {
|
|
42
|
+
kind: pending.kind,
|
|
43
|
+
run_folder: pending.run_folder,
|
|
44
|
+
expected_n_shots: pending.expected_n_shots,
|
|
45
|
+
submitted_at: pending.submitted_at,
|
|
46
|
+
note: pending.note,
|
|
47
|
+
elapsed_s: pending.submitted_at ? Math.round((Date.now() - Date.parse(pending.submitted_at)) / 1000) : undefined,
|
|
48
|
+
}
|
|
49
|
+
if (!pending.run_folder) return base
|
|
50
|
+
try {
|
|
51
|
+
const progress = await runtime.runBridge('progress', {
|
|
52
|
+
run_folder: pending.run_folder,
|
|
53
|
+
expected_n_shots: pending.expected_n_shots,
|
|
54
|
+
}, { signal: exec.signal })
|
|
55
|
+
return Object.assign(base, progress)
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return base
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function requireApproval(runtime, exec, toolName, reason, approvalKey) {
|
|
62
|
+
if (runtime.isMock()) return
|
|
63
|
+
if (approvalKey && runtime.config.approval[approvalKey] === false) return
|
|
64
|
+
const approval = typeof runtime.ctx.get === 'function' ? runtime.ctx.get('approval') : undefined
|
|
65
|
+
if (!approval) {
|
|
66
|
+
throw new LabError('APPROVAL_UNAVAILABLE', 'no approval service is composed; refusing to run a real-hardware action')
|
|
67
|
+
}
|
|
68
|
+
const outcome = await approval.request({
|
|
69
|
+
agent: exec.agent,
|
|
70
|
+
toolName: toolName,
|
|
71
|
+
callId: exec.callId,
|
|
72
|
+
reason: reason,
|
|
73
|
+
displayReason: reason,
|
|
74
|
+
signal: exec.signal,
|
|
75
|
+
})
|
|
76
|
+
if (outcome !== 'allowed-once') {
|
|
77
|
+
throw new LabError('APPROVAL_DENIED', 'action not approved (' + outcome + '): ' + reason)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function clearStalePending(runtime, exec) {
|
|
82
|
+
const pending = runtime.state.pending
|
|
83
|
+
if (!pending) return
|
|
84
|
+
if (pending.run_folder) {
|
|
85
|
+
try {
|
|
86
|
+
const progress = await runtime.runBridge('progress', {
|
|
87
|
+
run_folder: pending.run_folder,
|
|
88
|
+
expected_n_shots: pending.expected_n_shots,
|
|
89
|
+
}, { signal: exec.signal })
|
|
90
|
+
if (!progress.complete) {
|
|
91
|
+
throw new LabError('RUN_PENDING', 'a run is still in progress (' + progress.n_h5 + '/' + progress.expected_n_shots + ' shots): call labs_wait first, or labs_abort')
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error instanceof LabError && error.code === 'RUN_PENDING') throw error
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
runtime.clearPending()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function mockApprovalSkipped(runtime) {
|
|
101
|
+
return runtime.isMock()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function registerTools(ctx, runtime) {
|
|
105
|
+
|
|
106
|
+
ctx.tools.register({
|
|
107
|
+
name: 'labs_status',
|
|
108
|
+
description: 'Read the labscript runmanager panel: loaded labscript, globals (evaluated; raw expressions with raw=true), n_shots, error_in_globals, run_shots, shot output folder, pending run progress, and the panel snapshot directory. Read-only; call this before any write.',
|
|
109
|
+
parameters: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
properties: {
|
|
112
|
+
keys: { type: 'array', items: { type: 'string' }, description: 'Only return these panel globals' },
|
|
113
|
+
raw: { type: 'boolean', description: 'Also return raw (expression) values' },
|
|
114
|
+
},
|
|
115
|
+
additionalProperties: false,
|
|
116
|
+
},
|
|
117
|
+
isConcurrencySafe: () => true,
|
|
118
|
+
output: looseOutput((_args, value) => text(lines([
|
|
119
|
+
'backend: ' + value.backend + ' plugin: v' + runtime.version,
|
|
120
|
+
'labscript: ' + value.labscript,
|
|
121
|
+
'n_shots: ' + value.n_shots + ' error_in_globals: ' + value.error_in_globals + ' run_shots: ' + value.run_shots,
|
|
122
|
+
progressLine(value.last_run),
|
|
123
|
+
'panel history: ' + (value.panel_history ? value.panel_history.dir + ' (' + value.panel_history.count + ' snapshots)' : 'n/a'),
|
|
124
|
+
]))),
|
|
125
|
+
async execute(args, exec) {
|
|
126
|
+
const data = await runtime.runBridge('status', {
|
|
127
|
+
keys: Array.isArray(args.keys) ? args.keys : undefined,
|
|
128
|
+
raw: args.raw === true,
|
|
129
|
+
}, { signal: exec.signal })
|
|
130
|
+
if (runtime.state.pending) data.last_run = await pendingProgress(runtime, exec)
|
|
131
|
+
return data
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
ctx.tools.register({
|
|
136
|
+
name: 'labs_set',
|
|
137
|
+
description: 'Write runmanager panel globals. values are JSON scalars (bool/number/string); pass raw=true to write expression strings, or reps=N to write the repetition counter (np.arange(N)). A full panel snapshot is taken before the write. Does not run anything.',
|
|
138
|
+
parameters: {
|
|
139
|
+
type: 'object',
|
|
140
|
+
properties: {
|
|
141
|
+
values: { type: 'object', additionalProperties: true, description: 'Panel globals to write: key -> scalar' },
|
|
142
|
+
raw: { type: 'boolean', description: 'values are raw expression strings (e.g. np.arange(0, 8, 0.3))' },
|
|
143
|
+
reps: { type: 'integer', minimum: 1, description: 'Write the configured repetition global as np.arange(reps)' },
|
|
144
|
+
note: { type: 'string', description: 'One sentence: what this batch is for (written to the event log)' },
|
|
145
|
+
},
|
|
146
|
+
required: ['values'],
|
|
147
|
+
additionalProperties: false,
|
|
148
|
+
},
|
|
149
|
+
isConcurrencySafe: () => false,
|
|
150
|
+
output: looseOutput((_args, value) => text(lines([
|
|
151
|
+
'wrote ' + Object.keys(value.written || {}).length + ' keys, n_shots now ' + value.n_shots,
|
|
152
|
+
value.missing && value.missing.length ? 'MISSING: ' + value.missing.join(', ') : 'readback ok',
|
|
153
|
+
value.snapshot ? 'snapshot: ' + value.snapshot.name : '',
|
|
154
|
+
]))),
|
|
155
|
+
async execute(args, exec) {
|
|
156
|
+
return runtime.serial(() => runtime.runBridge('set', {
|
|
157
|
+
values: args.values || {},
|
|
158
|
+
raw: args.raw === true,
|
|
159
|
+
reps: args.reps,
|
|
160
|
+
note: args.note,
|
|
161
|
+
}, { signal: exec.signal }))
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
ctx.tools.register({
|
|
166
|
+
name: 'labs_engage',
|
|
167
|
+
description: 'Submit the current panel for a run (compiles and sends to BLACS). Returns immediately with run_folder, n_shots_at_submit and expected_n_shots; use labs_wait later to wait for completion. Refuses when error_in_globals is true or another run is still pending.',
|
|
168
|
+
parameters: {
|
|
169
|
+
type: 'object',
|
|
170
|
+
properties: {
|
|
171
|
+
note: { type: 'string', description: 'One sentence: what this run is for (written to the event log)' },
|
|
172
|
+
},
|
|
173
|
+
additionalProperties: false,
|
|
174
|
+
},
|
|
175
|
+
isConcurrencySafe: () => false,
|
|
176
|
+
output: looseOutput((_args, value) => text(lines([
|
|
177
|
+
'submitted run: ' + (value.run_folder || '<run folder pending>'),
|
|
178
|
+
'n_shots_at_submit: ' + value.n_shots_at_submit + ' expected: ' + value.expected_n_shots,
|
|
179
|
+
'call labs_wait when results are needed.',
|
|
180
|
+
]))),
|
|
181
|
+
async execute(args, exec) {
|
|
182
|
+
return runtime.serial(async () => {
|
|
183
|
+
await clearStalePending(runtime, exec)
|
|
184
|
+
const before = await runtime.runBridge('status', {}, { signal: exec.signal })
|
|
185
|
+
await requireApproval(runtime, exec, 'labs_engage', 'Submit a labscript run: ' + (before.labscript || '<script>') + ', n_shots=' + before.n_shots + ', backend=' + before.backend, 'engage')
|
|
186
|
+
const data = await runtime.runBridge('engage', { note: args.note }, { signal: exec.signal, timeoutMs: 180000 })
|
|
187
|
+
runtime.setLastRun({
|
|
188
|
+
run_folder: data.run_folder,
|
|
189
|
+
expected_n_shots: data.expected_n_shots,
|
|
190
|
+
submitted_at: data.submitted_at,
|
|
191
|
+
})
|
|
192
|
+
runtime.setPending({
|
|
193
|
+
kind: 'engage',
|
|
194
|
+
run_folder: data.run_folder,
|
|
195
|
+
expected_n_shots: data.expected_n_shots,
|
|
196
|
+
submitted_at: data.submitted_at,
|
|
197
|
+
note: args.note,
|
|
198
|
+
snapshot: null,
|
|
199
|
+
})
|
|
200
|
+
return data
|
|
201
|
+
})
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
ctx.tools.register({
|
|
206
|
+
name: 'labs_wait',
|
|
207
|
+
description: 'Wait for the pending run to finish (blocking, timeout in seconds). If the pending run came from labs_protocol run, this call also finalizes it: postprocess, finish write-back of panel and calibration history, and panel restore. Returns expected vs actual shots and, for protocols, the postprocess/finish/restore results.',
|
|
208
|
+
parameters: {
|
|
209
|
+
type: 'object',
|
|
210
|
+
properties: {
|
|
211
|
+
timeout_s: { type: 'number', minimum: 1, description: 'Override the configured run timeout (default config.engage.timeoutS)' },
|
|
212
|
+
run_folder: { type: 'string', description: 'Wait for this run folder instead of the pending run' },
|
|
213
|
+
note: { type: 'string', description: 'One sentence for the event log' },
|
|
214
|
+
},
|
|
215
|
+
additionalProperties: false,
|
|
216
|
+
},
|
|
217
|
+
isConcurrencySafe: () => true,
|
|
218
|
+
output: looseOutput((_args, value) => text(lines([
|
|
219
|
+
'run: ' + value.run_folder,
|
|
220
|
+
'shots: ' + value.n_h5 + '/' + (value.expected_n_shots || '?') + ' complete: ' + value.complete + (value.timed_out ? ' TIMED OUT' : '') + (value.diagnosis ? ' diagnosis: ' + value.diagnosis : ''),
|
|
221
|
+
value.hint ? 'hint: ' + value.hint : '',
|
|
222
|
+
value.finalize && value.finalize.postprocess ? 'postprocess: ' + JSON.stringify(value.finalize.postprocess).slice(0, 400) : '',
|
|
223
|
+
value.finalize && value.finalize.finish ? 'finish: ' + JSON.stringify(value.finalize.finish).slice(0, 300) : '',
|
|
224
|
+
value.finalize && value.finalize.restore ? 'restore: ' + (value.finalize.restore.restored ? 'yes' : 'no') : '',
|
|
225
|
+
]))),
|
|
226
|
+
async execute(args, exec) {
|
|
227
|
+
return runtime.serial(async () => {
|
|
228
|
+
const pending = runtime.state.pending
|
|
229
|
+
const runFolder = args.run_folder || (pending ? pending.run_folder : '')
|
|
230
|
+
if (!runFolder) throw new LabError('NO_PENDING_RUN', 'no run folder: submit with labs_engage or labs_protocol run first, or pass run_folder')
|
|
231
|
+
const expected = pending && pending.run_folder === runFolder ? pending.expected_n_shots : undefined
|
|
232
|
+
const timeoutS = Number(args.timeout_s || runtime.config.engage.timeoutS || 900)
|
|
233
|
+
let waited
|
|
234
|
+
try {
|
|
235
|
+
waited = await runtime.runBridge('wait', {
|
|
236
|
+
run_folder: runFolder,
|
|
237
|
+
expected_n_shots: expected,
|
|
238
|
+
timeout_s: timeoutS,
|
|
239
|
+
}, { signal: exec.signal, timeoutMs: (timeoutS + 60) * 1000 })
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error instanceof LabError && (error.code === 'TIMEOUT' || error.code === 'ABORTED')) {
|
|
242
|
+
throw new LabError(error.code, error.message + '. The run may still be executing: call labs_wait with run_folder to reconnect, or labs_abort.', error.detail)
|
|
243
|
+
}
|
|
244
|
+
throw error
|
|
245
|
+
}
|
|
246
|
+
const out = {
|
|
247
|
+
wait: waited,
|
|
248
|
+
run_folder: waited.run_folder,
|
|
249
|
+
expected_n_shots: waited.expected_n_shots,
|
|
250
|
+
n_h5: waited.n_h5,
|
|
251
|
+
complete: waited.complete,
|
|
252
|
+
timed_out: waited.timed_out,
|
|
253
|
+
elapsed: waited.elapsed,
|
|
254
|
+
diagnosis: waited.diagnosis,
|
|
255
|
+
executed: waited.executed,
|
|
256
|
+
hint: waited.hint,
|
|
257
|
+
mapped_run_folder: waited.mapped_run_folder,
|
|
258
|
+
folder_exists: waited.folder_exists,
|
|
259
|
+
}
|
|
260
|
+
if (waited.complete && runtime.state.lastRun && runtime.state.lastRun.run_folder === runFolder) {
|
|
261
|
+
runtime.setLastRun(Object.assign({}, runtime.state.lastRun, {
|
|
262
|
+
completed_at: new Date().toISOString(),
|
|
263
|
+
n_h5: waited.n_h5,
|
|
264
|
+
}))
|
|
265
|
+
}
|
|
266
|
+
if (waited.complete && pending && pending.kind === 'protocol' && pending.run_folder === runFolder) {
|
|
267
|
+
const finalize = await runtime.runBridge('protocol_finalize', {
|
|
268
|
+
protocol: pending.plan,
|
|
269
|
+
run_folder: runFolder,
|
|
270
|
+
snapshot: pending.snapshot,
|
|
271
|
+
note: args.note || pending.note,
|
|
272
|
+
}, { signal: exec.signal, timeoutMs: 180000 })
|
|
273
|
+
out.finalize = finalize
|
|
274
|
+
runtime.clearPending()
|
|
275
|
+
} else if (waited.complete && pending && pending.run_folder === runFolder) {
|
|
276
|
+
runtime.clearPending()
|
|
277
|
+
}
|
|
278
|
+
return out
|
|
279
|
+
})
|
|
280
|
+
},
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
ctx.tools.register({
|
|
284
|
+
name: 'labs_abort',
|
|
285
|
+
description: 'Abort the current labscript submission (runmanager abort) and drop the pending-run state. Use only when something looks wrong or the user asks; aborting is not a hardware interlock.',
|
|
286
|
+
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
287
|
+
isConcurrencySafe: () => false,
|
|
288
|
+
output: looseOutput((_args, value) => text(lines([
|
|
289
|
+
'aborted',
|
|
290
|
+
value.note ? 'note: ' + value.note : '',
|
|
291
|
+
value.result ? 'result: ' + JSON.stringify(value.result).slice(0, 300) : '',
|
|
292
|
+
]))),
|
|
293
|
+
async execute(_args, exec) {
|
|
294
|
+
const data = await runtime.runBridge('abort', {}, { signal: exec.signal })
|
|
295
|
+
runtime.clearPending()
|
|
296
|
+
return data
|
|
297
|
+
},
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
ctx.tools.register({
|
|
301
|
+
name: 'labs_data',
|
|
302
|
+
description: 'Read a run folder HDF5: globals of the first shot (execution facts) and, with scan: a per-shot table of scan values and numeric results. Read-only.',
|
|
303
|
+
parameters: {
|
|
304
|
+
type: 'object',
|
|
305
|
+
properties: {
|
|
306
|
+
run_folder: { type: 'string', description: 'Run folder; defaults to the pending/last submitted run' },
|
|
307
|
+
scan: { type: 'string', description: 'Panel global name used as the scan axis; adds a per-shot table' },
|
|
308
|
+
globals: { type: 'boolean', description: 'Include the first shot globals (default true)' },
|
|
309
|
+
results: { type: 'boolean', description: 'Include the results layout' },
|
|
310
|
+
},
|
|
311
|
+
additionalProperties: false,
|
|
312
|
+
},
|
|
313
|
+
isConcurrencySafe: () => true,
|
|
314
|
+
output: looseOutput((_args, value) => text(lines([
|
|
315
|
+
'run: ' + value.run_folder,
|
|
316
|
+
'h5: ' + value.n_h5,
|
|
317
|
+
value.scan_table ? 'scan ' + value.scan_table.scan + ': ' + value.scan_table.n_points + ' shots' : '',
|
|
318
|
+
value.globals ? 'globals: ' + Object.keys(value.globals).length + ' keys' : '',
|
|
319
|
+
]))),
|
|
320
|
+
async execute(args, exec) {
|
|
321
|
+
const runFolder = args.run_folder || (runtime.state.pending ? runtime.state.pending.run_folder : '') || (runtime.state.lastRun ? runtime.state.lastRun.run_folder : '')
|
|
322
|
+
if (!runFolder) throw new LabError('NO_RUN_FOLDER', 'no run folder: pass run_folder or submit a run first')
|
|
323
|
+
return runtime.runBridge('data', {
|
|
324
|
+
run_folder: runFolder,
|
|
325
|
+
scan: args.scan,
|
|
326
|
+
globals: args.globals !== false,
|
|
327
|
+
results: args.results === true,
|
|
328
|
+
}, { signal: exec.signal })
|
|
329
|
+
},
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
ctx.tools.register({
|
|
333
|
+
name: 'labs_protocol',
|
|
334
|
+
description: 'Work with YAML fixed-operation protocols. action=list lists protocol names in protocolsDir; action=show validates a plan and reports expected shots plus hard-limit check; action=run submits the protocol (set, scan, reps, engage) and returns immediately with engage-time numbers; the pending protocol is finalized by labs_wait (postprocess, finish write-back, restore). Protocol references may be a bare name, name.yaml, or an absolute path.',
|
|
335
|
+
parameters: {
|
|
336
|
+
type: 'object',
|
|
337
|
+
properties: {
|
|
338
|
+
action: { type: 'string', enum: ['list', 'show', 'run'], description: 'list | show | run' },
|
|
339
|
+
protocol: { type: 'string', description: 'Protocol name (e.g. raman_pi_qubit), file name, or absolute path' },
|
|
340
|
+
note: { type: 'string', description: 'One sentence: what this protocol run is for (written to the event log)' },
|
|
341
|
+
},
|
|
342
|
+
required: ['action'],
|
|
343
|
+
additionalProperties: false,
|
|
344
|
+
},
|
|
345
|
+
isConcurrencySafe: () => false,
|
|
346
|
+
output: looseOutput((_args, value) => text(lines([
|
|
347
|
+
value.protocols ? 'protocols: ' + value.protocols.map((item) => item.name + (item.error ? ' (error)' : '')).join(', ') : '',
|
|
348
|
+
value.plan ? 'protocol: ' + (value.plan.name || value.name) : '',
|
|
349
|
+
value.plan ? 'expected_n_shots: ' + value.plan.expected_n_shots + ' reps: ' + value.plan.reps : '',
|
|
350
|
+
value.plan && value.plan.scan ? 'scan: ' + Object.keys(value.plan.scan).join(', ') : '',
|
|
351
|
+
value.hard_limits ? 'hard limits: ' + JSON.stringify(value.hard_limits).slice(0, 240) : '',
|
|
352
|
+
value.warnings && value.warnings.length ? 'warnings: ' + value.warnings.join('; ') : '',
|
|
353
|
+
value.engage ? 'submitted: ' + (value.engage.run_folder || '<run folder pending>') + ' n_shots_at_submit: ' + value.n_shots_at_submit : '',
|
|
354
|
+
value.engage ? 'call labs_wait to finalize this protocol.' : '',
|
|
355
|
+
]))),
|
|
356
|
+
async execute(args, exec) {
|
|
357
|
+
const dir = runtime.config.protocolsDir
|
|
358
|
+
if (args.action === 'list') {
|
|
359
|
+
return { protocols: listProtocols(dir), protocolsDir: dir || null }
|
|
360
|
+
}
|
|
361
|
+
if (!args.protocol) throw new LabError('BAD_ARGS', 'protocol is required for show/run')
|
|
362
|
+
const loaded = loadProtocol(dir, args.protocol)
|
|
363
|
+
if (args.action === 'show') {
|
|
364
|
+
const planned = await runtime.runBridge('protocol_plan', { protocol: loaded.plan }, { signal: exec.signal })
|
|
365
|
+
return { name: loaded.name, file: loaded.file, warnings: loaded.warnings.concat(planned.warnings || []), plan: planned.plan, hard_limits: planned.hard_limits }
|
|
366
|
+
}
|
|
367
|
+
return runtime.serial(async () => {
|
|
368
|
+
await clearStalePending(runtime, exec)
|
|
369
|
+
const planned = await runtime.runBridge('protocol_plan', { protocol: loaded.plan }, { signal: exec.signal })
|
|
370
|
+
if (planned.hard_limits && planned.hard_limits.checked) {
|
|
371
|
+
// plan command already fails on violations; nothing to do
|
|
372
|
+
}
|
|
373
|
+
await requireApproval(runtime, exec, 'labs_protocol', 'Run protocol ' + loaded.name + ': expected ' + planned.plan.expected_n_shots + ' shots, labscript ' + (loaded.plan.labscript || loaded.plan.joint_script) + ', backend ' + runtime.config.backend, 'protocolRun')
|
|
374
|
+
const started = await runtime.runBridge('protocol_start', { protocol: loaded.plan, note: args.note }, { signal: exec.signal, timeoutMs: 180000 })
|
|
375
|
+
if (started.engage) {
|
|
376
|
+
runtime.setLastRun({
|
|
377
|
+
run_folder: started.engage.run_folder,
|
|
378
|
+
expected_n_shots: started.expected_n_shots,
|
|
379
|
+
submitted_at: started.submitted_at,
|
|
380
|
+
})
|
|
381
|
+
runtime.setPending({
|
|
382
|
+
kind: 'protocol',
|
|
383
|
+
plan: loaded.plan,
|
|
384
|
+
run_folder: started.engage.run_folder,
|
|
385
|
+
expected_n_shots: started.expected_n_shots,
|
|
386
|
+
snapshot: started.snapshot,
|
|
387
|
+
submitted_at: started.submitted_at,
|
|
388
|
+
note: args.note,
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
return Object.assign({ name: loaded.name, file: loaded.file, warnings: loaded.warnings.concat(planned.warnings || []) }, started)
|
|
392
|
+
})
|
|
393
|
+
},
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
ctx.tools.register({
|
|
397
|
+
name: 'labs_restore',
|
|
398
|
+
description: 'Restore the panel from a snapshot taken before a previous write. Defaults to the latest snapshot. Reads the snapshot and writes the raw globals back; returns the readback.',
|
|
399
|
+
parameters: {
|
|
400
|
+
type: 'object',
|
|
401
|
+
properties: {
|
|
402
|
+
snapshot: { type: 'string', description: 'Snapshot file name, absolute path, or "latest" (default)' },
|
|
403
|
+
full: { type: 'boolean', description: 'Restore every global in the snapshot (default: only the keys that write touched)' },
|
|
404
|
+
},
|
|
405
|
+
additionalProperties: false,
|
|
406
|
+
},
|
|
407
|
+
isConcurrencySafe: () => false,
|
|
408
|
+
output: looseOutput((_args, value) => text(lines([
|
|
409
|
+
'restored from: ' + (value.restored_from && value.restored_from.file),
|
|
410
|
+
'snapshot ts: ' + (value.restored_from && value.restored_from.ts),
|
|
411
|
+
value.selective ? 'selective restore of ' + (value.keys || []).length + ' touched keys' : 'full panel restore',
|
|
412
|
+
(value.keys || []).length ? 'keys: ' + (value.keys || []).join(', ') : '',
|
|
413
|
+
]))),
|
|
414
|
+
async execute(args, exec) {
|
|
415
|
+
return runtime.serial(() => runtime.runBridge('restore', { snapshot: args.snapshot || 'latest', full: args.full === true }, { signal: exec.signal }))
|
|
416
|
+
},
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
ctx.tools.register({
|
|
420
|
+
name: 'labs_doctor',
|
|
421
|
+
description: 'Read-only configuration diagnostic. Checks the python interpreter, numpy/h5py, labscript/runmanager (real backend), configured paths, the approval service and version. Run this first after installing, or when a tool fails with a configuration error.',
|
|
422
|
+
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
423
|
+
isConcurrencySafe: () => true,
|
|
424
|
+
output: looseOutput((_args, value) => text(lines((value.checks || []).map((check) => '[' + check.status + '] ' + check.name + ': ' + check.detail + (check.hint ? ' -- ' + check.hint : ''))))),
|
|
425
|
+
async execute() {
|
|
426
|
+
return runtime.doctor()
|
|
427
|
+
},
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
void mockApprovalSkipped
|
|
431
|
+
void number
|
|
432
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-labscript",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-labscript",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Control labscript-suite experiments from DeepSeek Harness: runmanager globals and scans, submits and waits, YAML fixed-operation protocols with postprocess write-back, panel snapshots, a built-in mock backend and a read-only doctor.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./client": {
|
|
12
|
+
"default": "./lib/client.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"lib",
|
|
18
|
+
"python/bridge.py",
|
|
19
|
+
"python/tests/run_tests.py",
|
|
20
|
+
"examples/config.example.yml",
|
|
21
|
+
"examples/postprocess_demo.py",
|
|
22
|
+
"examples/protocols",
|
|
23
|
+
"docs",
|
|
24
|
+
"cordis.patch.yml",
|
|
25
|
+
"README.md",
|
|
26
|
+
"CHANGELOG.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"dsh": {
|
|
30
|
+
"manifestVersion": 1,
|
|
31
|
+
"bundle": {
|
|
32
|
+
"patch": "./cordis.patch.yml"
|
|
33
|
+
},
|
|
34
|
+
"client": {
|
|
35
|
+
"platform": "web",
|
|
36
|
+
"inject": []
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": "^22.19.0 || >=24.0.0",
|
|
41
|
+
"dsh": ">=0.2.0 <0.3.0"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"dsh",
|
|
45
|
+
"dsh-plugin",
|
|
46
|
+
"deepseek-harness",
|
|
47
|
+
"labscript",
|
|
48
|
+
"runmanager",
|
|
49
|
+
"laboratory",
|
|
50
|
+
"automation"
|
|
51
|
+
],
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"author": "ycAlphi (https://github.com/ycAlphi)",
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "git+https://github.com/ycAlphi/dsh-labscript.git"
|
|
57
|
+
},
|
|
58
|
+
"homepage": "https://github.com/ycAlphi/dsh-labscript#readme",
|
|
59
|
+
"bugs": {
|
|
60
|
+
"url": "https://github.com/ycAlphi/dsh-labscript/issues"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"test": "node --test test/bridge.test.mjs test/protocols.test.mjs test/tools.test.mjs test/runtime.test.mjs test/store.test.mjs test/client.test.mjs",
|
|
64
|
+
"test:python": "python python/tests/run_tests.py"
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"yaml": "^2.5.0"
|
|
68
|
+
}
|
|
69
|
+
}
|