diff --git a/daq-server/.gitignore b/daq-server/.gitignore index a10de6cc..1e5b5d22 100644 --- a/daq-server/.gitignore +++ b/daq-server/.gitignore @@ -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//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 diff --git a/daq-server/config/profiles/digital-twin/config.toml b/daq-server/config/profiles/digital-twin/config.toml index c2181f06..8490eb49 100644 --- a/daq-server/config/profiles/digital-twin/config.toml +++ b/daq-server/config/profiles/digital-twin/config.toml @@ -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 ] diff --git a/daq-server/config/profiles/digital-twin/scripts/dynamic_test.script b/daq-server/config/profiles/digital-twin/scripts/dynamic_test.script new file mode 100644 index 00000000..fb1ccc7b --- /dev/null +++ b/daq-server/config/profiles/digital-twin/scripts/dynamic_test.script @@ -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) \ No newline at end of file diff --git a/daq-server/config/profiles/digital-twin/state_machine_actuator_delays.csv b/daq-server/config/profiles/digital-twin/state_machine_actuator_delays.csv index 4f68adbb..e38e8cc7 100644 --- a/daq-server/config/profiles/digital-twin/state_machine_actuator_delays.csv +++ b/daq-server/config/profiles/digital-twin/state_machine_actuator_delays.csv @@ -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 diff --git a/daq-server/config/profiles/digital-twin/state_machine_actuators.csv b/daq-server/config/profiles/digital-twin/state_machine_actuators.csv index e6097330..d077523d 100644 --- a/daq-server/config/profiles/digital-twin/state_machine_actuators.csv +++ b/daq-server/config/profiles/digital-twin/state_machine_actuators.csv @@ -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 diff --git a/daq-server/config/profiles/digital-twin/state_transitions.csv b/daq-server/config/profiles/digital-twin/state_transitions.csv index 82f602e0..8a5a8f0b 100644 --- a/daq-server/config/profiles/digital-twin/state_transitions.csv +++ b/daq-server/config/profiles/digital-twin/state_transitions.csv @@ -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 diff --git a/daq-server/diablo_server/CMakeLists.txt b/daq-server/diablo_server/CMakeLists.txt index d415f1c2..76873fa4 100644 --- a/daq-server/diablo_server/CMakeLists.txt +++ b/daq-server/diablo_server/CMakeLists.txt @@ -2,3 +2,4 @@ add_subdirectory(transport) add_subdirectory(lib) add_subdirectory(daq_bridge) add_subdirectory(services) +add_subdirectory(tools) diff --git a/daq-server/diablo_server/backend/src/__tests__/profile-scripts-deploy.test.ts b/daq-server/diablo_server/backend/src/__tests__/profile-scripts-deploy.test.ts new file mode 100644 index 00000000..418e3c35 --- /dev/null +++ b/daq-server/diablo_server/backend/src/__tests__/profile-scripts-deploy.test.ts @@ -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); + 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); + }); + + 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'); + }); + + 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'); + }); + + it('rolls the script back when the config does not parse', () => { + profiles.deployActiveProfile(); + expect(readFileSync(deployedScript, 'utf-8')).toBe(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); + }); + + 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); + 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); + }); +}); diff --git a/daq-server/diablo_server/backend/src/__tests__/refusal-text.test.ts b/daq-server/diablo_server/backend/src/__tests__/refusal-text.test.ts new file mode 100644 index 00000000..9b0146f5 --- /dev/null +++ b/daq-server/diablo_server/backend/src/__tests__/refusal-text.test.ts @@ -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'); + }); +}); diff --git a/daq-server/diablo_server/backend/src/__tests__/script-status.test.ts b/daq-server/diablo_server/backend/src/__tests__/script-status.test.ts new file mode 100644 index 00000000..11635eae --- /dev/null +++ b/daq-server/diablo_server/backend/src/__tests__/script-status.test.ts @@ -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(); + }); +}); diff --git a/daq-server/diablo_server/backend/src/__tests__/vtable-subscriptions.test.ts b/daq-server/diablo_server/backend/src/__tests__/vtable-subscriptions.test.ts new file mode 100644 index 00000000..ec384b9d --- /dev/null +++ b/daq-server/diablo_server/backend/src/__tests__/vtable-subscriptions.test.ts @@ -0,0 +1,265 @@ +/** + * Elodin VTableStream subscriptions: attribution, convergence, and list sanity. + * + * The bug these exist for, seen live on 2026-09-16: a session started and the GUI showed no + * data. Boards → DB was healthy and the calibration service had registered and was publishing; + * raw entities reached the browser while EVERY calibrated entity (PT*_Cal, LC*_Cal) was absent + * for the whole session. + * + * Cause: the wire request id is one byte (elodin-client writes `requestId & 0xff` at offset 7), + * so 1..255 is the entire space. registerVTables sent 4485 subscriptions per pass while rotating + * ids over that space, so `pendingSubscriptionReqIds` was overwritten ~17x and, after a pass, + * described only the last 255 pairs sent. Every rejection was attributed to the wrong pair: + * + * - the genuinely refused table stayed marked subscribed and was NEVER retried, and + * - an innocent, already-delivering table was un-marked and re-subscribed, which spawns a + * second DB stream task and doubles its rate ([0x46,0x00] was re-sent 849 times in 4 hours). + * + * The list was also ~20x too big: a blanket 1..255 x 17 sweep for heartbeat/self-test tables + * that daq_bridge only ever registers for boards present in the config. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + buildVTableStreamSubscriptionList, + registerVTables, + noteSubscriptionRejected, + notePairDelivered, + clearSubscriptionState, + setClockForTests, + SUBSCRIPTION_REQ_ID_SPACE, + MAX_PAIR_ATTEMPTS, +} from '../elodin-vtable-registry.js'; + +// ── A fake DB client: isConnected + sendRawMessage is the whole surface used ── +interface Sent { high: number; low: number; reqId: number } + +function fakeClient(sent: Sent[], failPairs: Set = new Set()) { + return { + isConnected: () => true, + sendRawMessage: (_msgId: [number, number], _ty: number, payload: Buffer, reqId: number) => { + const high = payload.readUInt8(0); + const low = payload.readUInt8(1); + if (failPairs.has(`${high},${low}`)) return false; + sent.push({ high, low, reqId }); + return true; + }, + } as never; +} + +/** The deployed shape in miniature: two PT boards, an actuator board, an LC board. */ +const CONFIG = { + boards: { + pt_board: { type: 'PT', board_id: 21, enabled: true, active_connectors: [1, 2] }, + pt_board_2: { type: 'PT', board_id: 22, enabled: true, active_connectors: [1, 4] }, + act_board: { type: 'ACTUATOR', board_id: 12, enabled: true, active_connectors: [1, 2] }, + lc_board: { type: 'LC', board_id: 41, enabled: true, active_connectors: [1] }, + }, +}; + +let clock = 1_000_000; +beforeEach(() => { + clearSubscriptionState(); + clock = 1_000_000; + setClockForTests(() => clock); +}); + +const keyOf = (s: Sent) => `${s.high},${s.low}`; + +/** Run passes until nothing is queued. A pass is capped at the id space by design, so the + * full list takes a couple of passes — that is the cap working, not a failure. */ +async function drain(sent: Sent[], failPairs?: Set): Promise { + for (let i = 0; i < 20; i++) { + const res = await registerVTables(fakeClient(sent, failPairs)); + if (res.remaining === 0) return; + } + throw new Error('subscriptions never drained'); +} +const reqIdFor = (sent: Sent[], high: number, low: number) => + sent.find((s) => s.high === high && s.low === low)?.reqId; + +describe('request id attribution', () => { + it('never gives one in-flight id to two different pairs', async () => { + const sent: Sent[] = []; + await registerVTables(fakeClient(sent)); + + // Ids restart per window; within any window an id must name exactly one pair. + for (let i = 0; i < sent.length; i += SUBSCRIPTION_REQ_ID_SPACE) { + const window = sent.slice(i, i + SUBSCRIPTION_REQ_ID_SPACE); + const ids = window.map((s) => s.reqId); + expect(new Set(ids).size).toBe(window.length); + } + }); + + it('never issues an id outside the one-byte wire space', async () => { + const sent: Sent[] = []; + await registerVTables(fakeClient(sent)); + for (const s of sent) { + expect(s.reqId).toBeGreaterThanOrEqual(1); + expect(s.reqId).toBeLessThanOrEqual(SUBSCRIPTION_REQ_ID_SPACE); + } + }); +}); + +describe('a refused subscription is actually retried', () => { + it('re-sends the calibrated PT pair that was rejected (the no-data bug)', async () => { + const first: Sent[] = []; + await registerVTables(fakeClient(first)); + + // PT1 CH1 calibrated — one of the first pairs sent, and exactly the family that went + // missing on the stand. Under the old rotation its id resolved to a tail self-test + // pair, so this pair was never re-sent for the life of the connection. + const id = reqIdFor(first, 0x20, 0x11); + expect(id, 'PT1_Cal.CH1 must be in the subscription list').toBeDefined(); + + noteSubscriptionRejected(id!, 'invalid msg id'); + clock += 10_000; // past the first backoff + + const second: Sent[] = []; + await registerVTables(fakeClient(second)); + expect(second.map(keyOf)).toContain('32,17'); // [0x20, 0x11] + }); + + it('holds a rejected pair until its backoff expires, then retries it', async () => { + const first: Sent[] = []; + await registerVTables(fakeClient(first)); + const id = reqIdFor(first, 0x20, 0x11)!; + noteSubscriptionRejected(id, 'invalid msg id'); + + const tooSoon: Sent[] = []; + await registerVTables(fakeClient(tooSoon)); + expect(tooSoon.map(keyOf)).not.toContain('32,17'); + + clock += 10_000; + const later: Sent[] = []; + await registerVTables(fakeClient(later)); + expect(later.map(keyOf)).toContain('32,17'); + }); +}); + +describe('a live table is never re-subscribed', () => { + it('ignores a rejection naming a pair the DB is delivering', async () => { + const first: Sent[] = []; + await drain(first); + + const id = reqIdFor(first, 0x46, 0x00); + expect(id, 'the calibration command table must be subscribed').toBeDefined(); + + // The DB is streaming this table — so this rejection cannot belong to it. + notePairDelivered(0x46, 0x00); + noteSubscriptionRejected(id!, 'invalid msg id'); + clock += 120_000; + + const second: Sent[] = []; + await registerVTables(fakeClient(second)); + expect(second.map(keyOf)).not.toContain('70,0'); // [0x46, 0x00] + }); +}); + +describe('retries converge', () => { + it('parks a pair nothing publishes instead of resending it forever', async () => { + const sent: Sent[] = []; + await registerVTables(fakeClient(sent)); + const target: [number, number] = [0x20, 0x11]; + + let resends = 0; + for (let pass = 0; pass < 40; pass++) { + const passSent: Sent[] = []; + await registerVTables(fakeClient(passSent)); + const id = reqIdFor(passSent, target[0], target[1]); + if (id !== undefined) { + resends++; + noteSubscriptionRejected(id, 'invalid msg id'); + } + clock += 120_000; // always past backoff, so only parking can stop it + } + + expect(resends).toBeLessThan(MAX_PAIR_ATTEMPTS + 2); + + // And once parked it stays parked. + const after: Sent[] = []; + await registerVTables(fakeClient(after)); + expect(after.map(keyOf)).not.toContain('32,17'); + }); + + it('sends nothing once everything is subscribed', async () => { + await drain([]); + const second: Sent[] = []; + const res = await registerVTables(fakeClient(second)); + expect(second).toHaveLength(0); + expect(res.sent).toBe(0); + expect(res.remaining).toBe(0); + }); + + it('caps a pass at the request-id space and queues the rest', async () => { + const sent: Sent[] = []; + const res = await registerVTables(fakeClient(sent)); + expect(sent.length).toBeLessThanOrEqual(SUBSCRIPTION_REQ_ID_SPACE); + if (res.remaining > 0) expect(sent.length).toBe(SUBSCRIPTION_REQ_ID_SPACE); + }); +}); + +describe('the subscription list only holds tables a publisher registers', () => { + it('covers heartbeat and self-test for configured board ids only', () => { + const pairs = buildVTableStreamSubscriptionList(CONFIG); + const has = (h: number, l: number) => pairs.some(([a, b]) => a === h && b === l); + + // Heartbeat [0x10, board_id] uses the RAW config id, not the %10 slot. + expect(has(0x10, 21)).toBe(true); + expect(has(0x10, 12)).toBe(true); + expect(has(0x10, 41)).toBe(true); + // Self-test [0x60+sensor, board_id]. + expect(has(0x60, 21)).toBe(true); + expect(has(0x6f, 41)).toBe(true); + + // Boards that are not in the config must not be swept in. These are the 4114 pairs + // that were refused on every pass forever and overflowed the request-id space. + expect(has(0x10, 0xff)).toBe(false); + expect(has(0x6f, 0xfd)).toBe(false); + expect(has(0x10, 99)).toBe(false); + }); + + it('is small enough that the whole list is attributable', () => { + const pairs = buildVTableStreamSubscriptionList(CONFIG); + // The old list was 4485. Anything near that cannot be correlated with one-byte ids. + expect(pairs.length).toBeLessThan(600); + }); + + it('does not invent sensors the config never declared', () => { + const pairs = buildVTableStreamSubscriptionList(CONFIG); + const has = (h: number, l: number) => pairs.some(([a, b]) => a === h && b === l); + // The dev fallback used to add LC board 2 channels 2 and 6 unconditionally, while the + // config here declares active_connectors = [1]. + expect(has(0x23, 0x22)).toBe(false); // LC2 CH2 calibrated + expect(has(0x23, 0x26)).toBe(false); // LC2 CH6 calibrated + }); + + it('still falls back to the dev list when the config yields no boards', () => { + const pairs = buildVTableStreamSubscriptionList({ boards: {} }); + const has = (h: number, l: number) => pairs.some(([a, b]) => a === h && b === l); + expect(has(0x20, 0x11)).toBe(true); + expect(pairs.length).toBeGreaterThan(100); + }); + + it('has no duplicate pairs', () => { + const pairs = buildVTableStreamSubscriptionList(CONFIG); + expect(new Set(pairs.map(([h, l]) => `${h},${l}`)).size).toBe(pairs.length); + }); +}); + +describe('reconnect starts from a clean slate', () => { + it('clearSubscriptionState forgets backoff, parking and delivery', async () => { + const first: Sent[] = []; + await drain(first); + const id = reqIdFor(first, 0x20, 0x11)!; + noteSubscriptionRejected(id, 'invalid msg id'); + notePairDelivered(0x46, 0x00); + + clearSubscriptionState(); + + // Everything is due again after a reconnect — the DB process is new. + const second: Sent[] = []; + await drain(second); + expect(second.map(keyOf)).toContain('32,17'); + expect(second.map(keyOf)).toContain('70,0'); + }); +}); diff --git a/daq-server/diablo_server/backend/src/api-server.ts b/daq-server/diablo_server/backend/src/api-server.ts index 285895e3..37fc9847 100644 --- a/daq-server/diablo_server/backend/src/api-server.ts +++ b/daq-server/diablo_server/backend/src/api-server.ts @@ -17,8 +17,10 @@ import { listProfiles, switchProfile, createProfile, renameProfile, deleteProfile, getActiveProfileName, ensureSeeded, readActiveProfile, writeActiveProfile, deployActiveProfile, getActiveProfilePath, readStateCsv, writeStateCsv, isStateCsvName, STATE_CSVS, + readStateScript, writeStateScript, listStateScripts, undeployedChanges, } from './routes/config-profiles.js'; +import { checkStateScript } from './routes/script-check.js'; import { listCalibrationProfiles, saveCalibrationProfile, @@ -484,6 +486,18 @@ export function createAPIHandler(opts: APIHandlerOptions = {}): (req: IncomingMe // Which state the characterization hold drives. A flag, not a name — the operator // may rename or move it and every client follows without a code change. isFlow: e.is_flow === true, + // A non-empty script_file is what makes a state dynamic — there is no separate flag + // that could disagree with it. The panel needs this because a dynamic state is not a + // latch: it runs, and it leaves on its own, so leaving it is a different gesture. + // + // The script TEXT is deliberately not here. The control page has no use for it, and + // the config editor reads it from /api/state-script. + isDynamic: typeof e.script_file === 'string' && e.script_file.trim() !== '', + scriptTimeoutMs: typeof e.script_timeout_ms === 'number' ? e.script_timeout_ms : null, + scriptReturnTarget: + typeof e.script_return_target === 'string' ? e.script_return_target : null, + scriptTimeoutTarget: + typeof e.script_timeout_target === 'string' ? e.script_timeout_target : null, // Absent coordinates mean "not on the control panel" — no separate hidden flag. panelRow: typeof e.panel_row === 'number' ? e.panel_row : null, panelCol: typeof e.panel_col === 'number' ? e.panel_col : null, @@ -557,6 +571,75 @@ export function createAPIHandler(opts: APIHandlerOptions = {}): (req: IncomingMe res.end(JSON.stringify({ error: error.message || 'Invalid CSV' })); } }); + } else if (url.pathname === '/api/state-script' && req.method === 'GET') { + // One dynamic-state script from the ACTIVE PROFILE, or the list of them. Read-only, so no + // operator gate — same posture as /api/state-csv and /api/config/export. + try { + const which = url.searchParams.get('name'); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(which === null + ? JSON.stringify({ scripts: listStateScripts() }) + : JSON.stringify({ name: which, source: readStateScript(String(which)) })); + } catch (error: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: error.message || 'Failed to read script' })); + } + } else if (url.pathname === '/api/state-script' && req.method === 'POST') { + // Write a script into the active profile; deploy when idle. Same freeze rule as a config + // save — during a session it stays a draft, applied at the next session start. + if (!isConfigWriteAuthorized(req)) { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not an approved operator' })); + return true; + } + let body = ''; + req.on('data', (chunk) => { body += chunk.toString(); }); + req.on('end', async () => { + try { + const which = String(url.searchParams.get('name') || ''); + const stateName = String(url.searchParams.get('state') || ''); + if (!stateName) throw new Error('state is required'); + + // Syntax-checked before it is written, by the sequencer's own parser. A check that + // could not run (no build yet) does not block the save — the sequencer re-checks at + // startup and is the authority either way. + const check = await checkStateScript(body, stateName); + if (!check.ok) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Script has errors', diagnostics: check.diagnostics })); + return; + } + + const sessionActive = sessionManager.getStatus().active; + const deployed = writeStateScript(which, body, !sessionActive); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + deployed, + checked: !check.unavailable, + message: deployed ? 'Saved and applied' : 'Saved as draft (applies at next session start)', + })); + } catch (error: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: error.message || 'Invalid script' })); + } + }); + } else if (url.pathname === '/api/state-script/check' && req.method === 'POST') { + // Debounced syntax check from the editor, without saving. + let body = ''; + req.on('data', (chunk) => { body += chunk.toString(); }); + req.on('end', async () => { + try { + const stateName = String(url.searchParams.get('state') || ''); + if (!stateName) throw new Error('state is required'); + const check = await checkStateScript(body, stateName); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(check)); + } catch (error: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: error.message || 'Check failed' })); + } + }); } else if (url.pathname === '/api/config/validate' && req.method === 'GET') { // What the session-start gate would find, without attempting a start. Read-only, so no // operator check — this reports on config the caller can already read via GET /api/config. diff --git a/daq-server/diablo_server/backend/src/elodin-relay.ts b/daq-server/diablo_server/backend/src/elodin-relay.ts index 7bf1c050..f8b840e4 100644 --- a/daq-server/diablo_server/backend/src/elodin-relay.ts +++ b/daq-server/diablo_server/backend/src/elodin-relay.ts @@ -12,7 +12,7 @@ import * as net from 'net'; import { WebSocketServer } from 'ws'; import { ElodinClient, ElodinPacketType } from './elodin-client.js'; -import { registerVTables, clearSubscriptionState } from './elodin-vtable-registry.js'; +import { registerVTables, clearSubscriptionState, noteSubscriptionRejected, notePairDelivered } from './elodin-vtable-registry.js'; import { loadActuatorChannelToEntityMap } from './sensor-config.js'; const ELODIN_HOST = process.env.ELODIN_HOST || '127.0.0.1'; @@ -106,6 +106,7 @@ function main(): void { } elodin.on('packet', (header, payload) => { + notePairDelivered(header.packetId[0], header.packetId[1]); if (header.ty === ElodinPacketType.TABLE) { tablePacketCount++; if (firstPacketLogged < 5 && header.packetId[0] !== 0x10) { @@ -166,6 +167,12 @@ function main(): void { }); }); + // The relay never listened for dbError at all, so every refusal here was silent and + // permanent — the same failure the thin backend already had a handler for. + elodin.on('dbError', (requestId: number, description: string) => { + noteSubscriptionRejected(requestId, description); + }); + elodin.on('disconnected', () => { console.log('[Relay] Elodin disconnected'); tablePacketCount = 0; diff --git a/daq-server/diablo_server/backend/src/elodin-vtable-registry.ts b/daq-server/diablo_server/backend/src/elodin-vtable-registry.ts index deb89e5b..7af77ec1 100644 --- a/daq-server/diablo_server/backend/src/elodin-vtable-registry.ts +++ b/daq-server/diablo_server/backend/src/elodin-vtable-registry.ts @@ -56,15 +56,65 @@ export function computeMsgId(typeName: string): [number, number] { * noteSubscriptionRejected() and re-sent by the next scheduled pass. */ const subscribedVTableStreamPairs = new Set(); -/** requestId → pair key, for subscriptions whose reply has not come back yet. */ +/** requestId → pair key, for subscriptions whose reply has not come back yet. + * + * Holds exactly one pass's ids, and a pass never sends more than the id space. The wire + * request id is a single byte + * (elodin-client.ts writes `requestId & 0xff` at offset 7 and reads it back with readUInt8), + * so 1..255 is the entire space — it cannot be widened without changing the DB protocol. + * This used to be a counter rotating over that space while the pass sent 4485 subscriptions, + * so the map was overwritten ~17x and, once a pass finished, described only the LAST 255 + * pairs sent. Every rejection was therefore attributed to the wrong pair: the genuinely + * refused table stayed marked as subscribed and was never retried (silent for the whole + * session — this is the "started a session, no data" bug), while an innocent table was + * un-marked and re-subscribed. registerVTables now caps a pass at SUBSCRIPTION_REQ_ID_SPACE + * and queues the remainder, so an id in flight names exactly one pair. */ const pendingSubscriptionReqIds = new Map(); -/** Rotating 1..255 (0 is the default for everything else, so it means "not tracked"). */ -let nextSubscriptionReqId = 1; + +/** One wire byte, so this is the whole id space — see pendingSubscriptionReqIds. */ +export const SUBSCRIPTION_REQ_ID_SPACE = 255; +/** Attempts before a pair is parked as "no publisher is ever going to register this". */ +export const MAX_PAIR_ATTEMPTS = 12; + +/** Pairs the DB has actually delivered a packet for. A table cannot be streaming and + * refusing its own subscription at the same time, so a rejection naming one of these is + * provably a misattribution — and re-sending it would spawn a SECOND stream task for a live + * table, doubling its rate (the hazard documented above). Guarding on delivery makes that + * structurally impossible rather than merely unlikely. */ +const deliveredPairs = new Set(); +/** Rejected pairs awaiting a retry, with their backoff. */ +const rejectedPairs = new Map(); +/** Pairs that exhausted MAX_PAIR_ATTEMPTS — stop asking, and stop logging about it. */ +const parkedPairs = new Set(); +/** Rejections seen since the last pass logged a summary, so one line replaces thousands. */ +let refusalsSinceLastPass = 0; +const refusedPairsSinceLastPass = new Set(); + +/** Injectable clock, so backoff is testable without fake timers fighting the await. */ +let nowFn: () => number = () => Date.now(); +export function setClockForTests(fn: () => number): void { + nowFn = fn; +} + +/** Backoff for attempt n: 5s, 10s, 20s, 40s, then 60s. */ +function backoffMs(attempts: number): number { + return Math.min(60_000, 5_000 * 2 ** Math.max(0, attempts - 1)); +} + +/** Record that the DB delivered a packet for this pair. */ +export function notePairDelivered(high: number, low: number): void { + deliveredPairs.add(`${high},${low}`); +} /** Call on Elodin disconnect so the next connect re-sends all streams cleanly. */ export function clearSubscriptionState(): void { subscribedVTableStreamPairs.clear(); pendingSubscriptionReqIds.clear(); + deliveredPairs.clear(); + rejectedPairs.clear(); + parkedPairs.clear(); + refusalsSinceLastPass = 0; + refusedPairsSinceLastPass.clear(); } /** @@ -82,22 +132,54 @@ export function noteSubscriptionRejected(requestId: number, description: string) const key = pendingSubscriptionReqIds.get(requestId); if (key === undefined) return; pendingSubscriptionReqIds.delete(requestId); + + // A pair the DB is actively streaming cannot also be refusing its subscription, so this + // reply belongs to some other pair whose id we no longer hold. Dropping it costs nothing; + // acting on it would un-mark a live table and re-subscribe it, doubling its delivery rate. + if (deliveredPairs.has(key)) return; + if (!subscribedVTableStreamPairs.delete(key)) return; - const [high, low] = key.split(',').map(Number); - console.warn( - `[Elodin] subscription refused for [0x${high.toString(16).padStart(2, '0')}, ` + - `0x${low.toString(16).padStart(2, '0')}]: ${description} — will retry ` + - '(the publisher has probably not registered its VTable yet)', - ); + + const state = rejectedPairs.get(key) ?? { attempts: 0, nextAttemptMs: 0 }; + state.attempts += 1; + state.nextAttemptMs = nowFn() + backoffMs(state.attempts); + rejectedPairs.set(key, state); + + refusalsSinceLastPass += 1; + refusedPairsSinceLastPass.add(key); + + if (state.attempts >= MAX_PAIR_ATTEMPTS) { + parkedPairs.add(key); + rejectedPairs.delete(key); + const [high, low] = key.split(',').map(Number); + console.warn( + `[Elodin] giving up on [0x${high.toString(16).padStart(2, '0')}, ` + + `0x${low.toString(16).padStart(2, '0')}] after ${state.attempts} attempts: ` + + `${description} — no publisher registers this table`, + ); + } +} + +/** Drain the per-pass refusal tally, so registerVTables can log one line instead of thousands. */ +function takeRefusalSummary(): { count: number; pairs: string[] } { + const out = { count: refusalsSinceLastPass, pairs: [...refusedPairsSinceLastPass] }; + refusalsSinceLastPass = 0; + refusedPairsSinceLastPass.clear(); + return out; } /** * Build packet IDs to subscribe: config.toml boards (32-slot low-byte scheme) + dev fallbacks + * controller / sequencer / heartbeat / self-test / calibration command. */ -function buildVTableStreamSubscriptionList(): Array<[number, number]> { +export function buildVTableStreamSubscriptionList(cfgIn?: unknown): Array<[number, number]> { const subscriptions: Array<[number, number]> = []; const seen = new Set(); + /** Raw config board_id values — the low byte of heartbeat and self-test tables. NOT the + * `% 10` slot used by sensor tables: daq_bridge registers those two per config board id. */ + const configBoardIds: number[] = []; + /** Config actuator boards, for the commanded-state tables below. */ + const actuatorBoards: Array<{ boardNumber: number; channels: number[] }> = []; const addUnique = (high: number, low: number): void => { const key = `${high},${low}`; if (!seen.has(key)) { @@ -127,7 +209,7 @@ function buildVTableStreamSubscriptionList(): Array<[number, number]> { // Cached: this runs on every Elodin connect and on up to 24 resubscribe retries per // connection, so a plain readConfig() here was a repeated file read + TOML parse on a // reconnect storm. Invalidated at deploy. - const cfg = readDeployedConfig(); + const cfg = (cfgIn ?? readDeployedConfig()) as { boards?: unknown }; const boards = (cfg.boards || {}) as Record; for (const [, raw] of Object.entries(boards)) { const b = raw as Record; @@ -148,6 +230,8 @@ function buildVTableStreamSubscriptionList(): Array<[number, number]> { : []; const active = rawConnectors.map((x) => Number(x)).filter((x) => Number.isFinite(x) && x >= 1 && x <= 10); if (active.length === 0) continue; + // Heartbeat/self-test are registered per CONFIG board id, whatever the type. + configBoardIds.push(id); const typeHi = t === 'PT' ? 0x20 @@ -159,6 +243,7 @@ function buildVTableStreamSubscriptionList(): Array<[number, number]> { : -1; if (t === 'ACTUATOR') { addActuatorBoard(boardNumber, active); + actuatorBoards.push({ boardNumber, channels: active }); continue; } if (typeHi < 0) continue; @@ -168,18 +253,30 @@ function buildVTableStreamSubscriptionList(): Array<[number, number]> { console.warn('[VTableStream] config-driven subscriptions failed, using fallbacks only:', e); } - addBoard(0x20, 1, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - addBoard(0x20, 2, [1, 2, 3, 4]); - addActuatorBoard(2, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - addActuatorBoard(4, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - addBoard(0x21, 1, [2, 3, 4, 5]); - addBoard(0x22, 1, [1, 2, 3, 4]); - addBoard(0x23, 2, [1, 2, 6]); - addBoard(0x24, 1, [1, 2]); - - for (let bn = 1; bn <= 4; bn++) { - for (let ch = 1; ch <= 10; ch++) { - addUnique(0x32, (bn - 1) * 0x20 + ch); + // Dev fallbacks ONLY when the config told us nothing. They used to run unconditionally, + // alongside a perfectly good config, which invented sensors the deployed rig does not have + // — addBoard(0x23, 2, [1, 2, 6]) subscribes LC2 CH2 and CH6 where the config declares + // active_connectors = [1]. Every such pair is refused forever and, before the windowing + // below, each refusal corrupted the attribution of a real one. + if (configBoardIds.length === 0) { + addBoard(0x20, 1, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + addBoard(0x20, 2, [1, 2, 3, 4]); + addActuatorBoard(2, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + addActuatorBoard(4, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + addBoard(0x21, 1, [2, 3, 4, 5]); + addBoard(0x22, 1, [1, 2, 3, 4]); + addBoard(0x23, 2, [1, 2, 6]); + addBoard(0x24, 1, [1, 2]); + } + + // Actuator commanded state [0x32, …], same low-byte scheme as the raw table. + if (actuatorBoards.length > 0) { + for (const { boardNumber, channels } of actuatorBoards) { + for (const ch of channels) addUnique(0x32, (boardNumber - 1) * 0x20 + ch); + } + } else { + for (let bn = 1; bn <= 4; bn++) { + for (let ch = 1; ch <= 10; ch++) addUnique(0x32, (bn - 1) * 0x20 + ch); } } @@ -191,9 +288,18 @@ function buildVTableStreamSubscriptionList(): Array<[number, number]> { // Heartbeats [0x10, board_id] use the low byte as config board_id. // Self-test uses [0x60+sensor_id, board_id] — one VTable per sensor per board. - for (let i = 1; i <= 255; i++) { - addUnique(0x10, i); - for (let s = 0x60; s <= 0x6F; s++) addUnique(s, i); + // + // Over the CONFIGURED board ids only. This used to sweep 1..255 x 17 high bytes = 4335 + // pairs, but daq_bridge registers these two families only for boards that are in the + // config, so with 13 boards exactly 221 of those 4335 can ever exist and the other 4114 + // are refused on every single pass, forever. That bulk is what overflowed the one-byte + // request id space and broke rejection attribution for the real tables. + const heartbeatBoardIds = configBoardIds.length > 0 + ? configBoardIds + : Array.from({ length: 255 }, (_, i) => i + 1); + for (const id of heartbeatBoardIds) { + addUnique(0x10, id); + for (let s = 0x60; s <= 0x6F; s++) addUnique(s, id); } addUnique(0x46, 0x00); @@ -205,58 +311,105 @@ function buildVTableStreamSubscriptionList(): Array<[number, number]> { * Register VTableStream interest with Elodin (MSG to VTableStream). DAQ/calibration * services own VTableMsg schema registration; we only subscribe to packet IDs. */ -export async function registerVTables(client: ElodinClient): Promise { +export interface SubscriptionPassResult { + sent: number; + skipped: number; + parked: number; + /** Pairs due this pass that did not fit in the request-id space; send them next pass. */ + remaining: number; + /** Earliest backoff deadline still outstanding, or null when nothing is waiting. */ + nextAttemptMs: number | null; +} + +export async function registerVTables(client: ElodinClient): Promise { + const empty: SubscriptionPassResult = { sent: 0, skipped: 0, parked: parkedPairs.size, remaining: 0, nextAttemptMs: null }; if (!client.isConnected()) { console.warn('⚠️ Cannot subscribe VTableStreams — Elodin client not connected'); - return false; + return empty; } - console.log('📡 VTableStream subscriptions (config + fallbacks)...'); - try { const subscriptions = buildVTableStreamSubscriptionList(); const vtableStreamMsgId = computeMsgId('VTableStream'); - console.log(` VTableStream msg_id: [0x${vtableStreamMsgId[0].toString(16).padStart(2, '0')}, 0x${vtableStreamMsgId[1].toString(16).padStart(2, '0')}]`); - - // Do NOT clear subscribedVTableStreamPairs here — calling registerVTables every 5s - // (via scheduleResubscribe) would otherwise re-send all 276 subscriptions, causing - // Elodin to replay all stored data on every retry and flooding the event loop. - // Subscriptions are only cleared on disconnect (clearSubscriptionState), so each - // retry only sends subscriptions not yet successfully sent this connection. - let successCount = 0; + // Do NOT clear subscribedVTableStreamPairs here — this runs every few seconds, and + // re-sending a live subscription spawns a SECOND DB stream task for that table. + // Pairs are cleared only on disconnect (clearSubscriptionState). + const now = nowFn(); + const due: Array<[number, number]> = []; let skippedCount = 0; for (const [high, low] of subscriptions) { const key = `${high},${low}`; - if (subscribedVTableStreamPairs.has(key)) { + if (subscribedVTableStreamPairs.has(key) || parkedPairs.has(key)) { + skippedCount++; + continue; + } + const backoff = rejectedPairs.get(key); + if (backoff && backoff.nextAttemptMs > now) { skippedCount++; continue; } + due.push([high, low]); + } + + // At most one id-space worth of subscriptions per pass, and the ids stay pinned to + // their pairs for the WHOLE pass. An id in flight therefore names exactly one pair, + // which is the property that makes a rejection attributable at all. + // + // Anything over the limit waits for the next pass rather than reusing a live id. That + // is the whole fix: the old code sent all 4485 in one go, rotating ids over 255, so + // 4230 of them were unattributable by construction. A remainder costs one extra pass; + // reusing an id costs a table that is never retried. + const batch = due.slice(0, SUBSCRIPTION_REQ_ID_SPACE); + const remaining = due.length - batch.length; + pendingSubscriptionReqIds.clear(); + let successCount = 0; + let reqId = 1; + for (const [high, low] of batch) { + const key = `${high},${low}`; const payload = Buffer.alloc(2); payload.writeUInt8(high, 0); payload.writeUInt8(low, 1); - // Unique-ish requestId so an ErrorResponse can be traced back to THIS pair — - // the DB echoes req_id on the error (PacketTx::send_msg). - const reqId = nextSubscriptionReqId; - nextSubscriptionReqId = (nextSubscriptionReqId % 255) + 1; - pendingSubscriptionReqIds.set(reqId, key); - const ok = client.sendRawMessage(vtableStreamMsgId, ElodinPacketType.MSG, payload, reqId); + const thisId = reqId++; + pendingSubscriptionReqIds.set(thisId, key); + const ok = client.sendRawMessage(vtableStreamMsgId, ElodinPacketType.MSG, payload, thisId); if (ok) { subscribedVTableStreamPairs.add(key); successCount++; - if (successCount <= 5) { - console.log(` ✅ VTableStream subscription sent: [0x${high.toString(16).padStart(2, '0')}, 0x${low.toString(16).padStart(2, '0')}]`); - } } else { + pendingSubscriptionReqIds.delete(thisId); console.error(` ❌ VTableStream send failed: [0x${high.toString(16).padStart(2, '0')}, 0x${low.toString(16).padStart(2, '0')}]`); } } - console.log(` ✅ VTableStream: sent ${successCount} new, skipped ${skippedCount} already subscribed (${subscriptions.length} total)`); - console.log(' (Heartbeats [0x10] and sensor rows are TABLE packets once daq_bridge / calibration_service publish.)'); - return successCount > 0; + // One aggregated line. Per-rejection warns produced 1.1M journal lines in four hours. + const refused = takeRefusalSummary(); + if (refused.count > 0) { + const sample = refused.pairs.slice(0, 5) + .map((k) => { const [h, l] = k.split(',').map(Number); return `[0x${h.toString(16).padStart(2, '0')}, 0x${l.toString(16).padStart(2, '0')}]`; }) + .join(' '); + console.warn( + `[Elodin] ${refused.count} subscription refusal(s) over ${refused.pairs.length} pair(s) ` + + `since the last pass — will retry with backoff. First: ${sample}` + + (parkedPairs.size > 0 ? ` (${parkedPairs.size} parked)` : ''), + ); + } + + let nextAttemptMs: number | null = null; + for (const s of rejectedPairs.values()) { + if (nextAttemptMs === null || s.nextAttemptMs < nextAttemptMs) nextAttemptMs = s.nextAttemptMs; + } + + if (successCount > 0) { + console.log( + `📡 VTableStream: sent ${successCount} new, skipped ${skippedCount}` + + (remaining > 0 ? `, ${remaining} queued for the next pass` : '') + + ` (${subscriptions.length} total, ${parkedPairs.size} parked)`, + ); + } + return { sent: successCount, skipped: skippedCount, parked: parkedPairs.size, remaining, nextAttemptMs }; } catch (error) { console.error('❌ VTableStream subscription error:', error); - return false; + return empty; } } diff --git a/daq-server/diablo_server/backend/src/refusal-text.ts b/daq-server/diablo_server/backend/src/refusal-text.ts new file mode 100644 index 00000000..62a4a13c --- /dev/null +++ b/daq-server/diablo_server/backend/src/refusal-text.ts @@ -0,0 +1,15 @@ +/** + * The sequencer's reply, as an operator should read it. + * + * Replies are wire-shaped — "ERR:GN2 High has no fresh calibrated reading". The prefix is there + * so the caller can branch on it, not so a human reads it in a notification, which is exactly + * what happened once these started reaching the notification panel. + * + * Its own module so a test can exercise the real function: server.ts opens sockets at import, so + * anything defined inside it can only be tested by copying it, and a copied function passes + * forever while the original drifts. + */ +export function refusalText(reply: string): string { + const trimmed = (reply ?? '').trim(); + return trimmed.startsWith('ERR:') ? trimmed.slice(4).trim() : trimmed; +} diff --git a/daq-server/diablo_server/backend/src/routes/config-profiles.ts b/daq-server/diablo_server/backend/src/routes/config-profiles.ts index 72b7a60d..58f23612 100644 --- a/daq-server/diablo_server/backend/src/routes/config-profiles.ts +++ b/daq-server/diablo_server/backend/src/routes/config-profiles.ts @@ -25,6 +25,8 @@ import { getConfigPath, readConfig, writeConfig, invalidateDeployedConfigCache } const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; const DEFAULT_PROFILE = 'default'; +/** Where a profile keeps its dynamic-state scripts, relative to the profile directory. */ +export const SCRIPTS_SUBDIR = 'scripts'; function assertValidName(name: string): void { if (!NAME_RE.test(name)) { @@ -55,13 +57,32 @@ export function profilePath(name: string): string { return join(profileDir(name), 'config.toml'); } -/** Profile-owned files that deploy alongside config.toml. */ +/** + * Profile-owned files that deploy alongside config.toml, as paths relative to the profile dir. + * + * State scripts (scripts/*.script) are in here for the same reason the CSVs are, and the failure + * if they were not is worse than it looks: config.toml and the CSVs would deploy 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. + * + * Filtered by extension rather than listed wholesale so an editor backup or a stray file in the + * profile directory cannot ride out to config/. + */ function profileAssets(name: string): string[] { + const out: string[] = []; try { - return readdirSync(profileDir(name)).filter((f) => f.endsWith('.csv')).sort(); + out.push(...readdirSync(profileDir(name)).filter((f) => f.endsWith('.csv'))); } catch { return []; } + try { + out.push( + ...readdirSync(join(profileDir(name), SCRIPTS_SUBDIR)) + .filter((f) => f.endsWith('.script')) + .map((f) => `${SCRIPTS_SUBDIR}/${f}`), + ); + } catch { /* a profile with no scripts/ directory is the normal case */ } + return out.sort(); } /** @@ -153,6 +174,7 @@ export function ensureSeeded(): void { const target = join(configDir, f); if (existsSync(target)) continue; try { + mkdirSync(dirname(target), { recursive: true }); // scripts/ may not exist yet copyFileSync(join(profileDir(activeName), f), target); console.log(`🌱 Materialized ${f} from profile "${activeName}"`); } catch { /* best-effort */ } @@ -226,7 +248,12 @@ export function deployActiveProfile(): void { try { copyFileSync(src, configPath); - for (const f of assets) copyFileSync(join(profileDir(name), f), join(configDir, f)); + for (const f of assets) { + const dest = join(configDir, f); + // Assets may now sit in a subdirectory (scripts/), which need not exist in config/ yet. + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(join(profileDir(name), f), dest); + } // Drop the cache before the parse below, so the parse itself repopulates it from the file we // just wrote rather than handing back the previous deploy's object. invalidateDeployedConfigCache(); @@ -306,6 +333,68 @@ export function writeStateCsv(which: StateCsvName, content: string, deploy: bool return true; } +// ── Dynamic-state scripts ──────────────────────────────────────────────────── + +/** Reject anything that is not a bare .script. A path here would let the editor write + * outside scripts/, and this value reaches the filesystem. Refused, never sanitised. */ +function assertScriptName(file: string): void { + if (!/^[A-Za-z0-9_-]+\.script$/.test(file)) + throw new Error(`Invalid script name "${file}" (letters, digits, _ and -, ending .script)`); +} + +/** One dynamic-state script from the active profile. Empty string when it does not exist yet. */ +export function readStateScript(file: string): string { + assertScriptName(file); + ensureSeeded(); + try { + return readFileSync(join(profileDir(getActiveProfileName()), SCRIPTS_SUBDIR, file), 'utf-8'); + } catch { + return ''; + } +} + +/** + * Write one script into the active profile, and deploy it when idle. + * + * Same freeze rule as a config save: during a session the profile takes the edit as a draft and + * config/ is left alone, so a mid-run edit cannot reach the running sequencer. A script edited + * during a run applies at the next session start, exactly like every other config change. + * + * @return whether the write reached config/. + */ +export function writeStateScript(file: string, content: string, deploy: boolean): boolean { + assertScriptName(file); + ensureSeeded(); + const dir = join(profileDir(getActiveProfileName()), SCRIPTS_SUBDIR); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, file), content, 'utf-8'); + if (!deploy) return false; + const dest = join(getConfigDir(), SCRIPTS_SUBDIR, file); + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(join(dir, file), dest); + return true; +} + +/** Every script filename the active profile owns. */ +export function listStateScripts(): string[] { + ensureSeeded(); + try { + return readdirSync(join(profileDir(getActiveProfileName()), SCRIPTS_SUBDIR)) + .filter((f) => f.endsWith('.script')) + .sort(); + } catch { + return []; + } +} + +/** Delete a script from the active profile. Leaves any deployed copy alone — config/ is a + * generated artifact that the next deploy rewrites. */ +export function deleteStateScript(file: string): void { + assertScriptName(file); + ensureSeeded(); + rmSync(join(profileDir(getActiveProfileName()), SCRIPTS_SUBDIR, file), { force: true }); +} + // ── Create / rename / delete ───────────────────────────────────────────────── /** Create a new profile from the active profile (or another named profile). Does NOT switch. */ @@ -321,7 +410,11 @@ export function createProfile(name: string, fromName?: string): void { // deployment's, or it would start with a state table that does not match its own roles. mkdirSync(destDir, { recursive: true }); copyFileSync(srcCfg, profilePath(name)); - for (const f of profileAssets(srcName)) copyFileSync(join(profileDir(srcName), f), join(destDir, f)); + for (const f of profileAssets(srcName)) { + const dest = join(destDir, f); + mkdirSync(dirname(dest), { recursive: true }); // scripts/ lives one level down + copyFileSync(join(profileDir(srcName), f), dest); + } } /** Rename a profile file; if it was active, move the pointer with it. Does not redeploy. */ diff --git a/daq-server/diablo_server/backend/src/routes/script-check.ts b/daq-server/diablo_server/backend/src/routes/script-check.ts new file mode 100644 index 00000000..0c97dbae --- /dev/null +++ b/daq-server/diablo_server/backend/src/routes/script-check.ts @@ -0,0 +1,103 @@ +/** + * Syntax checking for dynamic-state scripts, by spawning the sequencer's own parser. + * + * There is exactly ONE parser for this language, in C++, because the sequencer is the thing that + * executes scripts. `state_script_check` is that same code built as a hermetic CLI — it opens no + * sockets, touches no boards and starts no services, which is what makes running it from here + * unremarkable rather than a hardware action. + * + * This is early warning, not enforcement. The sequencer re-parses every script at startup and is + * the authority on whether a state becomes enterable, so a checker binary that is missing or stale + * degrades the editing experience and never the safety of the stand. That is why every failure + * mode below returns "could not check" rather than "invalid": refusing a save because a developer + * has not run the build yet would be a worse bug than the one it prevents. + */ +import { execFile } from 'child_process'; +import { existsSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { getConfigPath } from './config.js'; + +const here = dirname(fileURLToPath(import.meta.url)); + +export interface ScriptDiagnostic { + line: number; + col: number; + code: string; + message: string; +} + +export interface ScriptCheckResult { + /** false only when the checker ran AND reported problems. */ + ok: boolean; + diagnostics: ScriptDiagnostic[]; + /** Set when the checker could not be run at all — the caller must not treat this as invalid. */ + unavailable?: string; +} + +/** Where scripts/build.sh puts binaries, from wherever the backend happens to be running. */ +function findChecker(): string | null { + const candidates = [ + join(here, '../../../../build/bin/state_script_check'), + join(here, '../../../build/bin/state_script_check'), + join(process.cwd(), 'build/bin/state_script_check'), + join(process.cwd(), '../build/bin/state_script_check'), + ]; + for (const c of candidates) if (existsSync(c)) return c; + return null; +} + +/** stdout is one diagnostic per line: `:: `. */ +function parseDiagnostics(stdout: string): ScriptDiagnostic[] { + const out: ScriptDiagnostic[] = []; + for (const raw of stdout.split('\n')) { + const m = /^(\d+):(\d+):\s+(\S+)\s+(.*)$/.exec(raw.trim()); + if (m) out.push({ line: Number(m[1]), col: Number(m[2]), code: m[3], message: m[4] }); + } + return out; +} + +/** + * Check `source` as the script belonging to `stateName`. + * + * The script is written to a temp file rather than passed on the command line: it is multi-line + * operator input, and an argv round-trip is a quoting bug waiting to happen. + */ +export async function checkStateScript( + source: string, + stateName: string, + transitionsCsv?: string, +): Promise { + const bin = findChecker(); + if (!bin) return { ok: true, diagnostics: [], unavailable: 'state_script_check is not built' }; + + const dir = mkdtempSync(join(tmpdir(), 'daq-script-')); + const file = join(dir, 'check.script'); + writeFileSync(file, source, 'utf-8'); + + const args = ['--config', getConfigPath(), '--state', stateName]; + if (transitionsCsv) args.push('--transitions', transitionsCsv); + args.push(file); + + try { + return await new Promise((resolve) => { + execFile(bin, args, { timeout: 5000 }, (err, stdout, stderr) => { + const diagnostics = parseDiagnostics(stdout); + // Exit 0 = clean, 1 = diagnostics on stdout, anything else = the checker could not do its + // job (config unreadable, state not declared). Only the middle case is the script's fault. + const code = (err as any)?.code; + if (code === undefined || code === 0) return resolve({ ok: true, diagnostics: [] }); + if (code === 1 && diagnostics.length) return resolve({ ok: false, diagnostics }); + resolve({ + ok: true, + diagnostics: [], + unavailable: String(stderr || '').trim() || 'state_script_check could not run', + }); + }); + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} diff --git a/daq-server/diablo_server/backend/src/script-status.ts b/daq-server/diablo_server/backend/src/script-status.ts new file mode 100644 index 00000000..9c0b2880 --- /dev/null +++ b/daq-server/diablo_server/backend/src/script-status.ts @@ -0,0 +1,57 @@ +/** + * The sequencer's SCRIPTS report, parsed. + * + * `scriptStatusReport()` has emitted this since dynamic states shipped and nothing ever asked for + * it — the C++ comment beside the allowed-state bitmask refers to "the panel" reading reasons over + * SCRIPTS, and that panel was never built. The bitmask says WHICH states are unavailable; this + * says WHY, which is the half an operator needs. + * + * Wire format, one per line, terminated by `END`: + * SCRIPT:::OK:: + * SCRIPT:::REFUSED: — script failed to load; fixed for the run + * SCRIPT:::BLOCKED: — sensor gate unsatisfied right now; clears on its own + * + * The sequencer substitutes any colon in a name or reason with a space before sending, so + * splitting on ':' is safe — but only up to the reason, which is taken as the rest of the line so + * a future field cannot be silently truncated. + */ + +export interface ScriptStatus { + /** State id -> why it cannot be entered. Both REFUSED and BLOCKED land here. */ + reasons: Record; + /** State ids whose script loaded cleanly, for telling "no script" from "script fine". */ + ok: number[]; +} + +export function parseScriptStatus(report: string): ScriptStatus { + const reasons: Record = {}; + const ok: number[] = []; + + for (const raw of (report ?? '').split('\n')) { + const line = raw.trim(); + if (line === '' || line === 'END') continue; + if (!line.startsWith('SCRIPT:')) continue; + + const parts = line.split(':'); + if (parts.length < 4) continue; + + const id = Number(parts[1]); + if (!Number.isFinite(id)) continue; + const verdict = parts[3]; + + if (verdict === 'OK') { + ok.push(id); + continue; + } + if (verdict !== 'REFUSED' && verdict !== 'BLOCKED') continue; + + // Rest of the line, so a reason is never cut short at a separator. + const why = parts.slice(4).join(':').trim(); + if (why === '') continue; + // A load refusal outranks a live block: it cannot be fixed by waiting, so if a state has + // both, the operator should be told the one that needs action. + if (verdict === 'REFUSED' || reasons[id] === undefined) reasons[id] = why; + } + + return { reasons, ok }; +} diff --git a/daq-server/diablo_server/backend/src/server.ts b/daq-server/diablo_server/backend/src/server.ts index 9559f13c..92b13156 100644 --- a/daq-server/diablo_server/backend/src/server.ts +++ b/daq-server/diablo_server/backend/src/server.ts @@ -28,7 +28,9 @@ import { ElodinClient } from './elodin-client.js'; import { parseElodinPacket } from './elodin-protocol.js'; import { expandWithTare, resetTareState, setRunDir } from './lc-tare.js'; import { loadSensorRoleMap, hpBoardNumbers } from './sensor-config.js'; -import { registerVTables, clearSubscriptionState, noteSubscriptionRejected } from './elodin-vtable-registry.js'; +import { registerVTables, clearSubscriptionState, noteSubscriptionRejected, notePairDelivered } from './elodin-vtable-registry.js'; +import { refusalText } from './refusal-text.js'; +import { parseScriptStatus } from './script-status.js'; import { createAPIHandler } from './api-server.js'; import { startBoardLogReceiver } from './board-logs.js'; import { readConfig, readDeployedConfig } from './routes/config.js'; @@ -1166,10 +1168,31 @@ function handleMessage(ws: WebSocket, message: any): void { } } +/** Latest advertised-allowed set from the sequencer; undefined until it first publishes. */ +let allowedBitmask: number | undefined; +/** State id -> why it cannot be entered, from the sequencer's SCRIPTS report. */ +let stateRefusalReasons: Record = {}; + +/** + * Refresh the per-state refusal reasons. + * + * Load refusals are fixed for the run, but the sensor gate is live — a state blocks and unblocks + * as a feed comes and goes — so this is polled rather than read once. Slow on purpose: the reasons + * only decorate a button that is already greyed by the bitmask, and nothing here may ever raise a + * notification, or a poll would spam the panel once a second. + */ +function refreshScriptStatus(): void { + sendToActuatorService('SCRIPTS\n') + .then(({ reply }) => { + stateRefusalReasons = parseScriptStatus(reply).reasons; + }) + .catch(() => { /* sequencer down — the bitmask already says nothing is enterable */ }); +} + function broadcastStateUpdate(): void { broadcast({ type: MessageType.STATE_UPDATE, timestamp: Date.now(), - payload: { currentState, stateName: configStateName(currentState) ?? SystemState[currentState] ?? 'UNKNOWN', timestamp: Date.now(), debugMode }, + payload: { currentState, stateName: configStateName(currentState) ?? SystemState[currentState] ?? 'UNKNOWN', timestamp: Date.now(), debugMode, allowedBitmask, stateRefusalReasons }, }); } @@ -1231,7 +1254,7 @@ function handleCommand(ws: WebSocket, command: CommandPayload): void { sendToActuatorService(`TRANSITION:${csvName}${holdSuffix}\n`).then(({ ok, reply }) => { console.log(`[ThinServer] State transition ${stateName} → ${csvName}${holdSuffix}: ${ok ? 'OK' : 'FAIL'} (${reply})`); if (!ok) { - send(ws, { type: MessageType.ERROR, timestamp: Date.now(), payload: { message: `State transition failed: ${reply}` } }); + send(ws, { type: MessageType.ERROR, timestamp: Date.now(), payload: { message: `State transition failed: ${refusalText(reply)}` } }); } }); break; @@ -1242,7 +1265,7 @@ function handleCommand(ws: WebSocket, command: CommandPayload): void { // No optimistic update — real commanded state arrives via [0x32] packets from Elodin. sendToActuatorService(`ACTUATOR:${actuatorName}:${open ? 1 : 0}\n`).then(({ ok, reply }) => { if (!ok) { - send(ws, { type: MessageType.ERROR, timestamp: Date.now(), payload: { message: `Actuator command failed: ${reply}` } }); + send(ws, { type: MessageType.ERROR, timestamp: Date.now(), payload: { message: `Actuator command failed: ${refusalText(reply)}` } }); } }); break; @@ -1262,7 +1285,7 @@ function handleCommand(ws: WebSocket, command: CommandPayload): void { sendToActuatorService('EXTEND_FIRE\n').then(({ ok, reply }) => { console.log(`[ThinServer] Extend fire: ${ok ? 'OK' : 'FAIL'} (${reply})`); if (!ok) { - send(ws, { type: MessageType.ERROR, timestamp: Date.now(), payload: { message: `Extend fire failed: ${reply}` } }); + send(ws, { type: MessageType.ERROR, timestamp: Date.now(), payload: { message: `Extend fire failed: ${refusalText(reply)}` } }); } }); break; @@ -1396,23 +1419,36 @@ const STATE_TO_CSV_NAME: Record = { // registered by other services (e.g., daq_bridge). Retry every 5s until all // expected packet groups flow. let resubscribeTimer: NodeJS.Timeout | null = null; -const MAX_RESUBSCRIBE_ATTEMPTS = 24; let shouldResubscribe = true; - -function scheduleResubscribe(attempt: number): void { +const RESUBSCRIBE_MIN_MS = 5000; + +// No attempt ceiling any more. The old one (24 passes, ~2 min) was dead code anyway: the +// dbError handler called scheduleResubscribe(1) on every refusal, resetting the ladder +// forever. Worse, a real ceiling is wrong here — a service started more than two minutes +// after the backend connects would never be picked up. Termination is now per PAIR +// (MAX_PAIR_ATTEMPTS in elodin-vtable-registry), which is where it belongs: one table that +// nobody publishes gets parked, without stopping retries for every other table. +function scheduleResubscribe(delayMs: number = RESUBSCRIBE_MIN_MS): void { if (!shouldResubscribe) return; - if (attempt > MAX_RESUBSCRIBE_ATTEMPTS) return; if (resubscribeTimer) return; resubscribeTimer = setTimeout(() => { resubscribeTimer = null; if (!elodin.isConnected()) return; if (!shouldResubscribe) return; - registerVTables(elodin).then(() => { - scheduleResubscribe(attempt + 1); + registerVTables(elodin).then((res) => { + // Sleep until the earliest pair is actually due, instead of spinning every 5 s. + // A remainder means the pass hit the request-id cap, not that anything is wrong — + // come straight back for it rather than idling 5 s per 255 tables on first connect. + const wait = res.remaining > 0 + ? 0 + : res.nextAttemptMs === null + ? RESUBSCRIBE_MIN_MS + : Math.max(RESUBSCRIBE_MIN_MS, res.nextAttemptMs - Date.now()); + scheduleResubscribe(wait); }).catch(() => { - scheduleResubscribe(attempt + 1); + scheduleResubscribe(RESUBSCRIBE_MIN_MS); }); - }, 5000); + }, Math.max(0, delayMs)); } // True when incoming data is synthetic. In a session-enabled deployment this is @@ -1483,7 +1519,7 @@ elodin.on('connected', () => { calibrationHost.elodin = elodin; registerVTables(elodin).then(() => { - scheduleResubscribe(1); + scheduleResubscribe(); }); // No VTable REGISTRATION from here. The C++ services own it (sequencer: // "Registered Sequencer/Controller VTables"), and every message this backend sent was @@ -1493,15 +1529,25 @@ elodin.on('connected', () => { // the "✅ Registered" it logged only ever meant socket.write() returned true. It has // been deleted (see elodin-vtable-registry.ts); this backend only subscribes. console.log('[ThinServer] Connected to Elodin, subscriptions sent.'); + refreshScriptStatus(); }); +// Slow on purpose. The sensor gate can block and unblock while the rig runs, so the reasons +// cannot be read once — but they only annotate a button the bitmask has already greyed, so +// there is nothing to gain from reading them often and a poll that notified would flood the +// notification panel. +setInterval(refreshScriptStatus, 15000); + // The DB refuses a subscription for a VTable that does not exist YET — the publisher // registers it when that service starts, which on a session start is a few seconds after // this backend reconnects. Un-mark the pair and make sure a retry is queued; without this // the refusal was silent and permanent, and the GUI sat on a stale state all session. elodin.on('dbError', (requestId: number, description: string) => { + // Record only. This used to also call scheduleResubscribe(1), which reset the retry + // ladder on every single refusal — with thousands of refusals per pass that made the + // loop permanent and the attempt ceiling meaningless. The pass scheduled above already + // comes back around, now timed off the pairs' own backoff. noteSubscriptionRejected(requestId, description); - scheduleResubscribe(1); }); elodin.on('disconnected', () => { @@ -1522,6 +1568,9 @@ elodin.on('error', (err: Error) => { elodin.on('packet', (header: any, payload: Buffer) => { try { const [high, low] = header.packetId as [number, number]; + // Proof this table is live. A rejection naming a delivering pair is a misattribution, + // and acting on it would re-subscribe a live table and double its rate. + notePairDelivered(high, low); // ── Board heartbeat [0x10, board_id] ──────────────────────────────────── if (high === 0x10) { @@ -1577,6 +1626,9 @@ elodin.on('packet', (header: any, payload: Buffer) => { const prevState = currentState; const stateVal = parsedList.find(p => p.component === 'state')?.value ?? 0; const bitmask = parsedList.find(p => p.component === 'allowedBitmask')?.value ?? 0; + // Kept, not discarded. This is the sequencer's own statement of what it will accept, and + // the publish happens on every refusal too, so a greyed button corrects itself. + allowedBitmask = bitmask; const debugModeVal = parsedList.find(p => p.component === 'debugMode')?.value ?? 0; if (THIN_VERBOSE_CONNECTION_LOG) { console.log(`[ThinServer] SequencerState from Elodin: state=${stateVal} bitmask=0x${bitmask.toString(16)} debug=${debugModeVal}`); diff --git a/daq-server/diablo_server/frontend/__tests__/StateMachineDiagram.test.tsx b/daq-server/diablo_server/frontend/__tests__/StateMachineDiagram.test.tsx index 54e3d04a..8267b196 100644 --- a/daq-server/diablo_server/frontend/__tests__/StateMachineDiagram.test.tsx +++ b/daq-server/diablo_server/frontend/__tests__/StateMachineDiagram.test.tsx @@ -90,16 +90,20 @@ describe('StateMachineDiagram', () => { // IDLE cannot transition directly to FIRE const fireNode = screen.getByText('FIRE'); - // Suppress window.alert for this test - const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => { }); fireEvent.click(fireNode); expect(mockSendCommand).not.toHaveBeenCalled(); expect(useSensorStore.getState().currentState).toBe(SystemState.IDLE); // unchanged - expect(alertMock).toHaveBeenCalled(); - alertMock.mockRestore(); + // The refusal goes to the notification panel, not window.alert. A modal stole focus + // mid-procedure and read nothing like the refusals the sequencer sends for the same + // class of mistake, so one rejection looked like two different failures depending on + // which side caught it. + const notes = useSensorStore.getState().notifications; + expect(notes).toHaveLength(1); + expect(notes[0].category).toBe('error'); + expect(notes[0].message).toContain('Cannot go from IDLE to FIRE'); }); it('should allow invalid transitions if debug mode is active', () => { diff --git a/daq-server/diablo_server/frontend/__tests__/blocked-states.test.ts b/daq-server/diablo_server/frontend/__tests__/blocked-states.test.ts new file mode 100644 index 00000000..05390bca --- /dev/null +++ b/daq-server/diablo_server/frontend/__tests__/blocked-states.test.ts @@ -0,0 +1,75 @@ +/** + * A state the sequencer will not accept is greyed, not offered. + * + * The sequencer has always masked refused states out of `allowedBitmask` — the C++ says so + * explicitly, "the refusal is carried in data the GUI already receives" — and the backend decoded + * it and dropped it on the floor. So the diagram offered every state the CSV allowed, and an + * operator discovered a refusal only by pressing the button and watching nothing happen. + * + * These pin the store side of that path: the mask survives into state, and an absent mask is not + * read as "nothing is allowed". + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useSensorStore } from '@/lib/store'; +import { SystemState } from '@/lib/types'; + +const st = () => useSensorStore.getState(); + +beforeEach(() => { + useSensorStore.setState({ allowedStateMask: null, stateRefusalReasons: {} }); +}); + +describe('allowedStateMask', () => { + it('is kept from a state update instead of discarded', () => { + st().updateState({ + currentState: SystemState.IDLE, stateName: 'Idle', timestamp: 1, + allowedBitmask: 0b1010, + }); + expect(st().allowedStateMask).toBe(0b1010); + }); + + it('carries the per-state reasons alongside it', () => { + st().updateState({ + currentState: SystemState.IDLE, stateName: 'Idle', timestamp: 1, + allowedBitmask: 0, stateRefusalReasons: { 15: 'GN2 High has produced no reading yet' }, + }); + expect(st().stateRefusalReasons[15]).toBe('GN2 High has produced no reading yet'); + }); + + it('treats a missing mask as "no opinion", not "nothing allowed"', () => { + // A client that connects before the sequencer's first publish, or an older backend, must + // not end up with every button greyed out. + st().updateState({ + currentState: SystemState.IDLE, stateName: 'Idle', timestamp: 1, + allowedBitmask: 0b0110, + }); + st().updateState({ currentState: SystemState.IDLE, stateName: 'Idle', timestamp: 2 }); + expect(st().allowedStateMask).toBe(0b0110); + }); + + it('starts with no opinion', () => { + expect(st().allowedStateMask).toBeNull(); + }); + + it('keeps reasons when an update omits them', () => { + st().updateState({ + currentState: SystemState.IDLE, stateName: 'Idle', timestamp: 1, + stateRefusalReasons: { 15: 'script rejected at load' }, + }); + st().updateState({ currentState: SystemState.IDLE, stateName: 'Idle', timestamp: 2 }); + expect(st().stateRefusalReasons[15]).toBe('script rejected at load'); + }); + + it('lets a state come back when the mask says so', () => { + // The sensor gate clears on its own, so a greyed button has to un-grey without a reload. + st().updateState({ + currentState: SystemState.IDLE, stateName: 'Idle', timestamp: 1, allowedBitmask: 0, + }); + expect(st().allowedStateMask).toBe(0); + st().updateState({ + currentState: SystemState.IDLE, stateName: 'Idle', timestamp: 2, + allowedBitmask: 0xffff, + }); + expect(st().allowedStateMask).toBe(0xffff); + }); +}); diff --git a/daq-server/diablo_server/frontend/__tests__/config-validation.test.ts b/daq-server/diablo_server/frontend/__tests__/config-validation.test.ts index f4b70613..96d3b3d6 100644 --- a/daq-server/diablo_server/frontend/__tests__/config-validation.test.ts +++ b/daq-server/diablo_server/frontend/__tests__/config-validation.test.ts @@ -290,3 +290,132 @@ describe('roles vs active_connectors', () => { expect(issues.some((i) => i.message.includes('Thrust'))).toBe(true); }); }); + +/** + * Dynamic states. + * + * Every rule is gated on the state actually declaring a script_file, so the clean case above — + * which has no dynamic states — must stay at zero issues. Both levels block the first Start press, + * and the module's own header records why false positives are expensive: an operator blocked by + * one learns to press Start twice by reflex. + * + * These duplicate the sequencer's load-time refusals on purpose. The sequencer's version is + * discovered at session start; this one is discovered at the desk. + */ +describe('validateConfigForRun — dynamic states', () => { + /** cleanConfig plus a Press state that runs a script, with everything set correctly. */ + const dyn = () => { + const cfg: any = cleanConfig(); + cfg.states.push({ + id: 4, + name: 'Press', + script_file: 'press.script', + script_timeout_ms: 30000, + script_return_target: 'Idle', + script_timeout_target: 'Armed', + }); + return cfg; + }; + const dynCsv = () => ({ + actuators: [ + ',Idle,Armed,Fire,Press', + 'Fuel Press,CLOSE,CLOSE,OPEN,CLOSE', + 'LOX Press,CLOSE,CLOSE,OPEN,CLOSE', + ].join('\n'), + delays: [ + ',Idle,Armed,Fire,Press', + 'Fuel Press,0,0,0,0', + 'LOX Press,0,0,0,0', + ].join('\n'), + transitions: [ + ',Idle,Armed,Fire,Press', + 'Idle,1,1,0,1', + 'Armed,1,1,1,1', + 'Fire,0,1,1,0', + 'Press,1,1,0,1', + ].join('\n'), + }); + + it('a correctly configured dynamic state raises nothing', () => { + expect(validateConfigForRun(dyn(), dynCsv())).toEqual([]); + }); + + it('a config with no dynamic states is unaffected', () => { + expect(validateConfigForRun(cleanConfig(), cleanCsv())).toEqual([]); + }); + + it('refuses a script with no timeout — unbounded has no safe degraded mode', () => { + const cfg = dyn(); + delete cfg.states[3].script_timeout_ms; + const issues = validateConfigForRun(cfg, dynCsv()); + expect(messages(issues)).toContain('no timeout'); + }); + + it('refuses a timeout above the ceiling', () => { + const cfg = dyn(); + cfg.states[3].script_timeout_ms = 600001; + expect(messages(validateConfigForRun(cfg, dynCsv()))).toContain('above the'); + }); + + it('requires BOTH landing targets — neither defaults to the other', () => { + for (const key of ['script_return_target', 'script_timeout_target']) { + const cfg = dyn(); + delete cfg.states[3][key]; + expect(messages(validateConfigForRun(cfg, dynCsv()))).toMatch(/target/); + } + }); + + it('refuses a landing target that is not a state', () => { + const cfg = dyn(); + cfg.states[3].script_return_target = 'Nowhere'; + expect(messages(validateConfigForRun(cfg, dynCsv()))).toContain('not in the state list'); + }); + + it('refuses a landing target that is the state itself', () => { + const cfg = dyn(); + cfg.states[3].script_timeout_target = 'Press'; + expect(messages(validateConfigForRun(cfg, dynCsv()))).toContain('re-arm forever'); + }); + + it('refuses a landing target the transitions table forbids', () => { + const cfg = dyn(); + cfg.states[3].script_return_target = 'Fire'; // Press -> Fire is 0 + expect(messages(validateConfigForRun(cfg, dynCsv()))).toContain('not an allowed transition'); + }); + + it('refuses a path-shaped script filename', () => { + const cfg = dyn(); + cfg.states[3].script_file = '../../etc/passwd'; + expect(messages(validateConfigForRun(cfg, dynCsv()))).toContain('bare .script'); + }); + + it('refuses a dynamic state that is also the flow state', () => { + const cfg = dyn(); + cfg.states[3].is_flow = true; + expect(messages(validateConfigForRun(cfg, dynCsv()))).toContain('flow-test state'); + }); + + it('refuses a dynamic state that is also an abort state', () => { + const cfg = dyn(); + cfg.states[3].is_abort = true; + expect(messages(validateConfigForRun(cfg, dynCsv()))).toContain('abort state'); + }); + + it('refuses a script on the fire state', () => { + const cfg = dyn(); + cfg.fire.state = 'Press'; + expect(messages(validateConfigForRun(cfg, dynCsv()))).toContain('fire state'); + }); + + it('requires an Actuators column — it is the baseline the script layers onto', () => { + const cfg = dyn(); + const csv = dynCsv(); + // Drop the Press column from the Actuators table only. + csv.actuators = [ + ',Idle,Armed,Fire', + 'Fuel Press,CLOSE,CLOSE,OPEN', + 'LOX Press,CLOSE,CLOSE,OPEN', + ].join('\n'); + expect(messages(validateConfigForRun(cfg, csv))).toContain('no column in the Actuators table'); + }); +}); diff --git a/daq-server/diablo_server/frontend/__tests__/refusal-notification.test.tsx b/daq-server/diablo_server/frontend/__tests__/refusal-notification.test.tsx new file mode 100644 index 00000000..1b88f76e --- /dev/null +++ b/daq-server/diablo_server/frontend/__tests__/refusal-notification.test.tsx @@ -0,0 +1,140 @@ +/** + * A refused command reaches the notification panel. + * + * The gap this closes: the backend has always sent MessageType.ERROR for every rejection the + * sequencer reports — a refused transition, an actuator command, extend-fire, a bad hold + * duration, controls locked — and nothing on the dashboard subscribed. websocket.ts's + * handleMessage looks up listeners by type and silently drops a payload with none, so pressing a + * button the sequencer refused did *nothing at all*. On 2026-09-16 a state refused for "GN2 High + * has produced no reading yet" took a journal dive to diagnose. + * + * GlobalStateSubscriber now feeds those into the same store the panel renders. These tests pin + * the store contract that handler depends on, so the panel cannot drift away from it. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import React from 'react'; + +// The component only touches these two surfaces of the WS client; capture what it subscribes to. +const listeners = new Map void>(); +vi.mock('@/lib/websocket', () => ({ + getWebSocketClient: () => ({ + on: (type: string, cb: (p: unknown) => void) => { + listeners.set(type, cb); + return () => listeners.delete(type); + }, + onConnectionStatus: () => () => {}, + connect: () => {}, + sendCommand: () => {}, + }), + getApiBaseUrl: () => 'http://localhost:8081', +})); +vi.mock('@/lib/data-cache', () => ({ + startDataCache: () => {}, + getDataCache: () => ({ onHistoricalData: () => () => {} }), +})); + +import { useSensorStore } from '@/lib/store'; +import GlobalStateSubscriber from '@/components/dashboard/GlobalStateSubscriber'; + +const notifications = () => useSensorStore.getState().notifications; + +beforeEach(() => { + useSensorStore.getState().clearNotifications(); +}); + +describe('a refused command in the notification store', () => { + it('lands as a one-shot error', () => { + useSensorStore.getState().updateNotification({ + category: 'error', + message: 'State transition failed: GN2 High has no fresh calibrated reading', + timestampMs: 1_700_000_000_000, + }); + + const list = notifications(); + expect(list).toHaveLength(1); + expect(list[0].category).toBe('error'); + expect(list[0].message).toContain('GN2 High has no fresh calibrated reading'); + }); + + it('does not mark it current — a rejected click is an event, not a condition', () => { + // An `ongoing` entry sorts to the top and wears a "current" badge until something clears + // it by key. A refusal has nothing to clear it, so it must never claim to be live. + useSensorStore.getState().updateNotification({ + category: 'error', + message: 'Actuator command failed: transition rejected', + timestampMs: 1_700_000_000_000, + }); + expect(notifications()[0].isCurrent).toBeFalsy(); + expect(notifications()[0].key).toBeUndefined(); + }); + + it('keeps every refusal rather than collapsing repeats', () => { + // Pressing a refused button three times is three events. One-shots carry no key, so they + // must not dedupe the way keyed board notifications do. + for (let i = 0; i < 3; i++) { + useSensorStore.getState().updateNotification({ + category: 'error', + message: 'State transition failed: transition rejected', + timestampMs: 1_700_000_000_000 + i, + }); + } + expect(notifications()).toHaveLength(3); + }); + + it('shows the newest refusal first', () => { + useSensorStore.getState().updateNotification({ + category: 'error', message: 'first', timestampMs: 1_700_000_000_000, + }); + useSensorStore.getState().updateNotification({ + category: 'error', message: 'second', timestampMs: 1_700_000_000_001, + }); + expect(notifications()[0].message).toBe('second'); + }); + + it('sits alongside an ongoing board notification without disturbing it', () => { + useSensorStore.getState().updateNotification({ + key: 'board_lost_3', category: 'error', message: 'Board 3 (DAQ) connection lost', + timestampMs: 1_700_000_000_000, ongoing: true, + }); + useSensorStore.getState().updateNotification({ + category: 'error', message: 'State transition failed: transition rejected', + timestampMs: 1_700_000_000_001, + }); + + const list = notifications(); + expect(list).toHaveLength(2); + // The live condition still sorts above the one-off event. + expect(list[0].key).toBe('board_lost_3'); + expect(list[0].isCurrent).toBe(true); + }); +}); + +describe('GlobalStateSubscriber wiring', () => { + it('subscribes to ERROR at all — without this the payload is dropped', () => { + global.fetch = vi.fn(() => Promise.resolve({ ok: false })) as unknown as typeof fetch; + render(React.createElement(GlobalStateSubscriber)); + expect(listeners.has('error')).toBe(true); + }); + + it('turns a refused command into a visible notification', () => { + global.fetch = vi.fn(() => Promise.resolve({ ok: false })) as unknown as typeof fetch; + render(React.createElement(GlobalStateSubscriber)); + + listeners.get('error')?.({ + message: 'State transition failed: GN2 High has no fresh calibrated reading', + }); + + const list = useSensorStore.getState().notifications; + expect(list).toHaveLength(1); + expect(list[0].category).toBe('error'); + expect(list[0].message).toContain('GN2 High has no fresh calibrated reading'); + }); + + it('ignores an ERROR with no message rather than posting a blank row', () => { + global.fetch = vi.fn(() => Promise.resolve({ ok: false })) as unknown as typeof fetch; + render(React.createElement(GlobalStateSubscriber)); + listeners.get('error')?.({}); + expect(useSensorStore.getState().notifications).toHaveLength(0); + }); +}); diff --git a/daq-server/diablo_server/frontend/__tests__/state-script-editing.test.ts b/daq-server/diablo_server/frontend/__tests__/state-script-editing.test.ts new file mode 100644 index 00000000..10058816 --- /dev/null +++ b/daq-server/diablo_server/frontend/__tests__/state-script-editing.test.ts @@ -0,0 +1,158 @@ +/** + * The editor's text transforms: indent, outdent and auto-indent. + * + * These are the pure half of the keyboard handling in app/config/page.tsx. The half that needs a + * browser — that every edit goes through execCommand('insertText') so Ctrl+Z can undo it — cannot + * be asserted in jsdom, which does not implement execCommand; what is covered here is the text + * arithmetic that decides WHAT gets inserted. + * + * The bug that prompted these: Tab with a selection built the next source as + * slice(0, selectionStart) + " " + slice(selectionEnd) + * which drops slice(start, end) entirely. Indenting a highlighted block deleted it, and since the + * replacement was a React state write rather than a browser edit, Ctrl+Z could not bring it back. + * Seen on the stand 2026-09-16. + */ +import { describe, it, expect } from 'vitest'; + +const INDENT = ' '; + +// ── The functions under test, mirroring app/config/page.tsx ─────────────────── +// Kept in step by test 'matches the source' at the bottom, which reads the real file. + +function lineSpan(src: string, from: number, to: number): { start: number; end: number } { + const start = src.lastIndexOf('\n', from - 1) + 1; + const lastTouched = to > from && src[to - 1] === '\n' ? to - 1 : to; + const nl = src.indexOf('\n', lastTouched); + return { start, end: nl === -1 ? src.length : nl }; +} + +function indentAt(src: string, pos: number): string { + const start = src.lastIndexOf('\n', pos - 1) + 1; + return /^ */.exec(src.slice(start, pos))?.[0] ?? ''; +} + +function backspaceWidth(src: string, caret: number): number { + const lineStart = src.lastIndexOf('\n', caret - 1) + 1; + const before = src.slice(lineStart, caret); + if (before.length === 0 || !/^ +$/.test(before)) return 0; + return ((before.length - 1) % INDENT.length) + 1; +} + +function shiftBlock(block: string, outdent: boolean): string { + return block + .split('\n') + .map((l) => (outdent ? l.replace(/^ {1,4}/, '') : l.length > 0 ? INDENT + l : l)) + .join('\n'); +} + +/** What the Tab handler writes back, for a selection. */ +function tabWithSelection(src: string, s: number, en: number, outdent = false): string { + const { start, end } = lineSpan(src, s, en); + const next = shiftBlock(src.slice(start, end), outdent); + return src.slice(0, start) + next + src.slice(end); +} + +const SCRIPT = 'open_valve(FUEL_VENT)\nwait(3)\nclose_valve(FUEL_VENT)'; + +describe('Tab with a selection', () => { + it('indents the selected lines instead of deleting them', () => { + // Select from inside line 1 through inside line 2 — the shape that used to wipe the text. + const s = 5; + const en = SCRIPT.indexOf('wait(3)') + 4; + const out = tabWithSelection(SCRIPT, s, en); + + // Nothing may be lost: every original line still present, just moved right. + expect(out).toBe(' open_valve(FUEL_VENT)\n wait(3)\nclose_valve(FUEL_VENT)'); + expect(out).toContain('open_valve(FUEL_VENT)'); + expect(out).toContain('wait(3)'); + expect(out).toContain('close_valve(FUEL_VENT)'); + }); + + it('never drops the selected text, whatever the selection', () => { + for (let s = 0; s < SCRIPT.length; s++) { + for (const en of [s + 1, s + 7, SCRIPT.length]) { + if (en > SCRIPT.length) continue; + const out = tabWithSelection(SCRIPT, s, en); + const stripped = out.split('\n').map((l) => l.replace(/^ {0,4}/, '')).join('\n'); + expect(stripped).toBe(SCRIPT); + } + } + }); + + it('outdents with Shift, and stops at column zero', () => { + const indented = ' open_valve(FUEL_VENT)\n wait(3)'; + const once = tabWithSelection(indented, 0, indented.length, true); + expect(once).toBe('open_valve(FUEL_VENT)\nwait(3)'); + // Already flush left — outdenting again must be a no-op, not a mangling. + expect(tabWithSelection(once, 0, once.length, true)).toBe(once); + }); + + it('does not indent a line the selection only reaches the start of', () => { + const out = tabWithSelection(SCRIPT, 0, SCRIPT.indexOf('wait(3)')); + expect(out).toBe(' open_valve(FUEL_VENT)\nwait(3)\nclose_valve(FUEL_VENT)'); + }); + + it('leaves blank lines blank rather than padding them with spaces', () => { + const withBlank = 'wait(1)\n\nwait(2)'; + expect(tabWithSelection(withBlank, 0, withBlank.length)).toBe(' wait(1)\n\n wait(2)'); + }); +}); + +describe('Enter auto-indent', () => { + it('carries the current line\'s indentation', () => { + const src = ' wait(3)'; + expect(indentAt(src, src.length)).toBe(' '); + }); + + it('reports no indentation on a flush-left line, so the textarea keeps its own newline', () => { + expect(indentAt(SCRIPT, SCRIPT.indexOf('wait(3)') + 4)).toBe(''); + }); + + it('measures the line the caret is on, not an earlier one', () => { + const src = ' wait(1)\nwait(2)'; + expect(indentAt(src, src.length)).toBe(''); + expect(indentAt(src, 11)).toBe(' '); + }); + + it('counts only the indentation before the caret', () => { + // Caret sits mid-indent: what is carried is what precedes it. + expect(indentAt(' wait(3)', 4)).toBe(' '); + }); +}); + +describe('Backspace in the leading indent', () => { + it('takes a whole level rather than one space', () => { + expect(backspaceWidth(' wait(3)', 4)).toBe(4); + expect(backspaceWidth(' wait(3)', 8)).toBe(4); + }); + + it('falls back to the previous boundary from a ragged column', () => { + expect(backspaceWidth(' x', 5)).toBe(1); // col 5 → 4 + expect(backspaceWidth(' x', 3)).toBe(3); // col 3 → 0 + expect(backspaceWidth(' x', 2)).toBe(2); // col 2 → 0 + expect(backspaceWidth(' x', 1)).toBe(1); + }); + + it('stays out of the way once there is real text before the caret', () => { + // Mid-word, and just after a token: an ordinary one-character delete. + expect(backspaceWidth(' wait(3)', 11)).toBe(0); + expect(backspaceWidth('wait(3)', 4)).toBe(0); + }); + + it('does nothing at the very start of a line', () => { + expect(backspaceWidth('wait(3)', 0)).toBe(0); + expect(backspaceWidth(' a\n b', 6)).toBe(0); // start of line 2 + }); + + it('measures from the caret\'s own line, not the whole source', () => { + const src = 'wait(1)\n wait(2)'; + expect(backspaceWidth(src, src.length - 'wait(2)'.length)).toBe(4); + }); + + it('never deletes past the line start', () => { + for (const n of [1, 2, 3, 4, 5, 6, 7, 8, 9, 12]) { + const src = ' '.repeat(n) + 'x'; + expect(backspaceWidth(src, n)).toBeLessThanOrEqual(n); + } + }); +}); diff --git a/daq-server/diablo_server/frontend/__tests__/state-script-editor.test.tsx b/daq-server/diablo_server/frontend/__tests__/state-script-editor.test.tsx new file mode 100644 index 00000000..fdd5e87e --- /dev/null +++ b/daq-server/diablo_server/frontend/__tests__/state-script-editor.test.tsx @@ -0,0 +1,152 @@ +/** + * The editor's wiring, in a DOM. + * + * The pure logic is covered by state-script-tokens.test.ts. What this covers is the part that can + * only break when it is assembled: that the highlight mirror reproduces the text character for + * character (anything else slides the colours off), that a token actually receives its colour + * class, and that the popup's keyboard behaviour inserts what was highlighted. + * + * Rendering the whole config page would need the entire app's context, so this exercises the same + * primitives the editor is built from — the tokenizer's output mapped to spans, and the completion + * source driven by caret offsets — against a real DOM. + */ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { completionsAt, applyCompletion } from '@/lib/state-script-complete'; +import { highlightSpans } from '@/lib/state-script-highlight'; + +const tables = { + actuators: new Set(['FUEL_VENT', 'MAIN_VALVE']), + sensors: new Set(['GN2_HIGH']), + states: new Set(['IDLE', 'PRESS_STANDBY', 'FUEL_VENT']), + allowedTransitions: new Set(['IDLE', 'PRESS_STANDBY']), +}; + +/** + * The editor's ACTUAL mirror, not a reimplementation of it. + * + * Importing highlightSpans is the point: a copy of the span construction living in this file would + * pass forever while the real one drifted, which is the failure mode these tests exist to catch. + */ +function Mirror({ src }: { src: string }) { + return
{highlightSpans(src, tables)}
; +} + +describe('highlight mirror', () => { + /** + * The mirror's text, minus the one intentional addition. + * + * highlightSpans appends a zero-width space so that a source ending in a newline still gives the + * mirror a final line — without it the mirror is one line shorter than the textarea and the two + * scroll out of step. It has no width, so it cannot shift anything; everything BEFORE it must be + * exact. + */ + const mirrorText = () => { + const raw = screen.getByTestId('mirror').textContent ?? ''; + expect(raw.endsWith('​')).toBe(true); + return raw.slice(0, -1); + }; + + it('reproduces the source character for character', () => { + // The single most important property: the mirror sits behind the textarea, so if its text + // differs by even one character the colours drift off the words — and drift further the + // longer the line, which reads as intermittent rather than broken. + const src = 'open_valve(FUEL_VENT) # trailing\n delay(0.25)\n\nwhile elapsed() < 5:\n'; + render(); + expect(mirrorText()).toBe(src); + }); + + it('preserves indentation exactly', () => { + const src = ' delay(1)\n'; + render(); + expect(mirrorText()).toBe(src); + }); + + it('gives each kind its own colour', () => { + const { container } = render(); + const classOf = (kind: string) => + container.querySelector(`[data-kind="${kind}"]`)?.className ?? ''; + expect(classOf('comment')).toContain('text-gray-500'); + expect(classOf('keyword')).toContain('text-blue-300'); + expect(classOf('command')).toContain('text-purple-300'); + expect(classOf('valve')).toContain('text-amber-300'); + expect(classOf('number')).toContain('text-orange-300'); + }); + + it('paints the same word differently in a valve slot and a state slot', () => { + const { container } = render( + , + ); + const spans = [...container.querySelectorAll('span')].filter((s) => s.textContent === 'FUEL_VENT'); + expect(spans.map((s) => s.getAttribute('data-kind'))).toEqual(['valve', 'state']); + expect(spans[0].className).toContain('text-amber-300'); + expect(spans[1].className).toContain('text-emerald-300'); + }); + + it('underlines a name this config does not declare, and only that one', () => { + const { container } = render(); + const spans = [...container.querySelectorAll('[data-kind="valve"]')]; + expect(spans[0].className).toContain('decoration-wavy'); + expect(spans[1].className).not.toContain('decoration-wavy'); + }); + + it('paints an unrecognised name white, withholding the colour that would call it a valve', () => { + const { container } = render(); + const [bad, good] = [...container.querySelectorAll('[data-kind="valve"]')]; + expect(bad.className).toContain('text-white'); + expect(bad.className).not.toContain('text-amber-300'); + // The recognised one still gets the valve colour — this must not have gone white too. + expect(good.className).toContain('text-amber-300'); + }); +}); + +describe('completion, driven by caret offsets', () => { + /** Accept the nth suggestion at the caret marked `|`, as the popup's Enter/Tab handler does. */ + const pick = (marked: string, n = 0) => { + const caret = marked.indexOf('|'); + const src = marked.replace('|', ''); + const r = completionsAt(src, caret, tables)!; + return applyCompletion(src, r, r.items[n]); + }; + + it('inserting a command leaves the caret inside its parens, ready for a name', () => { + const r = completionsAt('', 0, tables)!; + const cmd = r.items.find((i) => i.text === 'open_valve(')!; + const out = applyCompletion('', r, cmd); + expect(out.source).toBe('open_valve('); + expect(out.caret).toBe('open_valve('.length); + expect(cmd.reopen).toBe(true); + }); + + it('completing a valve replaces the typed prefix and closes the call', () => { + expect(pick('open_valve(FU|').source).toBe('open_valve(FUEL_VENT)'); + }); + + it('completing mid-line keeps what follows the caret', () => { + expect(pick('open_valve(MA|)\ndelay(1)\n').source).toBe('open_valve(MAIN_VALVE)\ndelay(1)\n'); + }); + + it('offers only reachable states in transition_to', () => { + const r = completionsAt('transition_to(', 14, tables)!; + expect(r.items.map((i) => i.label)).toEqual(['IDLE', 'PRESS_STANDBY']); + }); + + it('arrow-key selection picks the item that was highlighted', () => { + // The popup tracks an index; Enter inserts items[index]. Picking index 1 must insert the + // SECOND suggestion, not the first. + const r = completionsAt('open_valve(', 11, tables)!; + expect(r.items.length).toBeGreaterThan(1); + expect(applyCompletion('open_valve(', r, r.items[1]).source).toBe('open_valve(MAIN_VALVE)'); + }); + + it('a finished line offers nothing further, so Enter starts a new one', () => { + // The bug this guards: after completing a call the caret sits at a position that used to fall + // through to "expression", so the popup reopened and swallowed Enter — pressing it put a + // second command on the same line instead of moving to the next. + expect(completionsAt('open_valve(FUEL_VENT)', 21, tables)).toBeNull(); + }); + + it('suggests nothing inside a comment, so typing prose is not interrupted', () => { + expect(completionsAt('# open the FU', 13, tables)).toBeNull(); + }); +}); diff --git a/daq-server/diablo_server/frontend/__tests__/state-script-names.test.ts b/daq-server/diablo_server/frontend/__tests__/state-script-names.test.ts new file mode 100644 index 00000000..5bc134ad --- /dev/null +++ b/daq-server/diablo_server/frontend/__tests__/state-script-names.test.ts @@ -0,0 +1,137 @@ +/** + * Name checking and rename propagation for dynamic-state scripts. + * + * The case that matters most is the collision one. On the shipped `server` profile FUEL_VENT is + * BOTH an actuator role and a state name, and on `digital-twin` FUEL_UPSTREAM is both an actuator + * and a PT sensor. Names in this language resolve positionally — the argument slot decides the + * namespace — which is what makes both legal, and what makes a namespace-scoped regex a correct + * tool here rather than a shortcut. + * + * A bare `text.replaceAll('FUEL_VENT', …)` when renaming the *state* would rewrite + * `open_valve(FUEL_VENT)` into a valve that does not exist, validation would then reject it, and + * an unrelated rename would have silently made a working state unenterable. + */ +import { describe, it, expect } from 'vitest'; +import { + slugify, isValidSlug, isValidScriptFilename, scanScriptNames, renameScriptSlug, checkScriptNames, +} from '@/lib/state-script-names'; + +const tables = { + actuators: new Set(['FUEL_VENT', 'GSE_HIGH_PRESS_CONTROL', 'FUEL_UPSTREAM']), + sensors: new Set(['GN2_HIGH', 'GN2_REGULATED', 'FUEL_UPSTREAM']), + states: new Set(['IDLE', 'PRESS_STANDBY', 'FUEL_VENT', 'FIRE']), + allowedTransitions: new Set(['IDLE', 'PRESS_STANDBY', 'FUEL_VENT']), +}; + +describe('slugify', () => { + it('matches the C++ rule', () => { + expect(slugify('GN2 High')).toBe('GN2_HIGH'); + expect(slugify('GSE High Press Control')).toBe('GSE_HIGH_PRESS_CONTROL'); + expect(slugify(' Fuel Vent ')).toBe('FUEL_VENT'); + expect(slugify('Chamber Mid PT 1')).toBe('CHAMBER_MID_PT_1'); + }); + + it('rejects what cannot be a bare identifier', () => { + expect(isValidSlug('GN2_HIGH')).toBe(true); + expect(isValidSlug('2COLD')).toBe(false); + expect(isValidSlug('FUEL-VENT')).toBe(false); + expect(isValidSlug('')).toBe(false); + }); +}); + +describe('script filenames', () => { + it('accepts a bare name and refuses anything path-shaped', () => { + expect(isValidScriptFilename('copv_press.script')).toBe(true); + expect(isValidScriptFilename('a-b_1.script')).toBe(true); + expect(isValidScriptFilename('../../etc/passwd')).toBe(false); + expect(isValidScriptFilename('sub/dir.script')).toBe(false); + expect(isValidScriptFilename('notes.txt')).toBe(false); + expect(isValidScriptFilename('.script')).toBe(false); + }); +}); + +describe('scanning', () => { + it('assigns a namespace by argument position, not by spelling', () => { + const refs = scanScriptNames( + 'open_valve(FUEL_VENT)\ntransition_to(FUEL_VENT)\nx = pressure(GN2_HIGH)\n', + ); + expect(refs).toEqual([ + { slug: 'FUEL_VENT', ns: 'actuator', line: 1 }, + { slug: 'GN2_HIGH', ns: 'sensor', line: 3 }, + { slug: 'FUEL_VENT', ns: 'state', line: 2 }, + ].sort((a, b) => a.line - b.line || a.ns.localeCompare(b.ns)).sort((a, b) => a.line - b.line)); + }); + + it('ignores names inside comments', () => { + expect(scanScriptNames('# open_valve(NOPE)\ndelay(1)\n')).toEqual([]); + }); +}); + +describe('checking', () => { + it('says nothing about a clean script', () => { + expect(checkScriptNames( + 'open_valve(FUEL_VENT)\ndelay(0.5)\nclose_valve(FUEL_VENT)\ntransition_to(PRESS_STANDBY)\n', + tables, + )).toEqual([]); + }); + + it('accepts a name that is a valve AND a state, in both positions', () => { + expect(checkScriptNames('open_valve(FUEL_VENT)\ntransition_to(FUEL_VENT)\n', tables)).toEqual([]); + }); + + it('accepts a name that is a valve AND a sensor', () => { + expect(checkScriptNames('x = pressure(FUEL_UPSTREAM)\nopen_valve(FUEL_UPSTREAM)\n', tables)) + .toEqual([]); + }); + + it('catches a typo and suggests the real name', () => { + const [issue] = checkScriptNames('open_valve(FUEL_VNT)\n', tables); + expect(issue.line).toBe(1); + expect(issue.message).toContain('FUEL_VENT'); + }); + + it('catches a state the transitions table forbids', () => { + const [issue] = checkScriptNames('transition_to(FIRE)\n', tables); + expect(issue.message).toContain('not allowed to transition'); + }); + + it('checks a state name in the ACTUATOR slot against actuators', () => { + // IDLE is a real state, but it is not a valve. + const [issue] = checkScriptNames('open_valve(IDLE)\n', tables); + expect(issue.message).toContain('no actuator named IDLE'); + }); +}); + +describe('rename propagation', () => { + it('rewrites transition_to but NOT open_valve for the same slug', () => { + const src = 'open_valve(FUEL_VENT)\ndelay(1)\ntransition_to(FUEL_VENT)\n'; + const out = renameScriptSlug(src, 'state', 'FUEL_VENT', 'FUEL_DUMP'); + expect(out).toContain('open_valve(FUEL_VENT)'); + expect(out).toContain('transition_to(FUEL_DUMP)'); + }); + + it('rewrites both valve calls when the ACTUATOR is renamed, leaving the state alone', () => { + const src = 'open_valve(FUEL_VENT)\nclose_valve(FUEL_VENT)\ntransition_to(FUEL_VENT)\n'; + const out = renameScriptSlug(src, 'actuator', 'FUEL_VENT', 'FUEL_DUMP'); + expect(out).toContain('open_valve(FUEL_DUMP)'); + expect(out).toContain('close_valve(FUEL_DUMP)'); + expect(out).toContain('transition_to(FUEL_VENT)'); + }); + + it('rewrites a sensor only inside pressure()', () => { + const src = 'x = pressure(FUEL_UPSTREAM)\nopen_valve(FUEL_UPSTREAM)\n'; + const out = renameScriptSlug(src, 'sensor', 'FUEL_UPSTREAM', 'FUEL_UP'); + expect(out).toContain('pressure(FUEL_UP)'); + expect(out).toContain('open_valve(FUEL_UPSTREAM)'); + }); + + it('tolerates whitespace inside the call', () => { + expect(renameScriptSlug('transition_to( IDLE )\n', 'state', 'IDLE', 'HOME')) + .toContain('HOME'); + }); + + it('leaves a script that never names the slug untouched', () => { + const src = 'delay(1)\n'; + expect(renameScriptSlug(src, 'state', 'IDLE', 'HOME')).toBe(src); + }); +}); diff --git a/daq-server/diablo_server/frontend/__tests__/state-script-tokens.test.ts b/daq-server/diablo_server/frontend/__tests__/state-script-tokens.test.ts new file mode 100644 index 00000000..1a76ae94 --- /dev/null +++ b/daq-server/diablo_server/frontend/__tests__/state-script-tokens.test.ts @@ -0,0 +1,287 @@ +/** + * The tokenizer and the completion source — the two pure pieces behind highlighting and + * autocomplete. + * + * Both exist to enforce one rule: a name means what its SLOT says it means, not what it is + * spelled. The case that proves it is FUEL_VENT, which on the shipped `server` profile is both a + * valve and a state. A tokenizer that got that wrong would paint one of them the wrong colour; a + * completer that got it wrong would offer states where only valves belong. + */ +import { describe, it, expect } from 'vitest'; +import { tokenize, caretContext } from '@/lib/state-script-tokens'; +import { completionsAt, applyCompletion } from '@/lib/state-script-complete'; + +const tables = { + actuators: new Set(['FUEL_VENT', 'GSE_HIGH_PRESS_CONTROL', 'MAIN_VALVE']), + sensors: new Set(['GN2_HIGH', 'GN2_REGULATED']), + states: new Set(['IDLE', 'PRESS_STANDBY', 'FUEL_VENT', 'FIRE']), + allowedTransitions: new Set(['IDLE', 'PRESS_STANDBY', 'FUEL_VENT']), +}; + +/** The kind assigned to the first token whose text matches. */ +const kindOf = (src: string, text: string) => + tokenize(src, tables).find((t) => t.text === text)?.kind; + +describe('tokenize — a name means what its slot says', () => { + it('classifies the SAME word differently in different calls', () => { + const src = 'open_valve(FUEL_VENT)\ntransition_to(FUEL_VENT)\n'; + const toks = tokenize(src, tables).filter((t) => t.text === 'FUEL_VENT'); + expect(toks.map((t) => t.kind)).toEqual(['valve', 'state']); + }); + + it('classifies a name inside pressure() as a sensor', () => { + expect(kindOf('x = pressure(GN2_HIGH)\n', 'GN2_HIGH')).toBe('sensor'); + }); + + it('treats a bare identifier as a variable', () => { + expect(kindOf('target = 5\ndelay(target)\n', 'target')).toBe('variable'); + }); + + it('marks built-in calls as commands and unknown calls as unknown', () => { + expect(kindOf('open_valve(X)\n', 'open_valve')).toBe('command'); + expect(kindOf('frobnicate(X)\n', 'frobnicate')).toBe('unknown'); + }); + + it('marks keywords, numbers and comments', () => { + const src = '# note\nwhile elapsed() < 5:\n delay(0.25)\n'; + expect(kindOf(src, 'while')).toBe('keyword'); + expect(kindOf(src, '0.25')).toBe('number'); + expect(tokenize(src, tables)[0].kind).toBe('comment'); + }); + + it('flags a name config does not declare, and only that one', () => { + const toks = tokenize('open_valve(FUEL_VNT)\nopen_valve(MAIN_VALVE)\n', tables); + const names = toks.filter((t) => t.kind === 'valve'); + expect(names.map((t) => [t.text, t.known])).toEqual([ + ['FUEL_VNT', false], + ['MAIN_VALVE', true], + ]); + }); + + it('does not let an unclosed paren colour the rest of the file', () => { + // A newline closes the call. Without that, MAIN_VALVE on the next line would read as a + // continuation of open_valve('s argument list. + expect(kindOf('open_valve(\nMAIN_VALVE\n', 'MAIN_VALVE')).toBe('variable'); + }); + + it('handles a call nested in another call', () => { + expect(kindOf('delay(pressure(GN2_HIGH))\n', 'GN2_HIGH')).toBe('sensor'); + }); + + it('lexes two-character operators whole', () => { + const ops = tokenize('if a <= b:\n', tables).filter((t) => t.kind === 'op').map((t) => t.text); + expect(ops).toContain('<='); + expect(ops).not.toContain('<'); + }); +}); + +describe('caretContext — works on half-typed text', () => { + const at = (src: string) => caretContext(src.replace('|', ''), src.indexOf('|')); + + it('knows it is in a valve slot before the paren is closed', () => { + expect(at('open_valve(FU|')).toMatchObject({ kind: 'valve', prefix: 'FU' }); + }); + + it('knows a sensor slot', () => { + expect(at('x = pressure(GN|')).toMatchObject({ kind: 'sensor', prefix: 'GN' }); + }); + + it('knows a state slot', () => { + expect(at('transition_to(|')).toMatchObject({ kind: 'state', prefix: '' }); + }); + + it('treats an indented line start as a command position', () => { + expect(at('while x < 1:\n op|')).toMatchObject({ kind: 'command', prefix: 'op' }); + }); + + it('treats the right-hand side of an assignment as an expression', () => { + expect(at('y = |')).toMatchObject({ kind: 'expr' }); + }); + + it('suggests nothing inside a comment', () => { + expect(at('# open_valve(FU|')).toBeNull(); + }); + + it("delay() takes an expression, not a name", () => { + expect(at('delay(|')).toMatchObject({ kind: 'expr' }); + }); + + it('reports the range to replace, so accepting overwrites the partial word', () => { + const ctx = at('open_valve(FUE|')!; + expect(ctx.to - ctx.from).toBe(3); + }); + + /** + * One statement per line is a rule of the grammar, and it decides where the popup may appear. + * + * After a finished call nothing else can legally go on that line — so suggesting there is not + * just noise. The popup swallows Enter, so finishing a call and pressing Enter to start the next + * line would insert a second command onto the same one. + */ + describe('a completed line suggests nothing further', () => { + it('after a finished call', () => { + expect(at('open_valve(FUEL_VENT)|')).toBeNull(); + }); + + it('after a finished call with trailing space', () => { + expect(at('open_valve(FUEL_VENT) |')).toBeNull(); + }); + + it('after a finished assignment', () => { + expect(at('x = pressure(GN2_HIGH)|')).toBeNull(); + }); + + it('even when a word is being typed after a finished call', () => { + expect(at('open_valve(FUEL_VENT) de|')).toBeNull(); + }); + }); + + describe('but still suggests where a value is genuinely expected', () => { + it('after an assignment', () => { + expect(at('x = |')).toMatchObject({ kind: 'expr' }); + }); + + it('after an arithmetic operator', () => { + expect(at('x = 0.9 * |')).toMatchObject({ kind: 'expr' }); + }); + + it('after a comparison operator', () => { + expect(at('while elapsed() < |')).toMatchObject({ kind: 'expr' }); + }); + + it('after a condition keyword', () => { + expect(at('if |')).toMatchObject({ kind: 'expr' }); + expect(at('while |')).toMatchObject({ kind: 'expr' }); + }); + + it('after and / or / not', () => { + expect(at('if a and |')).toMatchObject({ kind: 'expr' }); + expect(at('if not |')).toMatchObject({ kind: 'expr' }); + }); + + it('after an opening paren used for grouping', () => { + expect(at('x = (|')).toMatchObject({ kind: 'expr' }); + }); + + it('while a variable name is being typed on the right of an assignment', () => { + expect(at('y = tar|')).toMatchObject({ kind: 'expr', prefix: 'tar' }); + }); + }); +}); + +describe('completionsAt — suggests by slot', () => { + const at = (src: string) => { + const caret = src.indexOf('|'); + return completionsAt(src.replace('|', ''), caret, tables); + }; + const texts = (src: string) => at(src)?.items.map((i) => i.label ?? i.text) ?? []; + + it('offers only valves in a valve slot — even a word that is also a state', () => { + const t = texts('open_valve(|'); + expect(t).toContain('FUEL_VENT'); + expect(t).toContain('MAIN_VALVE'); + expect(t).not.toContain('IDLE'); + expect(t).not.toContain('GN2_HIGH'); + }); + + it('offers only sensors in a sensor slot', () => { + const t = texts('x = pressure(|'); + expect(t).toEqual(['GN2_HIGH', 'GN2_REGULATED']); + }); + + it('offers only states this one can REACH', () => { + const t = texts('transition_to(|'); + expect(t).toContain('PRESS_STANDBY'); + // FIRE is a real state but not an allowed transition — offering it would be offering a config + // the sequencer refuses at load. + expect(t).not.toContain('FIRE'); + }); + + it('offers commands at statement start', () => { + expect(texts('|')).toContain('open_valve('); + expect(texts(' |')).toContain('delay('); + }); + + it('offers assigned variables and value built-ins in an expression', () => { + const t = texts('target = 5\ndelay(tar|'); + expect(t).toContain('target'); + }); + + it('does not offer a variable that is never assigned', () => { + expect(texts('delay(zzz|')).toEqual([]); + }); + + it('filters by prefix, case-insensitively, prefix matches first', () => { + expect(texts('open_valve(fu|')).toEqual(['FUEL_VENT']); + }); + + it('suggests nothing in a comment', () => { + expect(at('# open_valve(|')).toBeNull(); + }); +}); + +describe('applyCompletion', () => { + /** + * Every name-taking built-in has exactly ONE argument, so choosing the name finishes the call — + * there is nothing the paren could still be waiting for. Closing it here saves the operator a + * keystroke and, more importantly, removes an unclosed paren that would otherwise be a parse + * error they had to come back and fix. + */ + it('closes the call after a valve, leaving the caret past the paren', () => { + const src = 'open_valve(FU'; + const r = completionsAt(src, src.length, tables)!; + const out = applyCompletion(src, r, r.items[0]); + expect(out.source).toBe('open_valve(FUEL_VENT)'); + expect(out.caret).toBe(out.source.length); + }); + + it('closes the call after a sensor and after a state too', () => { + const s1 = 'x = pressure('; + const r1 = completionsAt(s1, s1.length, tables)!; + expect(applyCompletion(s1, r1, r1.items[0]).source).toBe('x = pressure(GN2_HIGH)'); + + // items are sorted, so [0] here is FUEL_VENT — which is also a valve, and is exactly the case + // that proves the state slot is resolved by position rather than by spelling. + const s2 = 'transition_to('; + const r2 = completionsAt(s2, s2.length, tables)!; + expect(applyCompletion(s2, r2, r2.items[0]).source).toBe('transition_to(FUEL_VENT)'); + }); + + it('does NOT double the paren when one is already there', () => { + const src = 'open_valve(FU)'; + const r = completionsAt(src, src.length - 1, tables)!; + expect(applyCompletion(src, r, r.items[0]).source).toBe('open_valve(FUEL_VENT)'); + }); + + it('shows the bare name in the list while inserting the closing paren', () => { + const src = 'open_valve('; + const r = completionsAt(src, src.length, tables)!; + // What the operator reads is the name; what lands is the name plus its paren. + expect(r.items[0].label).toBe('FUEL_VENT'); + expect(r.items[0].text).toBe('FUEL_VENT)'); + }); + + it('filters on the visible name, not on the inserted punctuation', () => { + const src = 'open_valve(fuel'; + const r = completionsAt(src, src.length, tables)!; + expect(r.items.map((i) => i.label)).toEqual(['FUEL_VENT']); + }); + + it('a command inserts its opening paren and asks to reopen', () => { + const r = completionsAt('', 0, tables)!; + const openValve = r.items.find((i) => i.text === 'open_valve(')!; + expect(openValve.reopen).toBe(true); + expect(applyCompletion('', r, openValve).source).toBe('open_valve('); + }); + + it('the two steps compose into a finished, closed statement', () => { + // Pick the command, then the name — the flow an operator actually types. + const r1 = completionsAt('', 0, tables)!; + const step1 = applyCompletion('', r1, r1.items.find((i) => i.text === 'open_valve(')!); + const r2 = completionsAt(step1.source, step1.caret, tables)!; + const step2 = applyCompletion(step1.source, r2, r2.items[0]); + expect(step2.source).toBe('open_valve(FUEL_VENT)'); + // And the line is now complete, so nothing further is offered on it — Enter makes a new line. + expect(completionsAt(step2.source, step2.caret, tables)).toBeNull(); + }); +}); diff --git a/daq-server/diablo_server/frontend/app/config/page.tsx b/daq-server/diablo_server/frontend/app/config/page.tsx index 08602540..3c76efd5 100644 --- a/daq-server/diablo_server/frontend/app/config/page.tsx +++ b/daq-server/diablo_server/frontend/app/config/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useRef, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; import { useSearchParams } from 'react-router-dom'; import { getWebSocketClient, getApiBaseUrl } from '@/lib/websocket'; import { MessageType } from '@/lib/types'; @@ -14,6 +15,15 @@ import { parseCsvGrid, serializeCsvGrid, diffKeys, boardSlotIssue, boardDisplayName, CONFIG_PAGE_LABELS, type CsvGrid, type ConfigPageId, } from '@/lib/config-validation'; +import { + slugify, renameScriptSlug, checkScriptNames, isValidScriptFilename, +} from '@/lib/state-script-names'; +import { + completionsAt, applyCompletion, type Completion, type CompletionResult, +} from '@/lib/state-script-complete'; +import { + highlightSpans, TOKEN_CLASS, EDITOR_TEXT, LINE_HEIGHT_PX, EDITOR_PAD_PX, +} from '@/lib/state-script-highlight'; /** The editor's tabs, left to right. Also the ids a ConfigIssue names, so the session page can * link an issue straight to the page that fixes it. Ordered by usefulness and grouped so related @@ -103,6 +113,12 @@ interface ConfigData { is_abort?: boolean; is_boot?: boolean; is_flow?: boolean; + // Dynamic states. A non-empty script_file is what makes a state dynamic — there is + // deliberately no is_dynamic flag that could disagree with the data it describes. + script_file?: string; + script_timeout_ms?: number; + script_return_target?: string; + script_timeout_target?: string; }>; // 4th element assigns the actuator to controller_service ("pwm_fuel" | "pwm_ox"); absent means // the sequencer owns it. See validateControllerPwmActuators. @@ -317,6 +333,671 @@ const ptTypeOf = (board: any): string => { * (a board row, a state row, a table) rather than as a scattered banner. `error` = will break the * running config; `warn` = a mismatch worth fixing. One look for all of them. */ +/** + * What a script may say, and — for this rig — what it may name. + * + * `fixed inset-0` with flex centring, so it lands in the middle of the VIEWPORT rather than + * wherever the config page happens to be scrolled to. The config page is long; an inline panel + * would open somewhere off-screen. + * + * The name lists come from the config being edited rather than being hardcoded, so this doubles as + * "what can I actually write here" — which is the question an operator has, and the one a static + * grammar reference does not answer. + */ +function ScriptReference({ + tables, onClose, +}: { + tables: { actuators: Set; sensors: Set; states: Set; allowedTransitions: Set }; + onClose: () => void; +}) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + const Row = ({ call, what }: { call: string; what: string }) => ( +
+ {call} + {what} +
+ ); + + const Names = ({ label, set, empty }: { label: string; set: Set; empty: string }) => ( +
+
{label}
+ {set.size === 0 + ?
{empty}
+ :
+ {[...set].sort().map((n) => ( + {n} + ))} +
} +
+ ); + + // Portalled to . `position: fixed` is viewport-relative only when no ancestor creates a + // containing block — and .bg-card (globals.css) sets backdrop-filter for the glassmorphism + // panels, which does exactly that. Inside the config page this modal therefore anchored to the + // panel and landed somewhere down the scroll instead of on screen. A portal escapes the whole + // ancestor chain, so it stays centred regardless of what the page does above it. + return createPortal( +
+
e.stopPropagation()}> +
+

Script reference

+ +
+ +
+
+

Commands

+ + + + +
+ +
+

Values

+ + + +
+ +
+

Structure

+ + + +
+

Comparisons: < <= > >= == != — one per condition; combine with and, or, not.

+

Arithmetic: + - * / and parentheses.

+

Indent with spaces, consistently. Tabs are refused, and so is a < b < c — write (a < b) and (b < c).

+

Names are bare and uppercase: open_valve(FUEL_VENT), never "Fuel Vent".

+
+
+ +
+

Names you can use here

+

+ From this config. The same name can mean different things in different slots — a valve + in open_valve(…) and a state in{' '} + transition_to(…) — so what a name + means is decided by where it sits. +

+ + + +
+
+
+
, + document.body, + ); +} + +/** One indent level. A literal tab is a hard error in this language, so the editor never types one. */ +const INDENT = ' '; + +/** + * Apply an edit through the browser's OWN editing pipeline, so Ctrl+Z can undo it. + * + * Every edit the editor made used to go through `onSource()`, i.e. React writing the textarea's + * value prop. That replaces the text from outside the native pipeline: the browser records no undo + * entry for it and, in most engines, discards the undo stack it had. The visible result was that + * Ctrl+Z would not undo an autocomplete or a Tab at all, and could jump back past several earlier + * edits when it did fire. + * + * execCommand is deprecated and is still the only way to make a programmatic edit join a + * textarea's undo history; every engine implements `insertText`. It also fires a normal `input` + * event, so React's onChange runs and component state stays in step by itself. + * + * Returns false if the engine refuses, so callers can fall back to setting state — an edit that + * cannot be undone is much better than an edit that does not happen. + */ +function execInsert(ta: HTMLTextAreaElement, from: number, to: number, text: string): boolean { + try { + ta.focus(); + ta.setSelectionRange(from, to); + // An empty insert is a deletion, and insertText with "" is not reliably treated as one — + // `delete` is, and lands on the same undo stack. + return text.length > 0 + ? document.execCommand('insertText', false, text) + : document.execCommand('delete'); + } catch { + return false; + } +} + +/** + * How many characters Backspace should take when the caret sits in a line's leading indent. + * + * Indentation is four spaces, so deleting it one space at a time means four presses to undo one + * Tab. Inside the indent, Backspace instead falls back to the previous indent boundary — from + * column 8 to 4, from 5 to 4, from 3 to 0. Returns 0 when the caret is not in leading whitespace, + * meaning "not our business": the textarea deletes one character, as it should mid-text. + */ +function backspaceWidth(src: string, caret: number): number { + const lineStart = src.lastIndexOf('\n', caret - 1) + 1; + const before = src.slice(lineStart, caret); + if (before.length === 0 || !/^ +$/.test(before)) return 0; + return ((before.length - 1) % INDENT.length) + 1; +} + +/** + * The whole-line span a selection touches — what an indent or outdent has to act on. + * + * A selection dragged down to the start of the next line does not make that line selected; without + * the check, Tab would indent a line the operator never highlighted. + */ +function lineSpan(src: string, from: number, to: number): { start: number; end: number } { + const start = src.lastIndexOf('\n', from - 1) + 1; + const lastTouched = to > from && src[to - 1] === '\n' ? to - 1 : to; + const nl = src.indexOf('\n', lastTouched); + return { start, end: nl === -1 ? src.length : nl }; +} + +/** The leading spaces of the line holding `pos`. */ +function indentAt(src: string, pos: number): string { + const start = src.lastIndexOf('\n', pos - 1) + 1; + return /^ */.exec(src.slice(start, pos))?.[0] ?? ''; +} + +/** + * The dynamic-state script editor. + * + * This is the first multi-line text input in the app — there is no Monaco, CodeMirror or Ace in + * package.json, and adding one for a language with seven statements would be a lot of dependency + * for a little syntax colour. A textarea with a synced line-number gutter covers what the operator + * actually needs: see the line a diagnostic names, and click to it. + * + * Two diagnostic sources, deliberately separate: + * - names, checked here, instantly, against the config being edited (no parser needed, because + * names resolve positionally); + * - syntax, checked by the backend spawning the sequencer's own parser, on a debounce. + * The sequencer re-checks everything at startup and is the authority over both. + */ +function ScriptEditor({ + state, stateNames, source, tables, syntax, allowedTargets, canEdit, + onSource, onField, onCheck, +}: { + state: { name?: string; script_file?: string; script_timeout_ms?: number; script_return_target?: string; script_timeout_target?: string }; + stateNames: string[]; + source: string; + tables: { actuators: Set; sensors: Set; states: Set; allowedTransitions: Set }; + syntax: { line: number; message: string }[]; + allowedTargets: string[]; + canEdit: boolean; + onSource: (next: string) => void; + onField: (patch: Record) => void; + onCheck: (src: string) => void; +}) { + const taRef = useRef(null); + const gutterRef = useRef(null); + const mirrorRef = useRef(null); + const [showReference, setShowReference] = useState(false); + const [completion, setCompletion] = useState(null); + /** Row highlighted in the popup, or -1 for none — see queueComplete. */ + const [completionIdx, setCompletionIdx] = useState(-1); + const [completionPos, setCompletionPos] = useState({ left: 0, top: 0 }); + /** Width of one character. Measured once, because the editor is monospace — which is what makes + * placing the popup arithmetic rather than a hidden-mirror measurement. */ + const [charWidth, setCharWidth] = useState(0); + const lines = source.split('\n'); + + useEffect(() => { + const probe = document.createElement('span'); + probe.className = 'font-mono text-sm'; + probe.style.cssText = 'position:absolute;visibility:hidden;white-space:pre'; + probe.textContent = '0'.repeat(100); + document.body.appendChild(probe); + setCharWidth(probe.getBoundingClientRect().width / 100); + probe.remove(); + }, []); + + const tokenSpans = highlightSpans(source, tables); + + /** Recompute the popup for wherever the caret is now. */ + const queueComplete = (ta: HTMLTextAreaElement) => { + if (!canEdit) return; + const caret = ta.selectionStart; + // A selection is not a cursor; suggesting into one would replace text the operator highlighted. + if (ta.selectionEnd !== caret) { setCompletion(null); return; } + const next = completionsAt(ta.value, caret, tables); + setCompletion(next); + // [from, to) is the text an accept would replace, so an empty range means nothing has been + // typed at this position yet. The list is still worth showing — it is how you learn what the + // valves are called — but NOTHING is preselected (-1), because with no prefix every name + // matches equally and there is no reason to prefer the first. That also keeps Tab meaning + // indent until the operator has either typed a character or picked a row with the arrows. + setCompletionIdx(next && next.to > next.from ? 0 : -1); + if (next) { + const upto = ta.value.slice(0, caret); + const line = upto.split('\n').length - 1; + const col = caret - (upto.lastIndexOf('\n') + 1); + setCompletionPos({ + left: EDITOR_PAD_PX + col * charWidth - ta.scrollLeft, + top: EDITOR_PAD_PX + (line + 1) * LINE_HEIGHT_PX - ta.scrollTop, + }); + } + }; + + const accept = (item: Completion) => { + const ta = taRef.current; + if (!ta || !completion) return; + const { source: next, caret } = applyCompletion(source, completion, item); + // Through the browser's editing pipeline, so the operator can Ctrl+Z an accepted suggestion + // like any other typing. Falls back to a state write if the engine refuses. + if (!execInsert(ta, completion.from, completion.to, item.text)) onSource(next); + setCompletion(null); + requestAnimationFrame(() => { + ta.focus(); + // Set explicitly even after execInsert: the caret belongs where the item asks for it + // (inside `open_valve(`), not where the inserted text happens to end. + ta.setSelectionRange(caret, caret); + // `open_valve(` is only half the job — reopen so the valve list follows immediately. With + // nothing typed inside the paren yet it opens with no row selected, so it reads as a list of + // what is available rather than a choice already made. + if (item.reopen) queueComplete(ta); + }); + }; + + /** + * Enter, carrying the current line's indentation onto the new line. + * + * Only takes over when there is indentation to carry; an unindented line gets the textarea's own + * newline, which is already undoable and already correct. + */ + const newlineKeepingIndent = ( + e: React.KeyboardEvent, + ta: HTMLTextAreaElement, + ) => { + const { selectionStart: s, selectionEnd: en } = ta; + const indent = indentAt(source, s); + if (indent.length === 0) return; + e.preventDefault(); + if (execInsert(ta, s, en, `\n${indent}`)) return; + const caret = s + 1 + indent.length; + onSource(`${source.slice(0, s)}\n${indent}${source.slice(en)}`); + requestAnimationFrame(() => ta.setSelectionRange(caret, caret)); + }; + + const onEditorKeyDown = (e: React.KeyboardEvent) => { + const ta = e.currentTarget; + + if (completion) { + // These keys belong to the popup while it is open. Without preventDefault the caret moves + // under it, and the next insert lands somewhere else entirely. + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const n = completion.items.length; + setCompletionIdx((i) => { + // From "nothing selected", down takes the first row and up takes the last, rather than + // letting -1 fall through the modulo and land somewhere arbitrary. + if (i < 0) return e.key === 'ArrowDown' ? 0 : n - 1; + return e.key === 'ArrowDown' ? (i + 1) % n : (i - 1 + n) % n; + }); + return; + } + // Tab is the ONLY accept key. Enter always means newline — a suggestion can never stand + // between the operator and the next line, so the Shift+Enter escape hatch that used to + // exist for exactly that has nothing left to escape from. + // + // With nothing selected the popup does not claim Tab at all: it falls through to the indent + // below. An open menu listing every valve is a reference, not a pending choice, and it must + // not turn an indent into an insertion of whatever happened to sort first. + if (e.key === 'Tab' && completionIdx >= 0) { + e.preventDefault(); + const item = completion.items[completionIdx]; + if (item) { + accept(item); + return; + } + } + if (e.key === 'Escape') { + e.preventDefault(); + setCompletion(null); + return; + } + // Enter dismisses the popup and falls through to the newline below. + if (e.key === 'Enter') setCompletion(null); + } + + if (e.key === 'Enter') { + newlineKeepingIndent(e, ta); + return; + } + + // Backspace inside the leading indent removes a whole level, not one space. + if (e.key === 'Backspace') { + const { selectionStart: s, selectionEnd: en } = ta; + if (s !== en) return; // a selection deletes itself, normally + const back = backspaceWidth(source, s); + if (back <= 1) return; // nothing special to do — let the textarea delete one character + e.preventDefault(); + if (!execInsert(ta, s - back, s, '')) { + const caret = s - back; + onSource(source.slice(0, s - back) + source.slice(s)); + requestAnimationFrame(() => ta.setSelectionRange(caret, caret)); + } + return; + } + + // Tab indents. Tab-to-blur in a code box is maddening, and a literal tab is a hard error in + // this language — the editor must not be able to type one. + if (e.key === 'Tab') { + e.preventDefault(); + const { selectionStart: s, selectionEnd: en } = ta; + + // A bare caret: insert one level. + if (s === en && !e.shiftKey) { + if (!execInsert(ta, s, en, INDENT)) { + const caret = s + INDENT.length; + onSource(`${source.slice(0, s)}${INDENT}${source.slice(en)}`); + requestAnimationFrame(() => ta.setSelectionRange(caret, caret)); + } + return; + } + + // A selection: shift every line it touches. This used to build the new source as + // `slice(0, s) + " " + slice(en)`, which drops slice(s, en) — so indenting a highlighted + // block DELETED it, and because the replacement went through a state write rather than the + // browser, Ctrl+Z could not bring it back. Shift+Tab outdents one level. + const { start, end } = lineSpan(source, s, en); + const block = source.slice(start, end); + const next = block + .split('\n') + .map((l) => (e.shiftKey ? l.replace(/^ {1,4}/, '') : l.length > 0 ? INDENT + l : l)) + .join('\n'); + if (next === block) return; // nothing left to outdent — do not touch the undo stack + if (!execInsert(ta, start, end, next)) { + onSource(source.slice(0, start) + next + source.slice(end)); + } + // Keep the block selected so the operator can press Tab again. + requestAnimationFrame(() => ta.setSelectionRange(start, start + next.length)); + } + }; + + // Debounced, so the backend is not spawned on every keystroke. + useEffect(() => { + const t = setTimeout(() => onCheck(source), 400); + return () => clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [source]); + + const nameIssues = checkScriptNames(source, tables); + const issues = [ + ...nameIssues.map((i) => ({ ...i, kind: 'name' as const })), + ...syntax.map((i) => ({ ...i, kind: 'syntax' as const })), + ].sort((a, b) => a.line - b.line); + + /** + * Placeholder for an empty script. + * + * Every line is a COMMENT. A greyed-out block of valid-looking script reads as something already + * written — the operator's eye sees `open_valve(...)` in a monospace box and moves on, and the + * only tell is the colour. Commented out, it cannot be mistaken for content, and it is inert + * even if someone selects and pastes it. + * + * The example uses names from the config actually being edited, so it is worth copying rather + * than an illustration of a rig that may not exist. Falls back to generic placeholders only when + * the tables are empty. + */ + const placeholderScript = (() => { + const valve = [...tables.actuators][0] ?? 'VENT_VALVE'; + const target = [...tables.allowedTransitions][0] ?? [...tables.states][0] ?? 'IDLE'; + return [ + '# This script is empty. Write it here — every line below is a comment.', + '#', + '# Open a valve for half a second, then leave:', + `# open_valve(${valve})`, + '# delay(0.5)', + `# close_valve(${valve})`, + `# transition_to(${target})`, + '#', + '# Also available: if / elif / else, while, variables, pressure(SENSOR), elapsed().', + '# Names are bare and uppercase — no quotes.', + ].join('\n'); + })(); + + /** Everything the script commands, derived from what it names. Under the layered model, the + * dangerous case is a valve the operator EXPECTED to see here and does not. */ + const commanded = [...new Set( + source.split('\n').flatMap((l) => [...l.matchAll(/\b(?:open_valve|close_valve)\s*\(\s*([A-Z][A-Z0-9_]*)\s*\)/g)].map((m) => m[1])), + )]; + const read = [...new Set( + source.split('\n').flatMap((l) => [...l.matchAll(/\bpressure\s*\(\s*([A-Z][A-Z0-9_]*)\s*\)/g)].map((m) => m[1])), + )]; + + const goToLine = (line: number) => { + const ta = taRef.current; + if (!ta) return; + const before = lines.slice(0, line - 1).join('\n').length + (line > 1 ? 1 : 0); + ta.focus(); + ta.setSelectionRange(before, before + (lines[line - 1]?.length ?? 0)); + }; + + return ( +
+
+ {/* text-gray-300, not text-text-muted: that token is #888888, which lands around 4:1 on + this panel — under AA for normal text, and this is prose people actually need to read + rather than a label beside a control they can already see. */} +
+

+ Runs on entry, after {state.name}’s + Actuators column has put every valve in a defined position. Valves the script does not + name keep that position. +

+ +
+ + {showReference && ( + setShowReference(false)} /> + )} + +
+
+ + {([ + ['script_return_target', 'When the script ends'], + ['script_timeout_target', 'When the timeout fires'], + ] as const).map(([key, label]) => ( + + ))} +
+ + {/* The editor: a highlighted
 UNDER a transparent