Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5782ef9
daq: extract PT role to Elodin table resolution into fsw::config::Sen…
swissskimmilk Sep 14, 2026
9640290
daq: state-script language (lexer, parser, validator) and hermetic st…
swissskimmilk Sep 14, 2026
5541765
daq: dynamic-state config schema, load-time refusals, and script depl…
swissskimmilk Sep 14, 2026
a034040
daq: script positions layered over the state column in ActuatorCommander
swissskimmilk Sep 14, 2026
666a917
daq: ScriptRunner executes dynamic-state scripts on its own thread
swissskimmilk Sep 14, 2026
39ce349
daq: pressure() reads live calibrated PT by role, gated on staleness …
swissskimmilk Sep 14, 2026
4e070a7
daq: config UI for dynamic-state scripts, with name checks and a synt…
swissskimmilk Sep 14, 2026
2c359ee
daq: cover dynamic states in the integration test, and serialise the …
swissskimmilk Sep 14, 2026
ef91047
daq: integration test parks displaced operator state instead of delet…
swissskimmilk Sep 14, 2026
2373715
daq: dynamic is a checkbox, scripts move to a collapsible inline section
swissskimmilk Sep 14, 2026
c9f977e
daq: clearer script placeholder, readable contrast, and a script refe…
swissskimmilk Sep 14, 2026
f82b84e
daq: syntax highlighting, slot-aware autocomplete, and a colour legen…
swissskimmilk Sep 14, 2026
b7df114
daq: autocomplete closes the call, stops at line end, and the referen…
swissskimmilk Sep 14, 2026
b0bb8e1
daq: unrecognised script names render white with the red squiggle
swissskimmilk Sep 14, 2026
732672a
digital-twin: give the profile load cells, a LOX Upstream PT, and the…
swissskimmilk Sep 16, 2026
7e470fc
daq: make the script editor's keys behave, and stop Tab from eating t…
swissskimmilk Sep 16, 2026
6727e10
digital-twin: a Dynamic Test state, and ignore the deployed copy of i…
swissskimmilk Sep 16, 2026
a5f37e9
Merge remote-tracking branch 'origin/main' into worktree-daq-state-de…
swissskimmilk Sep 16, 2026
9c1e717
sequencer: stop going blind on a startup race, and say what a script …
swissskimmilk Sep 17, 2026
2f0cdcc
backend: fix the subscription race that starts a session with no data…
swissskimmilk Sep 17, 2026
d5057f0
daq: a refused command says why, and a state that cannot be entered g…
swissskimmilk Sep 17, 2026
013a28b
digital-twin: the Dynamic Test script fills GN2 High against the high…
swissskimmilk Sep 17, 2026
58f6074
format: clang-format the sequencer files added in this branch
swissskimmilk Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions daq-server/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,9 @@ config/config.toml
config/state_machine_actuators.csv
config/state_machine_actuator_delays.csv
config/state_transitions.csv
# Deployed dynamic-state scripts — copied out of the active profile's scripts/ dir the same
# way, by the same deploy. The tracked source is config/profiles/<name>/scripts/.
config/scripts/

# RLS state the calibration service auto-saves next to whatever calibration dir it is
# reading. tools/calibration/calibrations/adjustments.json is the checked-in seed; the copy
Expand Down
10 changes: 10 additions & 0 deletions daq-server/config/profiles/digital-twin/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,16 @@ id = 13
name = "Flow Test"
is_flow = true

[[states]]
id = 15
name = "Dynamic Test"
panel_row = 0
panel_col = 3
script_file = "dynamic_test.script"
script_return_target = "Press Standby"
script_timeout_target = "Press Standby"
script_timeout_ms = 10000

