mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-26 08:56:22 +02:00
Add tracks — auto-updating note blocks with scheduled and event-driven triggers
Track blocks are YAML-fenced sections embedded in markdown notes whose output is rewritten by a background agent. Three trigger types: manual (Run button or Copilot), scheduled (cron / window / once with a 2 min grace window), and event-driven (Gmail/Calendar sync events routed via an LLM classifier with a second-pass agent decision). Output lives between <!--track-target:ID--> comment markers that render as editable content in the Tiptap editor so users can read and extend AI-generated content inline. Core: - Schedule and event pipelines run as independent polling loops (15s / 5s), both calling the same triggerTrackUpdate orchestrator. Events are FIFO via monotonic IDs; a per-track Set guards against duplicate runs. - Track-run agent builds three message variants (manual/timed/event) — the event variant includes a Pass 2 directive to skip updates on false positives flagged by the liberal Pass 1 router. - IPC surface: track:run/get/update/replaceYaml/delete plus tracks:events forward of the pub-sub bus to the renderer. - Gmail emits per-thread events; Calendar bundles a digest per sync. Copilot: - New `tracks` skill (auto-generated canonical schema from Zod via z.toJSONSchema) teaches block creation, editing, and proactive suggestion. - `run-track-block` tool with optional `context` parameter for backfills (e.g. seeding a new email-tracking block from existing synced emails). Renderer: - Tiptap chip (display-only) opens a rich modal with tabs, toggle, schedule details, raw YAML editor, and confirm-to-delete. All mutations go through IPC so the backend stays the single writer. - Target regions use two atom marker nodes (open/close) around real editable content — custom blocks render natively, users can add their own notes. - "Edit with Copilot" seeds a chat session with the note attached. Docs: apps/x/TRACKS.md covers product flows, technical pipeline, and a catalog of every LLM prompt involved with file+line pointers.
This commit is contained in:
parent
ab0147d475
commit
e2c13f0f6f
33 changed files with 3405 additions and 2 deletions
|
|
@ -9,6 +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 trackBlock from './track-block.js';
|
||||
export * as frontmatter from './frontmatter.js';
|
||||
export * as bases from './bases.js';
|
||||
export { PrefixLogger };
|
||||
|
|
|
|||
|
|
@ -6,6 +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-block.js';
|
||||
import { UserMessageContent } from './message.js';
|
||||
import { RowboatApiConfig } from './rowboat-account.js';
|
||||
import { ZListToolkitsResponse } from './composio.js';
|
||||
|
|
@ -193,6 +194,10 @@ const ipcSchemas = {
|
|||
req: ServiceEvent,
|
||||
res: z.null(),
|
||||
},
|
||||
'tracks:events': {
|
||||
req: TrackEvent,
|
||||
res: z.null(),
|
||||
},
|
||||
'models:list': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
|
|
@ -560,6 +565,67 @@ const ipcSchemas = {
|
|||
response: z.string().nullable(),
|
||||
}),
|
||||
},
|
||||
// Track channels
|
||||
'track:run': {
|
||||
req: z.object({
|
||||
trackId: z.string(),
|
||||
filePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
summary: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:get': {
|
||||
req: z.object({
|
||||
trackId: z.string(),
|
||||
filePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
// Fresh, authoritative YAML of the track block from disk.
|
||||
// Renderer should use this for display/edit — never its Tiptap node attr.
|
||||
yaml: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:update': {
|
||||
req: z.object({
|
||||
trackId: z.string(),
|
||||
filePath: z.string(),
|
||||
// Partial TrackBlock updates — merged into the block's YAML 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({
|
||||
trackId: 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({
|
||||
trackId: z.string(),
|
||||
filePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
// Billing channels
|
||||
'billing:getInfo': {
|
||||
req: z.null(),
|
||||
|
|
|
|||
87
apps/x/packages/shared/src/track-block.ts
Normal file
87
apps/x/packages/shared/src/track-block.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import z from 'zod';
|
||||
|
||||
export const TrackScheduleSchema = 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 at most once per cron occurrence, only within a time-of-day window'),
|
||||
cron: z.string().describe('5-field cron expression, quoted'),
|
||||
startTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time'),
|
||||
endTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time'),
|
||||
}).describe('Recurring within a 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'),
|
||||
]).describe('Optional schedule. Omit entirely for manual-only tracks.');
|
||||
|
||||
export type TrackSchedule = z.infer<typeof TrackScheduleSchema>;
|
||||
|
||||
export const TrackBlockSchema = z.object({
|
||||
trackId: 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'),
|
||||
eventMatchCriteria: z.string().optional().describe('When set, this track participates in event-based triggering. Describe what kinds of events should consider this track for an update (e.g. "Emails about Q3 planning"). Omit to disable event triggers — the track will only run on schedule or manually.'),
|
||||
active: z.boolean().default(true).describe('Set false to pause without deleting'),
|
||||
schedule: TrackScheduleSchema.optional(),
|
||||
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 block 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 TrackBlock = z.infer<typeof TrackBlockSchema>;
|
||||
export type TrackEventType = z.infer<typeof TrackEvent>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue