Skip to content

Commit e715166

Browse files
feat(messaging): notify baseline node + lean service-messaging vertical slice (ADR-0012) (#1420)
Make the `notify` flow node a real capability instead of a no-op, backed by a minimal, ADR-0012-aligned messaging vertical slice. Spec - flow.zod.ts: add `notify` to the builtin flow-node action enum (+ test). service-messaging (new @objectstack/service-messaging) - MessagingChannel seam (channel.ts): Notification/Delivery/SendResult + MessagingChannel interface (id, send, optional classifyError). - MessagingService (registry + emit): register/unregister/get channels; emit defaults channels to ['inbox'], fans out per (channel × recipient), isolates a throwing/failing/unregistered channel into a failed delivery — never throws. - Always-on inbox channel: writes sys_inbox_message rows via the data engine; degrades to a logged no-op when no data engine is present. - sys_inbox_message object + MessagingServicePlugin (registers the `messaging` service, the inbox channel, and the object via the manifest). service-automation - notify-node.ts: baseline `notify` io node. Interpolates recipients/title/body/ channels/topic/severity/actionUrl/payload; resolves the `messaging` service through a local structural surface (no runtime dep on service-messaging, mirroring ConnectorRegistrySurface). Degrades to a logged success when no messaging service is installed; emits otherwise. Wired into installBuiltinNodes. Example - app-showcase: TaskAssignedNotifyFlow — worked `notify` example (inbox channel). Deferred to follow-ups (per chosen minimal-slice scope): email/push/webhook channels, topic↔preference matrix, outbox/retry extraction. Tests: service-messaging 19, service-automation 95, spec + runtime green. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
1 parent 90ca4e5 commit e715166

19 files changed

Lines changed: 1245 additions & 2 deletions

File tree

examples/app-showcase/src/flows/index.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,56 @@ export const ReassignWizardFlow = defineFlow({
8989
],
9090
});
9191

