feat: add announce delivery mode for automation runs
This commit is contained in:
@@ -644,6 +644,7 @@ Schedule automated messages on cron schedules. Each job fires an inbound message
|
|||||||
Set `automation.delivery_mode` to control automation session behavior:
|
Set `automation.delivery_mode` to control automation session behavior:
|
||||||
- `shared_session` (default): reuse one session per cron job/webhook name.
|
- `shared_session` (default): reuse one session per cron job/webhook name.
|
||||||
- `isolated_job`: create a fresh session per cron trigger/webhook request.
|
- `isolated_job`: create a fresh session per cron trigger/webhook request.
|
||||||
|
- `announce`: create a fresh ephemeral announce-style run per trigger (no shared automation history).
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
@@ -704,7 +705,7 @@ automation:
|
|||||||
|
|
||||||
| Field | Required | Description |
|
| Field | Required | Description |
|
||||||
|-------|----------|-------------|
|
|-------|----------|-------------|
|
||||||
| `automation.delivery_mode` | no | Automation session strategy: `shared_session` or `isolated_job` (default: `shared_session`) |
|
| `automation.delivery_mode` | no | Automation session strategy: `shared_session`, `isolated_job`, or `announce` (default: `shared_session`) |
|
||||||
| `name` | yes | Unique job identifier |
|
| `name` | yes | Unique job identifier |
|
||||||
| `schedule` | yes | Cron expression (standard 5-field) |
|
| `schedule` | yes | Cron expression (standard 5-field) |
|
||||||
| `message` | yes | Text sent to the agent when the job fires |
|
| `message` | yes | Text sent to the agent when the job fires |
|
||||||
@@ -803,7 +804,7 @@ Webhooks are available at `POST /webhooks/:name` on the gateway HTTP server. The
|
|||||||
|
|
||||||
| Field | Required | Description |
|
| Field | Required | Description |
|
||||||
|-------|----------|-------------|
|
|-------|----------|-------------|
|
||||||
| `automation.delivery_mode` | no | Automation session strategy: `shared_session` or `isolated_job` (default: `shared_session`) |
|
| `automation.delivery_mode` | no | Automation session strategy: `shared_session`, `isolated_job`, or `announce` (default: `shared_session`) |
|
||||||
| `name` | yes | Unique webhook identifier (used in URL path) |
|
| `name` | yes | Unique webhook identifier (used in URL path) |
|
||||||
| `secret` | no | HMAC secret for `X-Webhook-Signature` header verification (SHA-256) |
|
| `secret` | no | HMAC secret for `X-Webhook-Signature` header verification (SHA-256) |
|
||||||
| `message` | no | Template for the message sent to the agent (default: `{{body}}`) |
|
| `message` | no | Template for the message sent to the agent (default: `{{body}}`) |
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ hooks:
|
|||||||
# automation:
|
# automation:
|
||||||
# # shared_session: keep one session per cron job/webhook name.
|
# # shared_session: keep one session per cron job/webhook name.
|
||||||
# # isolated_job: create a fresh session per cron trigger/webhook request.
|
# # isolated_job: create a fresh session per cron trigger/webhook request.
|
||||||
|
# # announce: create a fresh announce-style run per trigger (no shared automation history).
|
||||||
# delivery_mode: shared_session
|
# delivery_mode: shared_session
|
||||||
# cron:
|
# cron:
|
||||||
# - name: daily-summary
|
# - name: daily-summary
|
||||||
|
|||||||
+20
-2
@@ -5196,10 +5196,28 @@
|
|||||||
"docs/plans/state.json"
|
"docs/plans/state.json"
|
||||||
],
|
],
|
||||||
"test_status": "pnpm test:run src/backends/native/orchestrator.test.ts src/config/schema.test.ts src/gateway/session-bridge.test.ts src/gateway/handlers/agent.test.ts + pnpm typecheck passing"
|
"test_status": "pnpm test:run src/backends/native/orchestrator.test.ts src/config/schema.test.ts src/gateway/session-bridge.test.ts src/gateway/handlers/agent.test.ts + pnpm typecheck passing"
|
||||||
|
},
|
||||||
|
"automation-announce-delivery-mode": {
|
||||||
|
"status": "completed",
|
||||||
|
"date": "2026-02-18",
|
||||||
|
"updated": "2026-02-18",
|
||||||
|
"summary": "Implemented Tier A3 proactive announce delivery mode as a first-class automation option (`automation.delivery_mode: announce`) for cron/webhooks. Added explicit announce-mode sender/metadata wiring, schema/docs/default-config updates, and regression tests.",
|
||||||
|
"files_modified": [
|
||||||
|
"src/config/schema.ts",
|
||||||
|
"src/config/schema.test.ts",
|
||||||
|
"src/automation/cron.ts",
|
||||||
|
"src/automation/cron.test.ts",
|
||||||
|
"src/automation/webhooks.ts",
|
||||||
|
"src/automation/webhooks.test.ts",
|
||||||
|
"README.md",
|
||||||
|
"config/default.yaml",
|
||||||
|
"docs/plans/state.json"
|
||||||
|
],
|
||||||
|
"test_status": "pnpm test:run src/automation/cron.test.ts src/automation/webhooks.test.ts src/config/schema.test.ts + pnpm typecheck passing"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 1908,
|
"total_test_count": 1911,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (100%)",
|
"p1_completion": "4/4 (100%)",
|
||||||
@@ -5219,7 +5237,7 @@
|
|||||||
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
|
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
|
||||||
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback",
|
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback",
|
||||||
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
|
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
|
||||||
"next_up": "Implement Tier A3 from the OpenClaw roadmap: proactive announce delivery mode for automation jobs"
|
"next_up": "Implement Tier A4 from the OpenClaw roadmap: TTS voice output with channel-aware audio responses"
|
||||||
},
|
},
|
||||||
"soul_md_and_cron_create": {
|
"soul_md_and_cron_create": {
|
||||||
"date": "2026-02-11",
|
"date": "2026-02-11",
|
||||||
|
|||||||
@@ -98,6 +98,22 @@ describe('CronScheduler', () => {
|
|||||||
expect(messages[0].metadata?.deliveryMode).toBe('isolated_job');
|
expect(messages[0].metadata?.deliveryMode).toBe('isolated_job');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses announce sender IDs and metadata when delivery mode is announce', async () => {
|
||||||
|
const jobs = [makeCronJob()];
|
||||||
|
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry), 'announce');
|
||||||
|
|
||||||
|
const messages: InboundMessage[] = [];
|
||||||
|
scheduler.onMessage((msg: InboundMessage) => messages.push(msg));
|
||||||
|
await scheduler.connect();
|
||||||
|
scheduler.triggerJob('test-job');
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messages[0].senderId).toMatch(/^test-job:announce:run-/);
|
||||||
|
expect(messages[0].metadata?.replyPeerId).toBe('test-job');
|
||||||
|
expect(messages[0].metadata?.deliveryMode).toBe('announce');
|
||||||
|
expect(messages[0].metadata?.announce).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('forwards response to output channel on send()', async () => {
|
it('forwards response to output channel on send()', async () => {
|
||||||
const mockOutputAdapter = {
|
const mockOutputAdapter = {
|
||||||
send: vi.fn().mockResolvedValue(undefined),
|
send: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface ChannelLookup {
|
|||||||
get(name: string): { send(peerId: string, message: OutboundMessage): Promise<void> } | undefined;
|
get(name: string): { send(peerId: string, message: OutboundMessage): Promise<void> } | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeliveryMode = 'shared_session' | 'isolated_job';
|
type DeliveryMode = 'shared_session' | 'isolated_job' | 'announce';
|
||||||
|
|
||||||
export class CronScheduler implements ChannelAdapter {
|
export class CronScheduler implements ChannelAdapter {
|
||||||
readonly name = 'cron';
|
readonly name = 'cron';
|
||||||
@@ -99,7 +99,11 @@ export class CronScheduler implements ChannelAdapter {
|
|||||||
this.lastTriggeredLocalDateByJob.set(jobName, dayKey);
|
this.lastTriggeredLocalDateByJob.set(jobName, dayKey);
|
||||||
}
|
}
|
||||||
const runId = `run-${randomUUID()}`;
|
const runId = `run-${randomUUID()}`;
|
||||||
const senderId = this.deliveryMode === 'isolated_job' ? `${jobName}:${runId}` : jobName;
|
const senderId = this.deliveryMode === 'shared_session'
|
||||||
|
? jobName
|
||||||
|
: this.deliveryMode === 'announce'
|
||||||
|
? `${jobName}:announce:${runId}`
|
||||||
|
: `${jobName}:${runId}`;
|
||||||
|
|
||||||
const msg: InboundMessage = {
|
const msg: InboundMessage = {
|
||||||
id: `cron-${jobName}-${Date.now()}`,
|
id: `cron-${jobName}-${Date.now()}`,
|
||||||
@@ -113,6 +117,7 @@ export class CronScheduler implements ChannelAdapter {
|
|||||||
scheduled: true,
|
scheduled: true,
|
||||||
modelTier: job.model_tier,
|
modelTier: job.model_tier,
|
||||||
deliveryMode: this.deliveryMode,
|
deliveryMode: this.deliveryMode,
|
||||||
|
announce: this.deliveryMode === 'announce',
|
||||||
runId,
|
runId,
|
||||||
replyPeerId: jobName,
|
replyPeerId: jobName,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -137,6 +137,26 @@ describe('WebhookHandler', () => {
|
|||||||
expect(messages[0].metadata?.deliveryMode).toBe('isolated_job');
|
expect(messages[0].metadata?.deliveryMode).toBe('isolated_job');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handleRequest uses announce sender IDs and metadata when delivery mode is announce', async () => {
|
||||||
|
const webhooks = [makeWebhook()];
|
||||||
|
handler = new WebhookHandler(webhooks, asWebhookChannelLookup(mockChannelRegistry), 'announce');
|
||||||
|
|
||||||
|
const messages: InboundMessage[] = [];
|
||||||
|
handler.onMessage((msg: InboundMessage) => messages.push(msg));
|
||||||
|
await handler.connect();
|
||||||
|
|
||||||
|
const req = mockRequest('hello world');
|
||||||
|
const res = mockResponse();
|
||||||
|
const result = await handler.handleRequest('test-hook', req, res);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messages[0].senderId).toMatch(/^test-hook:announce:run-/);
|
||||||
|
expect(messages[0].metadata?.replyPeerId).toBe('test-hook');
|
||||||
|
expect(messages[0].metadata?.deliveryMode).toBe('announce');
|
||||||
|
expect(messages[0].metadata?.announce).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns false for unknown webhook', async () => {
|
it('returns false for unknown webhook', async () => {
|
||||||
handler = new WebhookHandler([], asWebhookChannelLookup(mockChannelRegistry));
|
handler = new WebhookHandler([], asWebhookChannelLookup(mockChannelRegistry));
|
||||||
await handler.connect();
|
await handler.connect();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface ChannelLookup {
|
|||||||
get(name: string): { send(peerId: string, message: OutboundMessage): Promise<void> } | undefined;
|
get(name: string): { send(peerId: string, message: OutboundMessage): Promise<void> } | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeliveryMode = 'shared_session' | 'isolated_job';
|
type DeliveryMode = 'shared_session' | 'isolated_job' | 'announce';
|
||||||
|
|
||||||
/** Verify HMAC-SHA256 signature from the X-Webhook-Signature header. */
|
/** Verify HMAC-SHA256 signature from the X-Webhook-Signature header. */
|
||||||
function verifyHmac(body: string, secret: string, signature: string): boolean {
|
function verifyHmac(body: string, secret: string, signature: string): boolean {
|
||||||
@@ -162,7 +162,11 @@ export class WebhookHandler implements ChannelAdapter {
|
|||||||
// Render message template
|
// Render message template
|
||||||
const text = renderTemplate(webhook.message, body);
|
const text = renderTemplate(webhook.message, body);
|
||||||
const runId = `run-${randomUUID()}`;
|
const runId = `run-${randomUUID()}`;
|
||||||
const senderId = this.deliveryMode === 'isolated_job' ? `${webhookName}:${runId}` : webhookName;
|
const senderId = this.deliveryMode === 'shared_session'
|
||||||
|
? webhookName
|
||||||
|
: this.deliveryMode === 'announce'
|
||||||
|
? `${webhookName}:announce:${runId}`
|
||||||
|
: `${webhookName}:${runId}`;
|
||||||
|
|
||||||
const msg: InboundMessage = {
|
const msg: InboundMessage = {
|
||||||
id: `webhook-${webhookName}-${Date.now()}`,
|
id: `webhook-${webhookName}-${Date.now()}`,
|
||||||
@@ -175,6 +179,7 @@ export class WebhookHandler implements ChannelAdapter {
|
|||||||
webhookName,
|
webhookName,
|
||||||
body,
|
body,
|
||||||
deliveryMode: this.deliveryMode,
|
deliveryMode: this.deliveryMode,
|
||||||
|
announce: this.deliveryMode === 'announce',
|
||||||
runId,
|
runId,
|
||||||
replyPeerId: webhookName,
|
replyPeerId: webhookName,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1059,6 +1059,16 @@ describe('configSchema automation', () => {
|
|||||||
expect(result.automation.delivery_mode).toBe('isolated_job');
|
expect(result.automation.delivery_mode).toBe('isolated_job');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts announce automation delivery mode', () => {
|
||||||
|
const result = configSchema.parse({
|
||||||
|
...baseConfig,
|
||||||
|
automation: {
|
||||||
|
delivery_mode: 'announce',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.automation.delivery_mode).toBe('announce');
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts config with cron jobs', () => {
|
it('accepts config with cron jobs', () => {
|
||||||
const result = configSchema.parse({
|
const result = configSchema.parse({
|
||||||
...baseConfig,
|
...baseConfig,
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ const minioSyncAutomationSchema = z.object({
|
|||||||
notify_on_success: z.boolean().default(false),
|
notify_on_success: z.boolean().default(false),
|
||||||
}).default({});
|
}).default({});
|
||||||
|
|
||||||
const automationDeliveryModeSchema = z.enum(['shared_session', 'isolated_job']);
|
const automationDeliveryModeSchema = z.enum(['shared_session', 'isolated_job', 'announce']);
|
||||||
|
|
||||||
const automationSchema = z.object({
|
const automationSchema = z.object({
|
||||||
/** Session strategy for automation-triggered runs (cron/webhooks/gmail). */
|
/** Session strategy for automation-triggered runs (cron/webhooks/gmail). */
|
||||||
|
|||||||
Reference in New Issue
Block a user