Compare commits

..

3 Commits

Author SHA1 Message Date
alex-arc 6d79764ffe fix: pause timer over midnight 2026-08-09 16:46:31 +02:00
Claude a67dbd8a59 test(timer): assert elapsed stays frozen while paused over midnight
Make the midnight pause test's intent explicit: elapsed is active time
since start and must not advance during a pause (even one crossing
midnight). Add a frozen-elapsed assertion while paused and keep
pausedDuration - the corrupted pause count - as the headline assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136N3FnyuUmLJbMNJiZd6YX
2026-08-09 16:46:31 +02:00
Claude 361b6eb875 test(timer): expose pause-over-midnight duration bug
Pause is tracked as pausedAt (TimeOfDay, ms since local midnight) and
paused duration is derived via the naive `clock - pausedAt`. When a pause
spans midnight the clock has wrapped to a small value while pausedAt is
still large, so the subtraction goes negative and every paused-duration
result is corrupted (runtimeState.start resume accumulation, and
getExpectedFinish/getCurrent/getRuntimeOffset in timerUtils).

Add two currently-failing tests that reproduce this:
- runtimeState: full start/pause/resume cycle where the pause crosses
  midnight, asserting pausedDuration and elapsed exclude the pause.
- timerUtils.getRuntimeOffset: over-midnight variant of the paused-offset
  case (the site carrying the "brakes when crossing midnight" TODO).