[actuator_roles]
"Fuel Upstream" = [ "NC", 1, 12 ]
"High Press CTRL" = [ "NC", 2, 12 ]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
start = pressure(HIGH_PRESS)
while pressure(GN2_HIGH) < start * 0.7:
open_valve(HIGH_PRESS_CTRL)
delay(0.4)
close_valve(HIGH_PRESS_CTRL)
delay(2.5)
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
,Idle,Armed,Press Standby,Dome Press,COPV Fill,Fuel Press,LOX Press,Vent,Dome Vent,High Press Vent,Fuel Tank Vent,Ready,Fire,Flow Test
Fuel Upstream,0,0,0,0,0,0,0,0,0,0,0,0,0,0
High Press CTRL,0,0,0,0,0,0,0,0,0,0,0,0,0,0
Dome CTRL,0,0,0,0,0,0,0,0,0,0,0,0,0,0
Fuel Main,0,0,0,0,0,0,0,0,0,0,0,0,1,1
fuel vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0
lox vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0
Ox Upstream,0,0,0,0,0,0,0,0,0,0,0,0,0,0
Fuel Fill Press,0,0,0,0,0,0,0,0,0,0,0,0,0,0
High Press Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0
Fuel Fill Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0
Low Press Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0
Ox Main,0,0,0,0,0,0,0,0,0,0,0,0,0,1
Dome CTRL Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0
,Idle,Armed,Press Standby,Dome Press,COPV Fill,Fuel Press,LOX Press,Vent,Dome Vent,High Press Vent,Fuel Tank Vent,Ready,Fire,Flow Test,Dynamic Test
Fuel Upstream,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
High Press CTRL,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
Dome CTRL,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
Fuel Main,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0
fuel vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
lox vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
Ox Upstream,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
Fuel Fill Press,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
High Press Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
Fuel Fill Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
Low Press Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
Ox Main,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0
Dome CTRL Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
28 changes: 14 additions & 14 deletions daq-server/config/profiles/digital-twin/state_machine_actuators.csv
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
,Idle,Armed,Press Standby,Dome Press,COPV Fill,Fuel Press,LOX Press,Vent,Dome Vent,High Press Vent,Fuel Tank Vent,Ready,Fire,Flow Test
Fuel Upstream,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN
High Press CTRL,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE
Dome CTRL,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE
Fuel Main,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN
fuel vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE
lox vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE
Ox Upstream,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN
Fuel Fill Press,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE
High Press Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE
Fuel Fill Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE
Low Press Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE
Ox Main,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN
Dome CTRL Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE
,Idle,Armed,Press Standby,Dome Press,COPV Fill,Fuel Press,LOX Press,Vent,Dome Vent,High Press Vent,Fuel Tank Vent,Ready,Fire,Flow Test,Dynamic Test
Fuel Upstream,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN,CLOSE
High Press CTRL,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE
Dome CTRL,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE
Fuel Main,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN,CLOSE
fuel vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE
lox vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE
Ox Upstream,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN,CLOSE
Fuel Fill Press,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE
High Press Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE
Fuel Fill Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE
Low Press Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE
Ox Main,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN,CLOSE
Dome CTRL Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE
31 changes: 16 additions & 15 deletions daq-server/config/profiles/digital-twin/state_transitions.csv
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
,Idle,Armed,Press Standby,Dome Press,COPV Fill,Fuel Press,LOX Press,Vent,Dome Vent,High Press Vent,Fuel Tank Vent,Ready,Fire,Flow Test
Idle,1,1,0,0,0,0,0,0,0,0,0,0,0,0
Armed,1,1,1,0,0,0,0,1,0,0,0,0,0,0
Press Standby,1,1,1,1,1,1,1,1,1,1,1,1,0,0
Dome Press,0,0,1,1,0,0,0,0,0,0,0,0,0,0
COPV Fill,0,0,1,0,1,0,0,0,0,0,0,0,0,0
Fuel Press,0,0,1,0,0,1,0,0,0,0,0,0,0,0
LOX Press,0,0,1,0,0,0,1,0,0,0,0,0,0,0
Vent,0,0,1,0,0,0,0,1,1,1,1,1,0,0
Dome Vent,0,0,1,0,0,0,0,1,1,0,0,0,0,0
High Press Vent,0,0,1,0,0,0,0,1,0,1,0,0,0,0
Fuel Tank Vent,0,0,1,0,0,0,0,1,0,0,1,0,0,0
Ready,0,0,1,0,0,0,0,0,0,0,0,1,1,1
Fire,0,1,0,0,0,0,0,0,0,0,0,0,1,0
Flow Test,0,0,1,0,0,0,0,0,0,0,0,1,0,1
,Idle,Armed,Press Standby,Dome Press,COPV Fill,Fuel Press,LOX Press,Vent,Dome Vent,High Press Vent,Fuel Tank Vent,Ready,Fire,Flow Test,Dynamic Test
Idle,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0
Armed,1,1,1,0,0,0,0,1,0,0,0,0,0,0,0
Press Standby,1,1,1,1,1,1,1,1,1,1,1,1,0,0,1
Dome Press,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0
COPV Fill,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0
Fuel Press,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0
LOX Press,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0
Vent,0,0,1,0,0,0,0,1,1,1,1,1,0,0,0
Dome Vent,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0
High Press Vent,0,0,1,0,0,0,0,1,0,1,0,0,0,0,0
Fuel Tank Vent,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0
Ready,0,0,1,0,0,0,0,0,0,0,0,1,1,1,0
Fire,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0
Flow Test,0,0,1,0,0,0,0,0,0,0,0,1,0,1,0
Dynamic Test,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1
1 change: 1 addition & 0 deletions daq-server/diablo_server/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ add_subdirectory(transport)
add_subdirectory(lib)
add_subdirectory(daq_bridge)
add_subdirectory(services)
add_subdirectory(tools)
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Dynamic-state scripts deploy with their profile.
*
* A profile is a directory that owns its state-machine CSVs, and deploying copies the whole thing
* out to config/. Scripts joined that set, and the failure if they had not is nastier than it
* looks: config.toml and the CSVs would land while the script a `[[states]]` entry names did not,
* so the sequencer would refuse a state the operator had just watched save successfully — and it
* would read as a sequencer bug rather than a deploy bug.
*
* deployActiveProfile's own contract is all-or-nothing ("a half-applied deploy is worse than no
* deploy"), so the rollback case is tested too: a config that does not parse must leave the
* previously-deployed script exactly as it was.
*
* Hermetic: CONFIG_PATH points at a temp directory, which is what every config-profiles path
* resolves from, so nothing here touches the real config.
*/
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';

