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/CHANGELOG.md +43 -19
- package/LICENSE +21 -21
- package/README.md +167 -154
- package/cordis.patch.yml +6 -6
- package/docs/INSTALL.zh.md +64 -54
- package/docs/README.zh.md +57 -57
- package/examples/config.example.yml +33 -33
- package/examples/postprocess_demo.py +19 -19
- package/examples/protocols/demo_scan.yaml +18 -18
- package/lib/client.js +193 -193
- package/lib/index.js +138 -138
- package/lib/protocols.js +112 -112
- package/lib/runtime.js +350 -326
- package/lib/skill.js +60 -60
- package/lib/store.mjs +136 -136
- package/lib/tools.js +457 -432
- package/package.json +69 -69
- package/python/bridge.py +1506 -1445
- package/python/tests/run_tests.py +364 -239
package/lib/skill.js
CHANGED
|
@@ -1,60 +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
|
-
}
|
|
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
|
+
}
|
package/lib/store.mjs
CHANGED
|
@@ -1,136 +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
|
-
}
|
|
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
|
+
}
|