when the body carries no message', () => {
+ const err = toScriptsApiError({}, 502);
+ expect(err.status).toBe(502);
+ expect(err.errorCode).toBe('');
+ expect(err.message).toBe('HTTP 502');
+ });
+
+ it('falls back for a non-object error value', () => {
+ const err = toScriptsApiError('boom', 502);
+ expect(err.status).toBe(502);
+ expect(err.errorCode).toBe('');
+ expect(err.message).toBe('HTTP 502');
+ });
+});
+
+describe('scriptErrorMessage', () => {
+ it('uses the gateway message when present', () => {
+ const err = new ScriptsApiError('Managed script', 409, SCRIPT_ERROR_CODE.managed);
+ expect(scriptErrorMessage(err, 'fallback')).toBe('Managed script');
+ });
+
+ it('uses the fallback for an empty message', () => {
+ const err = new ScriptsApiError('', 500, '');
+ expect(scriptErrorMessage(err, 'fallback')).toBe('fallback');
+ });
+
+ it('uses the fallback for a non-error value', () => {
+ expect(scriptErrorMessage(undefined, 'fallback')).toBe('fallback');
+ });
+
+ it('uses the fallback for a bare network Error', () => {
+ const err = new TypeError('Failed to fetch');
+ expect(scriptErrorMessage(err, 'fallback')).toBe('fallback');
+ });
+});
+
+describe('toScriptExecution', () => {
+ it('returns the value unchanged when it has a string id and status', () => {
+ const value = { id: 'e1', status: 'running' };
+ expect(toScriptExecution(value)).toBe(value);
+ });
+
+ it('throws a ScriptsApiError for undefined data (an empty 2xx body, e.g. a 202)', () => {
+ expect(() => toScriptExecution(undefined)).toThrow(ScriptsApiError);
+ });
+
+ it('throws for a value missing status', () => {
+ expect(() => toScriptExecution({ id: 'e1' })).toThrow(ScriptsApiError);
+ });
+
+ it('throws for a value missing id', () => {
+ expect(() => toScriptExecution({ status: 'running' })).toThrow(ScriptsApiError);
+ });
+
+ it('throws for a non-object value', () => {
+ expect(() => toScriptExecution('e1')).toThrow(ScriptsApiError);
+ expect(() => toScriptExecution(null)).toThrow(ScriptsApiError);
+ });
+});
+
+describe('isPlainJsonObject', () => {
+ it('accepts a plain object', () => {
+ expect(isPlainJsonObject({ verbose: true })).toBe(true);
+ });
+
+ it('accepts an empty object', () => {
+ expect(isPlainJsonObject({})).toBe(true);
+ });
+
+ it('rejects an array', () => {
+ expect(isPlainJsonObject([1, 2])).toBe(false);
+ });
+
+ it('rejects null', () => {
+ expect(isPlainJsonObject(null)).toBe(false);
+ });
+
+ it('rejects a string', () => {
+ expect(isPlainJsonObject('hello')).toBe(false);
+ });
+
+ it('rejects a number', () => {
+ expect(isPlainJsonObject(42)).toBe(false);
+ });
+});
+
+describe('scriptOutput', () => {
+ it('returns null when parameters is null', () => {
+ const execution: ScriptExecution = { id: 'e1', status: 'completed', parameters: null };
+ expect(scriptOutput(execution)).toBeNull();
+ });
+
+ it('returns null for an empty object', () => {
+ const execution: ScriptExecution = { id: 'e1', status: 'completed', parameters: {} };
+ expect(scriptOutput(execution)).toBeNull();
+ });
+
+ it('returns a stdout entry when stdout is the only key', () => {
+ const execution: ScriptExecution = { id: 'e1', status: 'completed', parameters: { stdout: 'hi' } };
+ expect(scriptOutput(execution)).toEqual({ kind: 'stdout', text: 'hi' });
+ });
+
+ it('returns a json entry when stdout appears with other keys', () => {
+ const execution: ScriptExecution = {
+ id: 'e1',
+ status: 'completed',
+ parameters: { stdout: 'hi', exit_code: 0 },
+ };
+ expect(scriptOutput(execution)).toEqual({ kind: 'json', value: { stdout: 'hi', exit_code: 0 } });
+ });
+
+ it('leaves stdout under the cap untouched', () => {
+ const text = 'x'.repeat(SCRIPT_OUTPUT_HEAD_CHARS + SCRIPT_OUTPUT_TAIL_CHARS);
+ const execution: ScriptExecution = { id: 'e1', status: 'completed', parameters: { stdout: text } };
+ expect(scriptOutput(execution)).toEqual({ kind: 'stdout', text });
+ });
+
+ it('truncates stdout over the cap, keeping a head and a tail behind a marker', () => {
+ // A few megabytes is entirely gateway-controlled and would otherwise
+ // sit whole in a single DOM text node - the max-height CSS on the
+ // only limits the scrollable box, not the node's size.
+ const head = 'A'.repeat(SCRIPT_OUTPUT_HEAD_CHARS);
+ const middle = 'M'.repeat(50_000);
+ const tail = 'Z'.repeat(SCRIPT_OUTPUT_TAIL_CHARS);
+ const execution: ScriptExecution = {
+ id: 'e1',
+ status: 'completed',
+ parameters: { stdout: head + middle + tail },
+ };
+
+ const output = scriptOutput(execution);
+ expect(output?.kind).toBe('stdout');
+ const text = (output as { kind: 'stdout'; text: string }).text;
+
+ expect(text.startsWith(head)).toBe(true);
+ expect(text.endsWith(tail)).toBe(true);
+ expect(text).toContain('truncated');
+ expect(text).toContain('50000');
+ // Bounded well below the original size - not just "smaller".
+ expect(text.length).toBeLessThan(SCRIPT_OUTPUT_HEAD_CHARS + SCRIPT_OUTPUT_TAIL_CHARS + 200);
+ });
+
+ it('returns a json entry for a non-object payload', () => {
+ expect(scriptOutput({ id: 'e1', status: 'completed', parameters: 42 })).toEqual({
+ kind: 'json',
+ value: 42,
+ });
+ expect(scriptOutput({ id: 'e1', status: 'completed', parameters: [1, 2] })).toEqual({
+ kind: 'json',
+ value: [1, 2],
+ });
+ expect(scriptOutput({ id: 'e1', status: 'completed', parameters: 'text' })).toEqual({
+ kind: 'json',
+ value: 'text',
+ });
+ });
+});
+
+describe('scriptFailure', () => {
+ it('returns null when error is null', () => {
+ const execution: ScriptExecution = { id: 'e1', status: 'failed', error: null };
+ expect(scriptFailure(execution)).toBeNull();
+ });
+
+ it('extracts message and exit code from a failed execution', () => {
+ const execution: ScriptExecution = {
+ id: 'e1',
+ status: 'failed',
+ error: { message: 'Script exited with an error', exit_code: 3 },
+ };
+ expect(scriptFailure(execution)).toEqual({ message: 'Script exited with an error', exitCode: 3 });
+ });
+
+ it('returns exitCode null for a stop or timeout error', () => {
+ const execution: ScriptExecution = {
+ id: 'e1',
+ status: 'terminated',
+ error: { message: 'Execution stopped by user' },
+ };
+ expect(scriptFailure(execution)).toEqual({ message: 'Execution stopped by user', exitCode: null });
+ });
+
+ it('returns null when error is not an object', () => {
+ const execution: ScriptExecution = { id: 'e1', status: 'failed', error: 'boom' };
+ expect(scriptFailure(execution)).toBeNull();
+ });
+});
+
+describe('execution history reducers', () => {
+ it('prepends a new record and returns a new map and a new array', () => {
+ const existing = record('e1');
+ const initial: Map = new Map([['components/ecu', [existing]]]);
+ const incoming = record('e2');
+
+ const next = upsertExecutionRecord(initial, 'components/ecu', incoming);
+
+ expect(next).not.toBe(initial);
+ expect(next.get('components/ecu')).not.toBe(initial.get('components/ecu'));
+ expect(next.get('components/ecu')).toEqual([incoming, existing]);
+ });
+
+ it('replaces an existing record in place, preserving order', () => {
+ const r1 = record('e1');
+ const r2 = record('e2');
+ const initial: Map = new Map([['components/ecu', [r1, r2]]]);
+ const updated: ScriptExecutionRecord = { ...r1, execution: { ...r1.execution, status: 'completed' } };
+
+ const next = upsertExecutionRecord(initial, 'components/ecu', updated);
+
+ expect(next.get('components/ecu')).toEqual([updated, r2]);
+ });
+
+ it('trims history to MAX_EXECUTION_HISTORY, dropping the oldest inactive record', () => {
+ // records[0] is the newest, the last element is the oldest.
+ const records = Array.from({ length: MAX_EXECUTION_HISTORY }, (_, i) =>
+ record(`c${MAX_EXECUTION_HISTORY - 1 - i}`, 'completed')
+ );
+ const initial: Map = new Map([['components/ecu', records]]);
+
+ const next = upsertExecutionRecord(initial, 'components/ecu', record('c-new', 'completed'));
+
+ const arr = next.get('components/ecu')!;
+ expect(arr).toHaveLength(MAX_EXECUTION_HISTORY);
+ expect(arr[0]!.execution.id).toBe('c-new');
+ expect(arr.some((r) => r.execution.id === 'c0')).toBe(false);
+ expect(arr.some((r) => r.execution.id === 'c1')).toBe(true);
+ });
+
+ it('never drops a record that is still active when trimming', () => {
+ const completed = Array.from({ length: 25 }, (_, i) => record(`c${i}`, 'completed'));
+ const running = record('running-1', 'running');
+ const initial: Map = new Map([['components/ecu', [...completed, running]]]);
+
+ const next = upsertExecutionRecord(initial, 'components/ecu', record('c-new', 'completed'));
+
+ const arr = next.get('components/ecu')!;
+ expect(arr).toHaveLength(MAX_EXECUTION_HISTORY);
+ expect(arr.some((r) => r.execution.id === 'running-1')).toBe(true);
+ });
+
+ it('can return more than MAX_EXECUTION_HISTORY records when active executions alone exceed the cap', () => {
+ // The cap only ever applies to *inactive* records - it is not a
+ // guarantee on the list's overall length. With more active records
+ // than MAX_EXECUTION_HISTORY, none of them may be dropped (their ids
+ // could never be recovered - the gateway has no endpoint to list
+ // executions), so the result is longer than the cap.
+ const running = Array.from({ length: MAX_EXECUTION_HISTORY + 5 }, (_, i) => record(`r${i}`, 'running'));
+ const initial: Map = new Map([['components/ecu', running]]);
+
+ const next = upsertExecutionRecord(initial, 'components/ecu', record('r-new', 'running'));
+
+ const arr = next.get('components/ecu')!;
+ expect(arr.length).toBeGreaterThan(MAX_EXECUTION_HISTORY);
+ expect(arr).toHaveLength(running.length + 1);
+ });
+
+ it('marks a single record as lost without touching its neighbours', () => {
+ const r1 = record('e1');
+ const r2 = record('e2');
+ const initial: Map = new Map([['components/ecu', [r1, r2]]]);
+
+ const next = markExecutionLost(initial, 'components/ecu', 'e1');
+
+ expect(next).not.toBe(initial);
+ const arr = next.get('components/ecu')!;
+ expect(arr).not.toBe(initial.get('components/ecu'));
+ expect(arr[0]).toEqual({ ...r1, lost: true });
+ expect(arr[1]).toBe(r2);
+ });
+
+ it('returns the same map when marking an unknown key', () => {
+ const initial: Map = new Map([['components/ecu', [record('e1')]]]);
+
+ const next = markExecutionLost(initial, 'apps/other', 'e1');
+
+ expect(next).toBe(initial);
+ });
+
+ it('removes only the target record', () => {
+ const r1 = record('e1');
+ const r2 = record('e2');
+ const initial: Map = new Map([['components/ecu', [r1, r2]]]);
+
+ const next = removeExecutionRecord(initial, 'components/ecu', 'e1');
+
+ expect(next).not.toBe(initial);
+ const arr = next.get('components/ecu')!;
+ expect(arr).not.toBe(initial.get('components/ecu'));
+ expect(arr).toEqual([r2]);
+ });
+
+ it('drops the key entirely when the last record is removed', () => {
+ const initial: Map = new Map([['components/ecu', [record('e1')]]]);
+
+ const next = removeExecutionRecord(initial, 'components/ecu', 'e1');
+
+ expect(next.has('components/ecu')).toBe(false);
+ });
+
+ it('keeps other entities untouched', () => {
+ const ecuRecords = [record('e1')];
+ const otherRecords = [record('e2', 'running', 'other')];
+ const initial: Map = new Map([
+ ['components/ecu', ecuRecords],
+ ['apps/talker', otherRecords],
+ ]);
+
+ const next = removeExecutionRecord(initial, 'components/ecu', 'e1');
+
+ expect(next.get('apps/talker')).toBe(otherRecords);
+ });
+});
diff --git a/src/lib/scripts.ts b/src/lib/scripts.ts
new file mode 100644
index 0000000..7b91b2e
--- /dev/null
+++ b/src/lib/scripts.ts
@@ -0,0 +1,229 @@
+// Copyright 2026 bburda
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import type { ScriptEntityType, ScriptExecution, ScriptExecutionRecord } from './types';
+
+/** Wire values of GenericError.error_code used by the scripts endpoints. */
+export const SCRIPT_ERROR_CODE = {
+ notImplemented: 'not-implemented',
+ entityNotFound: 'entity-not-found',
+ resourceNotFound: 'resource-not-found',
+ invalidRequest: 'invalid-request',
+ invalidParameter: 'invalid-parameter',
+ managed: 'x-medkit-managed-script',
+ running: 'x-medkit-script-running',
+ notRunning: 'x-medkit-script-not-running',
+ alreadyExists: 'x-medkit-script-already-exists',
+ tooLarge: 'x-medkit-script-too-large',
+ concurrencyLimit: 'x-medkit-concurrency-limit',
+} as const;
+
+/**
+ * Error carrying the gateway status and wire error code. The `throw new
+ * Error(message)` pattern used elsewhere in the store drops both, and the
+ * scripts UI needs them to tell 409-managed from 409-running.
+ */
+export class ScriptsApiError extends Error {
+ readonly status: number;
+ readonly errorCode: string;
+
+ constructor(message: string, status: number, errorCode: string) {
+ super(message);
+ this.name = 'ScriptsApiError';
+ this.status = status;
+ this.errorCode = errorCode;
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null;
+}
+
+/** Extract GenericError.error_code from an openapi-fetch error body of unknown shape. */
+export function scriptErrorCode(error: unknown): string {
+ return isRecord(error) && typeof error.error_code === 'string' ? error.error_code : '';
+}
+
+/**
+ * Build a ScriptsApiError from an openapi-fetch error body and the response
+ * status. GenericError has no `status` field, so the status always comes from
+ * the response.
+ */
+export function toScriptsApiError(error: unknown, status: number): ScriptsApiError {
+ if (isRecord(error)) {
+ const message = typeof error.message === 'string' && error.message ? error.message : `HTTP ${status}`;
+ return new ScriptsApiError(message, status, scriptErrorCode(error));
+ }
+ return new ScriptsApiError(`HTTP ${status}`, status, '');
+}
+
+/** Message for the user: gateway text when present, otherwise the caller's fallback. */
+export function scriptErrorMessage(err: unknown, fallback: string): string {
+ return err instanceof ScriptsApiError && err.message ? err.message : fallback;
+}
+
+/**
+ * Validate an openapi-fetch success payload as a usable `ScriptExecution`
+ * before it enters the store. `data as ScriptExecution` is a cast, not a
+ * check: openapi-fetch yields `undefined` data for an empty body on a 2xx
+ * response (legitimate for a 202), and an unchecked cast would let that - or
+ * any other malformed payload - become a record whose `execution` is
+ * unusable. The very next read of `record.execution.status` (the next
+ * polling tick, or the card's render) would then throw on something that is
+ * not actually broken, just not yet reflected correctly.
+ */
+export function toScriptExecution(data: unknown): ScriptExecution {
+ if (isRecord(data) && typeof data.id === 'string' && typeof data.status === 'string') {
+ return data as ScriptExecution;
+ }
+ throw new ScriptsApiError('The gateway returned an unusable response for this execution', 0, '');
+}
+
+/**
+ * True for a plain JSON object: excludes arrays, `null`, and primitives.
+ * `JSON.parse` accepts all of those too, so a bare cast to
+ * `Record` would let `[1,2]`, `"hello"`, `42` and `null`
+ * all pass through as script parameters and earn a gateway 400 instead of
+ * the inline error the parameter field already knows how to show.
+ */
+export function isPlainJsonObject(value: unknown): value is Record {
+ return isRecord(value) && !Array.isArray(value);
+}
+
+/**
+ * Statuses worth polling. Deliberately a white list: `status` is a free-form
+ * string on the wire (plugin backends return their own values) and a black list
+ * of terminal statuses would poll an unknown status forever.
+ */
+export const ACTIVE_SCRIPT_STATUSES = ['prepared', 'running'] as const;
+
+export function isActiveScriptStatus(status: string): boolean {
+ return (ACTIVE_SCRIPT_STATUSES as readonly string[]).includes(status);
+}
+
+export function scriptEntityKey(entityType: ScriptEntityType, entityId: string): string {
+ return `${entityType}/${entityId}`;
+}
+
+export type ScriptOutput = { kind: 'stdout'; text: string } | { kind: 'json'; value: unknown };
+
+/**
+ * Characters of stdout kept from the start and the end of an oversized dump.
+ * The content is entirely gateway-controlled and rendered into a single
+ * `` text node whose CSS `max-height` only limits the scrollable box,
+ * not the size of the DOM node itself - a script that prints a few megabytes
+ * would otherwise sit there in full, and up to MAX_EXECUTION_HISTORY of
+ * them can be held per entity at once.
+ */
+export const SCRIPT_OUTPUT_HEAD_CHARS = 4000;
+export const SCRIPT_OUTPUT_TAIL_CHARS = 2000;
+const SCRIPT_OUTPUT_MAX_CHARS = SCRIPT_OUTPUT_HEAD_CHARS + SCRIPT_OUTPUT_TAIL_CHARS;
+
+/** Keeps a head and a tail of `text`, with an explicit marker showing what was cut. */
+function truncateOutput(text: string): string {
+ if (text.length <= SCRIPT_OUTPUT_MAX_CHARS) return text;
+ const head = text.slice(0, SCRIPT_OUTPUT_HEAD_CHARS);
+ const tail = text.slice(text.length - SCRIPT_OUTPUT_TAIL_CHARS);
+ const omitted = text.length - SCRIPT_OUTPUT_HEAD_CHARS - SCRIPT_OUTPUT_TAIL_CHARS;
+ return `${head}\n\n... [truncated ${omitted} characters] ...\n\n${tail}`;
+}
+
+/**
+ * Narrow `ScriptExecution.parameters` (typed `unknown | null`, because the spec
+ * declares it as free-form). The gateway parses stdout as JSON and falls back to
+ * `{stdout: "..."}` when it is not JSON, so the stdout-only shape gets rendered
+ * as text and everything else as JSON.
+ */
+export function scriptOutput(execution: ScriptExecution): ScriptOutput | null {
+ const value = execution.parameters;
+ if (value === null || value === undefined) return null;
+ if (isRecord(value)) {
+ const keys = Object.keys(value);
+ if (keys.length === 0) return null;
+ if (keys.length === 1 && keys[0] === 'stdout' && typeof value.stdout === 'string') {
+ return { kind: 'stdout', text: truncateOutput(value.stdout) };
+ }
+ }
+ return { kind: 'json', value };
+}
+
+export interface ScriptFailure {
+ message: string;
+ /** Present only for a non-zero exit; stop, force kill and timeout carry no exit code. */
+ exitCode: number | null;
+}
+
+/** Narrow `ScriptExecution.error` (typed `unknown | null`). */
+export function scriptFailure(execution: ScriptExecution): ScriptFailure | null {
+ const value = execution.error;
+ if (!isRecord(value)) return null;
+ const message = typeof value.message === 'string' ? value.message : '';
+ if (!message) return null;
+ return { message, exitCode: typeof value.exit_code === 'number' ? value.exit_code : null };
+}
+
+/** Upper bound on tracked executions per entity; a record can hold a full stdout dump. */
+export const MAX_EXECUTION_HISTORY = 20;
+
+type HistoryMap = Map;
+
+/**
+ * Caps the *inactive* records at MAX_EXECUTION_HISTORY, dropping the oldest
+ * ones first - not the list as a whole, which can end up longer than that
+ * when active executions push it over the cap. Dropping a running execution
+ * would orphan the process: the gateway has no endpoint to list executions,
+ * so its id could never be recovered. Every currently active record is kept
+ * regardless of how many there are.
+ */
+function trimHistory(records: ScriptExecutionRecord[]): ScriptExecutionRecord[] {
+ if (records.length <= MAX_EXECUTION_HISTORY) return records;
+ const active = records.filter((r) => isActiveScriptStatus(r.execution.status));
+ const inactive = records.filter((r) => !isActiveScriptStatus(r.execution.status));
+ const keepInactive = inactive.slice(0, Math.max(0, MAX_EXECUTION_HISTORY - active.length));
+ return records.filter((r) => active.includes(r) || keepInactive.includes(r));
+}
+
+/**
+ * Insert or replace a record. Always returns a new Map and a new array for the
+ * touched key so zustand's reference comparison re-renders subscribers.
+ */
+export function upsertExecutionRecord(map: HistoryMap, key: string, record: ScriptExecutionRecord): HistoryMap {
+ const current = map.get(key) ?? [];
+ const index = current.findIndex((r) => r.execution.id === record.execution.id);
+ const next = index >= 0 ? current.map((r, i) => (i === index ? record : r)) : [record, ...current];
+ const result = new Map(map);
+ result.set(key, trimHistory(next));
+ return result;
+}
+
+export function markExecutionLost(map: HistoryMap, key: string, executionId: string): HistoryMap {
+ const current = map.get(key);
+ if (!current || !current.some((r) => r.execution.id === executionId)) return map;
+ const result = new Map(map);
+ result.set(
+ key,
+ current.map((r) => (r.execution.id === executionId ? { ...r, lost: true } : r))
+ );
+ return result;
+}
+
+export function removeExecutionRecord(map: HistoryMap, key: string, executionId: string): HistoryMap {
+ const current = map.get(key);
+ if (!current) return map;
+ const next = current.filter((r) => r.execution.id !== executionId);
+ const result = new Map(map);
+ if (next.length === 0) result.delete(key);
+ else result.set(key, next);
+ return result;
+}
diff --git a/src/lib/store-connect.test.ts b/src/lib/store-connect.test.ts
new file mode 100644
index 0000000..d05bb91
--- /dev/null
+++ b/src/lib/store-connect.test.ts
@@ -0,0 +1,100 @@
+// Copyright 2026 bburda
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import type { MedkitClient } from '@selfpatch/ros2-medkit-client-ts';
+
+// -----------------------------------------------------------------------------
+// connect() must not let the scripts-capability probe (GET /) stall entity
+// loading: a gateway that answers /health and then hangs on GET / would
+// otherwise leave the UI showing "connected" over an empty tree for the
+// full 5s timeout, on every connect. createMedkitClient is mocked so the
+// test can hold GET / pending indefinitely while asserting that loading the
+// root entities does not wait for it.
+// -----------------------------------------------------------------------------
+
+vi.mock('@selfpatch/ros2-medkit-client-ts', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ createMedkitClient: vi.fn(),
+ };
+});
+
+import { useAppStore } from './store';
+import { createMedkitClient } from '@selfpatch/ros2-medkit-client-ts';
+
+describe('connect', () => {
+ const originalLoadRootEntities = useAppStore.getState().loadRootEntities;
+ const originalSubscribeFaultStream = useAppStore.getState().subscribeFaultStream;
+
+ beforeEach(() => {
+ useAppStore.setState({ client: null, isConnected: false, scriptsSupported: false });
+ });
+
+ afterEach(() => {
+ useAppStore.getState().stopScriptPolling();
+ useAppStore.setState({
+ client: null,
+ isConnected: false,
+ scriptsSupported: false,
+ loadRootEntities: originalLoadRootEntities,
+ subscribeFaultStream: originalSubscribeFaultStream,
+ });
+ vi.restoreAllMocks();
+ });
+
+ it('loads root entities without waiting for the capability probe to resolve', async () => {
+ let resolveCaps: (() => void) | undefined;
+ const capsPromise = new Promise((resolve) => {
+ resolveCaps = () => resolve({ data: { capabilities: { scripts: true } } });
+ });
+
+ const mockGet = vi.fn((path: string) => {
+ if (path === '/health') return Promise.resolve({ error: undefined });
+ if (path === '/') return capsPromise; // Never resolves until resolveCaps() is called.
+ return Promise.resolve({ data: undefined });
+ });
+
+ vi.mocked(createMedkitClient).mockReturnValue({ GET: mockGet } as unknown as MedkitClient);
+
+ const loadRootEntities = vi.fn().mockResolvedValue(undefined);
+ const subscribeFaultStream = vi.fn();
+ useAppStore.setState({ loadRootEntities, subscribeFaultStream });
+
+ // Race connect() against a short real-timer delay instead of relying
+ // on vitest's global test timeout: if connect() awaited the capability
+ // probe first (the bug), it would still be pending when the timer
+ // fires, since resolveCaps() is deliberately never called before this
+ // point.
+ const raceResult = await Promise.race([
+ useAppStore
+ .getState()
+ .connect('http://gateway.local')
+ .then((result) => ({ outcome: 'connected' as const, result })),
+ new Promise((resolve) => setTimeout(() => resolve({ outcome: 'timeout' as const }), 100)),
+ ]);
+
+ expect(raceResult).toEqual({ outcome: 'connected', result: true });
+ expect(loadRootEntities).toHaveBeenCalledTimes(1);
+ expect(subscribeFaultStream).toHaveBeenCalledTimes(1);
+
+ // The probe itself must still land once it resolves - the client-
+ // identity guard makes this late result safe, not meaningless.
+ resolveCaps?.();
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(useAppStore.getState().scriptsSupported).toBe(true);
+ });
+});
diff --git a/src/lib/store-scripts.test.ts b/src/lib/store-scripts.test.ts
new file mode 100644
index 0000000..940c946
--- /dev/null
+++ b/src/lib/store-scripts.test.ts
@@ -0,0 +1,222 @@
+// Copyright 2026 bburda
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { useAppStore } from './store';
+import { SCRIPT_ERROR_CODE, ScriptsApiError } from './scripts';
+import type { MedkitClient } from '@selfpatch/ros2-medkit-client-ts';
+import type { ScriptExecutionRecord, ScriptMetadata } from './types';
+
+// -----------------------------------------------------------------------------
+// These script store actions are not pure helpers, so they cannot be
+// exercised through store-helpers.test.ts. They are tested here against the
+// real store (setState/getState) rather than through a mock, since the bugs
+// covered - the `lost` flag never clearing, any 404 (not just
+// resource-not-found) setting it, an unchecked cast letting an unusable
+// payload into the store - live in the wiring between the fetch result and
+// the state update, not in a function that could be pulled out in isolation.
+// -----------------------------------------------------------------------------
+
+function makeRecord(overrides: Partial = {}): ScriptExecutionRecord {
+ return {
+ execution: { id: 'e1', status: 'completed' },
+ scriptId: 'diag',
+ scriptName: 'diag',
+ entityType: 'components',
+ entityId: 'ecu',
+ ...overrides,
+ };
+}
+
+function makeScript(overrides: Partial = {}): ScriptMetadata {
+ return { id: 'diag', name: 'Diagnostics', ...overrides };
+}
+
+/**
+ * `GET` always gets a harmless default response, even when the test is
+ * really exercising POST or PUT: a successful start/stop can make
+ * startScriptPolling create a real interval, and if that interval happens to
+ * fire before the test's own cleanup runs, it must not hit an undefined
+ * `client.GET`.
+ */
+function makeClient(method: 'GET' | 'POST' | 'PUT', response: unknown): MedkitClient {
+ const client: Record = {
+ GET: vi.fn().mockResolvedValue({ data: undefined, error: undefined, response: { status: 200 } }),
+ };
+ client[method] = vi.fn().mockResolvedValue(response);
+ return client as unknown as MedkitClient;
+}
+
+beforeEach(() => {
+ useAppStore.getState().stopScriptPolling();
+ useAppStore.setState({ client: null, scriptExecutions: new Map(), scriptPollingIntervalId: null });
+});
+
+afterEach(() => {
+ useAppStore.getState().stopScriptPolling();
+ useAppStore.setState({ client: null, scriptExecutions: new Map(), scriptPollingIntervalId: null });
+});
+
+describe('refreshScriptExecution', () => {
+ it('clears a stale lost flag once the gateway answers successfully', async () => {
+ const client = makeClient('GET', {
+ data: { id: 'e1', status: 'completed' },
+ error: undefined,
+ response: { status: 200 },
+ });
+ useAppStore.setState({
+ client,
+ scriptExecutions: new Map([['components/ecu', [makeRecord({ lost: true })]]]),
+ });
+
+ await useAppStore.getState().refreshScriptExecution('components', 'ecu', 'diag', 'e1');
+
+ const updated = useAppStore.getState().scriptExecutions.get('components/ecu')?.[0];
+ expect(updated?.lost).toBe(false);
+ });
+
+ it('leaves an untracked record alone on entity-not-found, since the entity may reappear, but still surfaces the failure', async () => {
+ const client = makeClient('GET', {
+ data: undefined,
+ error: { message: 'Entity not found', error_code: SCRIPT_ERROR_CODE.entityNotFound },
+ response: { status: 404 },
+ });
+ const original = makeRecord();
+ useAppStore.setState({ client, scriptExecutions: new Map([['components/ecu', [original]]]) });
+
+ // Not the "gone" case (resource-not-found) - this must reach the
+ // caller so the card's "Failed to refresh" toast can fire, instead
+ // of being swallowed the way it used to be.
+ await expect(useAppStore.getState().refreshScriptExecution('components', 'ecu', 'diag', 'e1')).rejects.toThrow(
+ ScriptsApiError
+ );
+
+ const record = useAppStore.getState().scriptExecutions.get('components/ecu')?.[0];
+ expect(record?.lost).toBeUndefined();
+ });
+
+ it('marks a record lost when the 404 is resource-not-found, without throwing', async () => {
+ const client = makeClient('GET', {
+ data: undefined,
+ error: { message: 'gone', error_code: SCRIPT_ERROR_CODE.resourceNotFound },
+ response: { status: 404 },
+ });
+ const original = makeRecord();
+ useAppStore.setState({ client, scriptExecutions: new Map([['components/ecu', [original]]]) });
+
+ await useAppStore.getState().refreshScriptExecution('components', 'ecu', 'diag', 'e1');
+
+ const record = useAppStore.getState().scriptExecutions.get('components/ecu')?.[0];
+ expect(record?.lost).toBe(true);
+ });
+
+ it('rethrows a generic gateway error instead of swallowing it', async () => {
+ const client = makeClient('GET', {
+ data: undefined,
+ error: { message: 'Internal error', error_code: '' },
+ response: { status: 500 },
+ });
+ useAppStore.setState({ client, scriptExecutions: new Map([['components/ecu', [makeRecord()]]]) });
+
+ await expect(useAppStore.getState().refreshScriptExecution('components', 'ecu', 'diag', 'e1')).rejects.toThrow(
+ ScriptsApiError
+ );
+ });
+
+ it('throws instead of storing an unusable execution when the gateway returns no body', async () => {
+ // openapi-fetch yields undefined data for an empty body on a 2xx
+ // response - legitimate for a 202. `data as ScriptExecution` used to
+ // let that through unchecked.
+ const client = makeClient('GET', { data: undefined, error: undefined, response: { status: 202 } });
+ const original = makeRecord();
+ useAppStore.setState({ client, scriptExecutions: new Map([['components/ecu', [original]]]) });
+
+ await expect(useAppStore.getState().refreshScriptExecution('components', 'ecu', 'diag', 'e1')).rejects.toThrow(
+ ScriptsApiError
+ );
+
+ // The existing record must be untouched by the rejected payload.
+ expect(useAppStore.getState().scriptExecutions.get('components/ecu')?.[0]).toEqual(original);
+ });
+
+ it('throws when not connected', async () => {
+ useAppStore.setState({ client: null });
+ await expect(useAppStore.getState().refreshScriptExecution('components', 'ecu', 'diag', 'e1')).rejects.toThrow(
+ ScriptsApiError
+ );
+ });
+});
+
+describe('startScriptExecutionAction', () => {
+ it('throws instead of storing an unusable execution when the gateway returns no body', async () => {
+ const client = makeClient('POST', { data: undefined, error: undefined, response: { status: 202 } });
+ useAppStore.setState({ client });
+
+ await expect(
+ useAppStore
+ .getState()
+ .startScriptExecutionAction('components', 'ecu', makeScript(), { execution_type: 'now' })
+ ).rejects.toThrow(ScriptsApiError);
+
+ // The map must stay empty - not hold a record whose `execution` is
+ // undefined, which would throw the next time anything reads
+ // record.execution.status (collectActiveExecutions, on the very
+ // next polling tick, in particular).
+ expect(useAppStore.getState().scriptExecutions.get('components/ecu')).toBeUndefined();
+ expect(() => useAppStore.getState().startScriptPolling()).not.toThrow();
+ });
+
+ it('stores the execution when the gateway returns a usable payload', async () => {
+ const client = makeClient('POST', {
+ data: { id: 'e1', status: 'running' },
+ error: undefined,
+ response: { status: 200 },
+ });
+ useAppStore.setState({ client });
+
+ await useAppStore.getState().startScriptExecutionAction('components', 'ecu', makeScript(), {
+ execution_type: 'now',
+ });
+
+ expect(useAppStore.getState().scriptExecutions.get('components/ecu')?.[0]?.execution.status).toBe('running');
+ });
+});
+
+describe('stopScriptExecution', () => {
+ it('throws instead of corrupting the record when the gateway returns no body', async () => {
+ const client = makeClient('PUT', { data: undefined, error: undefined, response: { status: 202 } });
+ const original = makeRecord();
+ useAppStore.setState({ client, scriptExecutions: new Map([['components/ecu', [original]]]) });
+
+ await expect(
+ useAppStore.getState().stopScriptExecution('components', 'ecu', 'diag', 'e1', 'stop')
+ ).rejects.toThrow(ScriptsApiError);
+
+ expect(useAppStore.getState().scriptExecutions.get('components/ecu')?.[0]).toEqual(original);
+ });
+
+ it('updates the record when the gateway returns a usable payload', async () => {
+ const client = makeClient('PUT', {
+ data: { id: 'e1', status: 'terminated' },
+ error: undefined,
+ response: { status: 200 },
+ });
+ const original = makeRecord();
+ useAppStore.setState({ client, scriptExecutions: new Map([['components/ecu', [original]]]) });
+
+ await useAppStore.getState().stopScriptExecution('components', 'ecu', 'diag', 'e1', 'stop');
+
+ expect(useAppStore.getState().scriptExecutions.get('components/ecu')?.[0]?.execution.status).toBe('terminated');
+ });
+});
diff --git a/src/lib/store.ts b/src/lib/store.ts
index c5d20f9..c6bbc5e 100644
--- a/src/lib/store.ts
+++ b/src/lib/store.ts
@@ -17,6 +17,13 @@ import type {
VersionInfo,
SovdFunction,
EntityStatusValue,
+ ScriptEntityType,
+ ScriptExecutionRecord,
+ ScriptMetadata,
+ ScriptsFetchResult,
+ ScriptUploadMetadata,
+ ScriptUploadResponse,
+ StartScriptExecutionRequest,
} from './types';
import { createMedkitClient, normalizeBaseUrl, type MedkitClient } from '@selfpatch/ros2-medkit-client-ts';
import type { SovdResourceEntityType } from './types';
@@ -50,8 +57,27 @@ import {
putEntityLogsConfiguration,
getStatus,
type LifecycleEntityType,
+ getEntityScripts,
+ uploadEntityScript,
+ deleteEntityScript,
+ startScriptExecution,
+ getScriptExecution,
+ controlScriptExecution,
+ deleteScriptExecution,
} from './api-dispatch';
import type { LogCollection, LogsConfiguration, LogsFetchResult, LogsQueryParams } from './log-types';
+import { collectActiveExecutions, pollScriptExecutionsOnce } from './scripts-polling';
+import {
+ ScriptsApiError,
+ toScriptsApiError,
+ scriptErrorCode,
+ scriptEntityKey,
+ upsertExecutionRecord,
+ markExecutionLost,
+ removeExecutionRecord,
+ toScriptExecution,
+ SCRIPT_ERROR_CODE,
+} from './scripts';
const STORAGE_KEY = 'ros2_medkit_web_ui_server_url';
const EXECUTION_POLL_INTERVAL_MS = 1000;
@@ -61,6 +87,7 @@ const EXECUTION_POLL_INTERVAL_MS = 1000;
// after a transition, which is why it is well under the time a node takes to
// come back up rather than a round number.
const STATUS_POLL_INTERVAL_MS = 5000;
+const SCRIPT_POLL_INTERVAL_MS = 1000;
const EXECUTION_CLEANUP_AFTER_MS = 5 * 60 * 1000; // 5 minutes
export type TreeViewMode = 'logical' | 'functional';
@@ -112,6 +139,13 @@ export interface AppState {
autoRefreshExecutions: boolean; // flag for auto-refresh polling
executionPollingIntervalId: ReturnType | null; // polling interval ID
+ // Scripts state (diagnostic scripts uploaded per app/component). In-memory
+ // only: the gateway has no endpoint listing executions, so history does
+ // not survive a reload.
+ scriptsSupported: boolean; // gateway capability from GET /
+ scriptExecutions: Map; // scriptEntityKey(entityType, entityId) -> history
+ scriptPollingIntervalId: ReturnType | null; // polling interval ID
+
// Faults state (diagnostic trouble codes)
faults: Fault[];
isLoadingFaults: boolean;
@@ -197,6 +231,46 @@ export interface AppState {
// Records whether an entity supports lifecycle actuation (see actuationByEntity).
setEntityActuation: (entityType: LifecycleEntityType, entityId: string, supported: boolean) => void;
+ // Scripts actions
+ fetchEntityScripts: (
+ entityType: ScriptEntityType,
+ entityId: string,
+ signal?: AbortSignal
+ ) => Promise;
+ uploadScript: (
+ entityType: ScriptEntityType,
+ entityId: string,
+ file: File,
+ metadata?: ScriptUploadMetadata
+ ) => Promise;
+ deleteScript: (entityType: ScriptEntityType, entityId: string, scriptId: string) => Promise;
+ startScriptExecutionAction: (
+ entityType: ScriptEntityType,
+ entityId: string,
+ script: ScriptMetadata,
+ request: StartScriptExecutionRequest
+ ) => Promise;
+ stopScriptExecution: (
+ entityType: ScriptEntityType,
+ entityId: string,
+ scriptId: string,
+ executionId: string,
+ action: string
+ ) => Promise;
+ removeScriptExecution: (
+ entityType: ScriptEntityType,
+ entityId: string,
+ scriptId: string,
+ executionId: string
+ ) => Promise;
+ refreshScriptExecution: (
+ entityType: ScriptEntityType,
+ entityId: string,
+ scriptId: string,
+ executionId: string
+ ) => Promise;
+ startScriptPolling: () => void;
+ stopScriptPolling: () => void;
// Faults actions
fetchFaults: () => Promise;
@@ -939,6 +1013,11 @@ export const useAppStore = create()(
autoRefreshExecutions: true,
executionPollingIntervalId: null,
+ // Scripts state
+ scriptsSupported: false,
+ scriptExecutions: new Map(),
+ scriptPollingIntervalId: null,
+
// Faults state
faults: [],
isLoadingFaults: false,
@@ -994,6 +1073,35 @@ export const useAppStore = create()(
client,
});
+ // A reconnect must not carry executions of the previous gateway:
+ // ids would 404 and show up as ghost records under matching entity ids.
+ get().stopScriptPolling();
+ set({ scriptExecutions: new Map(), scriptsSupported: false });
+
+ // Scripts tab visibility follows the gateway capability. Not
+ // awaited: a gateway that answers /health and then hangs on
+ // GET / must not stall entity loading behind this probe -
+ // that is exactly the "connected over an empty tree" state
+ // this is meant to avoid. It still carries its own 5s
+ // timeout, and the client-identity guard below makes a late
+ // (or stale, from a disconnect/reconnect mid-flight) result
+ // safe to ignore.
+ const capsController = new AbortController();
+ const capsTimeout = setTimeout(() => capsController.abort(), 5000);
+ void client
+ .GET('/', { signal: capsController.signal })
+ .then(({ data: root }) => {
+ if (get().client === client) {
+ set({ scriptsSupported: root?.capabilities.scripts === true });
+ }
+ })
+ .catch(() => {
+ if (get().client === client) {
+ set({ scriptsSupported: false });
+ }
+ })
+ .finally(() => clearTimeout(capsTimeout));
+
// Load root entities after successful connection
await get().loadRootEntities();
@@ -1026,6 +1134,8 @@ export const useAppStore = create()(
// an in-flight request must not be handed to the next session.
get().stopStatusPolling();
__resetStatusRequestCache();
+ // Stop script execution polling
+ get().stopScriptPolling();
// Unsubscribe from fault stream
get().unsubscribeFaultStream();
@@ -1044,6 +1154,8 @@ export const useAppStore = create()(
activeExecutions: new Map(),
actuationByEntity: {},
statusByEntity: {},
+ scriptsSupported: false,
+ scriptExecutions: new Map(),
});
},
@@ -2088,6 +2200,253 @@ export const useAppStore = create()(
set((s) => ({ actuationByEntity: { ...s.actuationByEntity, [key]: supported } }));
},
+ // ===========================================================================
+ // SCRIPTS ACTIONS (diagnostic scripts uploaded per app/component)
+ // ===========================================================================
+
+ fetchEntityScripts: async (entityType: ScriptEntityType, entityId: string, signal?: AbortSignal) => {
+ const { client } = get();
+ if (!client) return { items: [], errorStatus: -1 };
+ try {
+ const { data, error, response } = await getEntityScripts(client, entityType, entityId, signal);
+ if (error) {
+ return { items: [], errorStatus: response?.status ?? -1 };
+ }
+ return { items: data ? unwrapItems(data) : [] };
+ } catch (err) {
+ if ((err as { name?: string }).name === 'AbortError') throw err;
+ console.error('[store] fetchEntityScripts failed', err);
+ return { items: [], errorStatus: -1 };
+ }
+ },
+
+ uploadScript: async (
+ entityType: ScriptEntityType,
+ entityId: string,
+ file: File,
+ metadata?: ScriptUploadMetadata
+ ) => {
+ const { client } = get();
+ if (!client) throw new ScriptsApiError('Not connected', 0, '');
+
+ const form = new FormData();
+ form.append('file', file, file.name);
+ if (metadata?.name || metadata?.description) {
+ form.append('metadata', JSON.stringify(metadata));
+ }
+
+ const { data, error, response } = await uploadEntityScript(client, entityType, entityId, form);
+ if (error) throw toScriptsApiError(error, response?.status ?? 0);
+ return data as ScriptUploadResponse;
+ },
+
+ deleteScript: async (entityType: ScriptEntityType, entityId: string, scriptId: string) => {
+ const { client } = get();
+ if (!client) throw new ScriptsApiError('Not connected', 0, '');
+
+ const { error, response } = await deleteEntityScript(client, entityType, entityId, scriptId);
+ if (error) throw toScriptsApiError(error, response?.status ?? 0);
+ },
+
+ startScriptExecutionAction: async (
+ entityType: ScriptEntityType,
+ entityId: string,
+ script: ScriptMetadata,
+ request: StartScriptExecutionRequest
+ ) => {
+ const { client } = get();
+ if (!client) throw new ScriptsApiError('Not connected', 0, '');
+
+ const { data, error, response } = await startScriptExecution(
+ client,
+ entityType,
+ entityId,
+ script.id,
+ request
+ );
+ if (error) throw toScriptsApiError(error, response?.status ?? 0);
+ const execution = toScriptExecution(data);
+
+ const key = scriptEntityKey(entityType, entityId);
+ const record: ScriptExecutionRecord = {
+ execution,
+ scriptId: script.id,
+ scriptName: script.name,
+ entityType,
+ entityId,
+ };
+ set({ scriptExecutions: upsertExecutionRecord(get().scriptExecutions, key, record) });
+ get().startScriptPolling();
+ },
+
+ stopScriptExecution: async (
+ entityType: ScriptEntityType,
+ entityId: string,
+ scriptId: string,
+ executionId: string,
+ action: string
+ ) => {
+ const { client } = get();
+ if (!client) throw new ScriptsApiError('Not connected', 0, '');
+
+ const { data, error, response } = await controlScriptExecution(
+ client,
+ entityType,
+ entityId,
+ scriptId,
+ executionId,
+ action
+ );
+ if (error) throw toScriptsApiError(error, response?.status ?? 0);
+ const execution = toScriptExecution(data);
+
+ const key = scriptEntityKey(entityType, entityId);
+ const existing = get()
+ .scriptExecutions.get(key)
+ ?.find((r) => r.execution.id === executionId);
+ if (existing) {
+ const record: ScriptExecutionRecord = { ...existing, execution };
+ set({ scriptExecutions: upsertExecutionRecord(get().scriptExecutions, key, record) });
+ get().startScriptPolling();
+ }
+ },
+
+ removeScriptExecution: async (
+ entityType: ScriptEntityType,
+ entityId: string,
+ scriptId: string,
+ executionId: string
+ ) => {
+ const { client } = get();
+ if (!client) throw new ScriptsApiError('Not connected', 0, '');
+
+ const { error, response } = await deleteScriptExecution(
+ client,
+ entityType,
+ entityId,
+ scriptId,
+ executionId
+ );
+ // A 404 means the gateway already forgot this execution - treat as success.
+ if (error && response?.status !== 404) throw toScriptsApiError(error, response?.status ?? 0);
+
+ const key = scriptEntityKey(entityType, entityId);
+ set({ scriptExecutions: removeExecutionRecord(get().scriptExecutions, key, executionId) });
+ },
+
+ refreshScriptExecution: async (
+ entityType: ScriptEntityType,
+ entityId: string,
+ scriptId: string,
+ executionId: string
+ ) => {
+ const { client } = get();
+ if (!client) throw new ScriptsApiError('Not connected', 0, '');
+
+ const key = scriptEntityKey(entityType, entityId);
+ const { data, error, response } = await getScriptExecution(
+ client,
+ entityType,
+ entityId,
+ scriptId,
+ executionId
+ );
+ if (error) {
+ // Only resource-not-found means the execution itself is gone -
+ // mark it lost and treat that as the answer Refresh was
+ // looking for, not a failure to surface.
+ if (scriptErrorCode(error) === SCRIPT_ERROR_CODE.resourceNotFound) {
+ set({ scriptExecutions: markExecutionLost(get().scriptExecutions, key, executionId) });
+ return;
+ }
+ // Anything else - entity-not-found (the entity is momentarily
+ // absent, e.g. a node restarting under runtime discovery, and
+ // must not evict a record Refresh is meant to rescue), a 500,
+ // a network error - must reach the caller so the card's
+ // "Failed to refresh" toast can fire.
+ throw toScriptsApiError(error, response?.status ?? 0);
+ }
+ const execution = toScriptExecution(data);
+
+ const existing = get()
+ .scriptExecutions.get(key)
+ ?.find((r) => r.execution.id === executionId);
+ if (existing) {
+ // Clear `lost`: a successful refresh means the gateway answered,
+ // so this is exactly the rescue path a lost record needs.
+ const record: ScriptExecutionRecord = { ...existing, execution, lost: false };
+ set({ scriptExecutions: upsertExecutionRecord(get().scriptExecutions, key, record) });
+ get().startScriptPolling();
+ }
+ },
+
+ startScriptPolling: () => {
+ const { scriptPollingIntervalId, client } = get();
+ if (scriptPollingIntervalId || !client) return;
+ if (collectActiveExecutions(get().scriptExecutions).length === 0) return;
+
+ let inFlight = false;
+ const intervalId = setInterval(async () => {
+ const currentClient = get().client;
+ if (!currentClient) {
+ get().stopScriptPolling();
+ return;
+ }
+ // Skip the tick while the tab is hidden, but keep the interval
+ // running so returning to the tab resumes polling by itself.
+ if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
+ // Never let two cycles overlap: on a slow link the responses would
+ // arrive out of order and an old "running" could overwrite a fresh
+ // terminal status, freezing the card forever.
+ if (inFlight) return;
+
+ inFlight = true;
+ try {
+ const outcomes = await pollScriptExecutionsOnce(currentClient, get().scriptExecutions);
+ if (outcomes === null) {
+ get().stopScriptPolling();
+ return;
+ }
+ // The client may have changed while the cycle was in flight
+ // (disconnect or reconnect); dropping the result keeps the
+ // fresh session clean.
+ if (get().client !== currentClient) return;
+
+ // Fold onto state read AFTER the await, not the snapshot the cycle
+ // started with: a sibling action (start/stop/remove) may have
+ // written to scriptExecutions while the requests were in flight,
+ // and that write must not be discarded.
+ let next = get().scriptExecutions;
+ for (const outcome of outcomes) {
+ const key = scriptEntityKey(outcome.record.entityType, outcome.record.entityId);
+ const stillTracked = next
+ .get(key)
+ ?.some((r) => r.execution.id === outcome.record.execution.id);
+ // If the user removed the record mid-cycle, the tick must not resurrect it.
+ if (!stillTracked) continue;
+ next =
+ 'lost' in outcome
+ ? markExecutionLost(next, key, outcome.record.execution.id)
+ : upsertExecutionRecord(next, key, {
+ ...outcome.record,
+ execution: outcome.execution,
+ });
+ }
+ if (next !== get().scriptExecutions) set({ scriptExecutions: next });
+ } finally {
+ inFlight = false;
+ }
+ }, SCRIPT_POLL_INTERVAL_MS);
+
+ set({ scriptPollingIntervalId: intervalId });
+ },
+
+ stopScriptPolling: () => {
+ const { scriptPollingIntervalId } = get();
+ if (scriptPollingIntervalId) clearInterval(scriptPollingIntervalId);
+ set({ scriptPollingIntervalId: null });
+ },
+
// ===========================================================================
// FAULTS ACTIONS (Diagnostic Trouble Codes)
// ===========================================================================
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 7d75b98..2115cbc 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -1014,3 +1014,50 @@ export interface UpdateEntry {
id: string;
status: UpdateStatus | null; // null = status fetch failed
}
+
+// =============================================================================
+// Scripts
+// =============================================================================
+
+export type ScriptMetadata = components['schemas']['ScriptMetadata'];
+export type ScriptExecution = components['schemas']['ScriptExecution'];
+/** Wire wrapper of the list endpoint: {items, _links}. */
+export type ScriptList = components['schemas']['ScriptList'];
+export type ScriptUploadResponse = components['schemas']['ScriptUploadResponse'];
+
+/**
+ * Entity types exposing the scripts collection.
+ * The gateway registers script routes for apps and components only.
+ */
+export type ScriptEntityType = Extract;
+
+/**
+ * Execution record tracked client-side. The gateway has no endpoint listing
+ * executions, so the UI remembers the ids it started. In-memory only.
+ */
+export interface ScriptExecutionRecord {
+ execution: ScriptExecution;
+ scriptId: string;
+ scriptName: string;
+ entityType: ScriptEntityType;
+ entityId: string;
+ /** Set when polling returned 404: the gateway no longer knows this execution. */
+ lost?: boolean;
+}
+
+export interface StartScriptExecutionRequest {
+ execution_type: string;
+ parameters?: Record;
+ proximity_response?: string;
+}
+
+/** errorStatus lets the panel tell 501 from an empty list. */
+export interface ScriptsFetchResult {
+ items: ScriptMetadata[];
+ errorStatus?: number;
+}
+
+export interface ScriptUploadMetadata {
+ name?: string;
+ description?: string;
+}
diff --git a/src/test/setup.ts b/src/test/setup.ts
index 5ce1669..8bfc92c 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -22,3 +22,52 @@ if (typeof globalThis.ResizeObserver === 'undefined') {
disconnect(): void {}
};
}
+
+/**
+ * Recent Node versions ship an experimental global `localStorage` backed by a
+ * file that vitest-environment-jsdom's globals never get a chance to
+ * override, since it already occupies the slot before the environment is
+ * installed. Left in place, it throws "Cannot read properties of undefined
+ * (reading 'setItem')" the moment anything (e.g. zustand's persist
+ * middleware) touches it - jsdom's own window.localStorage works fine, it is
+ * only the copy merged onto the global object that is broken. Replace it
+ * with a minimal in-memory Storage only when it is actually broken, so this
+ * is a no-op on Node versions where jsdom's localStorage already works.
+ */
+function storageWorks(): boolean {
+ try {
+ globalThis.localStorage.setItem('__storage_probe__', '1');
+ globalThis.localStorage.removeItem('__storage_probe__');
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+if (!storageWorks()) {
+ class MemoryStorage implements Storage {
+ private readonly store = new Map();
+ get length(): number {
+ return this.store.size;
+ }
+ clear(): void {
+ this.store.clear();
+ }
+ getItem(key: string): string | null {
+ return this.store.has(key) ? this.store.get(key)! : null;
+ }
+ key(index: number): string | null {
+ return Array.from(this.store.keys())[index] ?? null;
+ }
+ removeItem(key: string): void {
+ this.store.delete(key);
+ }
+ setItem(key: string, value: string): void {
+ this.store.set(key, String(value));
+ }
+ }
+
+ for (const prop of ['localStorage', 'sessionStorage'] as const) {
+ Object.defineProperty(globalThis, prop, { configurable: true, value: new MemoryStorage() });
+ }
+}