const root = mkdtempSync(join(tmpdir(), 'daq-scripts-'));
const configDir = join(root, 'config');
const profileDir = join(configDir, 'profiles', 'default');
mkdirSync(profileDir, { recursive: true });
process.env.CONFIG_PATH = join(configDir, 'config.toml');

const profiles = await import('../routes/config-profiles.js');

const GOOD_CONFIG = `
[network]
actuator_cmd_port = 5005

[[states]]
id = 1
name = "Idle"
is_boot = true

[[states]]
id = 2
name = "COPV Press"
script_file = "copv_press.script"
script_timeout_ms = 30000
script_return_target = "Idle"
script_timeout_target = "Idle"
`;

const SCRIPT = `target = 0.9 * pressure(GN2_HIGH)
while pressure(GN2_REGULATED) < target:
open_valve(GSE_HIGH_PRESS_CONTROL)
delay(0.2)
close_valve(GSE_HIGH_PRESS_CONTROL)
delay(0.2)
transition_to(IDLE)
`;

const deployedScript = join(configDir, 'scripts', 'copv_press.script');
const profileScript = join(profileDir, 'scripts', 'copv_press.script');

function seedProfile(config = GOOD_CONFIG, script: string | null = SCRIPT): void {
writeFileSync(join(profileDir, 'config.toml'), config, 'utf-8');
writeFileSync(join(profileDir, 'state_transitions.csv'), ',Idle\nIdle,1\n', 'utf-8');
writeFileSync(join(profileDir, 'state_machine_actuators.csv'), ',Idle\nVent,CLOSE\n', 'utf-8');
rmSync(join(profileDir, 'scripts'), { recursive: true, force: true });
if (script !== null) {
mkdirSync(join(profileDir, 'scripts'), { recursive: true });
writeFileSync(profileScript, script, 'utf-8');
}
}