92+
/**
93+
* Task Assigned → Notify Assignee — the worked `notify` example (ADR-0012).
94+
*
95+
* Where {@link TaskCompletedFlow} hand-waves notification through a `script`
96+
* node, this flow uses the baseline `notify` node: it hands a topic +
97+
* recipient + message to the messaging service, which fans out to the user's
98+
* channels (inbox by default). The `notify` node ships in every automation
99+
* engine; delivery is backed by `@objectstack/service-messaging`
100+
* (`MessagingServicePlugin`). Without that plugin the node degrades to a
101+
* logged no-op instead of failing the flow — install it and this flow starts
102+
* landing inbox rows with no edit.
103+
*/
104+
export const TaskAssignedNotifyFlow = defineFlow({
105+
name: 'showcase_task_assigned_notify',
106+
label: 'Notify Assignee on Task Assignment',
107+
description: 'Notifies the new assignee (inbox channel) when a task is reassigned.',
108+
type: 'autolaunched',
109+
nodes: [
110+
{
111+
id: 'start',
112+
type: 'start',
113+
label: 'On Task Assignee Change',
114+
config: {
115+
objectName: 'showcase_task',
116+
triggerType: 'record-after-update',
117+
condition: 'assignee != previous.assignee',
118+
},
119+
},
120+
{
121+
id: 'notify_assignee',
122+
type: 'notify',
123+
label: 'Notify Assignee',
124+
config: {
125+
topic: 'task.assigned',
126+
recipients: ['{record.assignee}'],
127+
channels: ['inbox'],
128+
severity: 'info',
129+
title: 'New task assigned: {record.title}',
130+
message: 'You have been assigned "{record.title}".',
131+
actionUrl: '/showcase_task/{record.id}',
132+
},
133+
},
134+
{ id: 'end', type: 'end', label: 'End' },
135+
],
136+
edges: [
137+
{ id: 'e1', source: 'start', target: 'notify_assignee' },
138+
{ id: 'e2', source: 'notify_assignee', target: 'end' },
139+
],
140+
});
141+
92142
/**
93143
* Project Budget Approval — ADR-0019 approval-as-flow-node.
94144
*
@@ -213,4 +263,5 @@ export const allFlows = [
213263
ReassignWizardFlow,
214264
BudgetApprovalFlow,
215265
TaskCompletedSlackFlow,
266+
TaskAssignedNotifyFlow,
216267
];

packages/services/service-automation/src/builtin/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
* - human — screen / script (core flow capability)
1616
* - io — http_request (foundational outbound I/O)
1717
* - io — connector_action (generic integration dispatch)
18+
* - io — notify (outbound notification via messaging service)
1819
*
1920
* `connector_action` is the *generic dispatch* counterpart to `http_request`
2021
* (ADR-0018 §Addendum): the platform ships the node + an (initially empty)
@@ -31,12 +32,14 @@ import { registerCrudNodes } from './crud-nodes.js';
3132
import { registerScreenNodes } from './screen-nodes.js';
3233
import { registerHttpNodes } from './http-nodes.js';
3334
import { registerConnectorNodes } from './connector-nodes.js';
35+
import { registerNotifyNode } from './notify-node.js';
3436

3537
export { registerLogicNodes } from './logic-nodes.js';
3638
export { registerCrudNodes } from './crud-nodes.js';
3739
export { registerScreenNodes } from './screen-nodes.js';
3840
export { registerHttpNodes } from './http-nodes.js';
3941
export { registerConnectorNodes } from './connector-nodes.js';
42+
export { registerNotifyNode } from './notify-node.js';
4043

4144
/**
4245
* Seed every built-in node executor into the engine. Called by
@@ -49,6 +52,7 @@ export function installBuiltinNodes(engine: AutomationEngine, ctx: PluginContext
4952
registerScreenNodes(engine, ctx);
5053
registerHttpNodes(engine, ctx);
5154
registerConnectorNodes(engine, ctx);
55+
registerNotifyNode(engine, ctx);
5256

5357
const types = engine.getRegisteredNodeTypes();
5458
ctx.logger.info(
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach } from 'vitest';
4+
import { AutomationEngine } from '../engine.js';
5+
import { registerNotifyNode } from './notify-node.js';
6+
import type { MessagingServiceSurface } from './notify-node.js';
7+
8+
function createTestLogger() {
9+
return {
10+
info: () => {},
11+
warn: () => {},
12+
error: () => {},
13+
debug: () => {},
14+
child: () => createTestLogger(),
15+
} as any;
16+
}
17+
18+
/**
19+
* A PluginContext stub whose `messaging` service can be toggled on/off, so we
20+
* exercise both the wired path and the degrade-when-absent path.
21+
*/
22+
function createCtx(messaging?: MessagingServiceSurface) {
23+
return {
24+
logger: createTestLogger(),
25+
getService(name: string) {
26+
if (name === 'messaging') return messaging;
27+
return undefined;
28+
},
29+
} as any;
30+
}
31+
32+
/** A fake messaging service capturing emitted notifications. */
33+
function fakeMessaging() {
34+
const emitted: any[] = [];
35+
const service: MessagingServiceSurface = {
36+
async emit(n) {
37+
emitted.push(n);
38+
return { delivered: n.recipients.length, failed: 0 };
39+
},
40+
};
41+
return { service, emitted };
42+
}
43+
44+
function notifyFlow(config: Record<string, unknown>) {
45+
return {
46+
name: 'notify_flow',
47+
label: 'Notify Flow',
48+
type: 'autolaunched' as const,
49+
variables: [
50+
{ name: 'dealName', type: 'text' as const, isInput: true },
51+
{ name: 'dealId', type: 'text' as const, isInput: true },
52+
{ name: 'notify.delivered', type: 'number' as const, isOutput: true },
53+
],
54+
nodes: [
55+
{ id: 'start', type: 'start' as const, label: 'Start' },
56+
{ id: 'notify', type: 'notify' as const, label: 'Notify', config },
57+
{ id: 'end', type: 'end' as const, label: 'End' },
58+
],
59+
edges: [
60+
{ id: 'e1', source: 'start', target: 'notify' },
61+
{ id: 'e2', source: 'notify', target: 'end' },
62+
],
63+
};
64+
}
65+
66+
describe('notify (baseline node)', () => {
67+
it('publishes a builtin io descriptor in the action registry', () => {
68+
const engine = new AutomationEngine(createTestLogger());
69+
registerNotifyNode(engine, createCtx());
70+
expect(engine.getRegisteredNodeTypes()).toContain('notify');
71+
const descriptor = engine.getActionDescriptor('notify');
72+
expect(descriptor?.source).toBe('builtin');
73+
expect(descriptor?.category).toBe('io');
74+
expect(descriptor?.paradigms).toEqual(
75+
expect.arrayContaining(['flow', 'workflow_rule', 'approval']),
76+
);
77+
});
78+
79+
describe('with a messaging service registered', () => {
80+
let engine: AutomationEngine;
81+
let messaging: ReturnType<typeof fakeMessaging>;
82+
83+
beforeEach(() => {
84+
messaging = fakeMessaging();
85+
engine = new AutomationEngine(createTestLogger());
86+
registerNotifyNode(engine, createCtx(messaging.service));
87+
});
88+
89+
it('emits a notification, interpolating recipients/title/body, and reports delivered count', async () => {
90+
engine.registerFlow('notify_flow', notifyFlow({
91+
topic: 'deal.won',
92+
recipients: ['user_1', 'user_2'],
93+
title: 'Deal {dealName} closed',
94+
message: 'Congrats on {dealName}',
95+
channels: ['inbox', 'email'],
96+
severity: 'info',
97+
actionUrl: '/opps/{dealId}',
98+
}));
99+
100+
const result = await engine.execute('notify_flow', {
101+
params: { dealName: 'Acme', dealId: '42' },
102+
} as any);
103+
104+
expect(result.success).toBe(true);
105+
expect(messaging.emitted).toHaveLength(1);
106+
expect(messaging.emitted[0]).toMatchObject({
107+
topic: 'deal.won',
108+
title: 'Deal Acme closed',
109+
body: 'Congrats on Acme',
110+
recipients: ['user_1', 'user_2'],
111+
channels: ['inbox', 'email'],
112+
severity: 'info',
113+
actionUrl: '/opps/42',
114+
});
115+
expect(result.output).toMatchObject({ 'notify.delivered': 2 });
116+
});
117+
118+
it('accepts a single recipient string and the subject/to aliases', async () => {
119+
engine.registerFlow('notify_flow', notifyFlow({
120+
to: 'user_9',
121+
subject: 'Heads up',
122+
}));
123+
const result = await engine.execute('notify_flow');
124+
expect(result.success).toBe(true);
125+
expect(messaging.emitted[0]).toMatchObject({ title: 'Heads up', recipients: ['user_9'] });
126+
});
127+
128+
it('fails the step when title is missing', async () => {
129+
engine.registerFlow('notify_flow', notifyFlow({ recipients: ['user_1'] }));
130+
const result = await engine.execute('notify_flow');
131+
expect(result.success).toBe(false);
132+
expect(result.error).toContain('title');
133+
});
134+
135+
it('fails the step when no recipient is given', async () => {
136+
engine.registerFlow('notify_flow', notifyFlow({ title: 'Hi' }));
137+
const result = await engine.execute('notify_flow');
138+
expect(result.success).toBe(false);
139+
expect(result.error).toContain('recipient');
140+
});
141+
});
142+
143+
describe('without a messaging service', () => {
144+
it('degrades to a no-op success (skipped) rather than failing the flow', async () => {
145+
const engine = new AutomationEngine(createTestLogger());
146+
registerNotifyNode(engine, createCtx(undefined));
147+
engine.registerFlow('notify_flow', notifyFlow({
148+
recipients: ['user_1'],
149+
title: 'Hi',
150+
}));
151+
152+
const result = await engine.execute('notify_flow');
153+
expect(result.success).toBe(true);
154+
});
155+
});
156+
});
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { PluginContext } from '@objectstack/core';
4+
import { defineActionDescriptor } from '@objectstack/spec/automation';
5+
import type { AutomationEngine } from '../engine.js';
6+
import { interpolate } from './template.js';
7+
8+
/**
9+
* Structural view of `@objectstack/service-messaging`'s service (ADR-0012),
10+
* declared locally so service-automation does not take a runtime dependency on
11+
* it — mirrors the `ConnectorRegistrySurface` pattern. The `notify` node
12+
* resolves whatever object is registered under the `messaging` service and
13+
* dispatches through this shape; if no such service is present the node
14+
* degrades to a no-op success.
15+
*/
16+
export interface MessagingServiceSurface {
17+
emit(notification: {
18+
topic?: string;
19+
title: string;
20+
body: string;
21+
severity?: string;
22+
recipients: string[];
23+
channels?: string[];
24+
actionUrl?: string;
25+
payload?: Record<string, unknown>;
26+
}): Promise<{ delivered: number; failed: number }>;
27+
}
28+
29+
/** Coerce a config value (string | string[]) into a clean string[]. */
30+
function toStringList(value: unknown): string[] {
31+
if (Array.isArray(value)) return value.map((v) => String(v)).filter(Boolean);
32+
if (typeof value === 'string' && value.trim()) return [value.trim()];
33+
return [];
34+
}
35+
36+
/**
37+
* `notify` built-in node (ADR-0012) — outbound notification.
38+
*
39+
* Baseline node and the human-notification counterpart to `http_request`
40+
* ("raw call") and `connector_action` ("call a registered integration"):
41+
* `notify` hands a topic + recipients + message to the platform's messaging
42+
* service, which fans it out across the user's channels (inbox by default).
43+
*
44+
* Like the CRUD nodes degrade without a data engine, `notify` degrades to a
45+
* warning + success when no `messaging` service is registered — the capability
46+
* simply isn't installed in that stack. Install `MessagingServicePlugin`
47+
* (`@objectstack/service-messaging`) and the same flow starts delivering, with
48+
* no flow edit. This is the seam that fixes the "notify drops on the floor"
49+
* gap (#1292) once messaging is present.
50+
*/
51+
export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext): void {
52+
const getMessaging = (): MessagingServiceSurface | undefined => {
53+
try {
54+
return ctx.getService<MessagingServiceSurface>('messaging');
55+
} catch {
56+
return undefined;
57+
}
58+
};
59+
60+
engine.registerNodeExecutor({
61+
type: 'notify',
62+
descriptor: defineActionDescriptor({
63+
type: 'notify', version: '1.0.0', name: 'Notify',
64+
description: 'Send an outbound notification to users via the messaging service (inbox / email / push / …).',
65+
icon: 'bell', category: 'io', source: 'builtin',
66+
supportsRetry: true,
67+
paradigms: ['flow', 'workflow_rule', 'approval'],
68+
}),
69+
async execute(node, variables, context) {
70+
const cfg = (node.config ?? {}) as Record<string, unknown>;
71+
72+
const recipients = toStringList(interpolate(cfg.recipients ?? cfg.to ?? [], variables, context));
73+
const title = String(interpolate(cfg.title ?? cfg.subject ?? '', variables, context) ?? '');
74+
const body = String(interpolate(cfg.message ?? cfg.body ?? '', variables, context) ?? '');
75+
const channels = toStringList(cfg.channels);
76+
const topic = cfg.topic ? String(cfg.topic) : undefined;
77+
const severity = cfg.severity ? String(cfg.severity) : undefined;
78+
const actionUrl = cfg.actionUrl
79+
? String(interpolate(cfg.actionUrl, variables, context) ?? '')
80+
: undefined;
81+
const payload = cfg.payload
82+
? (interpolate(cfg.payload, variables, context) as Record<string, unknown>)
83+
: undefined;
84+
85+
if (!title) return { success: false, error: 'notify: title (or subject) is required' };
86+
if (recipients.length === 0) {
87+
return { success: false, error: 'notify: at least one recipient is required' };
88+
}
89+
90+
const messaging = getMessaging();
91+
if (!messaging) {
92+
ctx.logger.warn(
93+
`[notify] no messaging service registered; notification "${title}" not delivered`,
94+
);
95+
return {
96+
success: true,
97+
output: { delivered: 0, failed: 0, skipped: true },
98+
};
99+
}
100+
101+
try {
102+
const result = await messaging.emit({
103+
topic,
104+
title,
105+
body,
106+
severity,
107+
recipients,
108+
channels: channels.length ? channels : undefined,
109+
actionUrl,
110+
payload,
111+
});
112+
return {
113+
success: true,
114+
output: { delivered: result.delivered, failed: result.failed },
115+
};
116+
} catch (err) {
117+
return { success: false, error: `notify failed: ${(err as Error).message}` };
118+
}
119+
},
120+
});
121+
122+
ctx.logger.info('[Notify] 1 built-in node executor registered (notify)');
123+
}

0 commit comments

Comments
 (0)