mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-11 08:12:38 +02:00
feat: live notes — single objective per note replaces multi-track model
Folds the multi-`track:`-array model into one `live:` block per note: a single persistent objective the live-note agent maintains, plus an optional triggers object (`cronExpr` / `windows` / `eventMatchCriteria`, each independently optional). A note is now passive or live — no per-track scopes, no section ownership contract, no `once` trigger. The agent owns the whole body and makes patch-style incremental edits per run. Highlights: - Schema: `track:` array → single `live:` object (`packages/shared/src/live-note.ts`). - Runtime: scheduler / event processor / runner under `core/knowledge/live-note/`, with split `lastAttemptAt` (every run, drives 5-min backoff) vs `lastRunAt` (success only, anchors cycles). `throwOnError` on agent runs surfaces LLM / billing failures into `lastRunError`. - Today.md: regenerated by template v2 (single objective covering overview / calendar / emails / what-you-missed / priorities; existing files renamed to `Today.md.bkp.<stamp>`). - Renderer: `LiveNoteSidebar` mounts inside the editor row (no chat overlap, auto-closes on note switch); toolbar Radio button becomes a status pill; `LiveNotesView` replaces background-agents view. - Copilot: new `live-note` skill with act-first stance, default folder/cadence pickers, and a non-negotiable rule to extend an existing objective rather than add a second one. Shared `KNOWLEDGE_NOTE_STYLE_GUIDE` enforces terse-and-scannable writing across `doc-collab` and the live-note agent. - Analytics: `track_block` use-case → `live_note_agent`; trigger (`manual` / `cron` / `window` / `event`) becomes the Pass-2 sub-use-case, alongside `routing` for Pass 1. Legacy run files with the old value are read-mapped via `LegacyStartEvent` so they stay openable in the runs list. Hard cutover — no back-compat shims for legacy `track:` frontmatter arrays. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0bf7a55611
commit
dabca3da19
59 changed files with 3816 additions and 3212 deletions
|
|
@ -9,7 +9,7 @@ export * as agentScheduleState from './agent-schedule-state.js';
|
|||
export * as serviceEvents from './service-events.js'
|
||||
export * as inlineTask from './inline-task.js';
|
||||
export * as blocks from './blocks.js';
|
||||
export * as track from './track.js';
|
||||
export * as liveNote from './live-note.js';
|
||||
export * as promptBlock from './prompt-block.js';
|
||||
export * as frontmatter from './frontmatter.js';
|
||||
export * as bases from './bases.js';
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { LlmModelConfig } from './models.js';
|
|||
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
||||
import { AgentScheduleState } from './agent-schedule-state.js';
|
||||
import { ServiceEvent } from './service-events.js';
|
||||
import { TrackEvent } from './track.js';
|
||||
import { LiveNoteAgentEvent, LiveNoteSchema } from './live-note.js';
|
||||
import { UserMessageContent } from './message.js';
|
||||
import { RowboatApiConfig } from './rowboat-account.js';
|
||||
import { ZListToolkitsResponse } from './composio.js';
|
||||
|
|
@ -214,8 +214,8 @@ const ipcSchemas = {
|
|||
req: ServiceEvent,
|
||||
res: z.null(),
|
||||
},
|
||||
'tracks:events': {
|
||||
req: TrackEvent,
|
||||
'live-note-agent:events': {
|
||||
req: LiveNoteAgentEvent,
|
||||
res: z.null(),
|
||||
},
|
||||
'models:list': {
|
||||
|
|
@ -611,93 +611,83 @@ const ipcSchemas = {
|
|||
response: z.string().nullable(),
|
||||
}),
|
||||
},
|
||||
// Track channels
|
||||
'track:run': {
|
||||
// Live-note channels
|
||||
'live-note:run': {
|
||||
req: z.object({
|
||||
filePath: z.string(),
|
||||
context: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
runId: z.string().nullable().optional(),
|
||||
action: z.enum(['replace', 'no_update']).optional(),
|
||||
summary: z.string().nullable().optional(),
|
||||
contentAfter: z.string().nullable().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'live-note:get': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
filePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
summary: z.string().optional(),
|
||||
// Fresh, authoritative live-note object from frontmatter, or null when
|
||||
// the note is passive. Renderer should use this for display/edit —
|
||||
// never a stale cached copy.
|
||||
live: LiveNoteSchema.nullable().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:get': {
|
||||
'live-note:set': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
filePath: z.string(),
|
||||
live: LiveNoteSchema,
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
// Fresh, authoritative YAML of the track from frontmatter.
|
||||
// Renderer should use this for display/edit — never a stale cached copy.
|
||||
yaml: z.string().optional(),
|
||||
live: LiveNoteSchema.nullable().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:update': {
|
||||
'live-note:setActive': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
filePath: z.string(),
|
||||
// Partial Track updates — merged into the entry on disk.
|
||||
// Backend is the sole writer; avoids races with scheduler/runner writes.
|
||||
updates: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
yaml: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:replaceYaml': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
filePath: z.string(),
|
||||
yaml: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
yaml: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:delete': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
filePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:setNoteActive': {
|
||||
req: z.object({
|
||||
path: RelPath,
|
||||
active: z.boolean(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
note: z.object({
|
||||
path: RelPath,
|
||||
trackCount: z.number().int().positive(),
|
||||
createdAt: z.string().nullable(),
|
||||
lastRunAt: z.string().nullable(),
|
||||
isActive: z.boolean(),
|
||||
}).optional(),
|
||||
live: LiveNoteSchema.nullable().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:listNotes': {
|
||||
'live-note:delete': {
|
||||
req: z.object({
|
||||
filePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'live-note:stop': {
|
||||
req: z.object({
|
||||
filePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'live-note:listNotes': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
notes: z.array(z.object({
|
||||
path: RelPath,
|
||||
trackCount: z.number().int().positive(),
|
||||
createdAt: z.string().nullable(),
|
||||
lastRunAt: z.string().nullable(),
|
||||
isActive: z.boolean(),
|
||||
objective: z.string(),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
133
apps/x/packages/shared/src/live-note.ts
Normal file
133
apps/x/packages/shared/src/live-note.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import z from 'zod';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live notes
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A live note is a markdown file whose body is kept current by a background
|
||||
// agent. The user expresses intent via the `live:` block in the note's YAML
|
||||
// frontmatter:
|
||||
//
|
||||
// ---
|
||||
// live:
|
||||
// objective: |
|
||||
// Keep this note current with major developments in AI coding agents.
|
||||
// active: true
|
||||
// triggers:
|
||||
// cronExpr: "0 * * * *"
|
||||
// windows:
|
||||
// - { startTime: "09:00", endTime: "12:00" }
|
||||
// eventMatchCriteria: |
|
||||
// News, tweets, or emails about AI coding agents.
|
||||
// model: anthropic/claude-haiku-4.5
|
||||
// provider: anthropic
|
||||
// ---
|
||||
//
|
||||
// A note with no `live:` key is passive. Manual-only is `live:` with no
|
||||
// `triggers` (or all three trigger fields absent).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Hand-written types — single source of truth. Zod schemas below validate at
|
||||
// runtime *against* these types via `satisfies`. We don't `z.infer` here
|
||||
// because the resulting types pass through Zod's generic machinery and can
|
||||
// resolve to `any` once the dist .d.ts is consumed downstream (project-
|
||||
// references build, mismatched zod resolution, etc.). Plain types are stable.
|
||||
|
||||
export type TriggerWindow = {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
};
|
||||
|
||||
export type Triggers = {
|
||||
cronExpr?: string;
|
||||
windows?: TriggerWindow[];
|
||||
eventMatchCriteria?: string;
|
||||
};
|
||||
|
||||
export type LiveNote = {
|
||||
objective: string;
|
||||
active: boolean;
|
||||
triggers?: Triggers;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
lastAttemptAt?: string;
|
||||
lastRunAt?: string;
|
||||
lastRunId?: string;
|
||||
lastRunSummary?: string;
|
||||
lastRunError?: string;
|
||||
};
|
||||
|
||||
const TriggerWindowSchema = z.object({
|
||||
startTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. Also the daily cycle anchor — once the agent fires after this time, the window is done for the day.'),
|
||||
endTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. After this, the window is closed for the day.'),
|
||||
});
|
||||
|
||||
export const TriggersSchema = z.object({
|
||||
cronExpr: z.string().optional().describe('5-field cron expression (e.g. "0 * * * *"). Always quote when written by hand. Omit to skip cron-driven runs.'),
|
||||
windows: z.array(TriggerWindowSchema).optional().describe('A list of daily time-of-day bands. The agent fires once per day per window, anywhere inside the band — useful for "sometime in the morning" rather than an exact clock time. Omit to skip window-driven runs.'),
|
||||
eventMatchCriteria: z.string().optional().describe('Natural-language description of which incoming events (emails, calendar changes, etc.) should wake this note. Pass 1 routing uses this to decide candidacy; the agent does Pass 2 on the event payload. Omit to skip event-driven runs.'),
|
||||
}).describe('When the live-note agent fires. Each field is optional — omit any/all. The whole `triggers` object is also optional; absent (or fully empty) means manual-only.');
|
||||
|
||||
export const LiveNoteSchema = z.object({
|
||||
objective: z.string().min(1).describe('A persistent intent in the user\'s words — what should this note keep being? E.g. "Keep this note updated with important developments in AI coding agents." The agent re-reads the objective on every run and is responsible for maintaining the entire body to satisfy it.'),
|
||||
active: z.boolean().default(true).describe('Set false to pause without deleting.'),
|
||||
triggers: TriggersSchema.optional().describe('When the agent fires. Omit for manual-only.'),
|
||||
model: z.string().optional().describe('ADVANCED — leave unset. Per-note LLM model override (e.g. "anthropic/claude-sonnet-4.6"). Only set when the user explicitly asked for a specific model for THIS note. The global default already picks a tuned model for live-note runs; overriding usually makes things worse, not better.'),
|
||||
provider: z.string().optional().describe('ADVANCED — leave unset. Per-note provider name override (e.g. "openai", "anthropic"). Almost always omitted; the global default flows through correctly.'),
|
||||
lastAttemptAt: z.string().optional().describe('Runtime-managed — never write this yourself. Bumped at the start of every agent run; used by the scheduler for backoff so failures do not retry-storm.'),
|
||||
lastRunAt: z.string().optional().describe('Runtime-managed — never write this yourself. Bumped only when an agent run *succeeds*; used as the cycle anchor for cron / window triggers and as the freshness timestamp shown in the UI.'),
|
||||
lastRunId: z.string().optional().describe('Runtime-managed — never write this yourself. The id of the most recent run (success or failure); used by the live-note:stop handler.'),
|
||||
lastRunSummary: z.string().optional().describe('Runtime-managed — never write this yourself. Set on success; not overwritten on failure so the user keeps seeing the last good summary.'),
|
||||
lastRunError: z.string().optional().describe('Runtime-managed — never write this yourself. Set on a failed run; cleared on the next successful run.'),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Knowledge events (live-note event-driven pipeline)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const KnowledgeEventSchema = z.object({
|
||||
id: z.string().describe('Monotonically increasing ID; also the filename in events/pending/'),
|
||||
source: z.string().describe('Producer of the event (e.g. "gmail", "calendar")'),
|
||||
type: z.string().describe('Event type (e.g. "email.synced")'),
|
||||
createdAt: z.string().describe('ISO timestamp when the event was produced'),
|
||||
payload: z.string().describe('Human-readable event body, usually markdown'),
|
||||
targetFilePath: z.string().optional().describe('If set, skip routing and target this note directly (used for re-runs)'),
|
||||
// Enriched on move from pending/ to done/
|
||||
processedAt: z.string().optional(),
|
||||
candidateFilePaths: z.array(z.string()).optional(),
|
||||
runIds: z.array(z.string()).optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export type KnowledgeEvent = z.infer<typeof KnowledgeEventSchema>;
|
||||
|
||||
export const Pass1OutputSchema = z.object({
|
||||
filePaths: z.array(z.string()).describe('Note file paths whose objective and event-match criteria suggest the event might be relevant. The agent does Pass 2 on the event payload before editing.'),
|
||||
});
|
||||
|
||||
export type Pass1Output = z.infer<typeof Pass1OutputSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bus events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const LiveNoteTrigger = z.enum(['manual', 'cron', 'window', 'event']);
|
||||
export type LiveNoteTriggerType = z.infer<typeof LiveNoteTrigger>;
|
||||
|
||||
export const LiveNoteAgentStartEvent = z.object({
|
||||
type: z.literal('live_note_agent_start'),
|
||||
filePath: z.string(),
|
||||
trigger: LiveNoteTrigger,
|
||||
runId: z.string(),
|
||||
});
|
||||
|
||||
export const LiveNoteAgentCompleteEvent = z.object({
|
||||
type: z.literal('live_note_agent_complete'),
|
||||
filePath: z.string(),
|
||||
runId: z.string(),
|
||||
error: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
});
|
||||
|
||||
export const LiveNoteAgentEvent = z.union([LiveNoteAgentStartEvent, LiveNoteAgentCompleteEvent]);
|
||||
export type LiveNoteAgentEventType = z.infer<typeof LiveNoteAgentEvent>;
|
||||
|
|
@ -22,5 +22,5 @@ export const LlmModelConfig = z.object({
|
|||
// the curated gateway defaults). Read by helpers in core/models/defaults.ts.
|
||||
knowledgeGraphModel: z.string().optional(),
|
||||
meetingNotesModel: z.string().optional(),
|
||||
trackBlockModel: z.string().optional(),
|
||||
liveNoteAgentModel: z.string().optional(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const StartEvent = BaseRunEvent.extend({
|
|||
// run files written before these fields existed still parse cleanly.
|
||||
useCase: z.enum([
|
||||
"copilot_chat",
|
||||
"track_block",
|
||||
"live_note_agent",
|
||||
"meeting_note",
|
||||
"knowledge_sync",
|
||||
]).optional(),
|
||||
|
|
@ -137,7 +137,7 @@ export const AskHumanResponsePayload = AskHumanResponseEvent.pick({
|
|||
|
||||
export const UseCase = z.enum([
|
||||
"copilot_chat",
|
||||
"track_block",
|
||||
"live_note_agent",
|
||||
"meeting_note",
|
||||
"knowledge_sync",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
import z from 'zod';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Triggers — when a track fires
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A track can carry zero or more triggers under the `triggers:` key.
|
||||
// Each trigger is one of:
|
||||
// - cron: exact time, recurring
|
||||
// - window: once per day, anywhere inside a time-of-day band
|
||||
// - once: one-shot at a future time
|
||||
// - event: driven by incoming signals (emails, calendar events, etc.)
|
||||
//
|
||||
// A track can have multiple triggers — e.g. a daily cron trigger AND an event
|
||||
// trigger. Omit `triggers` (or pass an empty array) for a manual-only track.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TriggerSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('cron').describe('Fires at exact cron times'),
|
||||
expression: z.string().describe('5-field cron expression, quoted (e.g. "0 * * * *")'),
|
||||
}).describe('Recurring at exact times'),
|
||||
z.object({
|
||||
type: z.literal('window').describe('Fires once per day, anywhere inside a time-of-day band'),
|
||||
startTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. Also the daily cycle anchor — once the track fires after this time, it won\'t fire again until the next day.'),
|
||||
endTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. After this, the window is closed for the day.'),
|
||||
}).describe('Recurring within a daily time-of-day window'),
|
||||
z.object({
|
||||
type: z.literal('once').describe('Fires once and never again'),
|
||||
runAt: z.string().describe('ISO 8601 datetime, local time, no Z suffix (e.g. "2026-04-14T09:00:00")'),
|
||||
}).describe('One-shot future run'),
|
||||
z.object({
|
||||
type: z.literal('event').describe('Fires when a matching event arrives'),
|
||||
matchCriteria: z.string().describe('Describe the kinds of events that should consider this track for an update (e.g. "Emails about Q3 planning"). Pass 1 routing uses this to decide candidacy; the agent does Pass 2 on the event payload.'),
|
||||
}).describe('Event-driven'),
|
||||
]);
|
||||
|
||||
export type Trigger = z.infer<typeof TriggerSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track entity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TrackSchema = z.object({
|
||||
id: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/).describe('Kebab-case identifier, unique within the note file'),
|
||||
instruction: z.string().min(1).describe('What the agent should produce each run — specific, single-focus, imperative'),
|
||||
active: z.boolean().default(true).describe('Set false to pause without deleting'),
|
||||
triggers: z.array(TriggerSchema).optional().describe('When this track fires. A track can have multiple triggers — e.g. an hourly cron AND an event trigger. Omit (or use an empty array) for a manual-only track.'),
|
||||
model: z.string().optional().describe('ADVANCED — leave unset. Per-track LLM model override (e.g. "anthropic/claude-sonnet-4.6"). Only set when the user explicitly asked for a specific model for THIS track. The global default already picks a tuned model for tracks; overriding usually makes things worse, not better.'),
|
||||
provider: z.string().optional().describe('ADVANCED — leave unset. Per-track provider name override (e.g. "openai", "anthropic"). Only set when the user explicitly asked for a specific provider for THIS track. Almost always omitted; the global default flows through correctly.'),
|
||||
icon: z.string().optional().describe('Lucide icon name for status display (e.g. "clock", "calendar-days", "mail", "history", "list-todo"). Omit to use the default icon for this track.'),
|
||||
lastRunAt: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||
lastRunId: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||
lastRunSummary: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Knowledge events (event-driven track triggering pipeline)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const KnowledgeEventSchema = z.object({
|
||||
id: z.string().describe('Monotonically increasing ID; also the filename in events/pending/'),
|
||||
source: z.string().describe('Producer of the event (e.g. "gmail", "calendar")'),
|
||||
type: z.string().describe('Event type (e.g. "email.synced")'),
|
||||
createdAt: z.string().describe('ISO timestamp when the event was produced'),
|
||||
payload: z.string().describe('Human-readable event body, usually markdown'),
|
||||
targetTrackId: z.string().optional().describe('If set, skip routing and target this track directly (used for re-runs)'),
|
||||
targetFilePath: z.string().optional(),
|
||||
// Enriched on move from pending/ to done/
|
||||
processedAt: z.string().optional(),
|
||||
candidates: z.array(z.object({
|
||||
trackId: z.string(),
|
||||
filePath: z.string(),
|
||||
})).optional(),
|
||||
runIds: z.array(z.string()).optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export type KnowledgeEvent = z.infer<typeof KnowledgeEventSchema>;
|
||||
|
||||
export const Pass1OutputSchema = z.object({
|
||||
candidates: z.array(z.object({
|
||||
trackId: z.string().describe('The track identifier'),
|
||||
filePath: z.string().describe('The note file path the track lives in'),
|
||||
})).describe('Tracks that may be relevant to this event. trackIds are only unique within a file, so always return both fields.'),
|
||||
});
|
||||
|
||||
export type Pass1Output = z.infer<typeof Pass1OutputSchema>;
|
||||
|
||||
// Track bus events
|
||||
export const TrackRunStartEvent = z.object({
|
||||
type: z.literal('track_run_start'),
|
||||
trackId: z.string(),
|
||||
filePath: z.string(),
|
||||
trigger: z.enum(['timed', 'manual', 'event']),
|
||||
runId: z.string(),
|
||||
});
|
||||
|
||||
export const TrackRunCompleteEvent = z.object({
|
||||
type: z.literal('track_run_complete'),
|
||||
trackId: z.string(),
|
||||
filePath: z.string(),
|
||||
runId: z.string(),
|
||||
error: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
});
|
||||
|
||||
export const TrackEvent = z.union([TrackRunStartEvent, TrackRunCompleteEvent]);
|
||||
|
||||
export type Track = z.infer<typeof TrackSchema>;
|
||||
export type TrackEventType = z.infer<typeof TrackEvent>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue