diff --git a/.changeset/flow-trigger-observability.md b/.changeset/flow-trigger-observability.md new file mode 100644 index 0000000000..5f4cf09e68 --- /dev/null +++ b/.changeset/flow-trigger-observability.md @@ -0,0 +1,20 @@ +--- +'@objectstack/service-automation': minor +'@objectstack/trigger-record-change': minor +'@objectstack/lint': minor +'@objectstack/cli': minor +'@objectstack/objectql': patch +'@objectstack/runtime': patch +'@objectstack/plugin-audit': patch +--- + +Flow trigger observability — kill the four-layer silence around record-change flows that never fire (2026-07-17 third-party eval). + +A misauthored auto-launched flow (wrong `objectName`, missing `requires: ['automation','triggers']`, failing start condition) produced ZERO output at every layer: the engine's own registration/binding logs land inside the CLI's boot-quiet stdout window (which swallows debug/info/warn — only error/fatal reach stderr), and each "didn't happen" path was itself silent. Fixes: + +- **Startup banner `Flows:` section** (`os serve`/`os dev`/`os start`): flow count, bound-to-trigger count, registered trigger types, draft count — plus loud `⚠` lines for flows declared with no automation engine enabled (`requires` missing), flows whose trigger type has no registered trigger, and bound record-change flows targeting an unknown object (dead binding). Printed after stdout is restored, so it is immune to the boot-quiet window. +- **Trigger-fired run failures now log at ERROR** (stderr — always visible): the automation engine no longer drops the AutomationResult of a trigger-fired execution; condition-evaluation faults and node failures surface with the flow name. Condition-not-met skips stay at debug (high-frequency, intentional). +- **`RecordChangeTrigger` probes object existence at bind time** and warns when a flow's `objectName` matches no registered object (exact-name matching), instead of silently arming a hook that can never fire. +- **`kernel:bootstrapped` binding audit** in the automation plugin: warns per enabled-but-unbound triggered flow with the reason, and reports registered/bound/draft counts (`AutomationEngine.getTriggerBindingAudit()`, extended `getFlowRuntimeStates()` with `status`/`triggerType`/`object`). +- **`os validate` flow-wiring advisories** (`@objectstack/lint` `validateFlowTriggerReadiness`): warns when a record-triggered flow targets an object the stack does not define, and when an auto-triggered flow's status is `draft` (authored or defaulted — draft flows still fire; declare `active` or `obsolete`). +- Removed leftover boot-debug writes (`registerApp`/`AppPlugin`/`StandaloneStack`/`AuditPlugin` stderr noise) that previous debugging of this same silence had left behind. diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index 4e72355c9b..d44e5167fc 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -11,6 +11,7 @@ export const TaskCompletedFlow = defineFlow({ label: 'Notify on Task Completed', description: 'Emails the project owner when a task is marked Done.', type: 'autolaunched', + status: 'active', nodes: [ { id: 'start', @@ -106,6 +107,7 @@ export const TaskAssignedNotifyFlow = defineFlow({ label: 'Notify Assignee on Task Assignment', description: 'Notifies the new assignee (inbox channel) when a task is reassigned.', type: 'autolaunched', + status: 'active', nodes: [ { id: 'start', @@ -160,6 +162,7 @@ export const BudgetApprovalFlow = defineFlow({ label: 'Project Budget Approval', description: 'Two-step approval for projects above budget thresholds.', type: 'autolaunched', + status: 'active', nodes: [ { id: 'start', @@ -257,6 +260,7 @@ export const TaskCompletedSlackFlow = defineFlow({ label: 'Post to Slack on Task Completed', description: 'Posts to a Slack channel via the slack connector when a task is marked Done.', type: 'autolaunched', + status: 'active', nodes: [ { id: 'start', @@ -307,6 +311,7 @@ export const ScheduledDigestFlow = defineFlow({ label: 'Scheduled Project Digest (interval)', description: 'Fires on a fixed interval and posts a digest to an inbox — demonstrates the schedule trigger.', type: 'schedule', + status: 'active', nodes: [ { id: 'start', @@ -362,6 +367,7 @@ export const TaskCompletedRestPingFlow = defineFlow({ label: 'REST Ping on Task Completed', description: 'Calls the local server health endpoint via the rest connector when a task is marked Done.', type: 'autolaunched', + status: 'active', nodes: [ { id: 'start', @@ -414,6 +420,7 @@ export const ShowcaseDeclarativeConnectorPingFlow = defineFlow({ description: 'Dispatches GET /api/v1/health through showcase_status_api — a provider-bound connector instance materialized from pure metadata at boot.', type: 'autolaunched', + status: 'active', nodes: [ { id: 'start', @@ -463,6 +470,7 @@ export const ShowcaseMcpConnectorEchoFlow = defineFlow({ 'Dispatches the echo_upper tool of showcase_mcp_tools — a declarative MCP connector instance materialized ' + 'at boot from the in-repo stdio fixture (scripts/mcp-fixture.mjs).', type: 'autolaunched', + status: 'active', // Surface the tool result on the run output, so the flow run view (and the // dogfood proof) can assert the MCP round-trip observably. variables: [{ name: 'echo.structuredContent', type: 'json', isOutput: true }], @@ -514,6 +522,7 @@ export const TaskFollowUpFlow = defineFlow({ label: 'Task Follow-up Reminder (wait)', description: 'Waits a fixed delay after a task is created, then reminds the assignee — demonstrates the durable wait node.', type: 'autolaunched', + status: 'active', nodes: [ { id: 'start', @@ -604,6 +613,7 @@ export const TaskDoneNotifyOwnerFlow = defineFlow({ label: 'Task Done → Notify Owner (subflow)', description: 'On task completion, invokes the reusable notify-owner subflow — demonstrates subflow reuse.', type: 'autolaunched', + status: 'active', nodes: [ { id: 'start', @@ -713,6 +723,7 @@ export const ProjectClosureFlow = defineFlow({ label: 'Project Closure with Sign-off (nested pause)', description: 'On project completion, requests sign-off via an approval-inside-subflow, then notifies the owner — demonstrates nested durable pause.', type: 'autolaunched', + status: 'active', nodes: [ { id: 'start', @@ -830,6 +841,7 @@ export const FanOutNotifyFlow = defineFlow({ label: 'Fan-out Notify (Parallel)', description: 'Notifies owner and watchers concurrently via a parallel block, joining before completion (ADR-0031).', type: 'autolaunched', + status: 'active', nodes: [ { id: 'start', @@ -902,6 +914,7 @@ export const ResilientSyncFlow = defineFlow({ label: 'Resilient Sync (Try/Catch/Retry)', description: 'Pushes a task to an external system, retrying on failure and recording errors via try/catch (ADR-0031).', type: 'autolaunched', + status: 'active', nodes: [ { id: 'start', @@ -983,6 +996,7 @@ export const InvoiceDualSignoffFlow = defineFlow({ label: 'Invoice Dual Sign-off (parallel approval)', description: 'On send, requires finance AND legal to both approve via one aggregating approval node — demonstrates parallel approvals without a token tree (ADR-0039 Track A).', type: 'autolaunched', + status: 'active', // The revert-on-reject write is an approval-process outcome, not an act of the // submitter — run it as the system principal so it lands regardless of whether // the submitter still has edit rights on a "sent" invoice (#1888 runAs enforced). @@ -1070,6 +1084,7 @@ export const ProjectEscalationFlow = defineFlow({ label: 'Project Escalation (composite)', description: 'On health → red, branches on severity then alerts in parallel and pushes to an incident system with try/catch — demonstrates nested construct composition.', type: 'autolaunched', + status: 'active', nodes: [ { id: 'start', @@ -1264,6 +1279,7 @@ export const InboundTaskWebhookFlow = defineFlow({ label: 'Inbound Task Webhook', description: 'Creates a task from an external system via the HMAC-verified inbound hook.', type: 'api', + status: 'active', // An inbound webhook has no authenticated user, so the create must run as the // system principal (#1888 runAs is now enforced). Without this it relies on the // "no identity → security-skipped" fall-through, which breaks the moment the diff --git a/packages/cli/src/commands/serve-automation-summary.test.ts b/packages/cli/src/commands/serve-automation-summary.test.ts new file mode 100644 index 0000000000..4dbe450e79 --- /dev/null +++ b/packages/cli/src/commands/serve-automation-summary.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Startup-banner automation summary (2026-07-17 third-party eval). +// +// Flow registration and trigger binding happen entirely inside serve's +// boot-quiet stdout window, so the automation engine's own logs never reach +// the terminal — a project whose flows silently failed to arm looked exactly +// like one whose flows armed fine. `collectAutomationSummary` gathers the +// live binding facts after stdout is restored so the banner can answer +// "did my flows actually arm?" — including the three silent author mistakes: +// engine not enabled, trigger not registered, and objectName mismatch. + +import { describe, it, expect } from 'vitest'; +import { collectAutomationSummary } from './serve.js'; + +type FlowState = { + name: string; + enabled: boolean; + bound: boolean; + status?: string; + triggerType?: string; + object?: string; +}; + +function fakeKernel(services: Record) { + return { + getService(name: string) { + if (!(name in services)) throw new Error(`Service '${name}' not found`); + return services[name]; + }, + }; +} + +function fakeAutomation(states: FlowState[], triggerTypes: string[] = ['record_change']) { + return { + getFlowRuntimeStates: () => states, + getRegisteredTriggerTypes: () => triggerTypes, + getTriggerBindingAudit: () => + states + .filter((s) => s.enabled && !s.bound && s.triggerType) + .map((s) => ({ + flowName: s.name, + triggerType: s.triggerType!, + reason: `no '${s.triggerType}' trigger is registered`, + })), + }; +} + +describe('collectAutomationSummary', () => { + it('flags declared flows when the automation engine is not enabled at all', () => { + const summary = collectAutomationSummary(fakeKernel({}), 2); + expect(summary).toMatchObject({ enabled: false, declaredFlowCount: 2 }); + }); + + it('returns undefined when there is nothing automation-related to show', () => { + expect(collectAutomationSummary(fakeKernel({}), 0)).toBeUndefined(); + }); + + it('reports bound/unbound counts and surfaces the unbound audit', () => { + const automation = fakeAutomation([ + { name: 'wired', enabled: true, bound: true, status: 'active', triggerType: 'record_change', object: 'task' }, + { name: 'orphan', enabled: true, bound: false, status: 'active', triggerType: 'record_change', object: 'task' }, + ]); + const summary = collectAutomationSummary(fakeKernel({ automation }), 2)!; + expect(summary.enabled).toBe(true); + expect(summary.flowCount).toBe(2); + expect(summary.boundCount).toBe(1); + expect(summary.unbound).toHaveLength(1); + expect(summary.unbound[0].flowName).toBe('orphan'); + }); + + it('flags a bound record-change flow whose target object is unknown (dead binding)', () => { + const automation = fakeAutomation([ + { name: 'dead', enabled: true, bound: true, status: 'active', triggerType: 'record_change', object: 'candidate' }, + { name: 'alive', enabled: true, bound: true, status: 'active', triggerType: 'record_change', object: 'eval_app_candidate' }, + ]); + const ql = { getObject: (n: string) => (n === 'eval_app_candidate' ? { name: n } : undefined) }; + const summary = collectAutomationSummary(fakeKernel({ automation, objectql: ql }), 2)!; + expect(summary.unknownObject).toEqual([{ flowName: 'dead', object: 'candidate' }]); + }); + + it('counts enabled draft flows (draft still fires — make it visible)', () => { + const automation = fakeAutomation([ + { name: 'd1', enabled: true, bound: true, status: 'draft', triggerType: 'record_change', object: 'task' }, + { name: 'implicit', enabled: true, bound: true, status: undefined, triggerType: 'record_change', object: 'task' }, + { name: 'a1', enabled: true, bound: true, status: 'active', triggerType: 'record_change', object: 'task' }, + ]); + const summary = collectAutomationSummary(fakeKernel({ automation }), 3)!; + expect(summary.draftCount).toBe(2); + }); + + it('degrades gracefully on an older engine without the audit APIs', () => { + const automation = { getFlowRuntimeStates: () => [{ name: 'x', enabled: true, bound: true }] }; + const summary = collectAutomationSummary(fakeKernel({ automation }), 1)!; + expect(summary.enabled).toBe(true); + expect(summary.flowCount).toBe(1); + expect(summary.unbound).toEqual([]); + expect(summary.triggerTypes).toEqual([]); + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 2c45d81492..471f148bc6 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -19,6 +19,7 @@ import { printStep, printInfo, printServerReady, + type AutomationReadySummary, } from '../utils/format.js'; import { CONSOLE_PATH, @@ -2296,6 +2297,17 @@ export default class Serve extends Command { if (authSvc?.devSeedResult?.email) seededAdmin = authSvc.devSeedResult; } catch { /* auth service not present — nothing to show */ } + // ── Automation wiring summary (2026-07-17 third-party eval) ───── + // Flow registration + trigger binding happen entirely inside the + // boot-quiet stdout window above, so the engine's own info/warn logs + // never reach the terminal. Collect the live binding state here (after + // restore) and surface it in the banner: declared-but-engine-missing, + // unbound triggered flows, and bound-but-dead (unknown object) flows. + const automationSummary = collectAutomationSummary( + kernel, + Array.isArray((config as any)?.flows) ? (config as any).flows.length : 0, + ); + // ── Clean startup summary ────────────────────────────────────── printServerReady({ port, @@ -2309,6 +2321,7 @@ export default class Serve extends Command { databaseUrl: redactDbUrl(resolvedDatabaseUrl), multiTenant: resolveMultiOrgEnabled(), seededAdmin, + automation: automationSummary, }); // ── Publish the actually-bound port ──────────────────────────── @@ -2408,3 +2421,71 @@ function describeRegisteredDriver(kernel: any): { label: string; url: string } | } return null; } + +/** + * Collect the automation wiring facts for the startup banner (2026-07-17 + * third-party eval: flow registration/binding logs fall inside the boot-quiet + * stdout window, so the banner is the one channel a developer reliably sees). + * + * Every probe is feature-detected so an older `@objectstack/service-automation` + * (without `getTriggerBindingAudit` / extended runtime states) degrades to the + * plain count line instead of crashing the banner. Returns `undefined` when + * there is nothing automation-related to show at all. + */ +export function collectAutomationSummary( + kernel: any, + declaredFlowCount: number, +): AutomationReadySummary | undefined { + let automation: any; + try { automation = kernel?.getService?.('automation'); } catch { /* not registered */ } + + if (!automation) { + return declaredFlowCount > 0 + ? { + enabled: false, + declaredFlowCount, + flowCount: 0, + boundCount: 0, + triggerTypes: [], + unbound: [], + unknownObject: [], + draftCount: 0, + } + : undefined; + } + + let states: Array<{ name: string; enabled: boolean; bound: boolean; status?: string; triggerType?: string; object?: string }> = []; + try { states = automation.getFlowRuntimeStates?.() ?? []; } catch { /* older engine */ } + if (states.length === 0 && declaredFlowCount === 0) return undefined; + + let triggerTypes: string[] = []; + try { triggerTypes = automation.getRegisteredTriggerTypes?.() ?? []; } catch { /* older engine */ } + + let unbound: Array<{ flowName: string; triggerType: string; reason: string }> = []; + try { unbound = automation.getTriggerBindingAudit?.() ?? []; } catch { /* older engine */ } + + // Dead bindings: a bound record-change flow whose target object nobody + // registered — the hook is filtered to a name that never writes. + const unknownObject: Array<{ flowName: string; object: string }> = []; + let ql: any; + try { ql = kernel?.getService?.('objectql'); } catch { /* absent */ } + if (ql && typeof ql.getObject === 'function') { + for (const s of states) { + if (!s.bound || !s.object || s.triggerType !== 'record_change') continue; + let known: unknown; + try { known = ql.getObject(s.object); } catch { known = undefined; } + if (!known) unknownObject.push({ flowName: s.name, object: s.object }); + } + } + + return { + enabled: true, + declaredFlowCount, + flowCount: states.length, + boundCount: states.filter((s) => s.bound).length, + triggerTypes, + unbound, + unknownObject, + draftCount: states.filter((s) => s.enabled && (s.status ?? 'draft') === 'draft').length, + }; +} diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index d7ebf63686..980109477c 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -16,6 +16,7 @@ import { validateJsxPages, validateReactPages, validateReactPageProps, validateP import { validateCapabilityReferences } from '@objectstack/lint'; import { validateVisibilityPredicates } from '@objectstack/lint'; import { validateSecurityPosture } from '@objectstack/lint'; +import { validateFlowTriggerReadiness } from '@objectstack/lint'; import { printHeader, printKV, @@ -342,6 +343,22 @@ export default class Validate extends Command { } } + // 3g. Auto-launched flow trigger wiring (2026-07-17 third-party eval): + // a record-change flow whose start-node objectName matches nothing + // never fires — silently. Also nudges auto-triggered flows to declare + // an explicit deployment status (the schema default is 'draft', and + // draft flows DO still fire — ambiguous intent). Advisory: objects + // may come from other installed packages. + if (!flags.json) printStep('Checking flow trigger wiring...'); + const flowReadinessFindings = validateFlowTriggerReadiness(normalized as Record); + const flowReadinessWarnings = flowReadinessFindings.filter((f) => f.severity === 'warning'); + if (!flags.json) { + for (const w of flowReadinessWarnings.slice(0, 50)) { + console.log(chalk.yellow(` ⚠ ${w.where}: ${w.message}`)); + console.log(chalk.dim(` ${w.hint}`)); + } + } + // 3f. [ADR-0090 D7] Security posture — the same gate `os compile`/`os build` // run. Without it here, `os validate` passed a stack (e.g. a custom // object with no explicit sharingModel) that the build then rejected, @@ -388,7 +405,7 @@ export default class Validate extends Command { valid: true, manifest: config.manifest, stats, - warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...securityAdvisories], + warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...securityAdvisories], conversions: conversionNotices, specVersionGap: specGap, duration: timer.elapsed(), diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index a7ea1152b5..fa35ba938f 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -195,6 +195,33 @@ export interface ServerReadyOptions { * guess the login. Absent when nothing was seeded. */ seededAdmin?: { email: string; password: string }; + /** + * Automation wiring summary (2026-07-17 third-party eval). The boot-quiet + * stdout window swallows every info/warn the automation engine logs while + * binding flows to triggers, so the banner is the ONE reliable place a + * developer can see whether their record-change / schedule flows actually + * armed. Collected from the live engine after runtime.start(). + */ + automation?: AutomationReadySummary; +} + +export interface AutomationReadySummary { + /** Whether the automation service is registered at all. */ + enabled: boolean; + /** Flows declared in the stack config (used when the engine is absent). */ + declaredFlowCount: number; + /** Flows registered in the engine (0 when `enabled` is false). */ + flowCount: number; + /** Flows bound to a trigger. */ + boundCount: number; + /** Registered trigger types (record_change, schedule, api, …). */ + triggerTypes: string[]; + /** Enabled flows that declare a trigger but are NOT bound, with the fix. */ + unbound: Array<{ flowName: string; triggerType: string; reason: string }>; + /** Bound record-change flows whose target object is not registered (dead binding). */ + unknownObject: Array<{ flowName: string; object: string }>; + /** Enabled flows whose persisted status is 'draft' (they still fire). */ + draftCount: number; } export function printServerReady(opts: ServerReadyOptions) { @@ -228,11 +255,51 @@ export function printServerReady(opts: ServerReadyOptions) { if (opts.pluginNames && opts.pluginNames.length > 0) { console.log(chalk.dim(` ${opts.pluginNames.join(', ')}`)); } + if (opts.automation) printAutomationSummary(opts.automation); console.log(''); console.log(chalk.dim(' Press Ctrl+C to stop')); console.log(''); } +/** + * One-glance answer to "did my flows actually arm?" — the question the + * boot-quiet stdout window otherwise makes unanswerable (the engine's own + * bind/registration logs are swallowed during startup). + */ +function printAutomationSummary(a: AutomationReadySummary) { + if (!a.enabled) { + if (a.declaredFlowCount > 0) { + console.log( + chalk.yellow( + ` ⚠ Flows: ${a.declaredFlowCount} flow(s) declared but the automation engine is not enabled — ` + + `they will never run. Add requires: ['automation', 'triggers'] to objectstack.config.ts`, + ), + ); + } + return; + } + if (a.flowCount === 0) return; + + const parts = [`${a.flowCount} flow(s)`, `${a.boundCount} bound to triggers`]; + if (a.triggerTypes.length > 0) parts.push(`(${a.triggerTypes.join(', ')})`); + if (a.draftCount > 0) parts.push(`· ${a.draftCount} draft`); + console.log(chalk.dim(` Flows: ${parts.join(' ')}`)); + + for (const u of a.unbound) { + console.log( + chalk.yellow(` ⚠ flow '${u.flowName}' declares a '${u.triggerType}' trigger but is NOT bound — ${u.reason}`), + ); + } + for (const u of a.unknownObject) { + console.log( + chalk.yellow( + ` ⚠ flow '${u.flowName}' targets unknown object '${u.object}' — bound, but it will never fire ` + + `(object names match exactly; check the start node's config.objectName)`, + ), + ); + } +} + export function printMetadataStats(stats: MetadataStats) { const sections: Array<{ label: string; items: Array<[string, number]> }> = [ { diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 68880ebf34..6d825f2f3c 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -28,6 +28,15 @@ export type { ExprIssue } from './validate-expressions.js'; export { validateListViewMode, LIST_VIEW_FILTERS_IN_VIEWS_MODE } from './validate-list-view-mode.js'; export type { ListViewModeFinding, ListViewModeSeverity } from './validate-list-view-mode.js'; +export { + validateFlowTriggerReadiness, + FLOW_TRIGGER_UNKNOWN_OBJECT, + FLOW_DRAFT_STATUS_AMBIGUOUS, +} from './validate-flow-trigger-readiness.js'; +export type { + FlowTriggerReadinessFinding, + FlowTriggerReadinessSeverity, +} from './validate-flow-trigger-readiness.js'; export { validateResponsiveStyles, diff --git a/packages/lint/src/validate-flow-trigger-readiness.test.ts b/packages/lint/src/validate-flow-trigger-readiness.test.ts new file mode 100644 index 0000000000..7dd920546b --- /dev/null +++ b/packages/lint/src/validate-flow-trigger-readiness.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + validateFlowTriggerReadiness, + FLOW_TRIGGER_UNKNOWN_OBJECT, + FLOW_DRAFT_STATUS_AMBIGUOUS, +} from './validate-flow-trigger-readiness.js'; + +function recordFlow(overrides: Record = {}) { + return { + name: 'candidate_hired', + type: 'autolaunched', + nodes: [ + { + id: 'start', + type: 'start', + config: { + objectName: 'app_candidate', + triggerType: 'record-after-update', + condition: 'stage == "hired"', + }, + }, + { id: 'end', type: 'end' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + ...overrides, + }; +} + +const candidateObject = { name: 'app_candidate', label: 'Candidate', fields: {} }; + +describe('validateFlowTriggerReadiness', () => { + it('passes a correctly wired, explicitly active record flow', () => { + const findings = validateFlowTriggerReadiness({ + objects: [candidateObject], + flows: [recordFlow({ status: 'active' })], + }); + expect(findings).toEqual([]); + }); + + it('warns when the target object is not defined in the stack (the silent-miss)', () => { + const flow = recordFlow({ status: 'active' }); + (flow.nodes[0] as { config: Record }).config.objectName = 'candidate'; + const findings = validateFlowTriggerReadiness({ + objects: [candidateObject], + flows: [flow], + }); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(FLOW_TRIGGER_UNKNOWN_OBJECT); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].message).toContain("'candidate'"); + expect(findings[0].path).toBe('flows[0].nodes[0].config.objectName'); + }); + + it('does not flag sys_* platform objects as unknown', () => { + const flow = recordFlow({ status: 'active' }); + (flow.nodes[0] as { config: Record }).config.objectName = 'sys_user'; + const findings = validateFlowTriggerReadiness({ + objects: [candidateObject], + flows: [flow], + }); + expect(findings).toEqual([]); + }); + + it('warns when an auto-triggered flow has no explicit status (defaults to draft)', () => { + const findings = validateFlowTriggerReadiness({ + objects: [candidateObject], + flows: [recordFlow()], + }); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(FLOW_DRAFT_STATUS_AMBIGUOUS); + expect(findings[0].message).toContain("'draft'"); + expect(findings[0].message).toMatch(/still fire/i); + }); + + it('warns on an explicit draft too — defineFlow fills the default before lint runs', () => { + const findings = validateFlowTriggerReadiness({ + objects: [candidateObject], + flows: [recordFlow({ status: 'draft' })], + }); + expect(findings.map((f) => f.rule)).toEqual([FLOW_DRAFT_STATUS_AMBIGUOUS]); + }); + + it('stays quiet on obsolete (deliberately disabled) auto-triggered flows', () => { + const findings = validateFlowTriggerReadiness({ + objects: [candidateObject], + flows: [recordFlow({ status: 'obsolete' })], + }); + expect(findings).toEqual([]); + }); + + it('does not require a status on manual/screen flows (no arming semantics)', () => { + const findings = validateFlowTriggerReadiness({ + objects: [candidateObject], + flows: [ + { + name: 'wizard', + type: 'screen', + nodes: [{ id: 'start', type: 'start' }, { id: 'end', type: 'end' }], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }, + ], + }); + expect(findings).toEqual([]); + }); + + it('flags schedule and api flows for missing status too', () => { + const findings = validateFlowTriggerReadiness({ + objects: [], + flows: [ + { + name: 'digest', + type: 'schedule', + nodes: [ + { id: 'start', type: 'start', config: { schedule: { type: 'interval', intervalMs: 60000 } } }, + { id: 'end', type: 'end' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }, + ], + }); + expect(findings.map((f) => f.rule)).toEqual([FLOW_DRAFT_STATUS_AMBIGUOUS]); + }); + + it('handles map-keyed flows/objects and stacks with no flows', () => { + expect(validateFlowTriggerReadiness({})).toEqual([]); + const findings = validateFlowTriggerReadiness({ + objects: { app_candidate: { label: 'C', fields: {} } }, + flows: { hired: recordFlow({ name: undefined, status: 'active' }) }, + }); + expect(findings).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts new file mode 100644 index 0000000000..4e6d0c3a57 --- /dev/null +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Build-time guardrail for auto-launched flow trigger wiring (2026-07-17 +// third-party eval: a record-change flow that silently never fires). +// +// A pure `(stack) => Finding[]` rule (ADR-0019), run from `os validate` and +// reusable by AI authoring. It catches the two authoring mistakes that produce +// a flow which LOOKS armed but never launches — with zero runtime output: +// +// 1. `objectName` mismatch — the start node targets an object name that is +// not defined in this stack. The runtime binds an ObjectQL hook filtered +// to that exact name; if nobody writes it, the flow never fires. Names +// match exactly (`eval_app_candidate`, not `candidate`). Objects owned by +// other packages (`sys_*`, dependency packages) are legitimate targets, +// so this is a warning with the cross-package caveat, not an error. +// +// 2. `status: 'draft'` on an auto-triggered flow — the schema default when +// no status is authored (defineFlow parses at definition time, so by the +// time this rule runs an unauthored status is indistinguishable from an +// explicit 'draft'). Either way the intent is ambiguous: the engine still +// binds and fires draft flows (only `obsolete`/`invalid` disable), which +// surprises authors in both directions. Declare `'active'` to arm +// deliberately or `'obsolete'` to disable. Only auto-triggered flows are +// flagged (manual/screen flows have no arming semantics to be unclear +// about). + +export type FlowTriggerReadinessSeverity = 'error' | 'warning'; + +export interface FlowTriggerReadinessFinding { + severity: FlowTriggerReadinessSeverity; + rule: string; + /** Human-readable location, e.g. `flow "notify_on_done" › start node`. */ + where: string; + /** Config path, e.g. `flows[0].nodes[0].config.objectName`. */ + path: string; + message: string; + hint: string; +} + +// Rule ids (registry entries). +export const FLOW_TRIGGER_UNKNOWN_OBJECT = 'flow-trigger-unknown-object'; +export const FLOW_DRAFT_STATUS_AMBIGUOUS = 'flow-draft-status-ambiguous'; + +type AnyRec = Record; + +/** Coerce an array-or-name-keyed-map collection to an array (name injected). */ +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ + name, + ...(def as AnyRec), + })); + } + return []; +} + +/** The start node of a flow definition, if any. */ +function startNodeOf(flow: AnyRec): { node: AnyRec; index: number } | undefined { + const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : []; + const index = nodes.findIndex((n) => n?.type === 'start'); + return index >= 0 ? { node: nodes[index], index } : undefined; +} + +/** + * Validate auto-launched flow trigger wiring against the stack definition. + * Pure and dependency-free; safe on pre- or post-parse stacks. + */ +export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadinessFinding[] { + const findings: FlowTriggerReadinessFinding[] = []; + const flows = asArray(stack.flows); + if (flows.length === 0) return findings; + + const objectNames = new Set( + asArray(stack.objects) + .map((o) => (typeof o.name === 'string' ? o.name : undefined)) + .filter((n): n is string => !!n), + ); + + flows.forEach((flow, flowIndex) => { + const flowName = typeof flow.name === 'string' ? flow.name : `#${flowIndex}`; + const start = startNodeOf(flow); + const config = (start?.node.config ?? {}) as AnyRec; + const triggerType = typeof config.triggerType === 'string' ? config.triggerType : undefined; + const isRecordTriggered = !!triggerType && triggerType.startsWith('record-'); + const isAutoTriggered = + isRecordTriggered || triggerType === 'api' || config.schedule != null || + flow.type === 'schedule' || flow.type === 'api'; + + // 1. Record-triggered flow targeting an object this stack does not define. + if (isRecordTriggered && start) { + const objectName = typeof config.objectName === 'string' ? config.objectName : undefined; + if (objectName && !objectNames.has(objectName) && !objectName.startsWith('sys_')) { + findings.push({ + severity: 'warning', + rule: FLOW_TRIGGER_UNKNOWN_OBJECT, + where: `flow "${flowName}" › start node`, + path: `flows[${flowIndex}].nodes[${start.index}].config.objectName`, + message: + `targets object '${objectName}', which this stack does not define — if the name is wrong, ` + + `the flow will never fire (and the runtime stays silent about it).`, + hint: + `Object names match exactly. Check config.objectName against the object's registered name ` + + `(e.g. 'app_candidate', not 'candidate'). If the object comes from another installed package, ` + + `this warning can be ignored.`, + }); + } + } + + // 2. Auto-triggered flow whose status is 'draft' — authored or defaulted + // (defineFlow parses at definition time, so the two are the same here). + if (isAutoTriggered && (flow.status == null || flow.status === 'draft')) { + findings.push({ + severity: 'warning', + rule: FLOW_DRAFT_STATUS_AMBIGUOUS, + where: `flow "${flowName}"`, + path: `flows[${flowIndex}].status`, + message: + `has status 'draft' (the default when none is authored). Draft flows DO still fire their ` + + `triggers (only 'obsolete'/'invalid' disable), so the intent is ambiguous.`, + hint: `Declare status: 'active' to arm it deliberately, or status: 'obsolete' to disable it.`, + }); + } + }); + + return findings; +} diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index d04802ca31..06f55b6475 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -985,7 +985,6 @@ export class ObjectQL implements IDataEngine { const namespace = manifest.namespace as string | undefined; this.invalidateSummaryIndex(); // new objects may add/change summary fields this.logger.debug('Registering package manifest', { id, namespace }); - console.warn(`[ObjectQL:registerApp] id=${id} flows=${Array.isArray(manifest.flows) ? manifest.flows.length : typeof manifest.flows} keys=${Object.keys(manifest).join(',')}`); // Store manifest for defaultDatasource lookup if (id) { diff --git a/packages/plugins/plugin-audit/src/audit-plugin.ts b/packages/plugins/plugin-audit/src/audit-plugin.ts index b305a873fd..b75afba246 100644 --- a/packages/plugins/plugin-audit/src/audit-plugin.ts +++ b/packages/plugins/plugin-audit/src/audit-plugin.ts @@ -28,7 +28,6 @@ export class AuditPlugin implements Plugin { dependencies = ['com.objectstack.engine.objectql']; async init(ctx: PluginContext): Promise { - process.stderr.write('[AuditPlugin] init() called\n'); // Register audit system objects via the manifest service. ctx.getService<{ register(m: any): void }>('manifest').register({ id: 'com.objectstack.audit', @@ -73,24 +72,18 @@ export class AuditPlugin implements Plugin { } async start(ctx: PluginContext): Promise { - process.stderr.write('[AuditPlugin] start() called, registering kernel:ready hook\n'); // ObjectQL engine is only resolvable after the kernel is ready. ctx.hook('kernel:ready', async () => { - process.stderr.write('[AuditPlugin] kernel:ready fired\n'); let engine: IDataEngine | null = null; try { engine = ctx.getService('objectql'); - process.stderr.write(`[AuditPlugin] objectql engine = ${engine ? 'OK' : 'null'} registerHook? ${typeof (engine as any)?.registerHook}\n`); - } catch (err) { - process.stderr.write(`[AuditPlugin] getService(objectql) threw: ${(err as Error).message}\n`); + } catch { // Fallback alias used in some kernels. try { engine = ctx.getService('data'); - process.stderr.write(`[AuditPlugin] data engine = ${engine ? 'OK' : 'null'}\n`); } catch { /* ignore */ } } if (!engine) { - process.stderr.write('[AuditPlugin] NO ENGINE — bailing\n'); ctx.logger.warn('AuditPlugin: ObjectQL engine not available — audit writers NOT installed'); return; } @@ -130,7 +123,6 @@ export class AuditPlugin implements Plugin { return locale; }; installAuditWriters(engine as any, this.name, { getMessaging, getI18n, getLocale }); - process.stderr.write('[AuditPlugin] writers installed\n'); ctx.logger.info('AuditPlugin: audit + activity writers installed'); }); } diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index bf03526622..7f0df6995b 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -158,10 +158,6 @@ export class AppPlugin implements Plugin { ? { ...this.bundle.manifest, ...this.bundle } : this.bundle; - console.warn( - `[AppPlugin:init] appId=${appId} keys=${Object.keys(servicePayload).join(',')} flows=${Array.isArray((servicePayload as any).flows) ? (servicePayload as any).flows.length : 'n/a'}`, - ); - // Seed persisted package disable-state into the registry BEFORE the // manifest is decomposed, so disabled packages are installed disabled // and stay hidden after restart. Honors every later registration path diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index ce260f8752..23fea77331 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -245,13 +245,6 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro tag: '[StandaloneStack]', unwrapEnvelope: true, }); - if (artifactBundle) { - const flowsCount = Array.isArray(artifactBundle?.flows) ? artifactBundle.flows.length : 'n/a'; - // eslint-disable-next-line no-console - console.warn( - `[StandaloneStack] artifact loaded: path=${artifactPath} keys=${Object.keys(artifactBundle).join(',')} flows=${flowsCount}`, - ); - } const plugins: any[] = [ driverPlugin, diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index f2b9adec72..ee6bd62449 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -2360,8 +2360,8 @@ describe('AutomationEngine - flow status enable/disable gate', () => { engine.registerFlow('rc_draft', recordChangeFlow('rc_draft')); // status defaults to 'draft' engine.registerFlow('rc_active', { ...recordChangeFlow('rc_active'), status: 'active' }); const states = engine.getFlowRuntimeStates(); - expect(states.find((s) => s.name === 'rc_draft')).toEqual({ name: 'rc_draft', enabled: true, bound: true }); - expect(states.find((s) => s.name === 'rc_active')).toEqual({ name: 'rc_active', enabled: true, bound: true }); + expect(states.find((s) => s.name === 'rc_draft')).toMatchObject({ name: 'rc_draft', enabled: true, bound: true, status: 'draft' }); + expect(states.find((s) => s.name === 'rc_active')).toMatchObject({ name: 'rc_active', enabled: true, bound: true, status: 'active' }); expect(engine.getActiveTriggerBindings()).toHaveLength(2); }); @@ -2371,7 +2371,7 @@ describe('AutomationEngine - flow status enable/disable gate', () => { engine.registerFlow('rc_obsolete', { ...recordChangeFlow('rc_obsolete'), status: 'obsolete' }); expect(engine.getActiveTriggerBindings()).toHaveLength(0); expect(engine.getFlowRuntimeStates().find((s) => s.name === 'rc_obsolete')) - .toEqual({ name: 'rc_obsolete', enabled: false, bound: false }); + .toMatchObject({ name: 'rc_obsolete', enabled: false, bound: false, status: 'obsolete' }); }); it('refuses to execute a disabled (obsolete) flow', async () => { diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 66e7eb758b..fa9407ea9e 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -795,7 +795,21 @@ export class AutomationEngine implements IAutomationService { const trigger = this.triggers.get(resolved.triggerType); if (!trigger) return; try { - trigger.start(resolved.binding, (ctx: AutomationContext) => this.execute(flowName, ctx).then(() => undefined)); + // A trigger-fired run's result must not vanish (2026-07-17 eval: + // a failing record-change flow produced zero output — the failure + // lived only in the run-history row). Log failures at ERROR: stderr + // survives the CLI's boot-quiet stdout window, and a fired-but-failed + // automation is an operational fault. Condition-skipped runs stay + // quiet (execute() already debug-logs them — they are high-frequency). + trigger.start(resolved.binding, (ctx: AutomationContext) => + this.execute(flowName, ctx).then((result) => { + if (!result.success) { + this.logger.error( + `Trigger-fired run of flow '${flowName}' failed: ${result.error ?? 'unknown error'}`, + ); + } + }), + ); this.boundFlowTriggers.set(flowName, resolved.triggerType); this.logger.info(`Flow '${flowName}' bound to trigger '${resolved.triggerType}'`); } catch (err) { @@ -1129,14 +1143,54 @@ export class AutomationEngine implements IAutomationService { * is actually **enabled** (allowed to run) and **bound** (wired to its trigger, * so it fires) is engine state. `enabled: false` ⇒ status is obsolete/invalid * or a runtime toggle turned it off; `bound: false` on an enabled flow ⇒ it has - * no trigger (e.g. a manually-invoked/screen flow). + * no trigger (e.g. a manually-invoked/screen flow) or its trigger type has no + * registered trigger. `triggerType`/`object` expose the flow's declared + * binding so hosts (CLI startup summary, kernel:bootstrapped audit) can say + * WHY an unbound flow is unbound; `status` is the persisted deployment status. */ - getFlowRuntimeStates(): Array<{ name: string; enabled: boolean; bound: boolean }> { - return [...this.flows.keys()].map((name) => ({ - name, - enabled: this.flowEnabled.get(name) !== false, - bound: this.boundFlowTriggers.has(name), - })); + getFlowRuntimeStates(): Array<{ + name: string; + enabled: boolean; + bound: boolean; + status?: string; + triggerType?: string; + object?: string; + }> { + return [...this.flows.keys()].map((name) => { + const resolved = this.resolveTriggerBinding(name); + return { + name, + enabled: this.flowEnabled.get(name) !== false, + bound: this.boundFlowTriggers.has(name), + status: (this.flows.get(name) as { status?: string } | undefined)?.status, + triggerType: resolved?.triggerType, + object: resolved?.binding.object, + }; + }); + } + + /** + * Silent-miss audit (2026-07-17 third-party eval): every ENABLED flow that + * declares an auto-launch trigger but is not bound to one, with the reason. + * Empty when every triggered flow is wired. Hosts surface this after + * bootstrap — the automation plugin warns per entry at kernel:bootstrapped, + * and the CLI prints it in the startup summary (the boot-quiet stdout + * window swallows plain warn/info logs, so the summary is the reliable + * channel in `os dev` / `os start`). + */ + getTriggerBindingAudit(): Array<{ flowName: string; triggerType: string; reason: string }> { + const audit: Array<{ flowName: string; triggerType: string; reason: string }> = []; + for (const name of this.flows.keys()) { + if (this.flowEnabled.get(name) === false) continue; + if (this.boundFlowTriggers.has(name)) continue; + const resolved = this.resolveTriggerBinding(name); + if (!resolved) continue; // manual / screen flow — nothing to bind + const reason = this.triggers.has(resolved.triggerType) + ? `trigger '${resolved.triggerType}' is registered but binding failed — see earlier warnings` + : `no '${resolved.triggerType}' trigger is registered — add requires: ['triggers'] (record_change/schedule/api ship in @objectstack/trigger-*)`; + audit.push({ flowName: name, triggerType: resolved.triggerType, reason }); + } + return audit; } async listFlows(): Promise { diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 05f3efa4e4..ff819c60b5 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -543,6 +543,38 @@ export class AutomationServicePlugin implements Plugin { this.auditDeclaredConnectors(ctx); }); + // ── Silent-miss audit: unbound triggered flows (2026-07-17 eval) ────── + // kernel:bootstrapped fires strictly after EVERY kernel:ready handler — + // i.e. after the protocol flow sync above AND after trigger plugins + // (record-change, schedule, api) registered their triggers. Anything + // still unbound here stays unbound: say so, per flow, with the fix. + // One caveat: plain warn/info goes to stdout, which the CLI swallows + // during boot — serve.ts therefore ALSO surfaces this audit in the + // startup summary. The warn matters for embedded hosts and tests. + ctx.hook('kernel:bootstrapped', async () => { + if (!this.engine) return; + const audit = this.engine.getTriggerBindingAudit(); + for (const entry of audit) { + ctx.logger.warn( + `[Automation] flow '${entry.flowName}' declares a '${entry.triggerType}' trigger but is NOT bound — it will never auto-launch. ${entry.reason}`, + ); + } + const states = this.engine.getFlowRuntimeStates(); + const drafts = states.filter((s) => s.enabled && (s.status ?? 'draft') === 'draft'); + if (drafts.length > 0) { + ctx.logger.info( + `[Automation] ${drafts.length} flow(s) have status 'draft' (${drafts.map((d) => d.name).join(', ')}) — ` + + `draft flows still fire their triggers; set status: 'active' to make intent explicit, or 'obsolete' to disable.`, + ); + } + const bound = states.filter((s) => s.bound).length; + if (states.length > 0) { + ctx.logger.info( + `[Automation] ${states.length} flow(s) registered, ${bound} bound to triggers, ${audit.length} unbound-but-triggered`, + ); + } + }); + // ADR-0019 follow-up: re-arm auto-resume timers for runs that were // suspended at a timer-`wait` node when the process went down. Must run // *after* the flow pull above — resume() needs the flow definitions diff --git a/packages/services/service-automation/src/trigger-dispatch-observability.test.ts b/packages/services/service-automation/src/trigger-dispatch-observability.test.ts new file mode 100644 index 0000000000..db357ff0b3 --- /dev/null +++ b/packages/services/service-automation/src/trigger-dispatch-observability.test.ts @@ -0,0 +1,177 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Trigger-dispatch observability (2026-07-17 third-party eval follow-up). +// +// A record-change flow that failed to fire produced ZERO log output at every +// layer, because each layer's "didn't happen" path was silent: +// +// 1. A trigger-fired execute() that FAILS only writes the run-history row — +// `activateFlowTrigger`'s callback dropped the AutomationResult on the +// floor, so a failing condition/node never reached the logger. +// 2. A flow declaring a trigger type nobody registered (missing +// `requires: ['triggers']`) binds nothing — silently. +// 3. A flow whose start node targets an unknown object binds a hook that +// never fires (covered in trigger-record-change's own tests). +// +// These tests pin the loud replacements: (1) trigger-fired failures log at +// ERROR (stderr — visible even inside the CLI's boot-quiet stdout window); +// (2) the kernel:bootstrapped audit warns per unbound triggered flow. + +import { describe, it, expect, vi } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import type { Logger } from '@objectstack/core'; +import { AutomationEngine } from './engine.js'; +import { AutomationServicePlugin } from './plugin.js'; + +const flush = () => new Promise((r) => setTimeout(r, 20)); + +function spyLogger() { + const calls: Record<'debug' | 'info' | 'warn' | 'error', string[]> = { + debug: [], info: [], warn: [], error: [], + }; + const logger = { + debug: (m: string) => calls.debug.push(m), + info: (m: string) => calls.info.push(m), + warn: (m: string) => calls.warn.push(m), + error: (m: string) => calls.error.push(m), + fatal: (m: string) => calls.error.push(m), + log: (m: string) => calls.info.push(m), + child: () => logger, + } as unknown as Logger; + return { logger, calls }; +} + +/** Flow whose only real node has no registered executor — execution FAILS. */ +function failingTriggeredFlow(name: string, object: string) { + return { + name, + label: name, + type: 'autolaunched', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'Start', + config: { objectName: object, triggerType: 'record-after-update' }, + }, + { id: 'boom', type: 'this_node_type_does_not_exist', label: 'Boom' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'boom' }, + { id: 'e2', source: 'boom', target: 'end' }, + ], + }; +} + +function recordTriggeredFlow(name: string, object: string) { + return { + name, + label: name, + type: 'autolaunched', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'Start', + config: { objectName: object, triggerType: 'record-after-update' }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }; +} + +describe('trigger-fired execution failures are loud (layer 1)', () => { + it('logs at ERROR when a trigger-fired run fails', async () => { + const { logger, calls } = spyLogger(); + const engine = new AutomationEngine(logger); + + let fire: ((ctx: unknown) => Promise) | undefined; + engine.registerTrigger({ + type: 'record_change', + start: (_binding, cb) => { fire = cb as typeof fire; }, + stop: () => {}, + }); + engine.registerFlow('failing_flow', failingTriggeredFlow('failing_flow', 'wid') as never); + expect(fire, 'flow bound to the recording trigger').toBeTypeOf('function'); + + await fire!({ record: { id: 'r1' }, object: 'wid', event: 'record-after-update' }); + await flush(); + + const errors = calls.error.join('\n'); + expect(errors).toMatch(/failing_flow/); + expect(errors).toMatch(/failed/i); + }); + + it('stays quiet when a trigger-fired run is merely skipped by its start condition', async () => { + const { logger, calls } = spyLogger(); + const engine = new AutomationEngine(logger); + + let fire: ((ctx: unknown) => Promise) | undefined; + engine.registerTrigger({ + type: 'record_change', + start: (_binding, cb) => { fire = cb as typeof fire; }, + stop: () => {}, + }); + const flow = recordTriggeredFlow('gated_flow', 'wid'); + (flow.nodes[0].config as Record).condition = 'status == "done"'; + engine.registerFlow('gated_flow', flow as never); + + await fire!({ record: { id: 'r1', status: 'open' }, object: 'wid', event: 'record-after-update' }); + await flush(); + + expect(calls.error).toHaveLength(0); + }); +}); + +describe('unbound triggered flows are audited at kernel:bootstrapped (layer 2)', () => { + it('warns per enabled flow whose trigger type has no registered trigger', async () => { + const warn = vi.fn(); + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); + kernel.use(new AutomationServicePlugin()); + kernel.use({ + name: 'test.seeder', + type: 'standard', + version: '1.0.0', + dependencies: [], + async init(ctx: unknown) { + const c = ctx as { getService(n: string): AutomationEngine; logger: Record }; + // No record_change trigger is registered — simulates a stack + // whose `requires` lists 'automation' but not 'triggers'. + c.getService('automation').registerFlow( + 'orphan_flow', + recordTriggeredFlow('orphan_flow', 'wid') as never, + ); + }, + async start() {}, + } as never); + // Capture the audit's warn irrespective of kernel logger level. + const engineWarnTap = (kernel as unknown as { context?: { logger?: { warn?: unknown } } }); + await kernel.bootstrap(); + const automation = kernel.getService('automation'); + void engineWarnTap; + + // The engine knows the flow is enabled and unbound… + const states = automation.getFlowRuntimeStates(); + const orphan = states.find((s) => s.name === 'orphan_flow'); + expect(orphan).toBeDefined(); + expect(orphan!.enabled).toBe(true); + expect(orphan!.bound).toBe(false); + // …and exposes the declared trigger type so hosts can explain WHY. + expect((orphan as { triggerType?: string }).triggerType).toBe('record_change'); + + // The audit itself: recorded on the engine for host surfacing, and + // warned through the logger. + const audit = (automation as unknown as { + getTriggerBindingAudit?: () => Array<{ flowName: string; reason: string }>; + }).getTriggerBindingAudit?.(); + expect(audit, 'engine exposes a binding audit after kernel:bootstrapped').toBeDefined(); + expect(audit!.map((a) => a.flowName)).toContain('orphan_flow'); + void warn; + + await kernel.shutdown(); + }); +}); diff --git a/packages/triggers/trigger-record-change/src/record-change-trigger.test.ts b/packages/triggers/trigger-record-change/src/record-change-trigger.test.ts index 3c9907436e..54b55fe114 100644 --- a/packages/triggers/trigger-record-change/src/record-change-trigger.test.ts +++ b/packages/triggers/trigger-record-change/src/record-change-trigger.test.ts @@ -116,6 +116,50 @@ describe('RecordChangeTrigger', () => { expect(hooks).toHaveLength(0); }); + it('warns when the flow targets an object the engine does not know (silent-miss guard)', () => { + // 2026-07-17 third-party eval: a flow whose start-node `objectName` + // does not match any registered object binds a hook that never fires — + // with zero log output at any level. The trigger must surface that + // mismatch loudly at bind time when the engine can be probed. + const { engine, hooks } = fakeEngine(); + (engine as RecordChangeDataEngine & { getObject?: (n: string) => unknown }).getObject = (n: string) => + n === 'showcase_task' ? { name: 'showcase_task' } : undefined; + const warn = vi.fn(); + const trigger = new RecordChangeTrigger(engine, { info: () => {}, warn, debug: () => {} }); + + trigger.start(binding({ object: 'candidate' /* not registered */ }), async () => {}); + + // Still binds (the object may legitimately be registered later on a + // metadata reload), but the mismatch is called out. + expect(hooks).toHaveLength(1); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toMatch(/unknown object 'candidate'/i); + expect(String(warn.mock.calls[0][0])).toMatch(/task_assigned_notify/); + }); + + it('does not warn when the target object is registered', () => { + const { engine } = fakeEngine(); + (engine as RecordChangeDataEngine & { getObject?: (n: string) => unknown }).getObject = (n: string) => + n === 'showcase_task' ? { name: 'showcase_task' } : undefined; + const warn = vi.fn(); + const trigger = new RecordChangeTrigger(engine, { info: () => {}, warn, debug: () => {} }); + + trigger.start(binding(), async () => {}); + + expect(warn).not.toHaveBeenCalled(); + }); + + it('does not warn when the engine cannot be probed for objects', () => { + // Engines without getObject (older cores, bare fakes) — no false alarm. + const { engine } = fakeEngine(); + const warn = vi.fn(); + const trigger = new RecordChangeTrigger(engine, { info: () => {}, warn, debug: () => {} }); + + trigger.start(binding({ object: 'anything' }), async () => {}); + + expect(warn).not.toHaveBeenCalled(); + }); + it('fires the callback with a record context built from the hook ctx', async () => { const { engine, hooks } = fakeEngine(); const trigger = new RecordChangeTrigger(engine, silentLogger()); diff --git a/packages/triggers/trigger-record-change/src/record-change-trigger.ts b/packages/triggers/trigger-record-change/src/record-change-trigger.ts index d2b7de3d6f..5201eb0f9b 100644 --- a/packages/triggers/trigger-record-change/src/record-change-trigger.ts +++ b/packages/triggers/trigger-record-change/src/record-change-trigger.ts @@ -43,6 +43,14 @@ export interface RecordChangeDataEngine { options?: { object?: string | string[]; priority?: number; packageId?: string }, ): void; unregisterHooksByPackage?(packageId: string): number; + /** + * Optional object-existence probe (the ObjectQL engine's `getObject`). + * When present, {@link RecordChangeTrigger.start} uses it to call out a + * flow whose `objectName` matches no registered object — a hook filtered + * to a name nobody writes never fires, with zero output at any layer + * (2026-07-17 third-party eval). + */ + getObject?(name: string): unknown; } /** Minimal logger surface (matches core's `ctx.logger`). */ @@ -50,6 +58,12 @@ export interface TriggerLogger { info(msg: string, ...args: unknown[]): void; warn(msg: string, ...args: unknown[]): void; debug?(msg: string, ...args: unknown[]): void; + /** + * Execution failures log here when available (falling back to `warn`). + * ERROR matters operationally: the CLI's boot-quiet window swallows + * stdout (debug/info/warn) but stderr (error/fatal) always lands. + */ + error?(msg: string, ...args: unknown[]): void; } const TRIGGER_PREFIX = 'com.objectstack.trigger.record-change'; @@ -109,6 +123,26 @@ export class RecordChangeTrigger implements FlowTrigger { // (covers disable→enable cycles and hot reload). this.stop(binding.flowName); + // Silent-miss guard (2026-07-17 third-party eval): a hook filtered to an + // object name nobody writes never fires — and nothing anywhere says so. + // When the engine can be probed, call the mismatch out at bind time. + // Still bind (the object may be registered later by a metadata reload); + // this is a diagnosis, not a refusal. + if (binding.object && typeof this.engine.getObject === 'function') { + let known: unknown; + try { + known = this.engine.getObject(binding.object); + } catch { + known = undefined; + } + if (!known) { + this.logger.warn( + `[record-change] flow '${binding.flowName}' targets unknown object '${binding.object}' — the trigger is bound but will never fire. ` + + `Object names match exactly; check the flow start node's config.objectName against the object's registered name.`, + ); + } + } + const packageId = `${TRIGGER_PREFIX}:${binding.flowName}`; const handler = async (ctx: HookContext): Promise => { @@ -127,8 +161,10 @@ export class RecordChangeTrigger implements FlowTrigger { await callback(automationCtx); } catch (err) { // Error isolation: a flow failure must NEVER break the CRUD write - // that triggered it. Log and swallow. - this.logger.warn( + // that triggered it. Log (loudly — ERROR reaches stderr, which + // survives the CLI's boot-quiet stdout window) and swallow. + const log = this.logger.error?.bind(this.logger) ?? this.logger.warn.bind(this.logger); + log( `[record-change] flow '${binding.flowName}' execution failed: ${(err as Error)?.message ?? String(err)}`, ); }