beforeEach(() => {
rmSync(join(configDir, 'scripts'), { recursive: true, force: true });
rmSync(join(configDir, 'config.toml'), { force: true });
writeFileSync(join(configDir, '.active_profile'), 'default\n', 'utf-8');
seedProfile();
});

afterAll(() => rmSync(root, { recursive: true, force: true }));

describe('profile deploy carries dynamic-state scripts', () => {
it('copies scripts/ alongside config.toml and the CSVs', () => {
profiles.deployActiveProfile();
expect(existsSync(deployedScript)).toBe(true);

Check failure on line 83 in daq-server/diablo_server/backend/src/__tests__/profile-scripts-deploy.test.ts

View workflow job for this annotation

GitHub Actions / Web GUI (Vitest + Vite build)

src/__tests__/profile-scripts-deploy.test.ts > profile deploy carries dynamic-state scripts > copies scripts/ alongside config.toml and the CSVs

AssertionError: expected false to be true // Object.is equality - Expected + Received - true + false ❯ src/__tests__/profile-scripts-deploy.test.ts:83:40
expect(readFileSync(deployedScript, 'utf-8')).toBe(SCRIPT);
});

it('creates config/scripts/ when it does not already exist', () => {
expect(existsSync(join(configDir, 'scripts'))).toBe(false);
profiles.deployActiveProfile();
expect(existsSync(join(configDir, 'scripts'))).toBe(true);

Check failure on line 90 in daq-server/diablo_server/backend/src/__tests__/profile-scripts-deploy.test.ts

View workflow job for this annotation

GitHub Actions / Web GUI (Vitest + Vite build)

src/__tests__/profile-scripts-deploy.test.ts > profile deploy carries dynamic-state scripts > creates config/scripts/ when it does not already exist

AssertionError: expected false to be true // Object.is equality - Expected + Received - true + false ❯ src/__tests__/profile-scripts-deploy.test.ts:90:52
});

it('redeploys an edited script', () => {
profiles.deployActiveProfile();
writeFileSync(profileScript, 'delay(1)\n', 'utf-8');
profiles.deployActiveProfile();
expect(readFileSync(deployedScript, 'utf-8')).toBe('delay(1)\n');

Check failure on line 97 in daq-server/diablo_server/backend/src/__tests__/profile-scripts-deploy.test.ts

View workflow job for this annotation

GitHub Actions / Web GUI (Vitest + Vite build)

src/__tests__/profile-scripts-deploy.test.ts > profile deploy carries dynamic-state scripts > redeploys an edited script

Error: ENOENT: no such file or directory, open '/tmp/daq-scripts-peIKTG/config/scripts/copv_press.script' ❯ src/__tests__/profile-scripts-deploy.test.ts:97:12 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'open', path: '/tmp/daq-scripts-peIKTG/config/scripts/copv_press.script' }
});

it('lists an undeployed script edit as a pending change', () => {
profiles.deployActiveProfile();
expect(profiles.undeployedChanges()).toEqual([]);
writeFileSync(profileScript, 'delay(2)\n', 'utf-8');
expect(profiles.undeployedChanges()).toContain('scripts/copv_press.script');

Check failure on line 104 in daq-server/diablo_server/backend/src/__tests__/profile-scripts-deploy.test.ts

View workflow job for this annotation

GitHub Actions / Web GUI (Vitest + Vite build)

src/__tests__/profile-scripts-deploy.test.ts > profile deploy carries dynamic-state scripts > lists an undeployed script edit as a pending change

AssertionError: expected [] to include 'scripts/copv_press.script' ❯ src/__tests__/profile-scripts-deploy.test.ts:104:42
});

it('rolls the script back when the config does not parse', () => {
profiles.deployActiveProfile();
expect(readFileSync(deployedScript, 'utf-8')).toBe(SCRIPT);

Check failure on line 109 in daq-server/diablo_server/backend/src/__tests__/profile-scripts-deploy.test.ts

View workflow job for this annotation

GitHub Actions / Web GUI (Vitest + Vite build)

src/__tests__/profile-scripts-deploy.test.ts > profile deploy carries dynamic-state scripts > rolls the script back when the config does not parse

Error: ENOENT: no such file or directory, open '/tmp/daq-scripts-peIKTG/config/scripts/copv_press.script' ❯ src/__tests__/profile-scripts-deploy.test.ts:109:12 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'open', path: '/tmp/daq-scripts-peIKTG/config/scripts/copv_press.script' }

// A profile that will fail the post-copy parse, with a different script beside it.
seedProfile('this is not = = valid toml [[[', 'delay(99)\n');
expect(() => profiles.deployActiveProfile()).toThrow();

// All-or-nothing: the previously deployed script must be untouched, not the new one.
expect(readFileSync(deployedScript, 'utf-8')).toBe(SCRIPT);
});

