refactor(automation): trim the branch to reduce risk

Adoption is low, and the app must not be put at risk fixing that. A measured
look at the diff showed the risk was concentrated in exactly two places: the
timer's hot path and a new file-writing route. Everything that actually makes
automations discoverable — recipes, a panel that explains itself, one-step
creation, Test buttons that report — is client-only settings-panel code with
zero server churn. This cuts the two pieces that weren't, and simplifies two
more that only existed to serve them.

Removed entirely: templates. Newest, riskiest, and the least connected to the
adoption problem — it helps someone who already uses automations share them,
not someone who has never tried the feature. It was also the source of two of
the bugs found in review.

Removed: the websocket broadcast behind "last fired". reportFired ran inside
triggerAutomations, called from onClock every second and from onUpdate. A new
protocol message pushed to every connected client, including stage displays,
up to once a second per automation, is real new traffic on the busiest code
path in the app for a feature still trying to prove it's worth using. The log
line stays: it's additive, throttled, nowhere near the hot path once written,
and answers the same question — did this run — without a new message.

Trimmed: the demo goes from two automations tied together by necessity — a
danger-time message and a second automation whose only job was undoing the
first on finish — to one, attached by an event-level trigger instead of a
global one. Same two discovery wins, an automation visibly fires on Play and
per-event triggers are found by opening an event, with no message mutation
and nothing to keep in sync. That pairing was also the first thing review
found broken.

Trimmed: the delete dialog no longer deletes blocking triggers and restores
them if the automation still won't delete. That rollback was the other
multi-request destructive sequence review found a bug in. It now confirms,
names what's blocking, and says to remove global triggers from the Global
Triggers list first — a single always-safe request the user already has.

