Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/flow-trigger-observability.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions examples/app-showcase/src/automation/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 }],
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions packages/cli/src/commands/serve-automation-summary.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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([]);
});
});
81 changes: 81 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
printStep,
printInfo,
printServerReady,
type AutomationReadySummary,
} from '../utils/format.js';
import {
CONSOLE_PATH,
Expand Down Expand Up @@ -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,
Expand All @@ -2309,6 +2321,7 @@ export default class Serve extends Command {
databaseUrl: redactDbUrl(resolvedDatabaseUrl),
multiTenant: resolveMultiOrgEnabled(),
seededAdmin,
automation: automationSummary,
});

// ── Publish the actually-bound port ────────────────────────────
Expand Down Expand Up @@ -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,
};
}
19 changes: 18 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>);
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,
Expand Down Expand Up @@ -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(),
Expand Down
Loading