diff --git a/packages/cli/src/cli/commands/trigger.ts b/packages/cli/src/cli/commands/trigger.ts index f95a58b..c10579b 100644 --- a/packages/cli/src/cli/commands/trigger.ts +++ b/packages/cli/src/cli/commands/trigger.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import * as workgraph from '@versatly/workgraph-kernel'; import { addWorkspaceOption, + csv, resolveWorkspacePath, runCommand, } from '../core.js'; @@ -9,7 +10,285 @@ import { export function registerTriggerCommands(program: Command, defaultActor: string): void { const triggerCmd = program .command('trigger') - .description('Trigger primitives and run dispatch lifecycle'); + .description('Programmable trigger primitives and evaluation engine'); + + addWorkspaceOption( + triggerCmd + .command('create ') + .description('Create a trigger primitive (cron|webhook|event|manual)') + .option('-a, --actor ', 'Actor', defaultActor) + .option('--type ', 'cron|webhook|event|manual', 'event') + .option('--condition ', 'Condition as cron text or JSON') + .option('--action ', 'Action as objective text or JSON') + .option('--objective ', 'Dispatch objective template shortcut') + .option('--adapter ', 'Dispatch adapter shortcut') + .option('--context ', 'Dispatch context JSON object shortcut') + .option('--enabled ', 'Enable trigger (true|false)', 'true') + .option('--cooldown ', 'Cooldown seconds', '0') + .option('--tags ', 'Comma-separated tags') + .option('--body ', 'Markdown body') + .option('--path ', 'Optional trigger markdown path override') + .option('--json', 'Emit structured JSON output'), + ).action((name, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + return { + trigger: workgraph.trigger.createTrigger(workspacePath, { + actor: opts.actor, + name, + type: parseTriggerType(opts.type), + condition: parseUnknownOption(opts.condition), + action: resolveActionInput(opts), + enabled: parseOptionalBoolean(opts.enabled, 'enabled'), + cooldown: parseOptionalInt(opts.cooldown, 'cooldown') ?? 0, + tags: csv(opts.tags), + body: opts.body, + path: opts.path, + }), + }; + }, + (result) => [ + `Created trigger: ${result.trigger.path}`, + `Type: ${String(result.trigger.fields.type ?? 'event')}`, + `Enabled: ${String(result.trigger.fields.enabled ?? true)}`, + ], + ), + ); + + addWorkspaceOption( + triggerCmd + .command('list') + .description('List trigger primitives') + .option('--type ', 'Filter by cron|webhook|event|manual') + .option('--enabled ', 'Filter by enabled state (true|false)') + .option('--json', 'Emit structured JSON output'), + ).action((opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + const triggers = workgraph.trigger.listTriggers(workspacePath, { + type: opts.type ? parseTriggerType(opts.type) : undefined, + enabled: parseOptionalBoolean(opts.enabled, 'enabled'), + }); + return { + triggers, + count: triggers.length, + }; + }, + (result) => { + if (result.triggers.length === 0) return ['No triggers found.']; + return [ + ...result.triggers.map((trigger) => + `[${String(trigger.fields.type ?? 'event')}] enabled=${String(trigger.fields.enabled ?? true)} ${trigger.path}`), + `${result.count} trigger(s)`, + ]; + }, + ), + ); + + addWorkspaceOption( + triggerCmd + .command('show ') + .description('Show one trigger primitive') + .option('--json', 'Emit structured JSON output'), + ).action((triggerRef, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + const trigger = workgraph.trigger.showTrigger(workspacePath, triggerRef); + const history = workgraph.trigger.triggerHistory(workspacePath, triggerRef); + return { + trigger, + historyCount: history.length, + }; + }, + (result) => [ + `Trigger: ${result.trigger.path}`, + `Name: ${String(result.trigger.fields.name ?? result.trigger.fields.title ?? result.trigger.path)}`, + `Type: ${String(result.trigger.fields.type ?? 'event')} Enabled: ${String(result.trigger.fields.enabled ?? true)}`, + `History entries: ${result.historyCount}`, + ], + ), + ); + + addWorkspaceOption( + triggerCmd + .command('update ') + .description('Update a trigger primitive') + .option('-a, --actor ', 'Actor', defaultActor) + .option('--name ', 'Rename trigger') + .option('--type ', 'cron|webhook|event|manual') + .option('--condition ', 'Condition as cron text or JSON') + .option('--action ', 'Action as objective text or JSON') + .option('--objective ', 'Dispatch objective template shortcut') + .option('--adapter ', 'Dispatch adapter shortcut') + .option('--context ', 'Dispatch context JSON object shortcut') + .option('--enabled ', 'Enable trigger (true|false)') + .option('--cooldown ', 'Cooldown seconds') + .option('--tags ', 'Comma-separated tags') + .option('--body ', 'Replace markdown body') + .option('--json', 'Emit structured JSON output'), + ).action((triggerRef, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + return { + trigger: workgraph.trigger.updateTrigger(workspacePath, triggerRef, { + actor: opts.actor, + name: opts.name, + type: opts.type ? parseTriggerType(opts.type) : undefined, + condition: opts.condition !== undefined ? parseUnknownOption(opts.condition) : undefined, + action: resolveActionInput(opts, true), + enabled: parseOptionalBoolean(opts.enabled, 'enabled'), + cooldown: parseOptionalInt(opts.cooldown, 'cooldown'), + tags: opts.tags !== undefined ? (csv(opts.tags) ?? []) : undefined, + body: opts.body, + }), + }; + }, + (result) => [ + `Updated trigger: ${result.trigger.path}`, + `Type: ${String(result.trigger.fields.type ?? 'event')}`, + `Enabled: ${String(result.trigger.fields.enabled ?? true)}`, + ], + ), + ); + + addWorkspaceOption( + triggerCmd + .command('delete ') + .description('Delete a trigger primitive (soft archive)') + .option('-a, --actor ', 'Actor', defaultActor) + .option('--json', 'Emit structured JSON output'), + ).action((triggerRef, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + workgraph.trigger.deleteTrigger(workspacePath, triggerRef, opts.actor); + return { deleted: triggerRef }; + }, + (result) => [`Deleted trigger: ${result.deleted}`], + ), + ); + + addWorkspaceOption( + triggerCmd + .command('enable ') + .description('Enable a trigger primitive') + .option('-a, --actor ', 'Actor', defaultActor) + .option('--json', 'Emit structured JSON output'), + ).action((triggerRef, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + return { + trigger: workgraph.trigger.enableTrigger(workspacePath, triggerRef, opts.actor), + }; + }, + (result) => [`Enabled trigger: ${result.trigger.path}`], + ), + ); + + addWorkspaceOption( + triggerCmd + .command('disable ') + .description('Disable a trigger primitive') + .option('-a, --actor ', 'Actor', defaultActor) + .option('--json', 'Emit structured JSON output'), + ).action((triggerRef, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + return { + trigger: workgraph.trigger.disableTrigger(workspacePath, triggerRef, opts.actor), + }; + }, + (result) => [`Disabled trigger: ${result.trigger.path}`], + ), + ); + + addWorkspaceOption( + triggerCmd + .command('evaluate [triggerRef]') + .description('Evaluate trigger engine once (all or one trigger)') + .option('-a, --actor ', 'Actor', defaultActor) + .option('--now ', 'Evaluation timestamp override (ISO-8601)') + .option('--json', 'Emit structured JSON output'), + ).action((triggerRef, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + const now = opts.now ? parseIsoDate(opts.now, 'now') : undefined; + if (triggerRef) { + return workgraph.trigger.evaluateTrigger(workspacePath, triggerRef, { + actor: opts.actor, + now, + }); + } + return workgraph.triggerEngine.runTriggerEngineCycle(workspacePath, { + actor: opts.actor, + now, + }); + }, + (result) => { + if ('cycle' in result) { + const triggerResult = result.trigger; + return [ + `Evaluated trigger: ${result.triggerPath}`, + `Fired: ${String(triggerResult?.fired ?? false)}`, + `Reason: ${String(triggerResult?.reason ?? 'n/a')}`, + ...(triggerResult?.nextFireAt ? [`Next fire: ${triggerResult.nextFireAt}`] : []), + ]; + } + return [ + `Evaluated: ${result.evaluated} triggers`, + `Fired: ${result.fired}`, + `Errors: ${result.errors}`, + ...result.triggers.map((entry) => + ` ${entry.triggerPath}: ${entry.fired ? 'FIRED' : 'skipped'} (${entry.reason})${entry.nextFireAt ? ` next=${entry.nextFireAt}` : ''}`), + ]; + }, + ), + ); + + addWorkspaceOption( + triggerCmd + .command('history ') + .description('Show trigger ledger history') + .option('--limit ', 'Limit number of history entries') + .option('--json', 'Emit structured JSON output'), + ).action((triggerRef, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + const entries = workgraph.trigger.triggerHistory(workspacePath, triggerRef); + const limit = parseOptionalInt(opts.limit, 'limit'); + const limited = limit ? entries.slice(-limit) : entries; + return { + triggerRef, + entries: limited, + count: limited.length, + }; + }, + (result) => { + if (result.entries.length === 0) return [`No history for ${result.triggerRef}.`]; + return [ + ...result.entries.map((entry) => `${entry.ts} ${entry.op} ${entry.actor}`), + `${result.count} entr${result.count === 1 ? 'y' : 'ies'}`, + ]; + }, + ), + ); addWorkspaceOption( triggerCmd @@ -141,3 +420,86 @@ export function registerTriggerCommands(program: Command, defaultActor: string): ), ); } + +function parseTriggerType(value: unknown): workgraph.trigger.TriggerPrimitiveType { + const normalized = String(value ?? '').trim().toLowerCase(); + if (normalized === 'cron' || normalized === 'webhook' || normalized === 'event' || normalized === 'manual') { + return normalized; + } + throw new Error(`Invalid trigger type "${String(value)}". Expected cron|webhook|event|manual.`); +} + +function parseOptionalBoolean(value: unknown, label: string): boolean | undefined { + if (value === undefined) return undefined; + if (typeof value === 'boolean') return value; + const normalized = String(value).trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + throw new Error(`Invalid ${label} value "${String(value)}". Expected true|false.`); +} + +function parseOptionalInt(value: unknown, label: string): number | undefined { + if (value === undefined) return undefined; + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid ${label} value "${String(value)}". Expected an integer.`); + } + return parsed; +} + +function parseUnknownOption(value: unknown): unknown { + if (value === undefined) return undefined; + const text = String(value).trim(); + if (!text) return undefined; + if (text.startsWith('{') || text.startsWith('[') || text.startsWith('"')) { + return JSON.parse(text); + } + return text; +} + +function parseJsonObjectOption(value: unknown, label: string): Record | undefined { + if (value === undefined) return undefined; + const text = String(value).trim(); + if (!text) return undefined; + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Invalid ${label} value. Expected a JSON object.`); + } + return parsed as Record; +} + +function parseIsoDate(value: unknown, label: string): Date { + const text = String(value ?? '').trim(); + const timestamp = Date.parse(text); + if (!text || Number.isNaN(timestamp)) { + throw new Error(`Invalid ${label} value "${String(value)}". Expected ISO-8601 date/time.`); + } + return new Date(timestamp); +} + +function resolveActionInput( + opts: { + action?: string; + objective?: string; + adapter?: string; + context?: string; + }, + allowUndefined: boolean = false, +): unknown { + if (opts.action !== undefined) { + return parseUnknownOption(opts.action); + } + const context = parseJsonObjectOption(opts.context, 'context'); + if (opts.objective === undefined && opts.adapter === undefined && context === undefined) { + return allowUndefined ? undefined : undefined; + } + const action: Record = { + type: 'dispatch-run', + }; + if (opts.objective !== undefined) action.objective = opts.objective; + if (opts.adapter !== undefined) action.adapter = opts.adapter; + if (context) action.context = context; + return { + ...action, + }; +} diff --git a/packages/kernel/src/registry.ts b/packages/kernel/src/registry.ts index 65cbffc..fcc89f1 100644 --- a/packages/kernel/src/registry.ts +++ b/packages/kernel/src/registry.ts @@ -365,18 +365,28 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ }, { name: 'trigger', - description: 'Event trigger contract with policy-aware activation.', + description: 'Programmable trigger primitive for cron/event/webhook/manual dispatch.', directory: 'triggers', builtIn: true, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { title: { type: 'string', required: true }, + name: { type: 'string', description: 'Human-readable trigger name' }, + type: { + type: 'string', + default: 'event', + enum: ['cron', 'webhook', 'event', 'manual'], + description: 'cron | webhook | event | manual', + }, event: { type: 'string', description: 'Legacy event selector for compatibility' }, condition: { type: 'any', description: 'Condition object (cron/event/file-watch/thread-complete)' }, status: { type: 'string', default: 'draft', enum: ['draft', 'proposed', 'approved', 'active', 'paused', 'retired'] }, - action: { type: 'any', required: true }, + action: { type: 'any', required: true, description: 'Dispatch template/action payload' }, + enabled: { type: 'boolean', default: true }, cooldown: { type: 'number', default: 0 }, + last_fired: { type: 'date' }, + next_fire_at: { type: 'date' }, cascade_on: { type: 'list', default: [] }, synthesis: { type: 'any', description: 'Optional synthesis-specific trigger configuration' }, idempotency_scope: { type: 'string', default: 'event+target' }, diff --git a/packages/kernel/src/trigger-engine.test.ts b/packages/kernel/src/trigger-engine.test.ts index 5edc23d..7509e23 100644 --- a/packages/kernel/src/trigger-engine.test.ts +++ b/packages/kernel/src/trigger-engine.test.ts @@ -115,6 +115,62 @@ describe('trigger engine', () => { expect(state.triggers[triggerPrimitive.path]?.cooldownUntil).toBeDefined(); }); + it('matches event trigger patterns against ledger events', () => { + const patternTrigger = store.create(workspacePath, 'trigger', { + title: 'Pattern match done events', + type: 'event', + enabled: true, + status: 'active', + condition: { type: 'event', pattern: 'thread.*' }, + action: { + type: 'create-thread', + title: 'Pattern follow-up {{matched_event_latest_target}}', + goal: 'Validate wildcard pattern matching', + }, + cooldown: 0, + }, '# Trigger\n', 'system'); + + const seed = thread.createThread(workspacePath, 'Pattern source', 'Complete source thread', 'agent-pattern'); + thread.claim(workspacePath, seed.path, 'agent-pattern'); + thread.done(workspacePath, seed.path, 'agent-pattern', 'Done https://github.com/versatly/workgraph/pull/33'); + + const first = triggerEngine.runTriggerEngineCycle(workspacePath, { actor: 'system' }); + expect(first.fired).toBe(0); + + const another = thread.createThread(workspacePath, 'Pattern source 2', 'Second completion', 'agent-pattern'); + thread.claim(workspacePath, another.path, 'agent-pattern'); + thread.done(workspacePath, another.path, 'agent-pattern', 'Done https://github.com/versatly/workgraph/pull/34'); + + const second = triggerEngine.runTriggerEngineCycle(workspacePath, { actor: 'system' }); + expect(second.fired).toBe(1); + const triggerResult = second.triggers.find((entry) => entry.triggerPath === patternTrigger.path); + expect(triggerResult?.reason).toContain('Matched'); + expect(store.list(workspacePath, 'thread').some((entry) => + String(entry.fields.title).startsWith('Pattern follow-up')) + ).toBe(true); + }); + + it('does not auto-fire manual triggers during engine cycles', () => { + const manualTrigger = store.create(workspacePath, 'trigger', { + title: 'Manual only trigger', + type: 'manual', + enabled: true, + status: 'active', + condition: { type: 'manual' }, + action: { + type: 'dispatch-run', + objective: 'Manual fire required', + }, + cooldown: 0, + }, '# Trigger\n', 'system'); + + const cycle = triggerEngine.runTriggerEngineCycle(workspacePath, { actor: 'system' }); + expect(cycle.fired).toBe(0); + const result = cycle.triggers.find((entry) => entry.triggerPath === manualTrigger.path); + expect(result?.fired).toBe(false); + expect(result?.reason).toContain('Manual trigger condition requires explicit'); + }); + it('fires cascade triggers immediately when thread reaches done state', () => { const cascadeTrigger = store.create(workspacePath, 'trigger', { title: 'Cascade on completion', diff --git a/packages/kernel/src/trigger-engine.ts b/packages/kernel/src/trigger-engine.ts index b54d7b6..8dbe067 100644 --- a/packages/kernel/src/trigger-engine.ts +++ b/packages/kernel/src/trigger-engine.ts @@ -5,6 +5,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import * as dispatch from './dispatch.js'; import * as ledger from './ledger.js'; import * as store from './store.js'; @@ -22,6 +23,7 @@ interface TriggerRuntimeState { fireCount: number; lastEvaluatedAt?: string; lastFiredAt?: string; + nextFireAt?: string; cooldownUntil?: string; lastError?: string; state?: TriggerRuntimeStatus; @@ -54,7 +56,7 @@ type TriggerCondition = } | { type: 'event'; - eventType: string; + pattern: string; } | { type: 'file-watch'; @@ -63,6 +65,9 @@ type TriggerCondition = | { type: 'thread-complete'; threadPath?: string; + } + | { + type: 'manual'; }; type TriggerAction = @@ -110,6 +115,8 @@ interface NormalizedTrigger { instance: PrimitiveInstance; path: string; title: string; + triggerType: 'cron' | 'webhook' | 'event' | 'manual'; + enabled: boolean; status: string; cooldownSeconds: number; condition: TriggerCondition | null; @@ -130,6 +137,7 @@ export interface TriggerEngineCycleTriggerResult { fired: boolean; reason: string; actionType?: string; + nextFireAt?: string; runtimeState: TriggerRuntimeStatus; error?: string; } @@ -147,6 +155,7 @@ export interface TriggerEngineCycleOptions { actor?: string; now?: Date; intervalSeconds?: number; + triggerPaths?: string[]; } export interface StartTriggerEngineOptions { @@ -272,9 +281,13 @@ export function runTriggerEngineCycle( const actor = options.actor ?? 'system'; const intervalSeconds = normalizeInt(options.intervalSeconds, DEFAULT_ENGINE_INTERVAL_SECONDS, 1); const state = loadTriggerState(workspacePath); - const triggers = listNormalizedTriggers(workspacePath); + const allTriggers = listNormalizedTriggers(workspacePath); + const triggerPathFilter = normalizeTriggerPathFilter(options.triggerPaths); + const triggers = triggerPathFilter + ? allTriggers.filter((trigger) => triggerPathFilter.has(trigger.path)) + : allTriggers; const requiresLedgerRead = triggers.some((trigger) => - trigger.status === 'active' && ( + isTriggerCycleEvaluable(trigger) && ( trigger.condition?.type === 'event' || trigger.condition?.type === 'thread-complete' ) @@ -292,13 +305,27 @@ export function runTriggerEngineCycle( runtime.lastEvaluatedAt = nowIso; runtime.state = 'ready'; runtime.lastError = undefined; + runtime.nextFireAt = computeNextFireAt(trigger, runtime, now); - if (trigger.status !== 'active') { + if (!trigger.enabled) { runtime.state = 'inactive'; results.push({ triggerPath: trigger.path, fired: false, - reason: `Trigger status is "${trigger.status}" (only "active" is evaluated).`, + reason: 'Trigger is disabled.', + nextFireAt: runtime.nextFireAt, + runtimeState: runtime.state, + }); + continue; + } + + if (!isTriggerStatusActive(trigger.status)) { + runtime.state = 'inactive'; + results.push({ + triggerPath: trigger.path, + fired: false, + reason: `Trigger status is "${trigger.status}" (only "active"/"approved" is evaluated).`, + nextFireAt: runtime.nextFireAt, runtimeState: runtime.state, }); continue; @@ -312,6 +339,7 @@ export function runTriggerEngineCycle( triggerPath: trigger.path, fired: false, reason: 'Invalid trigger condition.', + nextFireAt: runtime.nextFireAt, runtimeState: runtime.state, error: runtime.lastError, }); @@ -325,6 +353,7 @@ export function runTriggerEngineCycle( triggerPath: trigger.path, fired: false, reason: 'Invalid trigger action.', + nextFireAt: runtime.nextFireAt, runtimeState: runtime.state, error: runtime.lastError, }); @@ -338,6 +367,7 @@ export function runTriggerEngineCycle( triggerPath: trigger.path, fired: false, reason: cooldownBlock.reason, + nextFireAt: runtime.nextFireAt, runtimeState: runtime.state, }); continue; @@ -355,6 +385,7 @@ export function runTriggerEngineCycle( triggerPath: trigger.path, fired: false, reason: decision.reason, + nextFireAt: runtime.nextFireAt, runtimeState: runtime.state ?? 'ready', }); continue; @@ -381,11 +412,14 @@ export function runTriggerEngineCycle( runtime.state = 'ready'; } runtime.lastError = undefined; + runtime.nextFireAt = computeNextFireAt(trigger, runtime, now); + syncTriggerScheduleFields(workspacePath, trigger, runtime, actor); results.push({ triggerPath: trigger.path, fired: true, reason: decision.reason, actionType: trigger.action.type, + nextFireAt: runtime.nextFireAt, runtimeState: runtime.state, }); } catch (error) { @@ -397,6 +431,7 @@ export function runTriggerEngineCycle( fired: false, reason: decision.reason, actionType: trigger.action.type, + nextFireAt: runtime.nextFireAt, runtimeState: runtime.state, error: runtime.lastError, }); @@ -546,7 +581,8 @@ export function evaluateThreadCompleteCascadeTriggers( }; const candidates = listNormalizedTriggers(workspacePath) - .filter((trigger) => trigger.status === 'active') + .filter((trigger) => trigger.enabled) + .filter((trigger) => isTriggerStatusActive(trigger.status)) .filter((trigger) => trigger.condition?.type === 'thread-complete') .filter((trigger) => trigger.cascadeOn.length === 0 || trigger.cascadeOn.includes('thread-complete')); @@ -559,6 +595,7 @@ export function evaluateThreadCompleteCascadeTriggers( runtime.lastEvaluatedAt = nowIso; runtime.state = 'ready'; runtime.lastError = undefined; + runtime.nextFireAt = computeNextFireAt(trigger, runtime, now); if (!trigger.action || !trigger.condition || trigger.condition.type !== 'thread-complete') { runtime.state = 'error'; @@ -568,6 +605,7 @@ export function evaluateThreadCompleteCascadeTriggers( triggerPath: trigger.path, fired: false, reason: 'Invalid thread-complete trigger definition.', + nextFireAt: runtime.nextFireAt, runtimeState: runtime.state, error: runtime.lastError, }); @@ -582,6 +620,7 @@ export function evaluateThreadCompleteCascadeTriggers( triggerPath: trigger.path, fired: false, reason: `Completed thread ${actual} does not match cascade target ${expected}.`, + nextFireAt: runtime.nextFireAt, runtimeState: runtime.state, }); continue; @@ -595,6 +634,7 @@ export function evaluateThreadCompleteCascadeTriggers( triggerPath: trigger.path, fired: false, reason: cooldownBlock.reason, + nextFireAt: runtime.nextFireAt, runtimeState: runtime.state, }); continue; @@ -620,11 +660,14 @@ export function evaluateThreadCompleteCascadeTriggers( runtime.cooldownUntil = undefined; runtime.state = 'ready'; } + runtime.nextFireAt = computeNextFireAt(trigger, runtime, now); + syncTriggerScheduleFields(workspacePath, trigger, runtime, actor); results.push({ triggerPath: trigger.path, fired: true, reason: `Cascade fired for completed thread ${completedThreadPath}.`, actionType: trigger.action.type, + nextFireAt: runtime.nextFireAt, runtimeState: runtime.state, }); } catch (error) { @@ -636,6 +679,7 @@ export function evaluateThreadCompleteCascadeTriggers( fired: false, reason: `Cascade action failed for ${completedThreadPath}.`, actionType: trigger.action.type, + nextFireAt: runtime.nextFireAt, runtimeState: runtime.state, error: runtime.lastError, }); @@ -891,7 +935,9 @@ function appendTriggerFireLedger( } function buildDispatchIdempotencyKey(triggerPath: string, eventKey: string, objective: string): string { - return `${triggerPath}:${eventKey}:${objective}`.slice(0, 255); + return createHash('sha256') + .update(`${triggerPath}:${eventKey}:${objective}`) + .digest('hex'); } function evaluateTriggerCondition(input: { @@ -934,11 +980,16 @@ function evaluateTriggerCondition(input: { }; } case 'event': - return evaluateEventCondition(input, condition.eventType); + return evaluateEventCondition(input, condition.pattern); case 'thread-complete': return evaluateEventCondition(input, 'thread-complete'); case 'file-watch': return evaluateFileWatchCondition(input, condition.glob); + case 'manual': + return { + matched: false, + reason: 'Manual trigger condition requires explicit `workgraph trigger fire`.', + }; default: { const exhaustive: never = condition; return { @@ -1007,8 +1058,8 @@ function evaluateEventCondition(input: { runtime: TriggerRuntimeState; now: Date; ledgerEntries: ReturnType; -}, eventTypeRaw: string): TriggerConditionDecision { - const eventType = eventTypeRaw.toLowerCase(); +}, eventPatternRaw: string): TriggerConditionDecision { + const eventPattern = eventPatternRaw.toLowerCase(); const totalEntries = input.ledgerEntries.length; const latestEntry = input.ledgerEntries[totalEntries - 1]; @@ -1018,7 +1069,7 @@ function evaluateEventCondition(input: { input.runtime.lastEventCursorHash = latestEntry?.hash; return { matched: false, - reason: `Initialized event cursor for ${eventType} at offset ${input.runtime.lastEventCursorOffset}.`, + reason: `Initialized event cursor for pattern "${eventPattern}" at offset ${input.runtime.lastEventCursorOffset}.`, }; } @@ -1027,11 +1078,11 @@ function evaluateEventCondition(input: { if (newEntries.length === 0) { return { matched: false, - reason: `No new events for ${eventType} since ledger offset ${cursorOffset}.`, + reason: `No new events for pattern "${eventPattern}" since ledger offset ${cursorOffset}.`, }; } - const matching = newEntries.filter((entry) => ledgerEntryMatchesEventType(entry, eventType)); + const matching = newEntries.filter((entry) => ledgerEntryMatchesEventPattern(entry, eventPattern)); const latestProcessed = newEntries[newEntries.length - 1]!; input.runtime.lastEventCursorOffset = totalEntries; input.runtime.lastEventCursorTs = latestProcessed.ts; @@ -1040,16 +1091,17 @@ function evaluateEventCondition(input: { if (matching.length === 0) { return { matched: false, - reason: `No matching ${eventType} events in ${newEntries.length} new ledger entries.`, + reason: `No events matched pattern "${eventPattern}" in ${newEntries.length} new ledger entries.`, }; } const latest = matching[matching.length - 1]!; return { matched: true, - reason: `Matched ${matching.length} ${eventType} event(s).`, - eventKey: `${eventType}:${latest.ts}:${latest.target}`, + reason: `Matched ${matching.length} event(s) for pattern "${eventPattern}".`, + eventKey: `event:${eventPattern}:${latest.ts}:${latest.target}`, context: { + matched_event_pattern: eventPattern, matched_event_count: matching.length, matched_event_latest_target: latest.target, matched_event_latest_op: latest.op, @@ -1144,23 +1196,36 @@ function evaluateFileWatchCondition(input: { }; } -function ledgerEntryMatchesEventType( +function ledgerEntryMatchesEventPattern( entry: ReturnType[number], - eventType: string, + eventPattern: string, ): boolean { - const typeOp = `${String(entry.type ?? '').toLowerCase()}.${entry.op.toLowerCase()}`; - const opOnly = entry.op.toLowerCase(); const canonicalType = String(entry.type ?? '').toLowerCase(); + const opOnly = entry.op.toLowerCase(); + const typeOp = `${canonicalType}.${opOnly}`; + const pattern = eventPattern.toLowerCase(); - if (eventType === 'thread-complete') { + if (pattern === 'thread-complete') { return canonicalType === 'thread' && entry.op === 'done'; } - if (eventType === opOnly) return true; - if (eventType === typeOp) return true; + const dataEvent = typeof entry.data?.event_type === 'string' ? String(entry.data.event_type).toLowerCase() - : null; - return dataEvent === eventType; + : undefined; + const target = String(entry.target ?? '').toLowerCase(); + const candidates = [ + opOnly, + canonicalType, + typeOp, + target, + `${typeOp}:${target}`, + dataEvent, + ].filter((value): value is string => typeof value === 'string' && value.length > 0); + + if (!pattern.includes('*') && !pattern.includes('?')) { + return candidates.includes(pattern); + } + return candidates.some((candidate) => wildcardMatch(candidate, pattern)); } function listNormalizedTriggers(workspacePath: string): NormalizedTrigger[] { @@ -1171,14 +1236,16 @@ function listNormalizedTriggers(workspacePath: string): NormalizedTrigger[] { function normalizeTrigger(workspacePath: string, instance: PrimitiveInstance): NormalizedTrigger { const status = String(instance.fields.status ?? 'draft').toLowerCase(); - const title = String(instance.fields.title ?? instance.path); + const title = String(instance.fields.name ?? instance.fields.title ?? instance.path); const cooldownSeconds = normalizeInt( asNumber(instance.fields.cooldown) ?? asNumber(instance.fields.cooldown_seconds) ?? 0, 0, 0, ); - const condition = safeParseCondition(instance.fields.condition ?? instance.fields.event); + const triggerType = parseTriggerPrimitiveType(instance.fields.type, instance.fields.condition); + const enabled = asBoolean(instance.fields.enabled) ?? isTriggerStatusActive(status); + const condition = safeParseCondition(instance.fields.condition ?? instance.fields.event, triggerType); const action = parseTriggerAction(instance.fields.action); const synthesis = parseSynthesisConfig(instance.fields.synthesis, instance.fields); const cascadeOn = asStringList(instance.fields.cascade_on); @@ -1195,6 +1262,8 @@ function normalizeTrigger(workspacePath: string, instance: PrimitiveInstance): N instance, path: instance.path, title, + triggerType, + enabled, status, cooldownSeconds, condition, @@ -1204,15 +1273,21 @@ function normalizeTrigger(workspacePath: string, instance: PrimitiveInstance): N }; } -function safeParseCondition(raw: unknown): TriggerCondition | null { +function safeParseCondition(raw: unknown, triggerType: 'cron' | 'webhook' | 'event' | 'manual'): TriggerCondition | null { try { - return parseTriggerCondition(raw); + return parseTriggerCondition(raw, triggerType); } catch { return null; } } -function parseTriggerCondition(raw: unknown): TriggerCondition | null { +function parseTriggerCondition(raw: unknown, triggerType: 'cron' | 'webhook' | 'event' | 'manual'): TriggerCondition | null { + if (raw === undefined || raw === null || (typeof raw === 'string' && raw.trim().length === 0)) { + if (triggerType === 'manual') return { type: 'manual' }; + if (triggerType === 'webhook') return { type: 'event', pattern: 'webhook.*' }; + if (triggerType === 'event') return { type: 'event', pattern: '*' }; + return null; + } if (typeof raw === 'string') { const text = raw.trim(); if (!text) return null; @@ -1226,9 +1301,12 @@ function parseTriggerCondition(raw: unknown): TriggerCondition | null { if (text.toLowerCase() === 'thread-complete') { return { type: 'thread-complete' }; } + if (text.toLowerCase() === 'manual') { + return { type: 'manual' }; + } return { type: 'event', - eventType: text, + pattern: text, }; } @@ -1246,11 +1324,19 @@ function parseTriggerCondition(raw: unknown): TriggerCondition | null { }; } if (type === 'event' || obj.event !== undefined || obj.event_type !== undefined) { - const eventType = String(obj.event ?? obj.event_type ?? '').trim(); - if (!eventType) return null; + const pattern = String(obj.pattern ?? obj.event ?? obj.event_type ?? '').trim(); + if (!pattern) return null; return { type: 'event', - eventType, + pattern, + }; + } + if (type === 'webhook') { + const pattern = String(obj.pattern ?? obj.event ?? 'webhook.*').trim(); + if (!pattern) return null; + return { + type: 'event', + pattern, }; } if (type === 'file-watch' || obj.glob !== undefined || obj.pattern !== undefined) { @@ -1268,6 +1354,9 @@ function parseTriggerCondition(raw: unknown): TriggerCondition | null { threadPath: threadPath ? normalizeReferencePath(threadPath) : undefined, }; } + if (type === 'manual') { + return { type: 'manual' }; + } return null; } @@ -1302,6 +1391,15 @@ function parseTriggerAction(raw: unknown): TriggerAction | null { if (!raw || typeof raw !== 'object') return null; const obj = raw as Record; const type = String(obj.type ?? '').toLowerCase(); + if (!type && (obj.objective !== undefined || obj.adapter !== undefined || obj.context !== undefined)) { + return { + type: 'dispatch-run', + objective: asString(obj.objective), + adapter: asString(obj.adapter), + context: isRecord(obj.context) ? obj.context : undefined, + actor: asString(obj.actor), + }; + } switch (type) { case 'create-thread': return { @@ -1377,6 +1475,65 @@ function parseSynthesisConfig(raw: unknown, fields: Record): Sy }; } +function parseTriggerPrimitiveType( + rawType: unknown, + rawCondition: unknown, +): 'cron' | 'webhook' | 'event' | 'manual' { + const normalized = typeof rawType === 'string' + ? rawType.trim().toLowerCase() + : ''; + if (normalized === 'cron' || normalized === 'webhook' || normalized === 'event' || normalized === 'manual') { + return normalized; + } + if (typeof rawCondition === 'string' && looksLikeCron(rawCondition)) return 'cron'; + if (isRecord(rawCondition) && typeof rawCondition.type === 'string') { + const conditionType = String(rawCondition.type).toLowerCase(); + if (conditionType === 'cron') return 'cron'; + if (conditionType === 'manual') return 'manual'; + } + return 'event'; +} + +function isTriggerStatusActive(status: string): boolean { + return status === 'active' || status === 'approved'; +} + +function isTriggerCycleEvaluable(trigger: NormalizedTrigger): boolean { + return trigger.enabled && isTriggerStatusActive(trigger.status); +} + +function normalizeTriggerPathFilter(triggerPaths: string[] | undefined): Set | null { + if (!Array.isArray(triggerPaths) || triggerPaths.length === 0) return null; + const normalized = triggerPaths + .map((entry) => String(entry ?? '').trim().replace(/\\/g, '/').replace(/^\.\//, '')) + .filter(Boolean); + if (normalized.length === 0) return null; + return new Set(normalized); +} + +function syncTriggerScheduleFields( + workspacePath: string, + trigger: NormalizedTrigger, + runtime: TriggerRuntimeState, + actor: string, +): void { + const current = store.read(workspacePath, trigger.path); + if (!current || current.type !== 'trigger') return; + const currentLastFired = asString(current.fields.last_fired); + const currentNextFire = asString(current.fields.next_fire_at); + const nextLastFired = runtime.lastFiredAt; + const nextFireAt = runtime.nextFireAt; + + const shouldWriteLast = nextLastFired !== undefined && nextLastFired !== currentLastFired; + const shouldWriteNext = nextFireAt !== currentNextFire; + if (!shouldWriteLast && !shouldWriteNext) return; + + const updates: Record = {}; + if (shouldWriteLast) updates.last_fired = nextLastFired; + if (shouldWriteNext) updates.next_fire_at = nextFireAt ?? null; + store.update(workspacePath, trigger.path, updates, undefined, actor); +} + function getOrCreateRuntimeState(state: TriggerStateData, triggerPath: string): TriggerRuntimeState { if (!state.triggers[triggerPath]) { state.triggers[triggerPath] = { fireCount: 0 }; @@ -1427,7 +1584,7 @@ function deriveRuntimeState( runtime: TriggerRuntimeState, now: Date, ): TriggerRuntimeStatus { - if (trigger.status !== 'active') return 'inactive'; + if (!trigger.enabled || !isTriggerStatusActive(trigger.status)) return 'inactive'; if (runtime.lastError) return 'error'; if (runtime.cooldownUntil) { const until = Date.parse(runtime.cooldownUntil); @@ -1447,11 +1604,13 @@ function describeCondition(trigger: NormalizedTrigger): string { case 'cron': return `cron(${trigger.condition.expression})`; case 'event': - return `event(${trigger.condition.eventType})`; + return `event(${trigger.condition.pattern})`; case 'file-watch': return `file-watch(${trigger.condition.glob})`; case 'thread-complete': return `thread-complete(${trigger.condition.threadPath ?? '*'})`; + case 'manual': + return 'manual(explicit fire only)'; default: return 'invalid'; } @@ -1683,6 +1842,16 @@ function asNumber(value: unknown): number | undefined { return undefined; } +function asBoolean(value: unknown): boolean | undefined { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + } + return undefined; +} + function normalizeInt(value: unknown, fallback: number, minimum: number): number { const numeric = asNumber(value); if (numeric === undefined) return fallback; diff --git a/packages/kernel/src/trigger.test.ts b/packages/kernel/src/trigger.test.ts index 1299ece..26ba216 100644 --- a/packages/kernel/src/trigger.test.ts +++ b/packages/kernel/src/trigger.test.ts @@ -5,7 +5,17 @@ import path from 'node:path'; import * as ledger from './ledger.js'; import { loadRegistry, saveRegistry } from './registry.js'; import * as store from './store.js'; -import { fireTrigger } from './trigger.js'; +import { + createTrigger, + deleteTrigger, + disableTrigger, + enableTrigger, + fireTrigger, + listTriggers, + showTrigger, + triggerHistory, + updateTrigger, +} from './trigger.js'; let workspacePath: string; @@ -20,6 +30,53 @@ afterEach(() => { }); describe('trigger primitives', () => { + it('supports trigger primitive CRUD and state transitions', () => { + const created = createTrigger(workspacePath, { + actor: 'system', + name: 'Nightly digest', + type: 'cron', + condition: '0 2 * * *', + action: { + type: 'dispatch-run', + objective: 'Run nightly digest', + }, + cooldown: 120, + tags: ['ops', 'nightly'], + }); + expect(created.path).toContain('triggers/'); + expect(String(created.fields.name)).toBe('Nightly digest'); + expect(String(created.fields.type)).toBe('cron'); + expect(created.fields.enabled).toBe(true); + + const listed = listTriggers(workspacePath, { type: 'cron', enabled: true }); + expect(listed.map((entry) => entry.path)).toContain(created.path); + + const shown = showTrigger(workspacePath, 'nightly-digest'); + expect(shown.path).toBe(created.path); + + const updated = updateTrigger(workspacePath, created.path, { + actor: 'system', + cooldown: 300, + tags: ['ops', 'digest'], + }); + expect(updated.fields.cooldown).toBe(300); + expect(updated.fields.tags).toEqual(['ops', 'digest']); + + const disabled = disableTrigger(workspacePath, created.path, 'system'); + expect(disabled.fields.enabled).toBe(false); + expect(disabled.fields.status).toBe('paused'); + + const enabled = enableTrigger(workspacePath, created.path, 'system'); + expect(enabled.fields.enabled).toBe(true); + expect(enabled.fields.status).toBe('active'); + + const history = triggerHistory(workspacePath, created.path); + expect(history.length).toBeGreaterThan(0); + + deleteTrigger(workspacePath, created.path, 'system'); + expect(listTriggers(workspacePath).some((entry) => entry.path === created.path)).toBe(false); + }); + it('throws when trigger path does not exist', () => { expect(() => fireTrigger(workspacePath, 'triggers/missing-trigger.md', { actor: 'agent-x' })) .toThrow('Trigger not found: triggers/missing-trigger.md'); @@ -61,6 +118,62 @@ describe('trigger primitives', () => { .toThrow('Trigger must be approved/active to fire. Current status: draft'); }); + it('blocks manual fire when trigger is explicitly disabled', () => { + const triggerPrimitive = store.create( + workspacePath, + 'trigger', + { + title: 'Disabled trigger', + type: 'manual', + enabled: false, + status: 'active', + action: { + type: 'dispatch-run', + objective: 'Should never run', + }, + }, + '# Trigger\n', + 'system', + ); + + expect(() => fireTrigger(workspacePath, triggerPrimitive.path, { actor: 'agent-x', eventKey: 'evt-1' })) + .toThrow(`Trigger must be enabled to fire: ${triggerPrimitive.path}`); + }); + + it('fires using dispatch template interpolation and updates last_fired', () => { + const triggerPrimitive = createTrigger(workspacePath, { + actor: 'system', + name: 'Escalate incident', + type: 'manual', + condition: { type: 'manual' }, + action: { + type: 'dispatch-run', + objective: 'Escalate {{incident_id}} to {{owner}}', + context: { + severity: '{{severity}}', + incident_id: '{{incident_id}}', + }, + }, + }); + + const fired = fireTrigger(workspacePath, triggerPrimitive.path, { + actor: 'agent-gate', + eventKey: 'evt-manual-1', + context: { + incident_id: 'inc-17', + owner: 'agent-ops', + severity: 'critical', + }, + }); + expect(fired.run.objective).toBe('Escalate inc-17 to agent-ops'); + expect(fired.run.context?.severity).toBe('critical'); + expect(fired.run.context?.incident_id).toBe('inc-17'); + expect(fired.run.context?.trigger_type).toBe('manual'); + + const refreshed = showTrigger(workspacePath, triggerPrimitive.path); + expect(typeof refreshed.fields.last_fired).toBe('string'); + }); + it('fires active triggers with deterministic idempotency and writes ledger audit entries', () => { const triggerPrimitive = store.create( workspacePath, diff --git a/packages/kernel/src/trigger.ts b/packages/kernel/src/trigger.ts index 46237ec..d280226 100644 --- a/packages/kernel/src/trigger.ts +++ b/packages/kernel/src/trigger.ts @@ -3,10 +3,57 @@ */ import { createHash } from 'node:crypto'; +import path from 'node:path'; import * as dispatch from './dispatch.js'; import * as ledger from './ledger.js'; import * as store from './store.js'; -import type { DispatchRun } from './types.js'; +import * as triggerEngine from './trigger-engine.js'; +import type { DispatchRun, LedgerEntry, PrimitiveInstance } from './types.js'; + +export type TriggerPrimitiveType = 'cron' | 'webhook' | 'event' | 'manual'; + +export interface TriggerCreateInput { + actor: string; + name: string; + type: TriggerPrimitiveType; + condition?: unknown; + action?: unknown; + enabled?: boolean; + cooldown?: number; + body?: string; + tags?: string[]; + path?: string; +} + +export interface TriggerListOptions { + enabled?: boolean; + type?: TriggerPrimitiveType; +} + +export interface TriggerUpdateInput { + actor: string; + name?: string; + type?: TriggerPrimitiveType; + condition?: unknown; + action?: unknown; + enabled?: boolean; + cooldown?: number; + body?: string; + tags?: string[]; + lastFired?: string | null; + nextFireAt?: string | null; +} + +export interface TriggerEvaluateOptions { + actor?: string; + now?: Date; +} + +export interface TriggerEvaluateResult { + triggerPath: string; + cycle: triggerEngine.TriggerEngineCycleResult; + trigger: triggerEngine.TriggerEngineCycleTriggerResult | undefined; +} export interface FireTriggerOptions { actor: string; @@ -34,38 +81,206 @@ export interface FireTriggerAndExecuteResult extends FireTriggerResult { retriedFromRunId?: string; } +export function createTrigger( + workspacePath: string, + input: TriggerCreateInput, +): PrimitiveInstance { + const name = normalizeNonEmpty(input.name, 'Trigger name'); + const triggerType = normalizeTriggerType(input.type); + const enabled = input.enabled ?? true; + const fields: Record = { + title: name, + name, + type: triggerType, + condition: normalizeTriggerCondition(triggerType, input.condition), + action: normalizeTriggerAction(input.action, name), + enabled, + status: enabled ? 'active' : 'paused', + cooldown: normalizeCooldown(input.cooldown), + tags: normalizeTags(input.tags), + }; + return store.create( + workspacePath, + 'trigger', + fields, + input.body ?? defaultTriggerBody(name, triggerType), + input.actor, + { + pathOverride: normalizeTriggerPathOverride(input.path), + }, + ); +} + +export function listTriggers( + workspacePath: string, + options: TriggerListOptions = {}, +): PrimitiveInstance[] { + let triggers = store.list(workspacePath, 'trigger') + .sort((left, right) => left.path.localeCompare(right.path)); + if (options.enabled !== undefined) { + triggers = triggers.filter((trigger) => readTriggerEnabled(trigger.fields) === options.enabled); + } + if (options.type) { + const expectedType = normalizeTriggerType(options.type); + triggers = triggers.filter((trigger) => + readTriggerType(trigger.fields) === expectedType); + } + return triggers; +} + +export function showTrigger(workspacePath: string, triggerRef: string): PrimitiveInstance { + return readTriggerByReference(workspacePath, triggerRef); +} + +export function updateTrigger( + workspacePath: string, + triggerRef: string, + input: TriggerUpdateInput, +): PrimitiveInstance { + const trigger = readTriggerByReference(workspacePath, triggerRef); + const nextType = input.type + ? normalizeTriggerType(input.type) + : readTriggerType(trigger.fields); + const updates: Record = {}; + + if (input.name !== undefined) { + const name = normalizeNonEmpty(input.name, 'Trigger name'); + updates.name = name; + updates.title = name; + } + if (input.type !== undefined) { + updates.type = nextType; + } + if (input.condition !== undefined) { + updates.condition = normalizeTriggerCondition(nextType, input.condition); + } + if (input.action !== undefined) { + const fallbackName = String(trigger.fields.name ?? trigger.fields.title ?? trigger.path); + updates.action = normalizeTriggerAction(input.action, fallbackName); + } + if (input.enabled !== undefined) { + updates.enabled = input.enabled; + updates.status = input.enabled ? 'active' : 'paused'; + } + if (input.cooldown !== undefined) { + updates.cooldown = normalizeCooldown(input.cooldown); + } + if (input.tags !== undefined) { + updates.tags = normalizeTags(input.tags); + } + if (input.lastFired !== undefined) { + updates.last_fired = normalizeNullableDate(input.lastFired, 'lastFired'); + } + if (input.nextFireAt !== undefined) { + updates.next_fire_at = normalizeNullableDate(input.nextFireAt, 'nextFireAt'); + } + + return store.update( + workspacePath, + trigger.path, + updates, + input.body, + input.actor, + ); +} + +export function deleteTrigger(workspacePath: string, triggerRef: string, actor: string): void { + const trigger = readTriggerByReference(workspacePath, triggerRef); + store.remove(workspacePath, trigger.path, actor); +} + +export function enableTrigger(workspacePath: string, triggerRef: string, actor: string): PrimitiveInstance { + return updateTrigger(workspacePath, triggerRef, { actor, enabled: true }); +} + +export function disableTrigger(workspacePath: string, triggerRef: string, actor: string): PrimitiveInstance { + return updateTrigger(workspacePath, triggerRef, { actor, enabled: false }); +} + +export function triggerHistory(workspacePath: string, triggerRef: string): LedgerEntry[] { + const trigger = readTriggerByReference(workspacePath, triggerRef); + return ledger.historyOf(workspacePath, trigger.path); +} + +export function evaluateTrigger( + workspacePath: string, + triggerRef: string, + options: TriggerEvaluateOptions = {}, +): TriggerEvaluateResult { + const trigger = readTriggerByReference(workspacePath, triggerRef); + const cycle = triggerEngine.runTriggerEngineCycle(workspacePath, { + actor: options.actor, + now: options.now, + triggerPaths: [trigger.path], + }); + return { + triggerPath: trigger.path, + cycle, + trigger: cycle.triggers.find((entry) => entry.triggerPath === trigger.path), + }; +} + export function fireTrigger( workspacePath: string, - triggerPath: string, + triggerRef: string, options: FireTriggerOptions, ): FireTriggerResult { - const trigger = store.read(workspacePath, triggerPath); - if (!trigger) throw new Error(`Trigger not found: ${triggerPath}`); - if (trigger.type !== 'trigger') throw new Error(`Target is not a trigger primitive: ${triggerPath}`); + const trigger = readTriggerByReference(workspacePath, triggerRef); - const triggerStatus = String(trigger.fields.status ?? 'draft'); + const explicitEnabled = asBoolean(trigger.fields.enabled); + if (explicitEnabled === false) { + throw new Error(`Trigger must be enabled to fire: ${trigger.path}`); + } + const triggerStatus = String(trigger.fields.status ?? 'draft').toLowerCase(); + if (triggerStatus === 'retired') throw new Error(`Trigger is retired and cannot be fired: ${trigger.path}`); if (!['approved', 'active'].includes(triggerStatus)) { throw new Error(`Trigger must be approved/active to fire. Current status: ${triggerStatus}`); } - const objective = options.objective - ?? `Trigger ${String(trigger.fields.title ?? triggerPath)} fired action ${String(trigger.fields.action ?? 'run')}`; const eventSeed = options.eventKey ?? new Date().toISOString(); - const idempotencyKey = buildIdempotencyKey(triggerPath, eventSeed, objective); + const dispatchTemplate = parseDispatchTemplate(trigger.fields.action); + const templateContext = { + trigger_path: trigger.path, + trigger_name: String(trigger.fields.name ?? trigger.fields.title ?? trigger.path), + trigger_type: readTriggerType(trigger.fields), + event_key: eventSeed, + ...(options.context ?? {}), + }; + const objectiveTemplate = options.objective + ?? dispatchTemplate?.objective + ?? `Trigger ${String(trigger.fields.title ?? trigger.path)} fired`; + const objective = String(materializeTemplateValue(objectiveTemplate, templateContext)); + const actionContext = isRecord(dispatchTemplate?.context) + ? materializeTemplateValue(dispatchTemplate.context, templateContext) as Record + : {}; + const idempotencyKey = buildIdempotencyKey(trigger.path, eventSeed, objective); const run = dispatch.createRun(workspacePath, { actor: options.actor, - adapter: options.adapter, + adapter: options.adapter ?? dispatchTemplate?.adapter, objective, context: { - trigger_path: triggerPath, - trigger_event: String(trigger.fields.event ?? ''), + trigger_path: trigger.path, + trigger_event: describeTriggerEvent(trigger), + trigger_type: readTriggerType(trigger.fields), + event_key: eventSeed, + ...actionContext, ...options.context, }, idempotencyKey, }); - ledger.append(workspacePath, options.actor, 'create', triggerPath, 'trigger', { + store.update( + workspacePath, + trigger.path, + { + last_fired: new Date().toISOString(), + }, + undefined, + options.actor, + ); + + ledger.append(workspacePath, options.actor, 'create', trigger.path, 'trigger', { fired: true, event_key: eventSeed, run_id: run.id, @@ -73,7 +288,7 @@ export function fireTrigger( }); return { - triggerPath, + triggerPath: trigger.path, run, idempotencyKey, }; @@ -131,3 +346,248 @@ function buildIdempotencyKey(triggerPath: string, eventSeed: string, objective: .digest('hex') .slice(0, 32); } + +function normalizeTriggerType(value: unknown): TriggerPrimitiveType { + const normalized = String(value ?? '').trim().toLowerCase(); + if (normalized === 'cron' || normalized === 'webhook' || normalized === 'event' || normalized === 'manual') { + return normalized; + } + throw new Error(`Invalid trigger type "${String(value)}". Expected cron|webhook|event|manual.`); +} + +function readTriggerType(fields: Record): TriggerPrimitiveType { + const raw = fields.type; + if (raw === undefined) return 'event'; + return normalizeTriggerType(raw); +} + +function readTriggerEnabled(fields: Record): boolean { + const explicitEnabled = asBoolean(fields.enabled); + if (explicitEnabled !== undefined) return explicitEnabled; + const status = String(fields.status ?? '').toLowerCase(); + return status === 'active' || status === 'approved'; +} + +function normalizeTriggerCondition(triggerType: TriggerPrimitiveType, condition: unknown): unknown { + if (condition !== undefined) return condition; + switch (triggerType) { + case 'manual': + return { type: 'manual' }; + case 'webhook': + return { type: 'event', pattern: 'webhook.*' }; + case 'event': + return { type: 'event', pattern: '*' }; + case 'cron': + throw new Error('Cron triggers require a condition expression.'); + default: + return condition; + } +} + +function normalizeTriggerAction(action: unknown, triggerName: string): unknown { + if (action === undefined) { + return stripUndefinedDeep({ + type: 'dispatch-run', + objective: `Trigger ${triggerName} fired`, + }); + } + if (typeof action === 'string') { + return stripUndefinedDeep({ + type: 'dispatch-run', + objective: action, + }); + } + if (isRecord(action) && action.type === undefined) { + if (action.objective !== undefined || action.adapter !== undefined || action.context !== undefined) { + return stripUndefinedDeep({ + type: 'dispatch-run', + ...action, + }); + } + } + return stripUndefinedDeep(action); +} + +function normalizeTriggerPathOverride(pathOverride?: string): string | undefined { + if (!pathOverride) return undefined; + const normalized = String(pathOverride).trim().replace(/\\/g, '/').replace(/^\.\//, ''); + if (!normalized) return undefined; + const withExtension = normalized.endsWith('.md') ? normalized : `${normalized}.md`; + if (withExtension.startsWith('triggers/')) return withExtension; + return `triggers/${withExtension.replace(/^\/+/, '')}`; +} + +function normalizeCooldown(cooldown: unknown): number { + const parsed = Number(cooldown ?? 0); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid trigger cooldown "${String(cooldown)}". Expected a non-negative number.`); + } + return Math.trunc(parsed); +} + +function normalizeTags(tags: string[] | undefined): string[] { + if (!tags) return []; + return tags.map((tag) => String(tag).trim()).filter(Boolean); +} + +function defaultTriggerBody(name: string, triggerType: TriggerPrimitiveType): string { + return [ + '## Trigger Primitive', + '', + `- Name: ${name}`, + `- Type: ${triggerType}`, + '', + 'Dispatches runs when this trigger evaluates true.', + '', + ].join('\n'); +} + +function normalizeNullableDate(value: string | null, label: string): string | null { + if (value === null) return null; + const normalized = String(value ?? '').trim(); + if (!normalized) { + throw new Error(`Invalid ${label} value. Expected ISO timestamp or null.`); + } + const parsed = Date.parse(normalized); + if (Number.isNaN(parsed)) { + throw new Error(`Invalid ${label} value "${normalized}". Expected ISO timestamp.`); + } + return new Date(parsed).toISOString(); +} + +function parseDispatchTemplate(action: unknown): { + objective?: string; + adapter?: string; + context?: Record; +} | null { + if (typeof action === 'string') return null; + if (!isRecord(action)) return null; + if (action.type && String(action.type).toLowerCase() !== 'dispatch-run') { + return null; + } + const objective = typeof action.objective === 'string' + ? action.objective + : undefined; + const adapter = typeof action.adapter === 'string' + ? action.adapter + : undefined; + const context = isRecord(action.context) + ? action.context + : undefined; + return { objective, adapter, context }; +} + +function readTriggerByReference(workspacePath: string, triggerRef: string): PrimitiveInstance { + const normalizedRef = String(triggerRef ?? '').trim(); + if (!normalizedRef) throw new Error('Trigger reference is required.'); + + if (looksLikePathReference(normalizedRef)) { + const pathRef = normalizePathReference(normalizedRef); + const trigger = store.read(workspacePath, pathRef); + if (!trigger) throw new Error(`Trigger not found: ${pathRef}`); + if (trigger.type !== 'trigger') throw new Error(`Target is not a trigger primitive: ${pathRef}`); + return trigger; + } + + const slug = slugify(normalizedRef); + const candidates = listTriggers(workspacePath).filter((trigger) => + path.basename(trigger.path, '.md') === slug + || slugify(String(trigger.fields.name ?? trigger.fields.title ?? '')) === slug + ); + if (candidates.length === 0) { + throw new Error(`Trigger not found: ${normalizedRef}`); + } + if (candidates.length > 1) { + throw new Error(`Ambiguous trigger reference "${normalizedRef}". Use an explicit trigger path.`); + } + return candidates[0]!; +} + +function looksLikePathReference(value: string): boolean { + return value.includes('/') || value.endsWith('.md'); +} + +function normalizePathReference(value: string): string { + const normalized = value.replace(/\\/g, '/').replace(/^\.\//, ''); + if (normalized.endsWith('.md')) return normalized; + if (normalized.startsWith('triggers/')) return `${normalized}.md`; + return `triggers/${normalized}.md`; +} + +function describeTriggerEvent(trigger: PrimitiveInstance): string { + if (typeof trigger.fields.event === 'string' && trigger.fields.event.trim().length > 0) { + return trigger.fields.event.trim(); + } + const condition = trigger.fields.condition; + if (typeof condition === 'string') return condition; + if (isRecord(condition)) { + for (const key of ['pattern', 'event', 'event_type', 'expression', 'cron']) { + if (typeof condition[key] === 'string' && condition[key].trim().length > 0) { + return condition[key].trim(); + } + } + } + return readTriggerType(trigger.fields); +} + +function materializeTemplateValue(value: unknown, context: Record): unknown { + if (typeof value === 'string') { + return value.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_match, key: string) => { + const candidate = context[key]; + if (candidate === undefined || candidate === null) return ''; + if (typeof candidate === 'string') return candidate; + return JSON.stringify(candidate); + }); + } + if (Array.isArray(value)) { + return value.map((entry) => materializeTemplateValue(entry, context)); + } + if (isRecord(value)) { + const output: Record = {}; + for (const [key, inner] of Object.entries(value)) { + output[key] = materializeTemplateValue(inner, context); + } + return output; + } + return value; +} + +function normalizeNonEmpty(value: unknown, label: string): string { + const normalized = String(value ?? '').trim(); + if (!normalized) throw new Error(`${label} is required.`); + return normalized; +} + +function asBoolean(value: unknown): boolean | undefined { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + } + return undefined; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function slugify(value: string): string { + return String(value ?? '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function stripUndefinedDeep(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => stripUndefinedDeep(entry)); + } + if (!isRecord(value)) return value; + const output: Record = {}; + for (const [key, inner] of Object.entries(value)) { + if (inner === undefined) continue; + output[key] = stripUndefinedDeep(inner); + } + return output; +} diff --git a/tests/integration/trigger-cli.test.ts b/tests/integration/trigger-cli.test.ts new file mode 100644 index 0000000..e3cb5b7 --- /dev/null +++ b/tests/integration/trigger-cli.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { ensureCliBuiltForTests } from '../helpers/cli-build.js'; + +interface CliEnvelope { + ok: boolean; + data?: unknown; + error?: string; +} + +function runCli(args: string[]): CliEnvelope { + ensureCliBuiltForTests(); + const result = spawnSync('node', [path.resolve('bin/workgraph.js'), ...args], { + encoding: 'utf-8', + }); + const output = (result.stdout || result.stderr || '').trim(); + try { + return JSON.parse(output) as CliEnvelope; + } catch { + throw new Error(`CLI output was not valid JSON for args [${args.join(' ')}]: ${output}`); + } +} + +describe('trigger CLI programmable primitives', () => { + beforeAll(() => { + ensureCliBuiltForTests(); + }); + + it('supports trigger CRUD, evaluate, and history commands', () => { + const workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-trigger-cli-')); + try { + const init = runCli(['init', workspacePath, '--json']); + expect(init.ok).toBe(true); + + const create = runCli([ + 'trigger', 'create', 'CLI Manual Trigger', + '-w', workspacePath, + '--actor', 'system', + '--type', 'manual', + '--condition', '{"type":"manual"}', + '--objective', 'Run CLI manual dispatch', + '--cooldown', '45', + '--json', + ]); + expect(create.ok).toBe(true); + const triggerPath = String((create.data as { trigger: { path: string } }).trigger.path); + + const list = runCli(['trigger', 'list', '-w', workspacePath, '--json']); + expect(list.ok).toBe(true); + expect(((list.data as { count: number }).count) >= 1).toBe(true); + + const show = runCli(['trigger', 'show', triggerPath, '-w', workspacePath, '--json']); + expect(show.ok).toBe(true); + expect((show.data as { trigger: { fields: { type: string } } }).trigger.fields.type).toBe('manual'); + + const disable = runCli([ + 'trigger', 'disable', triggerPath, + '-w', workspacePath, + '--actor', 'system', + '--json', + ]); + expect(disable.ok).toBe(true); + + const enable = runCli([ + 'trigger', 'enable', triggerPath, + '-w', workspacePath, + '--actor', 'system', + '--json', + ]); + expect(enable.ok).toBe(true); + + const evaluateOne = runCli([ + 'trigger', 'evaluate', triggerPath, + '-w', workspacePath, + '--actor', 'system', + '--json', + ]); + expect(evaluateOne.ok).toBe(true); + + const fire = runCli([ + 'trigger', 'fire', triggerPath, + '-w', workspacePath, + '--actor', 'system', + '--event-key', 'cli-manual-evt-1', + '--json', + ]); + expect(fire.ok).toBe(true); + const runId = String((fire.data as { run: { id: string } }).run.id); + expect(runId.length > 0).toBe(true); + + const history = runCli([ + 'trigger', 'history', triggerPath, + '-w', workspacePath, + '--json', + ]); + expect(history.ok).toBe(true); + expect(((history.data as { count: number }).count) > 0).toBe(true); + + const update = runCli([ + 'trigger', 'update', triggerPath, + '-w', workspacePath, + '--actor', 'system', + '--type', 'event', + '--condition', '{"type":"event","pattern":"thread.*"}', + '--enabled', 'true', + '--json', + ]); + expect(update.ok).toBe(true); + expect((update.data as { trigger: { fields: { type: string } } }).trigger.fields.type).toBe('event'); + + const remove = runCli([ + 'trigger', 'delete', triggerPath, + '-w', workspacePath, + '--actor', 'system', + '--json', + ]); + expect(remove.ok).toBe(true); + } finally { + fs.rmSync(workspacePath, { recursive: true, force: true }); + } + }); +});