What stays: one-step creation (lifecycles picked on the automation form), the
recipe library, and the panel legibility work — none of it touches the server
or the runtime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpbLJVVT26tzWkduck1M9H
This commit is contained in:
Claude
2026-08-08 21:12:26 +00:00
parent cdde054b14
commit ed39f86a81
21 changed files with 40 additions and 725 deletions
@@ -1,6 +1,5 @@
import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
import { socket } from '../../../adapters/WebsocketAdapter.js';
import { logger } from '../../../classes/Logger.js';
import { makeRuntimeStoreData } from '../../../stores/__mocks__/runtimeStore.mocks.js';
import { RuntimeState } from '../../../stores/runtimeState.js';
@@ -653,12 +652,10 @@ describe('testConditions()', () => {
*/
describe('automation reporting', () => {
let logSpy = vi.spyOn(logger, 'info');
let socketSpy = vi.spyOn(socket, 'sendAsJson');
beforeEach(async () => {
vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => {});
logSpy = vi.spyOn(logger, 'info').mockImplementation(() => {});
socketSpy = vi.spyOn(socket, 'sendAsJson').mockImplementation(() => {});
await deleteAllTriggers();
resetAutomationLogState();
@@ -753,18 +750,4 @@ describe('automation reporting', () => {
triggerAutomations(TimerLifeCycle.onDanger);
expect(logSpy).toHaveBeenCalledTimes(2);
});
it('reports a fire to the clients at most once a second, including on continuous lifecycles', async () => {
vi.useFakeTimers();
await bind('reporting-clock', TimerLifeCycle.onClock);
socketSpy.mockClear();
triggerAutomations(TimerLifeCycle.onClock);
triggerAutomations(TimerLifeCycle.onClock);
expect(socketSpy).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1001);
triggerAutomations(TimerLifeCycle.onClock);
expect(socketSpy).toHaveBeenCalledTimes(2);
});
});
@@ -4,7 +4,6 @@ import {
type AutomationOutput,
type FilterRule,
LogOrigin,
MessageTag,
RuntimeStore,
TimerLifeCycle,
isHTTPOutput,
@@ -13,7 +12,6 @@ import {
} from 'ontime-types';
import { getPropertyFromPath } from 'ontime-utils';
import { socket } from '../../adapters/WebsocketAdapter.js';
import { logger } from '../../classes/Logger.js';
import { isOntimeCloud } from '../../setup/environment.js';
import { eventStore } from '../../stores/EventStore.js';
@@ -37,8 +35,6 @@ const reportThrottleMs = 1000;
const suppressionNotices = new Set<string>();
/** last time we logged a given automation + cycle pair */
const lastLoggedAt = new Map<string, number>();
/** last time we told the clients about a given automation */
const lastReportedAt = new Map<string, number>();
/**
* Clears the reporting state.
@@ -48,7 +44,6 @@ const lastReportedAt = new Map<string, number>();
export function resetAutomationLogState() {
suppressionNotices.clear();
lastLoggedAt.clear();
lastReportedAt.clear();
}
/**
@@ -115,19 +110,11 @@ function fireForCycle(cycle: TimerLifeCycle) {
}
/**
* Makes a successful automation visible, which it previously was not:
* the log answers what happened, the socket message answers whether an automation is alive
* Makes a successful automation fire visible in the log, which it previously was not
*/
function reportFired(automationId: string, automation: Automation, cycle: TimerLifeCycle) {
const now = Date.now();
// the panel shows a last fired time, so continuous lifecycles still report, but at most once a second
const lastReported = lastReportedAt.get(automationId);
if (lastReported === undefined || now - lastReported >= reportThrottleMs) {
lastReportedAt.set(automationId, now);
socket.sendAsJson(MessageTag.AutomationFired, { automationId, cycle });
}
if (continuousCycles.includes(cycle)) {
// one notice per load is enough to explain why the log goes quiet from here
if (!suppressionNotices.has(automationId)) {
@@ -231,30 +231,6 @@ export async function loadDemo(_req: Request, res: Response<MessageResponse | Er
}
}
/**
* Creates a template: a new project file containing only the selected sections of an existing one.
* The result is not loaded, so making a template does not disturb the running show.
*/
export async function partialDuplicateProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
const { filename } = req.params;
const { newFilename, sections } = req.body;
try {
// the created name can differ from what was asked for, generateUniqueFileName resolves collisions
const created = await projectService.createProjectFromSections(filename, newFilename, sections);
res.status(201).send({ filename: created });
} catch (error) {
const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
res.status(403).send({ message });
return;
}
res.status(500).send({ message });
}
}
/**
* Duplicates a project file.
* Receives the original project filename (`filename`) from the request parameters
-9
View File
@@ -8,7 +8,6 @@ import {
listProjects,
loadDemo,
loadProject,
partialDuplicateProjectFile,
patchPartialProjectFile,
postProjectFile,
projectDownload,
@@ -23,7 +22,6 @@ import {
validateNewProject,
validatePatchProject,
validateQuickProject,
validateSectionsBody,
} from './db.validation.js';
export const router: Router = express.Router();
@@ -41,12 +39,5 @@ router.get('/all', listProjects);
router.post('/load', validateFilenameBody, loadProject);
router.post('/demo', loadDemo);
router.post('/:filename/duplicate', validateFilenameParam, validateNewFilenameBody, duplicateProjectFile);
router.post(
'/:filename/partial-duplicate',
validateFilenameParam,
validateNewFilenameBody,
validateSectionsBody,
partialDuplicateProjectFile,
);
router.put('/:filename/rename', validateFilenameParam, validateNewFilenameBody, renameProjectFile);
router.delete('/:filename', validateFilenameParam, deleteProjectFile);
@@ -1,5 +1,4 @@
import { body, param } from 'express-validator';
import { isTemplateSection, templateSections } from 'ontime-types';
import sanitize from 'sanitize-filename';
import { ensureJsonExtension } from '../../utils/fileManagement.js';
@@ -68,21 +67,6 @@ export const validateNewFilenameBody = [
requestValidationFunction,
];
/**
* @description Validates a request to clone selected sections of a project into a template.
*/
export const validateSectionsBody = [
body('sections')
.isArray({ min: 1 })
.withMessage(`Select at least one of: ${templateSections.join(', ')}`)
.custom((sections: unknown[]) =>
sections.every((section) => typeof section === 'string' && isTemplateSection(section)),
)
.withMessage(`Sections must be any of: ${templateSections.join(', ')}`),
requestValidationFunction,
];
/**
* @description Validates request with filename in the body.
*/
+8 -36
View File
@@ -1,4 +1,4 @@
import { DatabaseModel, OntimeView, TimerLifeCycle } from 'ontime-types';
import { DatabaseModel, OntimeView } from 'ontime-types';
import { backstageRundown, broadcastRundown, stageRundown } from './demoRundowns.js';
@@ -77,12 +77,14 @@ export const demoDb: DatabaseModel = {
},
},
/**
* The demo ships with working automations so the engine is visible the first time
* The demo ships with a working automation so the engine is visible the first time
* someone presses Play, rather than hidden behind an empty settings panel.
*
* Everything that actually fires is an Ontime action: the demo must not put traffic
* on whatever network it happens to be opened on. The OSC entry is there to be read
* and edited, and is deliberately left without a trigger.
* It fires an Ontime action: the demo must not put traffic on whatever network it
* happens to be opened on. It is attached via an event-level trigger rather than a
* global one, so per-event triggers are also discoverable by browsing the rundown
* instead of reading docs. The OSC entry is there to be read and edited, and is
* deliberately left without a trigger of its own.
*
* The ids are hand written and must match the map keys. Ids are only generated for
* automations created through the DAO, so literals are safe here.
@@ -92,20 +94,7 @@ export const demoDb: DatabaseModel = {
// never open a listening socket without the user asking for it
enabledOscIn: false,
oscPortIn: 8888,
triggers: [
{
id: 'demo-trigger-aux',
title: 'Demo: aux timer on start',
trigger: TimerLifeCycle.onStart,
automationId: 'demo-aux-timer',
},
{
id: 'demo-trigger-clear',
title: 'Demo: clear the wrap up warning',
trigger: TimerLifeCycle.onFinish,
automationId: 'demo-clear-message',
},
],
triggers: [],
automations: {
'demo-aux-timer': {
id: 'demo-aux-timer',
@@ -117,23 +106,6 @@ export const demoDb: DatabaseModel = {
{ type: 'ontime', action: 'aux1-start' },
],
},
'demo-danger-message': {
id: 'demo-danger-message',
title: 'Demo: warn the stage at danger',
filterRule: 'all',
filters: [],
// self labelled, so nobody mistakes it for something Ontime does on its own
outputs: [{ type: 'ontime', action: 'message-set', text: 'Demo automation: please wrap up', visible: true }],
},
'demo-clear-message': {
id: 'demo-clear-message',
title: 'Demo: clear the wrap up warning',
filterRule: 'all',
filters: [],
// the pair to the warning above. Without it the message would stay on the stage
// timer for the rest of the session, blanking the countdown on every later event
outputs: [{ type: 'ontime', action: 'message-set', text: '', visible: false }],
},
'demo-osc-example': {
id: 'demo-osc-example',
title: 'Demo: OSC to a lighting console (example, not wired up)',
+3 -3
View File
@@ -125,9 +125,9 @@ export const stageRundown: Rundown = {
triggers: [
{
id: 'demo-event-trigger',
title: 'Wrap up warning',
trigger: TimerLifeCycle.onDanger,
automationId: 'demo-danger-message',
title: 'Aux timer with the event',
trigger: TimerLifeCycle.onStart,
automationId: 'demo-aux-timer',
},
],
},
@@ -1,7 +1,7 @@
import { copyFile, writeFile } from 'fs/promises';
import { copyFile } from 'fs/promises';
import { join } from 'path';
import { DatabaseModel, LogOrigin, ProjectFileListResponse, TemplateSection } from 'ontime-types';
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
@@ -9,7 +9,7 @@ import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
import { initRundown } from '../../api-data/rundown/rundown.service.js';
import { flushPendingWrites, getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
import { logger } from '../../classes/Logger.js';
import { makeNewProject } from '../../models/dataModel.js';
@@ -318,60 +318,6 @@ export async function createProjectWithPatch(fileName: string, initialData: Part
return createProject(fileName, sanitisedData);
}
/**
* Creates a new project file containing only the given sections of an existing one.
* This is how a user makes a template: a small project holding, say, only automations
* or only custom fields, which can then be shared and applied with a partial load.
*
* Unlike createProjectWithPatch, this does NOT load the result. Saving a template must
* not pull the operator out of the show they are running.
*
* @throws if the source does not exist or cannot be parsed
*/
export async function createProjectFromSections(
sourceFilename: string,
newFilename: string,
sections: TemplateSection[],
): Promise<string> {
const projectFilePath = doesProjectExist(sourceFilename);
if (projectFilePath === null) {
throw new Error('Project file not found');
}
if (sections.length === 0) {
throw new Error('At least one section must be selected');
}
// writes are debounced, and the natural flow here is "make some automations, then save them
// as a template". Without this the template silently misses anything from the last few seconds
await flushPendingWrites();
const fileData = await parseJsonFile(projectFilePath);
const { data } = parseDatabaseModel(fileData);
const patch: Partial<DatabaseModel> = {};
for (const section of sections) {
// a rundown without its custom fields is not usable on the other side
if (section === 'rundowns') {
patch.customFields = data.customFields;
}
Object.assign(patch, { [section]: data[section] });
}
const template = safeMerge(makeNewProject(), patch);
// makeNewProject seeds an empty rundown and safeMerge merges rundowns by key, so without
// this the template would ship a phantom "Default" rundown alongside the real ones
if (patch.rundowns !== undefined) {
template.rundowns = patch.rundowns;
}
const fileNameWithExtension = generateUniqueFileName(publicDir.projectsDir, ensureJsonExtension(newFilename));
await writeFile(getPathToProject(fileNameWithExtension), JSON.stringify(template, null, 2), 'utf-8');
return fileNameWithExtension;
}
/**
* Deletes a project file
*/
@@ -1,17 +1,8 @@
import { writeFile } from 'fs/promises';
import { OntimeView, TimerLifeCycle } from 'ontime-types';
import { Mock } from 'vitest';
import { makeNewProject } from '../../../models/dataModel.js';
import { isLastLoadedProject } from '../../app-state-service/AppStateService.js';
import {
createProjectFromSections,
deleteProjectFile,
duplicateProjectFile,
renameProjectFile,
} from '../ProjectService.js';
import { doesProjectExist, parseJsonFile } from '../projectServiceUtils.js';
import { deleteProjectFile, duplicateProjectFile, renameProjectFile } from '../ProjectService.js';
import { doesProjectExist } from '../projectServiceUtils.js';
// stop the database loading from initiating
vi.mock('../../../setup/loadDb.js', () => {
@@ -26,13 +17,7 @@ vi.mock('../../app-state-service/AppStateService.js', () => ({
vi.mock('../projectServiceUtils.js', () => ({
doesProjectExist: vi.fn(),
getPathToProject: vi.fn().mockImplementation((name: string) => `/projects/${name}`),
parseJsonFile: vi.fn(),
}));
vi.mock('fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof import('fs/promises')>()),
writeFile: vi.fn(),
getPathToProject: vi.fn(),
}));
/**
@@ -80,54 +65,3 @@ describe('renameProjectFile', () => {
);
});
});
describe('createProjectFromSections', () => {
it('throws an error if origin project does not exist', async () => {
(doesProjectExist as Mock).mockReturnValue(null);
await expect(createProjectFromSections('does not exist', 'template', ['automation'])).rejects.toThrow(
'Project file not found',
);
});
it('throws an error if nothing was selected', async () => {
(doesProjectExist as Mock).mockReturnValue('/projects/source.json');
await expect(createProjectFromSections('source.json', 'template', [])).rejects.toThrow(
'At least one section must be selected',
);
});
it('writes a project carrying only the selected sections', async () => {
(doesProjectExist as Mock).mockReturnValue('/projects/source.json');
(parseJsonFile as Mock).mockResolvedValue({
...makeNewProject(),
urlPresets: [{ target: OntimeView.Timer, enabled: true, alias: 'from-source', search: '', displayInNav: false }],
automation: {
enabledAutomations: true,
enabledOscIn: false,
oscPortIn: 8888,
triggers: [{ id: 't1', title: 'on start', trigger: TimerLifeCycle.onStart, automationId: 'a1' }],
automations: {
a1: {
id: 'a1',
title: 'from source',
filterRule: 'all',
filters: [],
outputs: [{ type: 'http', url: 'http://127.0.0.1/go' }],
},
},
},
});
await createProjectFromSections('source.json', 'template.json', ['automation']);
expect(writeFile).toHaveBeenCalledOnce();
const written = JSON.parse((writeFile as Mock).mock.calls[0][1] as string);
// the automations came across, triggers included
expect(written.automation.automations.a1.title).toBe('from source');
expect(written.automation.triggers).toHaveLength(1);
// and the url preset, which parses cleanly but was not selected, did not
expect(written.urlPresets).toEqual([]);
});
});