it('a profile with no scripts/ directory deploys normally', () => {
seedProfile(GOOD_CONFIG, null);
expect(() => profiles.deployActiveProfile()).not.toThrow();
expect(existsSync(join(configDir, 'config.toml'))).toBe(true);

Check failure on line 122 in daq-server/diablo_server/backend/src/__tests__/profile-scripts-deploy.test.ts

View workflow job for this annotation

GitHub Actions / Web GUI (Vitest + Vite build)

src/__tests__/profile-scripts-deploy.test.ts > profile deploy carries dynamic-state scripts > a profile with no scripts/ directory deploys normally

AssertionError: expected false to be true // Object.is equality - Expected + Received - true + false ❯ src/__tests__/profile-scripts-deploy.test.ts:122:56
});

it('a new profile created from another inherits its scripts', () => {
profiles.createProfile('copy-of-default', 'default');
const copied = join(configDir, 'profiles', 'copy-of-default', 'scripts', 'copv_press.script');
expect(existsSync(copied)).toBe(true);

Check failure on line 128 in daq-server/diablo_server/backend/src/__tests__/profile-scripts-deploy.test.ts

View workflow job for this annotation

GitHub Actions / Web GUI (Vitest + Vite build)

src/__tests__/profile-scripts-deploy.test.ts > profile deploy carries dynamic-state scripts > a new profile created from another inherits its scripts

AssertionError: expected false to be true // Object.is equality - Expected + Received - true + false ❯ src/__tests__/profile-scripts-deploy.test.ts:128:32
expect(readFileSync(copied, 'utf-8')).toBe(SCRIPT);
});

it('does not deploy stray non-script files from scripts/', () => {
writeFileSync(join(profileDir, 'scripts', 'copv_press.script.bak'), 'junk\n', 'utf-8');
profiles.deployActiveProfile();
expect(existsSync(join(configDir, 'scripts', 'copv_press.script.bak'))).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* What an operator reads when the sequencer refuses a command.
*
* The sequencer answers wire-shaped: `ERR:GN2 High has no fresh calibrated reading`
* (sequencer_main.cpp). The prefix exists so the caller can branch on it, not so a human reads it
* in a notification — the message used to arrive as
* "State transition failed: ERR:GN2 High has no fresh calibrated reading".
*
* Extracted here because the same shaping applies to transitions, actuator commands and
* extend-fire, and each of those used to interpolate the raw reply itself.
*/
import { describe, it, expect } from 'vitest';
import { refusalText } from '../refusal-text.js';

describe('refusalText', () => {
it('strips the wire prefix', () => {
expect(refusalText('ERR:GN2 High has no fresh calibrated reading'))
.toBe('GN2 High has no fresh calibrated reading');
});

it('keeps a reason that contains its own colon', () => {
// Hold-duration refusals read "ERR:hold not settable here (5000 ms)"; nothing after the
// first prefix may be eaten.
expect(refusalText('ERR:hold exceeds max: 5000 ms')).toBe('hold exceeds max: 5000 ms');
});

it('leaves a reply that is not prefixed alone', () => {
expect(refusalText('connection closed')).toBe('connection closed');
expect(refusalText('transition rejected')).toBe('transition rejected');
});

it('trims surrounding whitespace from the wire', () => {
expect(refusalText(' ERR:unknown state \n')).toBe('unknown state');
});

it('survives an empty or missing reply', () => {
expect(refusalText('')).toBe('');
expect(refusalText(undefined as unknown as string)).toBe('');
});

it('does not strip a prefix that only looks similar', () => {
expect(refusalText('ERROR:something')).toBe('ERROR:something');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* The sequencer's SCRIPTS report, parsed.
*
* This channel has existed since dynamic states shipped and nothing ever consumed it — the C++
* comment beside the allowed-state bitmask points at "the panel" reading reasons over SCRIPTS, and
* that panel was never built. The bitmask greys a button; this is what lets it explain itself.
*
* Two verdicts matter and they behave differently: REFUSED is a load failure, fixed for the run;
* BLOCKED is the live sensor gate, which appears and clears while the rig is running.
*/
import { describe, it, expect } from 'vitest';
import { parseScriptStatus } from '../script-status.js';

describe('parseScriptStatus', () => {
it('reads a load refusal', () => {
const { reasons } = parseScriptStatus(
'SCRIPT:15:Dynamic Test:REFUSED:loop body never calls delay()\nEND\n');
expect(reasons[15]).toBe('loop body never calls delay()');
});

it('reads a live sensor block', () => {
const { reasons } = parseScriptStatus(
'SCRIPT:15:Dynamic Test:BLOCKED:GN2 High has produced no reading yet\nEND\n');
expect(reasons[15]).toBe('GN2 High has produced no reading yet');
});

it('records states whose script loaded cleanly without calling them blocked', () => {
const { reasons, ok } = parseScriptStatus('SCRIPT:15:Dynamic Test:OK:6:10000\nEND\n');
expect(ok).toContain(15);
expect(reasons[15]).toBeUndefined();
});

it('prefers a load refusal over a live block for the same state', () => {
// A script that did not load cannot be fixed by waiting, so that is the reason worth
// showing even if the sensor gate is also unhappy.
const { reasons } = parseScriptStatus(
'SCRIPT:15:Dyn:BLOCKED:GN2 High has produced no reading yet\n' +
'SCRIPT:15:Dyn:REFUSED:script rejected at load\nEND\n');
expect(reasons[15]).toBe('script rejected at load');
});

it('keeps a reason that still contains a separator', () => {
// The sequencer substitutes colons before sending, but the parser must not depend on that
// — taking only one field would silently truncate a reason.
const { reasons } = parseScriptStatus('SCRIPT:7:S:BLOCKED:stale: 1200 ms old\nEND\n');
expect(reasons[7]).toBe('stale: 1200 ms old');
});

it('ignores junk, blank lines and the terminator', () => {
const { reasons, ok } = parseScriptStatus(
'\nEND\nnot a script line\nSCRIPT:bad:X:REFUSED:y\nSCRIPT:2\n');
expect(Object.keys(reasons)).toHaveLength(0);
expect(ok).toHaveLength(0);
});

it('survives an empty report', () => {
expect(parseScriptStatus('').reasons).toEqual({});
expect(parseScriptStatus(undefined as unknown as string).reasons).toEqual({});
});

it('handles a whole report with a mix of verdicts', () => {
const { reasons, ok } = parseScriptStatus(
'SCRIPT:15:Dynamic Test:OK:6:10000\n' +
'SCRIPT:16:Other:REFUSED:unknown valve FOO\n' +
'SCRIPT:17:Third:BLOCKED:High Press is not calibrated\n' +
'END\n');
expect(ok).toEqual([15]);
expect(reasons[16]).toBe('unknown valve FOO');
expect(reasons[17]).toBe('High Press is not calibrated');
expect(reasons[15]).toBeUndefined();
});
});
Loading
Loading