Both fail today (report ~ -86,100,000 instead of the real 5-minute pause)
and will pass once the pause math adopts the wrap-aware primitives
(timeCore.elapsedTime / epoch-based tracking).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136N3FnyuUmLJbMNJiZd6YX
2026-08-09 16:46:31 +02:00
19 changed files with 812 additions and 1199 deletions
@@ -200,7 +200,8 @@ $card-padding: 2rem;
.overlay {
position: absolute;
z-index: $zindex-backdrop;
inset: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(2px);
display: grid;
place-content: center;
@@ -1,7 +0,0 @@
.updateIndicator {
width: 0.5em;
height: 0.5em;
flex: 0 0 auto;
border-radius: 99px;
background-color: $red-400;
}
@@ -3,8 +3,6 @@ import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './AppVersion.module.scss';
export default function AppVersion() {
const { data, isError } = useAppVersion();
@@ -20,12 +18,7 @@ export default function AppVersion() {
return (
<Panel.ListItem>
<Panel.Field
title={
<>
<span className={style.updateIndicator} aria-hidden='true' />
{`Ontime ${appVersion}`}
</>
}
title={`Ontime ${appVersion}`}
description={
isOntimeCloud
? `Version ${data.version} is available. Restart your stage to update.`
@@ -33,7 +26,7 @@ export default function AppVersion() {
}
/>
{!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
)}
</Panel.ListItem>
);
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Loader isLoading={status === 'pending'} />
{rootError && <Panel.Error>{rootError}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{data.pendingRestart && (
<Info type='warning'>A port change is pending and will happen on the next restart.</Info>
)}
+1 -13
View File
@@ -100,7 +100,7 @@ function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download
submenu: [
{
label: 'New project...',
click: () => redirectWindow('/editor?settings=project__create'),
click: () => redirectWindow('/editor?settings=project__manage&new=true'),
},
{
label: 'Load...',
@@ -202,18 +202,6 @@ function makeSettingsMenu(redirectWindow) {
label: 'View settings',
click: () => redirectWindow('/editor?settings=settings__view'),
},
{
label: 'Custom views',
click: () => redirectWindow('/editor?settings=settings__custom-views'),
},
{
label: 'MCP Server',
click: () => redirectWindow('/editor?settings=settings__mcp'),
},
{
label: 'Server port',
click: () => redirectWindow('/editor?settings=settings__port'),
},
],
},
{
+1 -2
View File
@@ -22,8 +22,7 @@
"osc-min": "2.1.2",
"sanitize-filename": "^1.6.3",
"ws": "^8.18.0",
"xlsx": "^0.18.5",
"zod": "4.4.3"
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/cookie-parser": "1.4.10",
@@ -1,107 +0,0 @@
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
import { buildToolList, defineTool, makeToolRegistry, ok } from '../mcp.registry.js';
const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;
const echo = vi.fn(async (args: unknown) => ok(args));
const tools = [
defineTool(
'test_echo',
{
description: 'Echoes its arguments',
inputSchema: z.strictObject({
id: z.string().describe('An id'),
count: z.number().optional(),
filename: z
.string()
.optional()
.transform((filename) => filename?.toUpperCase()),
}),
annotations: READ,
},
echo,
),
defineTool(
'test_throws',
{
description: 'Always fails',
inputSchema: z.strictObject({}),
annotations: READ,
},
async () => {
throw new Error('the rundown is not loaded');
},
),
];
const dispatch = makeToolRegistry(tools);
describe('dispatchToolCall', () => {
it('passes validated arguments to the handler', async () => {
const result = await dispatch('test_echo', { id: 'entry-1', count: 2 });
expect(result.isError).toBeUndefined();
expect(echo).toHaveBeenCalledWith({ id: 'entry-1', count: 2 });
});
it('hands the handler the transformed value, not the raw one', async () => {
await dispatch('test_echo', { id: 'entry-1', filename: 'show.json' });
expect(echo).toHaveBeenLastCalledWith({ id: 'entry-1', filename: 'SHOW.JSON' });
});
it('rejects malformed arguments as a protocol error, without running the handler', async () => {
echo.mockClear();
// this is what the SDK itself does on the registerTool path, and what v2 will do
await expect(dispatch('test_echo', { count: 'two' })).rejects.toThrow(McpError);
await expect(dispatch('test_echo', { count: 'two' })).rejects.toMatchObject({ code: ErrorCode.InvalidParams });
expect(echo).not.toHaveBeenCalled();
});
it('names every invalid field in a single message', async () => {
await expect(dispatch('test_echo', { count: 'two', bogus: true })).rejects.toThrow(
/Invalid arguments for test_echo:.*id.*count.*bogus/s,
);
});
it('rejects an unknown tool as a protocol error', async () => {
await expect(dispatch('test_missing', {})).rejects.toMatchObject({
code: ErrorCode.MethodNotFound,
message: expect.stringContaining('Unknown tool: test_missing'),
});
});
/**
* Business failures are returned to the agent rather than thrown, so that it can read the
* message and recover. Only malformed calls are protocol errors.
*/
it('returns failures from the services as tool errors', async () => {
const result = await dispatch('test_throws', {});
expect(result.isError).toBe(true);
expect(result.content).toEqual([{ type: 'text', text: '{"error":"the rundown is not loaded"}' }]);
});
});
describe('buildToolList', () => {
it('advertises the input shape, before any transform is applied', () => {
const [echoTool] = buildToolList(tools);
expect(echoTool.inputSchema).toEqual({
type: 'object',
properties: {
id: { type: 'string', description: 'An id' },
count: { type: 'number' },
filename: { type: 'string' },
},
required: ['id'],
additionalProperties: false,
});
});
it('carries the description and annotations through', () => {
const [echoTool] = buildToolList(tools);
expect(echoTool.description).toBe('Echoes its arguments');
expect(echoTool.annotations).toEqual(READ);
});
});
@@ -1,123 +0,0 @@
import { describe, expect, it } from 'vitest';
import { TOOLS, TOOL_LIST } from '../mcp.tools.js';
describe('tool wiring', () => {
it('advertises every declared tool', () => {
expect(TOOL_LIST).toHaveLength(TOOLS.length);
expect(TOOL_LIST.map((tool) => tool.name)).toEqual(TOOLS.map((tool) => tool.name));
});
it('declares unique tool names', () => {
const names = TOOLS.map((tool) => tool.name);
expect(new Set(names).size).toBe(names.length);
});
it('gives every tool a handler, a description and annotations', () => {
const incomplete = TOOLS.filter(
(tool) => typeof tool.handler !== 'function' || !tool.config.description || !tool.config.annotations,
);
expect(incomplete.map((tool) => tool.name)).toEqual([]);
});
});
describe('generated input schemas', () => {
it('advertises an object schema for every tool', () => {
const notObjects = TOOL_LIST.filter((tool) => tool.inputSchema.type !== 'object');
expect(notObjects.map((tool) => tool.name)).toEqual([]);
});
/**
* Recursive schemas would generate $ref/$defs, which some MCP clients handle poorly.
* Batch creation is deliberately modelled two levels deep to avoid them.
*/
it('generates self contained schemas, without $ref or $defs', () => {
const withReferences = TOOL_LIST.filter((tool) => /\$ref|\$defs|\$schema/.test(JSON.stringify(tool.inputSchema)));
expect(withReferences.map((tool) => tool.name)).toEqual([]);
});
it('rejects unknown keys in every tool', () => {
const permissive = TOOL_LIST.filter((tool) => tool.inputSchema.additionalProperties !== false);
expect(permissive.map((tool) => tool.name)).toEqual([]);
});
it('describes required arguments', () => {
const byName = new Map(TOOL_LIST.map((tool) => [tool.name, tool.inputSchema]));
expect(byName.get('ontime_update_entry')?.required).toEqual(['id']);
expect(byName.get('ontime_delete_entries')?.required).toEqual(['ids']);
expect(byName.get('ontime_reorder_entry')?.required).toEqual(['entryId', 'destinationId', 'order']);
expect(byName.get('ontime_batch_update_entries')?.required).toEqual(['ids', 'data']);
expect(byName.get('ontime_create_custom_field')?.required).toEqual(['label', 'type', 'colour']);
expect(byName.get('ontime_rename_project')?.required).toEqual(['filename', 'newFilename']);
// tools which take no arguments advertise an empty object
expect(byName.get('ontime_list_rundowns')).toEqual({
type: 'object',
properties: {},
additionalProperties: false,
});
});
it('advertises the pre-sanitisation shape of filenames', () => {
// the agent must be told the shape it should send, not the transformed value
const schema = TOOL_LIST.find((tool) => tool.name === 'ontime_delete_project')?.inputSchema;
expect(schema?.properties?.filename).toEqual({
type: 'string',
minLength: 1,
description: 'Project filename to delete (with .json extension)',
});
});
it('generates a stable schema for a representative tool', () => {
const schema = TOOL_LIST.find((tool) => tool.name === 'ontime_group_entries')?.inputSchema;
expect(schema).toMatchInlineSnapshot(`
{
"additionalProperties": false,
"properties": {
"colour": {
"description": "Hex colour (#RRGGBB) for the group — prefer the default Ontime palette from ontime://style-guide",
"type": "string",
},
"custom": {
"additionalProperties": {
"type": "string",
},
"description": "Custom field values keyed by existing project field key",
"propertyNames": {
"type": "string",
},
"type": "object",
},
"ids": {
"description": "Existing top-level entry IDs to group",
"items": {
"type": "string",
},
"minItems": 1,
"type": "array",
},
"note": {
"description": "Free-text group note for production notes or references",
"type": "string",
},
"rundownId": {
"description": "Optional target rundown ID. Omit to target the currently loaded live rundown; provide an ID from ontime_list_rundowns to edit a background rundown without loading it.",
"type": "string",
},
"targetDuration": {
"description": "Planned length of the group in ms",
"type": "number",
},
"title": {
"description": "Group title shown in the rundown and views",
"type": "string",
},
},
"required": [
"ids",
],
"type": "object",
}
`);
});
});
@@ -1,186 +0,0 @@
import { dirname, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import type { z } from 'zod';
import { TOOLS } from '../mcp.tools.js';
const PROJECTS_DIR = resolve('/projects');
function schemaFor(name: string) {
const tool = TOOLS.find((candidate) => candidate.name === name);
if (!tool?.config.inputSchema) {
throw new Error(`No input schema for ${name}`);
}
return tool.config.inputSchema;
}
/** Parses and returns the sanitised arguments, failing the test on invalid input */
function parse(name: string, args: Record<string, unknown>): Record<string, unknown> {
const result = schemaFor(name).safeParse(args);
if (!result.success) {
throw new Error(`Expected ${name} to accept the arguments: ${issuesOf(result.error)}`);
}
return result.data;
}
function issuesOf(error: z.ZodError): string {
return error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ');
}
/** Returns the issue messages of arguments which must not be accepted */
function reject(name: string, args: Record<string, unknown>): string {
const result = schemaFor(name).safeParse(args);
if (result.success) {
throw new Error(`Expected ${name} to reject ${JSON.stringify(args)}`);
}
return issuesOf(result.error);
}
describe('required arguments', () => {
it('rejects an update without an entry id', () => {
expect(reject('ontime_update_entry', { title: 'Keynote' })).toContain('id');
});
it('rejects a reorder without a destination', () => {
expect(reject('ontime_reorder_entry', { entryId: 'entry-1', order: 'after' })).toContain('destinationId');
});
it('accepts a well formed update', () => {
expect(parse('ontime_update_entry', { id: 'entry-1', title: 'Keynote', timeStart: 32400000 })).toEqual({
id: 'entry-1',
title: 'Keynote',
timeStart: 32400000,
});
});
});
describe('argument types', () => {
it('rejects a stringified number where a time is expected', () => {
expect(reject('ontime_update_entry', { id: 'entry-1', timeStart: '09:00' })).toContain('timeStart');
});
it('rejects a non-array list of ids', () => {
expect(reject('ontime_delete_entries', { ids: 'entry-1' })).toContain('ids');
});
it('rejects an empty list of ids to group', () => {
expect(reject('ontime_group_entries', { ids: [] })).toContain('Provide at least one entry ID to group.');
});
it('rejects an unknown reorder position', () => {
expect(reject('ontime_reorder_entry', { entryId: 'a', destinationId: 'b', order: 'sideways' })).toContain('order');
});
it('rejects an unknown entry type', () => {
expect(reject('ontime_create_entry', { type: 'interlude' })).toContain('type');
});
it('rejects non-string custom field values', () => {
expect(reject('ontime_update_entry', { id: 'entry-1', custom: { Camera: 2 } })).toContain('custom.Camera');
});
it('accepts an insert anchor as an id or as true, but not as false', () => {
expect(parse('ontime_create_entry', { after: 'entry-1' })).toEqual({ after: 'entry-1' });
expect(parse('ontime_create_entry', { before: true })).toEqual({ before: true });
// `after: false` has no meaning: the services only check whether the anchor is present
expect(reject('ontime_create_entry', { after: false })).toContain('after');
});
});
describe('unknown keys', () => {
it('rejects fields which are not part of the tool', () => {
expect(reject('ontime_update_entry', { id: 'entry-1', titel: 'typo' })).toContain('titel');
});
/**
* editCurrentProjectData spreads its argument into the stored project, and reacts to a
* `logo` key by deleting the current logo file. Only declared fields may reach it.
*/
it('rejects undeclared writes to project data', () => {
expect(reject('ontime_update_project_info', { title: 'Show', logo: 'other.png' })).toContain('logo');
expect(parse('ontime_update_project_info', { title: 'Show' })).toEqual({ title: 'Show' });
});
it('rejects entry internals which the services must own', () => {
expect(reject('ontime_update_entry', { id: 'entry-1', revision: 99 })).toContain('revision');
expect(reject('ontime_update_entry', { id: 'entry-1', parent: 'group-1' })).toContain('parent');
});
});
describe('project filenames', () => {
/**
* Filenames reach the filesystem through join(projectsDir, name), so the invariant that
* matters is that nothing which could traverse out of that directory survives parsing.
*/
const directoryOf = (filename: unknown) => dirname(resolve(PROJECTS_DIR, String(filename)));
it.each(['../../../etc/passwd', '../sibling.json', '..\\windows\\system32\\show.json', '/etc/shadow', 'show.json'])(
'confines %s to the projects directory',
(filename) => {
expect(directoryOf(parse('ontime_delete_project', { filename }).filename)).toBe(PROJECTS_DIR);
expect(directoryOf(parse('ontime_load_project', { filename }).filename)).toBe(PROJECTS_DIR);
},
);
it('confines both filenames of a rename', () => {
const args = parse('ontime_rename_project', { filename: '../a.json', newFilename: '../../b.json' });
expect(directoryOf(args.filename)).toBe(PROJECTS_DIR);
expect(directoryOf(args.newFilename)).toBe(PROJECTS_DIR);
});
it('appends the extension and trims the name', () => {
expect(parse('ontime_delete_project', { filename: ' show ' })).toEqual({ filename: 'show.json' });
expect(parse('ontime_delete_project', { filename: 'show.json' })).toEqual({ filename: 'show.json' });
});
it('rejects a filename which is empty once sanitised', () => {
expect(reject('ontime_delete_project', { filename: '..' })).toContain('sanitised');
expect(reject('ontime_delete_project', { filename: ' ' })).toBeTruthy();
});
it('does not append an extension when creating a project', () => {
// ProjectService appends the extension itself, and de-duplicates the name
expect(parse('ontime_create_project', { filename: ' my-show ' })).toEqual({ filename: 'my-show' });
expect(directoryOf(parse('ontime_create_project', { filename: '../my-show' }).filename)).toBe(PROJECTS_DIR);
});
});
describe('batch creation', () => {
const entry = { type: 'event', title: 'Talk', duration: 600000 };
it('accepts a group with children', () => {
const args = parse('ontime_batch_create_entries', {
entries: [{ type: 'group', title: 'Morning', children: [entry] }],
});
expect(args.entries).toHaveLength(1);
});
it('rejects a group nested inside a group', () => {
const issues = reject('ontime_batch_create_entries', {
entries: [{ type: 'group', title: 'Morning', children: [{ type: 'group', title: 'Nested' }] }],
});
expect(issues).toContain('type');
});
it('rejects children on entries which are not groups', () => {
expect(
reject('ontime_batch_create_entries', { entries: [{ type: 'event', title: 'Talk', children: [entry] }] }),
).toContain('Only group entries can have children.');
});
it('rejects unknown keys inside nested children', () => {
expect(
reject('ontime_batch_create_entries', {
entries: [{ type: 'group', title: 'Morning', children: [{ ...entry, titel: 'typo' }] }],
}),
).toContain('titel');
});
});
describe('lookups which need one of several arguments', () => {
it('requires an id or a cue', () => {
expect(reject('ontime_get_entry', {})).toContain('Provide id or cue');
expect(parse('ontime_get_entry', { cue: '1a' })).toEqual({ cue: '1a' });
});
});
-135
View File
@@ -1,135 +0,0 @@
/**
* Tool registry for the Ontime MCP server.
*
* This is the only module aware of the low-level `@modelcontextprotocol/sdk` Server API.
* Tools are declared with `defineTool(name, config, handler)` — the same argument shape
* `McpServer.registerTool` takes — so migrating to the SDK v2 `registerTool` API means
* replacing the two functions below with a registration loop, leaving every tool untouched.
*
* Responsibilities kept here (and taken over by the SDK on v2):
* - deriving the advertised JSON Schema from the tool input schema
* - parsing tool arguments before a handler runs
*
* Argument shape is validated here; business rules (does this ID exist, is this custom
* field known) stay in mcp.service.ts.
*/
import {
ErrorCode,
McpError,
type CallToolResult,
type ListToolsResult,
type ToolAnnotations,
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
/** Input schemas must describe an object: MCP tool arguments are always a record */
type ToolInput = z.ZodType<Record<string, unknown>, Record<string, unknown>>;
type ToolConfig<Schema extends ToolInput> = {
description: string;
annotations: ToolAnnotations;
/** Tools which take no arguments declare an empty object, so that they too reject unknown keys */
inputSchema: Schema;
};
export type ToolDefinition = {
name: string;
config: ToolConfig<ToolInput>;
handler: (args: never) => Promise<CallToolResult>;
};
/**
* Declares a tool, tying the handler argument to the input schema.
* The generic is what keeps each entry of a heterogeneous tool array individually typed.
*/
export function defineTool<const Schema extends ToolInput>(
name: string,
config: ToolConfig<Schema>,
handler: (args: z.output<Schema>) => Promise<CallToolResult>,
): ToolDefinition {
return { name, config, handler } as ToolDefinition;
}
/**
* Converts a tool input schema to the JSON Schema advertised in tools/list.
*
* `io: 'input'` is required: schemas which sanitise their values with `.transform()`
* have an output type the agent must not be shown, and output mode cannot represent them.
*/
function toInputSchema(schema: ToolInput): ListToolsResult['tools'][number]['inputSchema'] {
// `$schema` is metadata about the document, not part of the tool contract
const { $schema: _$schema, ...jsonSchema } = z.toJSONSchema(schema, {
io: 'input',
target: 'draft-7',
unrepresentable: 'any',
reused: 'inline',
});
return jsonSchema as ListToolsResult['tools'][number]['inputSchema'];
}
/**
* Builds the tools/list payload.
* Call once at module scope: a new Server is created for every MCP request.
*/
export function buildToolList(tools: readonly ToolDefinition[]): ListToolsResult['tools'] {
return tools.map(({ name, config }) => ({
name,
description: config.description,
annotations: config.annotations,
inputSchema: toInputSchema(config.inputSchema),
}));
}
/**
* Flattens validation issues into a single line an agent can act on, eg.
* `id: Invalid input: expected string, received undefined; timeStart: ...`
*/
function formatIssues(error: z.ZodError): string {
return error.issues
.map((issue) => {
const path = issue.path.join('.');
return path ? `${path}: ${issue.message}` : issue.message;
})
.join('; ');
}
export function makeToolRegistry(tools: readonly ToolDefinition[]) {
const byName = new Map(tools.map((tool) => [tool.name, tool]));
/**
* Validates the arguments of a tool call and runs its handler.
*
* Malformed calls are protocol errors (as they are in the SDK's own registerTool path),
* while failures from the services are returned as tool errors so that the agent can recover.
*/
return async function dispatchToolCall(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
const tool = byName.get(name);
if (!tool) {
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
}
const result = tool.config.inputSchema.safeParse(args);
if (!result.success) {
throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: ${formatIssues(result.error)}`);
}
try {
return await tool.handler(result.data as never);
} catch (error) {
return err(error);
}
};
}
// ---- Response helpers ----
const text = (data: unknown): string => JSON.stringify(data);
export const ok = (data: unknown): CallToolResult => ({ content: [{ type: 'text', text: text(data) }] });
export const err = (e: unknown): CallToolResult => ({
content: [{ type: 'text', text: text({ error: e instanceof Error ? e.message : String(e) }) }],
isError: true,
});
+51 -58
View File
@@ -9,77 +9,70 @@
* Keep this file concise and update it when MCP-exposed fields change.
*/
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
import { z } from 'zod';
// ---- Shared event field schemas ----
// Imported by mcp.tools.ts and spread into the tool input schemas.
// The JSON Schema advertised to agents is generated from these, so the description
// of a field is written once and cannot drift from what is enforced at runtime.
// ---- Shared event field JSON schemas ----
// Imported by mcp.tools.ts and spread into tool inputSchema.properties.
export const EVENT_TIMER_FIELDS = {
timerType: z
.enum(TimerType)
.optional()
.describe('count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown'),
endAction: z
.enum(EndAction)
.optional()
.describe('Action when event ends: none = stop, load-next = cue next event, play-next = auto-start next event'),
linkStart: z
.boolean()
.optional()
.describe(
timerType: {
type: 'string',
enum: ['count-down', 'count-up', 'clock', 'none'],
description: 'count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown',
},
endAction: {
type: 'string',
enum: ['none', 'load-next', 'play-next'],
description: 'Action when event ends: none = stop, load-next = cue next event, play-next = auto-start next event',
},
linkStart: {
type: 'boolean',
description:
"Link this event's start time to the previous playable event's end time. Linked events allow time changes to propagate through the rundown. Unlinking would prevent propagation and lock this event's start time to the schedule",
),
countToEnd: z
.boolean()
.optional()
.describe(
},
countToEnd: {
type: 'boolean',
description:
'Advanced timing mode: countdown targets the scheduled timeEnd instead of the event duration. This can surprise operators when an event starts late or the schedule shifts; only set true after explaining the behaviour and confirming the user wants it. This can be useful for a deadline, where an event always needs to end at the schedule time, ie: a curfew or a broadcast window.',
),
timeStrategy: z
.enum(TimeStrategy)
.optional()
.describe(
},
timeStrategy: {
type: 'string',
enum: ['lock-duration', 'lock-end'],
description:
'How linked events adapt to an inherited start: lock-duration recalculates end, lock-end recalculates duration',
),
timeWarning: z.number().optional().describe('ms before timeEnd to enter warning state (e.g. 300000 = 5 min)'),
timeDanger: z.number().optional().describe('ms before timeEnd to enter danger state (e.g. 60000 = 1 min)'),
};
},
timeWarning: { type: 'number', description: 'ms before timeEnd to enter warning state (e.g. 300000 = 5 min)' },
timeDanger: { type: 'number', description: 'ms before timeEnd to enter danger state (e.g. 60000 = 1 min)' },
} as const;
export const EVENT_WRITABLE_FIELDS = {
cue: z.string().optional().describe('Short free-form cue label — ask the user what naming convention they prefer'),
title: z.string().optional().describe('Event title shown in the rundown and views'),
note: z.string().optional().describe('Free-text note for production notes or references'),
colour: z
.string()
.optional()
.describe(
cue: { type: 'string', description: 'Short free-form cue label — ask the user what naming convention they prefer' },
title: { type: 'string', description: 'Event title shown in the rundown and views' },
note: { type: 'string', description: 'Free-text note for production notes or references' },
colour: {
type: 'string',
description:
'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use, and prefer the default Ontime palette from ontime://style-guide so colours match the editor swatches',
),
skip: z.boolean().optional().describe('If true, event is skipped during playback'),
flag: z
.boolean()
.optional()
.describe('Mark the event as a critical operational marker — use sparingly for maximum impact'),
custom: z
.record(z.string(), z.string())
.optional()
.describe(
},
skip: { type: 'boolean', description: 'If true, event is skipped during playback' },
flag: {
type: 'boolean',
description: 'Mark the event as a critical operational marker — use sparingly for maximum impact',
},
custom: {
type: 'object',
additionalProperties: { type: 'string' },
description:
'Custom field values keyed by project field key, e.g. { "Camera": "CAM 2" }. Keys are case-sensitive — get them with ontime_get_custom_fields, and create missing fields with ontime_create_custom_field.',
),
},
...EVENT_TIMER_FIELDS,
};
} as const;
export const RUNDOWN_TARGET_FIELD = {
rundownId: z
.string()
.optional()
.describe(
rundownId: {
type: 'string',
description:
'Optional target rundown ID. Omit to target the currently loaded live rundown; provide an ID from ontime_list_rundowns to edit a background rundown without loading it.',
),
};
},
} as const;
// ---- Agent-readable schema document ----
// Served at ontime://schema. Agents read this once per session to orient themselves
+7 -2
View File
@@ -12,7 +12,7 @@ import {
import { PROMPT_DEFINITIONS, handleGetPrompt } from './mcp.prompts.js';
import { RESOURCE_DEFINITIONS, handleReadResource } from './mcp.resources.js';
import { TOOL_LIST, handleToolCall } from './mcp.tools.js';
import { TOOL_DEFINITIONS, handleToolCall } from './mcp.tools.js';
export function createMcpServer(): Server {
const server = new Server(
@@ -20,7 +20,12 @@ export function createMcpServer(): Server {
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({ tools: TOOL_LIST }));
server.setRequestHandler(
ListToolsRequestSchema,
async (): Promise<ListToolsResult> => ({
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
}),
);
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
const { name, arguments: args = {} } = request.params;
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { EndAction, Instant, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
import * as timeCore from '../../lib/time-core/timeCore.js';
import type { RuntimeState } from '../../stores/runtimeState.js';
import {
findDayOffset,
@@ -53,11 +54,12 @@ describe('getElapsed()', () => {
it('uses the current pause start while paused', () => {
const state = {
clock: 10 * MILLIS_PER_MINUTE,
_now: timeCore.toInstant((10 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
timer: {
startedAt: 2 * MILLIS_PER_MINUTE,
},
_timer: {
pausedAt: 7 * MILLIS_PER_MINUTE,
pausedAt: timeCore.toInstant((7 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
pausedDuration: 1 * MILLIS_PER_MINUTE,
},
} as RuntimeState;
@@ -975,6 +977,40 @@ describe('getRuntimeOffset()', () => {
expect(absolute).toBe(25);
});
it('paused time is delayed time when the pause spans midnight', () => {
const state = {
eventNow: {
id: '1',
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00
dayOffset: 0,
},
clock: 3 * MILLIS_PER_MINUTE, // 00:03 (after midnight)
_now: timeCore.toInstant((3 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
timer: {
startedAt: 23 * MILLIS_PER_HOUR, // started on time at 23:00
current: 25, // still counting down
addedTime: 0,
},
_timer: {
pausedAt: timeCore.toInstant(
(23 * MILLIS_PER_HOUR + 58 * MILLIS_PER_MINUTE) as TimeOfDay,
(timeCore.now() - dayInMs) as Instant,
), // 23:58, before midnight
pausedDuration: 0,
},
rundown: {
actualStart: 23 * MILLIS_PER_HOUR,
plannedStart: 23 * MILLIS_PER_HOUR,
currentDay: 0,
},
_startDayOffset: 0,
} as RuntimeState;
// paused from 23:58 to 00:03 -> so elapsed should still be 58 minutes
expect(getElapsed(state)).toBe(58 * MILLIS_PER_MINUTE);
});
it('offset doesnt exist if we havent started', () => {
const state = {
clock: 78480789,
+7 -5
View File
@@ -1,6 +1,7 @@
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import * as timeCore from '../lib/time-core/timeCore.js';
import type { RuntimeState } from '../stores/runtimeState.js';
/**
@@ -96,17 +97,18 @@ export function getCurrent(state: RuntimeState): number {
* Calculates active time elapsed since the timer started.
*/
export function getElapsed(state: RuntimeState): MaybeNumber {
const { clock } = state;
const { clock, _now } = state;
const { startedAt } = state.timer;
const { pausedAt, pausedDuration } = state._timer;
const { pausedDuration, pausedAt } = state._timer;
if (startedAt === null) {
return null;
}
const referenceClock = pausedAt ?? clock;
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration;
const currentPauseDuration = pausedAt !== null ? timeCore.timeSince(_now, pausedAt) : 0;
const elapsedSinceStart = getTimeSinceStart(clock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration - currentPauseDuration;
return Math.max(0, activeElapsed);
}
@@ -1,10 +1,11 @@
import { OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
import { Instant, OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import type { RuntimeState } from '../runtimeState.js';
const baseState: RuntimeState = {
clock: 0 as TimeOfDay,
_now: 0 as Instant,
eventNow: null,
eventNext: null,
eventFlag: null,
@@ -135,7 +135,7 @@ describe('mutation on runtimeState', () => {
playback: Playback.Pause,
addedTime: 0,
});
expect(newState._timer.pausedAt).toEqual(newState.clock);
expect(newState._timer.pausedAt).toEqual(newState._now);
success = pause();
expect(success).toBe(false);
@@ -248,6 +248,59 @@ describe('mutation on runtimeState', () => {
state = getState();
expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE);
});
test('elapsed excludes a pause that spans midnight', async () => {
clearState();
// an event that runs over midnight (23:00 -> 01:00)
const event = {
...mockEvent,
id: 'elapsed-pause-midnight',
timeStart: 23 * MILLIS_PER_HOUR,
timeEnd: 1 * MILLIS_PER_HOUR,
duration: 2 * MILLIS_PER_HOUR,
};
const mockRundown = makeRundown({
entries: { [event.id]: event },
order: [event.id],
});
await initRundown(mockRundown, {});
vi.runAllTimers();
const { metadata, rundown } = rundownCache.get();
// start before midnight
vi.setSystemTime('jan 1 23:50');
load(event, rundown, metadata);
start();
// 8 minutes of active running before we pause
vi.setSystemTime('jan 1 23:58');
update();
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
pause();
// elapsed is active time since start, so it must not advance while paused,
// not even when the pause itself crosses midnight
vi.setSystemTime('jan 2 00:01');
update();
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
// resume 5 minutes after pausing, having crossed midnight (23:58 -> 00:03)
vi.setSystemTime('jan 2 00:03');
start();
let state = getState();
// the accumulated pause count is 5 minutes, regardless of the midnight wrap
expect(state._timer.pausedDuration).toBe(5 * MILLIS_PER_MINUTE);
// and elapsed still reflects only the 8 active minutes
expect(state.timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
// 2 more active minutes after resume -> 10 minutes elapsed
vi.setSystemTime('jan 2 00:05');
update();
state = getState();
expect(state.timer.elapsed).toBe(10 * MILLIS_PER_MINUTE);
});
});
test('runtime offset', async () => {
+21 -16
View File
@@ -63,7 +63,9 @@ export type RuntimeState = {
// private properties of the timer calculations
_timer: {
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
pausedAt: Maybe<TimeOfDay>;
pausedAt: Maybe<Instant>;
/** Accumulate pause duration but dose not include the current pause */
pausedDuration: number;
secondaryTarget: Maybe<TimeOfDay>;
hasFinished: boolean;
@@ -76,10 +78,12 @@ export type RuntimeState = {
_end: ExpectedMetadata;
_startEpoch: Maybe<Instant>;
_startDayOffset: Maybe<Day>;
_now: Instant;
};
const runtimeState: RuntimeState = {
clock: timeCore.timeOfDayNow(),
_now: timeCore.now(),
groupNow: null,
eventNow: null,
eventNext: null,
@@ -104,6 +108,12 @@ const runtimeState: RuntimeState = {
_startDayOffset: null,
};
/** set the current clock to ensure parity between _now and clock */
function setClock(state: RuntimeState) {
state._now = timeCore.now();
state.clock = timeCore.toTimeOfDay(state._now);
}
export function getState(): Readonly<RuntimeState> {
// create a shallow copy of the state
return {
@@ -136,7 +146,7 @@ export function clearEventData() {
runtimeState.rundown.selectedEventIndex = null;
runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = timeCore.timeOfDayNow();
setClock(runtimeState);
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
@@ -169,7 +179,7 @@ export function clearState() {
runtimeState._end = null;
runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = timeCore.timeOfDayNow();
setClock(runtimeState);
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
@@ -422,15 +432,12 @@ export function start(state: RuntimeState = runtimeState): boolean {
return false;
}
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
state.clock = now;
setClock(state);
state.timer.secondaryTimer = null;
// add paused time if it exists
if (state._timer.pausedAt) {
const timeToAdd = state.clock - state._timer.pausedAt;
const timeToAdd = state._now - state._timer.pausedAt;
state.timer.addedTime += timeToAdd;
state._timer.pausedDuration += timeToAdd;
state._timer.pausedAt = null;
@@ -447,7 +454,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
if (state.rundown.actualStart === null) {
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
state.rundown.currentDay = state._startDayOffset;
state._startEpoch = epoch;
state._startEpoch = state._now;
state.rundown.actualStart = state.clock;
}
@@ -481,8 +488,8 @@ export function pause(state: RuntimeState = runtimeState): boolean {
}
state.timer.playback = Playback.Pause;
state.clock = timeCore.timeOfDayNow();
state._timer.pausedAt = state.clock;
setClock(state);
state._timer.pausedAt = state._now;
return true;
}
@@ -547,9 +554,7 @@ export type UpdateResult = {
export function update(): UpdateResult {
// 0. there are some things we always do
const previousClock = runtimeState.clock;
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now; // we update the clock on every update call
setClock(runtimeState); // we update the clock on every update call
// 1. is playback idle?
if (!isPlaybackActive(runtimeState.timer.playback)) {
@@ -558,13 +563,13 @@ export function update(): UpdateResult {
// calculate currentDay from epoch (days elapsed since playback was started)
if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now);
runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
}
// 2. are we waiting to roll?
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now);
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, runtimeState.clock);
return updateIfWaitingToRoll(clockHasCrossedMidnight);
}
-3
View File
@@ -275,9 +275,6 @@ importers:
xlsx:
specifier: ^0.18.5
version: 0.18.5
zod:
specifier: 4.4.3
version: 4.4.3
devDependencies:
'@types/cookie-parser':
specifier: 1.4.10