Reliable Automation Execution Core v1 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a durable, restart-safe automation runtime that converts telemetry into deduplicated endpoint events, creates idempotent command intents, dispatches correlated MQTT RPC commands, and only marks success after applied-state evidence is recorded.
Architecture: Keep the existing rule and endpoint authoring model, but replace inline telemetry dispatch with a staged runtime under backend/src/automation/. PostgreSQL is the source of truth for endpoint events, rule executions, command intents, and command receipts; Redis is only used for short-TTL dedupe, worker leases, inflight correlation, and per-endpoint dispatch serialization. Nest schedule-driven workers advance the state machine in small polling loops so the first production-grade version fits the current stack without introducing a queue platform.
Tech Stack: NestJS, TypeORM, PostgreSQL, Redis, MQTT, Jest, Bun, Biome
File Structure
New files
backend/src/automation/entities/automation-endpoint-event.entity.ts
Durable normalized telemetry events for automation evaluation and replay.backend/src/automation/entities/automation-command-intent.entity.ts
Durable command ledger and retry state machine anchor.backend/src/automation/entities/device-command-receipt.entity.ts
Raw ack/result/state-report evidence table.backend/src/automation/enums/automation-endpoint-event-status.enum.ts
Event worker lifecycle (pending,processed,discarded,failed).backend/src/automation/enums/automation-command-intent-status.enum.ts
Command lifecycle (pending_dispatch,dispatched,acknowledged,applied,retrying,failed,expired,uncertain).backend/src/automation/enums/device-command-receipt-type.enum.ts
Receipt kinds (rpc_ack,rpc_result,state_report,timeout_mark,retry_mark).backend/src/automation/runtime/automation-runtime.types.ts
Shared runtime payload and policy types.backend/src/automation/runtime/automation-runtime-policy.ts
Pure functions for dedupe keys, idempotency keys, retry policy, and state transitions.backend/src/automation/runtime/automation-endpoint-events.service.ts
Persists deduplicated endpoint events and owns event status updates.backend/src/automation/runtime/automation-command-payload.factory.ts
Builds RPC payloads from endpoint transport binding plus runtime correlation fields.backend/src/automation/runtime/automation-command-dispatcher.service.ts
Dispatches command intents, acquires serialization lease, and updates durable state.backend/src/automation/runtime/automation-reconciliation.service.ts
Persists receipts, detects applied evidence, handles timeout and uncertain transitions.backend/src/automation/runtime/automation-runtime-worker.service.ts
Poll workers for event evaluation, dispatch, retry, and recovery.backend/src/automation/runtime/automation-runtime-diagnostics.service.ts
Timeline and backlog queries for production debugging.backend/src/automation/runtime/automation-runtime-policy.spec.tsbackend/src/automation/runtime/automation-endpoint-events.service.spec.tsbackend/src/automation/runtime/automation-command-payload.factory.spec.tsbackend/src/automation/runtime/automation-command-dispatcher.service.spec.tsbackend/src/automation/runtime/automation-reconciliation.service.spec.tsbackend/src/automation/runtime/automation-runtime-worker.service.spec.tsbackend/src/automation/runtime/automation-runtime-diagnostics.service.spec.tsbackend/src/automation/automation-runtime.integration.spec.tsbackend/src/automation/automation-runtime.contract.spec.tsbackend/src/migrations/1778371200000-CreateReliableAutomationRuntimeTables.ts
Modified files
backend/src/automation/automation.module.ts
Register new entities, worker services, and MQTT/Redis dependencies.backend/src/automation/automation-engine.service.ts
Stop publishing MQTT directly; create executions plus command intents only.backend/src/automation/automation-engine.service.spec.ts
Update expectations from direct dispatch to durable command-intent creation.backend/src/automation/endpoint-event-normalizer.service.ts
Preserve enough endpoint context for event dedupe and applied-state reconciliation.backend/src/automation/entities/automation-rule-execution.entity.ts
Expand execution envelope with triggering event, command intent, outcome, and evidence metadata.backend/src/automation/enums/automation-rule-execution-status.enum.ts
Align execution envelope statuses with runtime lifecycle.backend/src/telemetry/telemetry.service.ts
Persist telemetry and endpoint events only; remove inline automation dispatch.backend/src/telemetry/telemetry.service.spec.ts
Assert durable event creation instead of inline rule execution.backend/src/mqtt/mqtt.service.ts
Add publish helper for correlated automation RPC and route responses/status to reconciliation.backend/src/mqtt/mqtt.service.spec.ts
Assert correlation payload and receipt forwarding behavior.backend/src/mqtt/dto/rpc-request.dto.ts
Add correlation fields needed by firmware contract.backend/src/devices/device-command-dispatch.service.ts
Convert to transport-binding helper used by automation dispatcher and existing manual flows.backend/src/devices/device-command-dispatch.service.spec.ts
Assert payload-building behavior instead of immediate durable completion assumptions.backend/src/redis/redis.service.ts
Add lease and short-TTL helper methods needed by worker coordination.backend/src/app.module.ts
Add new automation runtime entities to TypeORM registration.
Task 1: Define runtime statuses and pure policy helpers
Files:
Create:
backend/src/automation/enums/automation-endpoint-event-status.enum.tsCreate:
backend/src/automation/enums/automation-command-intent-status.enum.tsCreate:
backend/src/automation/enums/device-command-receipt-type.enum.tsCreate:
backend/src/automation/runtime/automation-runtime.types.tsCreate:
backend/src/automation/runtime/automation-runtime-policy.tsTest:
backend/src/automation/runtime/automation-runtime-policy.spec.tsModify:
backend/src/automation/enums/automation-rule-execution-status.enum.tsStep 1: Write the failing policy tests
import {
buildCommandIdempotencyKey,
buildEndpointEventDedupeKey,
classifyDispatchFailure,
computeNextAttemptAt,
shouldRetryIntent,
transitionIntentStatus,
} from '@/automation/runtime/automation-runtime-policy';
import { AutomationCommandIntentStatus } from '@/automation/enums/automation-command-intent-status.enum';
describe('automation-runtime-policy', () => {
it('builds a stable endpoint dedupe key from device, endpoint, timestamp, value, and fingerprint', () => {
expect(
buildEndpointEventDedupeKey({
deviceId: 'device-1',
deviceInstanceEndpointId: 'endpoint-1',
eventTimestamp: new Date('2026-05-10T10:00:00.000Z'),
normalizedValue: 3.8,
telemetryFingerprint: 'telemetry:abc',
}),
).toBe('device-1:endpoint-1:1746871200000:3.8:telemetry:abc');
});
it('builds a stable command idempotency key from rule, target endpoint, target value, and triggering event', () => {
expect(
buildCommandIdempotencyKey({
ruleId: 'rule-1',
targetDeviceEndpointId: 'target-endpoint-1',
targetValue: true,
triggeringEventId: 'event-1',
}),
).toBe('rule-1:target-endpoint-1:true:event-1');
});
it('uses exponential backoff with jitter-free reference schedule 5s, 15s, 45s', () => {
expect(computeNextAttemptAt(new Date('2026-05-10T10:00:00.000Z'), 1)?.toISOString()).toBe(
'2026-05-10T10:00:05.000Z',
);
expect(computeNextAttemptAt(new Date('2026-05-10T10:00:00.000Z'), 2)?.toISOString()).toBe(
'2026-05-10T10:00:15.000Z',
);
expect(computeNextAttemptAt(new Date('2026-05-10T10:00:00.000Z'), 3)?.toISOString()).toBe(
'2026-05-10T10:00:45.000Z',
);
});
it('only retries transient dispatch failures', () => {
expect(shouldRetryIntent({ attemptCount: 1, maxAttempts: 3, failureClass: 'timeout' })).toBe(
true,
);
expect(
shouldRetryIntent({ attemptCount: 1, maxAttempts: 3, failureClass: 'binding_invalid' }),
).toBe(false);
});
it('guards invalid lifecycle transitions', () => {
expect(
transitionIntentStatus(
AutomationCommandIntentStatus.PENDING_DISPATCH,
'dispatch_succeeded',
),
).toBe(AutomationCommandIntentStatus.DISPATCHED);
expect(() =>
transitionIntentStatus(AutomationCommandIntentStatus.APPLIED, 'dispatch_succeeded'),
).toThrow('Invalid automation command transition');
});
it('classifies permanent versus transient errors', () => {
expect(classifyDispatchFailure('Device is not online')).toBe('device_offline');
expect(classifyDispatchFailure('Target endpoint binding is invalid')).toBe('binding_invalid');
});
});
- Step 2: Run the policy test and verify it fails
Run from backend/: bun test -- src/automation/runtime/automation-runtime-policy.spec.ts
Expected: FAIL with missing module errors for automation-runtime-policy and the new enums.
- Step 3: Implement the minimal runtime policy layer
export enum AutomationCommandIntentStatus {
PENDING_DISPATCH = 'pending_dispatch',
DISPATCHED = 'dispatched',
ACKNOWLEDGED = 'acknowledged',
APPLIED = 'applied',
RETRYING = 'retrying',
FAILED = 'failed',
EXPIRED = 'expired',
UNCERTAIN = 'uncertain',
}
export enum AutomationEndpointEventStatus {
PENDING = 'pending',
PROCESSED = 'processed',
DISCARDED = 'discarded',
FAILED = 'failed',
}
export enum DeviceCommandReceiptType {
RPC_ACK = 'rpc_ack',
RPC_RESULT = 'rpc_result',
STATE_REPORT = 'state_report',
TIMEOUT_MARK = 'timeout_mark',
RETRY_MARK = 'retry_mark',
}
export enum AutomationRuleExecutionStatus {
PENDING = 'pending',
SKIPPED = 'skipped',
DISPATCHED = 'dispatched',
ACKNOWLEDGED = 'acknowledged',
APPLIED = 'applied',
RETRYING = 'retrying',
FAILED = 'failed',
EXPIRED = 'expired',
UNCERTAIN = 'uncertain',
}
const RETRY_DELAYS_MS = [5000, 15000, 45000] as const;
export function buildEndpointEventDedupeKey(input: {
deviceId: string;
deviceInstanceEndpointId: string;
eventTimestamp: Date;
normalizedValue: string | number | boolean;
telemetryFingerprint: string;
}): string {
return [
input.deviceId,
input.deviceInstanceEndpointId,
input.eventTimestamp.getTime(),
String(input.normalizedValue),
input.telemetryFingerprint,
].join(':');
}
export function buildCommandIdempotencyKey(input: {
ruleId: string;
targetDeviceEndpointId: string;
targetValue: string | number | boolean;
triggeringEventId: string;
}): string {
return [
input.ruleId,
input.targetDeviceEndpointId,
String(input.targetValue),
input.triggeringEventId,
].join(':');
}
export function computeNextAttemptAt(base: Date, attemptNumber: number): Date | null {
const delay = RETRY_DELAYS_MS[attemptNumber - 1];
return delay ? new Date(base.getTime() + delay) : null;
}
- Step 4: Run the policy test again and verify it passes
Run from backend/: bun test -- src/automation/runtime/automation-runtime-policy.spec.ts
Expected: PASS with 6 passing tests.
- Step 5: Commit
git add backend/src/automation/enums/automation-endpoint-event-status.enum.ts \
backend/src/automation/enums/automation-command-intent-status.enum.ts \
backend/src/automation/enums/device-command-receipt-type.enum.ts \
backend/src/automation/enums/automation-rule-execution-status.enum.ts \
backend/src/automation/runtime/automation-runtime.types.ts \
backend/src/automation/runtime/automation-runtime-policy.ts \
backend/src/automation/runtime/automation-runtime-policy.spec.ts
git commit -m "feat: add automation runtime policy primitives"
Task 2: Add durable automation runtime tables and execution envelope fields
Files:
Create:
backend/src/automation/entities/automation-endpoint-event.entity.tsCreate:
backend/src/automation/entities/automation-command-intent.entity.tsCreate:
backend/src/automation/entities/device-command-receipt.entity.tsCreate:
backend/src/migrations/1778371200000-CreateReliableAutomationRuntimeTables.tsTest:
backend/src/automation/entities/automation-runtime.entities.spec.tsModify:
backend/src/automation/entities/automation-rule-execution.entity.tsModify:
backend/src/automation/automation.module.tsModify:
backend/src/app.module.tsStep 1: Write the failing entity metadata test
import { DataSource } from 'typeorm';
import { AutomationCommandIntent } from '@/automation/entities/automation-command-intent.entity';
import { AutomationEndpointEvent } from '@/automation/entities/automation-endpoint-event.entity';
import { AutomationRuleExecution } from '@/automation/entities/automation-rule-execution.entity';
import { DeviceCommandReceipt } from '@/automation/entities/device-command-receipt.entity';
describe('automation runtime entities', () => {
const dataSource = new DataSource({ type: 'postgres', entities: [] });
it('defines the durable runtime tables and execution envelope fields', () => {
const eventMetadata = dataSource.getMetadata(AutomationEndpointEvent);
const intentMetadata = dataSource.getMetadata(AutomationCommandIntent);
const receiptMetadata = dataSource.getMetadata(DeviceCommandReceipt);
const executionMetadata = dataSource.getMetadata(AutomationRuleExecution);
expect(eventMetadata.tableName).toBe('automation_endpoint_events');
expect(intentMetadata.findColumnWithPropertyName('idempotencyKey')).toBeDefined();
expect(receiptMetadata.findColumnWithPropertyName('receiptType')).toBeDefined();
expect(executionMetadata.findColumnWithPropertyName('triggeringEventId')).toBeDefined();
expect(executionMetadata.findColumnWithPropertyName('commandIntentId')).toBeDefined();
});
});
- Step 2: Run the entity test and verify it fails
Run from backend/: bun test -- src/automation/entities/automation-runtime.entities.spec.ts
Expected: FAIL because the three new entities and expanded execution fields do not exist yet.
- Step 3: Implement the entities, migration, and registrations
@Entity('automation_endpoint_events')
export class AutomationEndpointEvent {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'organization_id', type: 'uuid', nullable: true })
organizationId: string | null;
@Column({ name: 'farm_id', type: 'uuid' })
farmId: string;
@Column({ name: 'device_id', type: 'uuid' })
deviceId: string;
@Column({ name: 'device_instance_endpoint_id', type: 'uuid' })
deviceInstanceEndpointId: string;
@Column({ name: 'source_telemetry_id', type: 'uuid', nullable: true })
sourceTelemetryId: string | null;
@Column({ name: 'event_timestamp', type: 'timestamptz' })
eventTimestamp: Date;
@Column({ name: 'normalized_value', type: 'jsonb' })
normalizedValue: string | number | boolean;
@Column({ name: 'raw_payload_snapshot', type: 'jsonb' })
rawPayloadSnapshot: Record<string, unknown>;
@Column({ name: 'telemetry_fingerprint', type: 'varchar', length: 255 })
telemetryFingerprint: string;
@Column({ name: 'dedupe_key', type: 'varchar', length: 255, unique: true })
dedupeKey: string;
@Column({
name: 'processing_status',
type: 'enum',
enum: AutomationEndpointEventStatus,
default: AutomationEndpointEventStatus.PENDING,
})
processingStatus: AutomationEndpointEventStatus;
}
@Entity('automation_command_intents')
export class AutomationCommandIntent {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'rule_execution_id', type: 'uuid' })
ruleExecutionId: string;
@Column({ name: 'triggering_event_id', type: 'uuid' })
triggeringEventId: string;
@Column({ name: 'rule_id', type: 'uuid' })
ruleId: string;
@Column({ name: 'target_device_id', type: 'uuid' })
targetDeviceId: string;
@Column({ name: 'target_device_endpoint_id', type: 'uuid' })
targetDeviceEndpointId: string;
@Column({ name: 'target_serial_number', type: 'varchar', length: 100 })
targetSerialNumber: string;
@Column({ name: 'command_method', type: 'varchar', length: 100 })
commandMethod: string;
@Column({ name: 'command_params', type: 'jsonb' })
commandParams: Record<string, unknown>;
@Column({ name: 'target_value', type: 'jsonb' })
targetValue: string | number | boolean;
@Column({ name: 'idempotency_key', type: 'varchar', length: 255, unique: true })
idempotencyKey: string;
}
@Entity('device_command_receipts')
export class DeviceCommandReceipt {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'command_intent_id', type: 'uuid' })
commandIntentId: string;
@Column({ name: 'device_id', type: 'uuid' })
deviceId: string;
@Column({ name: 'serial_number', type: 'varchar', length: 100 })
serialNumber: string;
@Column({ name: 'receipt_type', type: 'enum', enum: DeviceCommandReceiptType })
receiptType: DeviceCommandReceiptType;
@Column({ type: 'jsonb' })
payload: Record<string, unknown>;
@Column({ name: 'received_at', type: 'timestamptz', default: () => 'now()' })
receivedAt: Date;
@Column({ name: 'correlation_key', type: 'varchar', length: 255 })
correlationKey: string;
}
@Column({ name: 'triggering_event_id', type: 'uuid', nullable: true })
triggeringEventId: string | null;
@Column({ name: 'command_intent_id', type: 'uuid', nullable: true })
commandIntentId: string | null;
@Column({ name: 'final_outcome', type: 'varchar', length: 50, nullable: true })
finalOutcome: string | null;
@Column({ name: 'evidence_snapshot', type: 'jsonb', nullable: true })
evidenceSnapshot: Record<string, unknown> | null;
export class CreateReliableAutomationRuntimeTables1778371200000 implements MigrationInterface {
name = 'CreateReliableAutomationRuntimeTables1778371200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "automation_endpoint_events" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" UUID REFERENCES organizations(id) ON DELETE CASCADE,
"farm_id" UUID NOT NULL REFERENCES areas(id) ON DELETE CASCADE,
"device_id" UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
"device_instance_endpoint_id" UUID NOT NULL REFERENCES device_instance_endpoints(id) ON DELETE CASCADE,
"source_telemetry_id" UUID REFERENCES device_telemetry(id) ON DELETE SET NULL,
"event_timestamp" TIMESTAMPTZ NOT NULL,
"normalized_value" JSONB NOT NULL,
"raw_payload_snapshot" JSONB NOT NULL,
"telemetry_fingerprint" VARCHAR(255) NOT NULL,
"dedupe_key" VARCHAR(255) NOT NULL,
"processing_status" VARCHAR(50) NOT NULL DEFAULT 'pending',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT "uq_automation_endpoint_events_dedupe_key" UNIQUE ("dedupe_key")
);
`);
}
}
- Step 4: Run the metadata test and lint/format the touched files
Run from backend/: bun test -- src/automation/entities/automation-runtime.entities.spec.ts
Expected: PASS with entity metadata resolving.
Run from backend/: bun run lint:fix
Expected: PASS with Biome fixes applied.
Run from backend/: bun run format
Expected: PASS with formatted entity and migration files.
- Step 5: Commit
git add backend/src/automation/entities/automation-endpoint-event.entity.ts \
backend/src/automation/entities/automation-command-intent.entity.ts \
backend/src/automation/entities/device-command-receipt.entity.ts \
backend/src/automation/entities/automation-rule-execution.entity.ts \
backend/src/automation/enums/automation-rule-execution-status.enum.ts \
backend/src/automation/automation.module.ts \
backend/src/app.module.ts \
backend/src/migrations/1778371200000-CreateReliableAutomationRuntimeTables.ts \
backend/src/automation/entities/automation-runtime.entities.spec.ts
git commit -m "feat: add durable automation runtime schema"
Task 3: Persist endpoint events during telemetry ingestion and remove inline dispatch
Files:
Create:
backend/src/automation/runtime/automation-endpoint-events.service.tsTest:
backend/src/automation/runtime/automation-endpoint-events.service.spec.tsModify:
backend/src/telemetry/telemetry.service.tsModify:
backend/src/telemetry/telemetry.service.spec.tsModify:
backend/src/automation/endpoint-event-normalizer.service.tsStep 1: Write the failing telemetry ingestion tests
it('persists normalized automation endpoint events and does not dispatch automation inline', async () => {
endpointEventNormalizerService.normalizeTelemetryEvent.mockResolvedValue([
{
organizationId: 'org-1',
farmId: 'farm-1',
deviceId: 'device-1',
deviceInstanceEndpointId: 'endpoint-1',
eventTimestamp: new Date('2026-05-10T10:00:00.000Z'),
normalizedValue: 3.8,
rawPayloadSnapshot: { do: 3.8 },
telemetryFingerprint: 'telemetry:abc',
},
]);
await service.save({
deviceId: 'device-1',
timestamp: new Date('2026-05-10T10:00:00.000Z'),
data: { do: 3.8 },
});
expect(automationEndpointEventsService.persistNormalizedEvents).toHaveBeenCalledWith(
expect.objectContaining({ deviceId: 'device-1' }),
);
expect(automationEngineService.handleEndpointEvent).not.toHaveBeenCalled();
});
it('deduplicates replayed telemetry events on dedupe key', async () => {
repository.save.mockRejectedValueOnce(Object.assign(new Error('duplicate key'), { code: '23505' }));
await expect(
automationEndpointEventsService.persistNormalizedEvents([
{
organizationId: 'org-1',
farmId: 'farm-1',
deviceId: 'device-1',
deviceInstanceEndpointId: 'endpoint-1',
eventTimestamp: new Date('2026-05-10T10:00:00.000Z'),
normalizedValue: 3.8,
rawPayloadSnapshot: { do: 3.8 },
telemetryFingerprint: 'telemetry:abc',
},
]),
).resolves.toEqual([]);
});
- Step 2: Run the telemetry and event persistence tests and verify they fail
Run from backend/: bun test -- src/telemetry/telemetry.service.spec.ts src/automation/runtime/automation-endpoint-events.service.spec.ts
Expected: FAIL because AutomationEndpointEventsService does not exist and TelemetryService still calls automationEngineService.handleEndpointEvent.
- Step 3: Implement durable endpoint-event persistence
@Injectable()
export class AutomationEndpointEventsService {
constructor(
@InjectRepository(AutomationEndpointEvent)
private endpointEventRepository: Repository<AutomationEndpointEvent>,
) {}
async persistNormalizedEvents(
events: PersistableEndpointEvent[],
): Promise<AutomationEndpointEvent[]> {
const persisted: AutomationEndpointEvent[] = [];
for (const event of events) {
const entity = this.endpointEventRepository.create({
...event,
dedupeKey: buildEndpointEventDedupeKey({
deviceId: event.deviceId,
deviceInstanceEndpointId: event.deviceInstanceEndpointId,
eventTimestamp: event.eventTimestamp,
normalizedValue: event.normalizedValue,
telemetryFingerprint: event.telemetryFingerprint,
}),
processingStatus: AutomationEndpointEventStatus.PENDING,
});
try {
persisted.push(await this.endpointEventRepository.save(entity));
} catch (error) {
if ((error as { code?: string }).code === '23505') {
continue;
}
throw error;
}
}
return persisted;
}
}
const endpointEvents = await this.endpointEventNormalizerService.normalizeTelemetryEvent({
deviceId: dto.deviceId,
timestamp,
rawPayload: dto.data,
sourceTelemetryId: telemetry.id,
});
await this.automationEndpointEventsService.persistNormalizedEvents(endpointEvents);
- Step 4: Run the focused tests and then lint/format
Run from backend/: bun test -- src/telemetry/telemetry.service.spec.ts src/automation/runtime/automation-endpoint-events.service.spec.ts
Expected: PASS with telemetry persisting endpoint events and no inline dispatch.
Run from backend/: bun run lint:fix
Expected: PASS.
Run from backend/: bun run format
Expected: PASS.
- Step 5: Commit
git add backend/src/automation/runtime/automation-endpoint-events.service.ts \
backend/src/automation/runtime/automation-endpoint-events.service.spec.ts \
backend/src/automation/endpoint-event-normalizer.service.ts \
backend/src/telemetry/telemetry.service.ts \
backend/src/telemetry/telemetry.service.spec.ts
git commit -m "refactor: persist automation endpoint events from telemetry"
Task 4: Evaluate pending endpoint events into executions and command intents
Files:
Modify:
backend/src/automation/automation-engine.service.tsModify:
backend/src/automation/automation-engine.service.spec.tsCreate:
backend/src/automation/runtime/automation-runtime-worker.service.tsTest:
backend/src/automation/runtime/automation-runtime-worker.service.spec.tsStep 1: Write the failing evaluator and worker tests
it('creates a pending execution and a pending_dispatch command intent when a rule matches', async () => {
ruleRepository.find.mockResolvedValue([
{
id: 'rule-1',
status: 'active',
cooldownSeconds: 0,
conditions: [{ sourceDeviceEndpointId: 'source-endpoint-1', operator: '<', thresholdValue: 4.5 }],
actions: [{
targetDeviceEndpointId: 'target-endpoint-1',
targetValue: true,
targetEndpoint: {
id: 'target-endpoint-1',
isEnabled: true,
isWritable: true,
device: {
id: 'device-2',
serialNumber: 'GW-001',
areaId: 'farm-1',
inventoryStatus: 'assigned',
status: 'online',
},
},
}],
},
]);
await service.handleEndpointEvent(eventEntity);
expect(commandIntentRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
ruleId: 'rule-1',
status: 'pending_dispatch',
targetSerialNumber: 'GW-001',
}),
);
expect(dispatchService.dispatchEndpointValue).not.toHaveBeenCalled();
});
it('worker marks unmatched events as discarded', async () => {
endpointEventRepository.find.mockResolvedValue([eventEntity]);
ruleRepository.find.mockResolvedValue([]);
await worker.processPendingEndpointEvents();
expect(endpointEventRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ id: eventEntity.id, processingStatus: 'discarded' }),
);
});
- Step 2: Run the evaluator tests and verify they fail
Run from backend/: bun test -- src/automation/automation-engine.service.spec.ts src/automation/runtime/automation-runtime-worker.service.spec.ts
Expected: FAIL because AutomationEngineService still dispatches through MQTT and the worker service does not exist.
- Step 3: Implement event evaluation without direct MQTT publish
async handleEndpointEvent(event: AutomationEndpointEvent): Promise<'processed' | 'discarded'> {
const rules = await this.ruleRepository.find({
where: { farmId: event.farmId, status: AutomationRuleStatus.ACTIVE },
relations: ['conditions', 'actions', 'actions.targetEndpoint', 'actions.targetEndpoint.device'],
});
let matched = false;
for (const rule of rules) {
const condition = rule.conditions[0];
const action = rule.actions[0];
const targetEndpoint = action?.targetEndpoint;
const targetDevice = targetEndpoint?.device;
if (!condition || !action) continue;
if (condition.sourceDeviceEndpointId !== event.deviceInstanceEndpointId) continue;
if (!this.evaluateCondition(event.normalizedValue, condition.operator, condition.thresholdValue)) {
continue;
}
matched = true;
const execution = await this.executionRepository.save({
ruleId: rule.id,
triggeringEventId: event.id,
triggeredAt: event.eventTimestamp,
sourceTelemetryPayload: event.rawPayloadSnapshot,
evaluatedConditionSnapshot: {
operator: condition.operator,
thresholdValue: condition.thresholdValue,
actualValue: event.normalizedValue,
},
status: AutomationRuleExecutionStatus.PENDING,
failureReason: null,
});
await this.commandIntentRepository.save({
ruleExecutionId: execution.id,
triggeringEventId: event.id,
ruleId: rule.id,
targetDeviceId: targetDevice.id,
targetDeviceEndpointId: action.targetDeviceEndpointId,
targetSerialNumber: targetDevice.serialNumber,
commandMethod: 'setIO',
commandParams: { io1: action.targetValue },
targetValue: action.targetValue,
desiredStateFingerprint: JSON.stringify({ endpoint: action.targetDeviceEndpointId, value: action.targetValue }),
idempotencyKey: buildCommandIdempotencyKey({
ruleId: rule.id,
targetDeviceEndpointId: action.targetDeviceEndpointId,
targetValue: action.targetValue as boolean,
triggeringEventId: event.id,
}),
status: AutomationCommandIntentStatus.PENDING_DISPATCH,
attemptCount: 0,
maxAttempts: 3,
nextAttemptAt: new Date(),
});
}
return matched ? 'processed' : 'discarded';
}
@Cron(CronExpression.EVERY_5_SECONDS)
async processPendingEndpointEvents(): Promise<void> {
const events = await this.endpointEventRepository.find({
where: { processingStatus: AutomationEndpointEventStatus.PENDING },
order: { eventTimestamp: 'ASC' },
take: 50,
});
for (const event of events) {
const outcome = await this.automationEngineService.handleEndpointEvent(event);
event.processingStatus =
outcome === 'processed'
? AutomationEndpointEventStatus.PROCESSED
: AutomationEndpointEventStatus.DISCARDED;
await this.endpointEventRepository.save(event);
}
}
- Step 4: Run tests and then lint/format
Run from backend/: bun test -- src/automation/automation-engine.service.spec.ts src/automation/runtime/automation-runtime-worker.service.spec.ts
Expected: PASS with matching rules creating command intents and worker discarding unmatched events.
Run from backend/: bun run lint:fix
Expected: PASS.
Run from backend/: bun run format
Expected: PASS.
- Step 5: Commit
git add backend/src/automation/automation-engine.service.ts \
backend/src/automation/automation-engine.service.spec.ts \
backend/src/automation/runtime/automation-runtime-worker.service.ts \
backend/src/automation/runtime/automation-runtime-worker.service.spec.ts
git commit -m "feat: evaluate automation events into command intents"
Task 5: Dispatch command intents through MQTT with correlation payload and endpoint serialization
Files:
Create:
backend/src/automation/runtime/automation-command-payload.factory.tsTest:
backend/src/automation/runtime/automation-command-payload.factory.spec.tsCreate:
backend/src/automation/runtime/automation-command-dispatcher.service.tsTest:
backend/src/automation/runtime/automation-command-dispatcher.service.spec.tsModify:
backend/src/mqtt/dto/rpc-request.dto.tsModify:
backend/src/mqtt/mqtt.service.tsModify:
backend/src/mqtt/mqtt.service.spec.tsModify:
backend/src/devices/device-command-dispatch.service.tsModify:
backend/src/devices/device-command-dispatch.service.spec.tsStep 1: Write the failing dispatch and payload tests
it('builds an automation RPC payload with commandId, sentAt, and deadlineAt', () => {
expect(
factory.build({
commandId: 'cmd-1',
method: 'setIO',
params: { io1: true },
sentAt: new Date('2026-05-10T10:00:00.000Z'),
deadlineAt: new Date('2026-05-10T10:00:05.000Z'),
}),
).toEqual({
commandId: 'cmd-1',
method: 'setIO',
params: { io1: true },
sentAt: '2026-05-10T10:00:00.000Z',
deadlineAt: '2026-05-10T10:00:05.000Z',
});
});
it('dispatches a pending intent, writes correlation ids, and acquires endpoint serialization lease', async () => {
redisService.acquireLease.mockResolvedValue(true);
mqttService.publishAutomationCommand.mockResolvedValue({ requestId: 'req-1' });
await service.dispatchPendingIntent(intent);
expect(redisService.acquireLease).toHaveBeenCalledWith(
'automation:dispatch-lock:device-2:target-endpoint-1',
expect.any(String),
10,
);
expect(mqttService.publishAutomationCommand).toHaveBeenCalledWith(
'GW-001',
expect.objectContaining({ commandId: expect.stringMatching(/^cmd_/) }),
);
expect(commandIntentRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ status: 'dispatched', mqttRequestId: 'req-1' }),
);
});
- Step 2: Run the dispatch tests and verify they fail
Run from backend/: bun test -- src/automation/runtime/automation-command-payload.factory.spec.ts src/automation/runtime/automation-command-dispatcher.service.spec.ts src/mqtt/mqtt.service.spec.ts
Expected: FAIL because the payload factory, dispatcher, and publishAutomationCommand helper do not exist yet.
- Step 3: Implement correlated dispatch and serialization
export class RpcRequestDto {
@IsString()
method: string;
@IsObject()
params: Record<string, unknown>;
@IsOptional()
@IsString()
commandId?: string;
@IsOptional()
@IsISO8601()
sentAt?: string;
@IsOptional()
@IsISO8601()
deadlineAt?: string;
}
async publishAutomationCommand(
serialNumber: string,
command: RpcRequestDto,
): Promise<{ requestId: string }> {
const requestId = uuidv4();
const topic = `v1/devices/${serialNumber}/rpc/request/${requestId}`;
await new Promise<void>((resolve, reject) => {
this.client.publish(topic, JSON.stringify(command), { qos: 1 }, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
return { requestId };
}
async dispatchPendingIntent(intent: AutomationCommandIntent): Promise<void> {
const leaseKey = `automation:dispatch-lock:${intent.targetDeviceId}:${intent.targetDeviceEndpointId}`;
const leaseOwner = `${intent.id}:${Date.now()}`;
const acquired = await this.redisService.acquireLease(leaseKey, leaseOwner, 10);
if (!acquired) {
return;
}
const commandId = `cmd_${intent.id.replace(/-/g, '')}`;
const sentAt = new Date();
const deadlineAt = new Date(sentAt.getTime() + 5000);
const payload = this.payloadFactory.build({
commandId,
method: intent.commandMethod,
params: intent.commandParams,
sentAt,
deadlineAt,
});
const { requestId } = await this.mqttService.publishAutomationCommand(
intent.targetSerialNumber,
payload,
);
intent.status = AutomationCommandIntentStatus.DISPATCHED;
intent.deviceCommandId = commandId;
intent.mqttRequestId = requestId;
intent.attemptCount += 1;
intent.lastDispatchedAt = sentAt;
intent.nextAttemptAt = deadlineAt;
await this.commandIntentRepository.save(intent);
await this.redisService.setex(`automation:command:${commandId}`, 300, intent.id);
await this.redisService.releaseLease(leaseKey, leaseOwner);
}
- Step 4: Run focused tests and then lint/format
Run from backend/: bun test -- src/automation/runtime/automation-command-payload.factory.spec.ts src/automation/runtime/automation-command-dispatcher.service.spec.ts src/mqtt/mqtt.service.spec.ts src/devices/device-command-dispatch.service.spec.ts
Expected: PASS with correlation fields present and serialized dispatch enforced.
Run from backend/: bun run lint:fix
Expected: PASS.
Run from backend/: bun run format
Expected: PASS.
- Step 5: Commit
git add backend/src/automation/runtime/automation-command-payload.factory.ts \
backend/src/automation/runtime/automation-command-payload.factory.spec.ts \
backend/src/automation/runtime/automation-command-dispatcher.service.ts \
backend/src/automation/runtime/automation-command-dispatcher.service.spec.ts \
backend/src/mqtt/dto/rpc-request.dto.ts \
backend/src/mqtt/mqtt.service.ts \
backend/src/mqtt/mqtt.service.spec.ts \
backend/src/devices/device-command-dispatch.service.ts \
backend/src/devices/device-command-dispatch.service.spec.ts
git commit -m "feat: dispatch automation commands with correlation"
Task 6: Persist receipts and reconcile ack versus applied evidence
Files:
Create:
backend/src/automation/runtime/automation-reconciliation.service.tsTest:
backend/src/automation/runtime/automation-reconciliation.service.spec.tsModify:
backend/src/mqtt/mqtt.service.tsModify:
backend/src/mqtt/mqtt.service.spec.tsStep 1: Write the failing reconciliation tests
it('marks a command acknowledged on rpc ack and applied on matching state evidence', async () => {
commandIntentRepository.findOne.mockResolvedValue(intent);
await service.recordRpcReceipt({
serialNumber: 'GW-001',
commandId: 'cmd_1',
requestId: 'req-1',
payload: { commandId: 'cmd_1', status: 'accepted', resultCode: 'ok', message: null },
});
expect(commandIntentRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ status: 'acknowledged' }),
);
await service.recordStateReport({
serialNumber: 'GW-001',
commandId: 'cmd_1',
payload: { commandId: 'cmd_1', reportedState: { io1: true }, appliedAt: '2026-05-10T10:00:02.100Z' },
});
expect(commandIntentRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ status: 'applied' }),
);
});
it('marks a command uncertain when ack exists but no applied evidence arrives before deadline', async () => {
commandIntentRepository.find.mockResolvedValue([
{
...intent,
status: 'acknowledged',
lastDispatchedAt: new Date('2026-05-10T10:00:00.000Z'),
nextAttemptAt: new Date('2026-05-10T10:00:05.000Z'),
},
]);
await service.reconcileExpiredAcknowledgements(new Date('2026-05-10T10:00:10.000Z'));
expect(commandIntentRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ status: 'uncertain' }),
);
});
- Step 2: Run reconciliation tests and verify they fail
Run from backend/: bun test -- src/automation/runtime/automation-reconciliation.service.spec.ts src/mqtt/mqtt.service.spec.ts
Expected: FAIL because MqttService only resolves in-memory promises and no durable receipt service exists.
- Step 3: Implement durable receipt ingestion and applied-state reconciliation
async recordRpcReceipt(input: {
serialNumber: string;
commandId: string;
requestId: string;
payload: Record<string, unknown>;
}): Promise<void> {
const intentId = await this.redisService.get(`automation:command:${input.commandId}`);
if (!intentId) {
return;
}
const intent = await this.commandIntentRepository.findOne({ where: { id: intentId } });
if (!intent) {
return;
}
await this.receiptRepository.save({
commandIntentId: intent.id,
deviceId: intent.targetDeviceId,
serialNumber: input.serialNumber,
receiptType: DeviceCommandReceiptType.RPC_ACK,
payload: input.payload,
correlationKey: input.commandId,
});
intent.status = AutomationCommandIntentStatus.ACKNOWLEDGED;
await this.commandIntentRepository.save(intent);
}
async recordStateReport(input: {
serialNumber: string;
commandId: string;
payload: Record<string, unknown>;
}): Promise<void> {
const intentId = await this.redisService.get(`automation:command:${input.commandId}`);
if (!intentId) {
return;
}
const intent = await this.commandIntentRepository.findOne({ where: { id: intentId } });
if (!intent) {
return;
}
const reportedState = input.payload.reportedState as Record<string, unknown>;
const desiredValue = Object.values(intent.commandParams)[0];
const applied = Object.values(reportedState).some((value) => value === desiredValue);
await this.receiptRepository.save({
commandIntentId: intent.id,
deviceId: intent.targetDeviceId,
serialNumber: input.serialNumber,
receiptType: DeviceCommandReceiptType.STATE_REPORT,
payload: input.payload,
correlationKey: input.commandId,
});
intent.status = applied
? AutomationCommandIntentStatus.APPLIED
: AutomationCommandIntentStatus.UNCERTAIN;
await this.commandIntentRepository.save(intent);
await this.executionRepository.update(intent.ruleExecutionId, {
status: applied
? AutomationRuleExecutionStatus.APPLIED
: AutomationRuleExecutionStatus.UNCERTAIN,
finalOutcome: intent.status,
evidenceSnapshot: input.payload,
});
}
private async handleRpcResponse(topic: string, payload: Record<string, unknown>) {
const requestId = topic.match(/rpc\/response\/(.+)$/)?.[1];
const serialNumber = this.extractSerialNumber(topic);
const commandId = typeof payload.commandId === 'string' ? payload.commandId : undefined;
if (!requestId || !serialNumber || !commandId) {
return;
}
await this.automationReconciliationService.recordRpcReceipt({
serialNumber,
commandId,
requestId,
payload,
});
}
- Step 4: Run tests and then lint/format
Run from backend/: bun test -- src/automation/runtime/automation-reconciliation.service.spec.ts src/mqtt/mqtt.service.spec.ts
Expected: PASS with ack and applied evidence persisted durably.
Run from backend/: bun run lint:fix
Expected: PASS.
Run from backend/: bun run format
Expected: PASS.
- Step 5: Commit
git add backend/src/automation/runtime/automation-reconciliation.service.ts \
backend/src/automation/runtime/automation-reconciliation.service.spec.ts \
backend/src/mqtt/mqtt.service.ts \
backend/src/mqtt/mqtt.service.spec.ts
git commit -m "feat: reconcile automation command receipts"
Task 7: Add retry, timeout recovery, Redis leases, and restart-safe worker loops
Files:
Modify:
backend/src/redis/redis.service.tsModify:
backend/src/automation/runtime/automation-runtime-worker.service.tsModify:
backend/src/automation/runtime/automation-command-dispatcher.service.tsModify:
backend/src/automation/runtime/automation-reconciliation.service.tsTest:
backend/src/automation/runtime/automation-runtime-worker.service.spec.tsStep 1: Write the failing recovery tests
it('requeues a timed-out dispatched command for retry when attempts remain', async () => {
commandIntentRepository.find.mockResolvedValue([
{
...intent,
status: 'dispatched',
attemptCount: 1,
maxAttempts: 3,
nextAttemptAt: new Date('2026-05-10T10:00:05.000Z'),
},
]);
await worker.processTimedOutCommands(new Date('2026-05-10T10:00:06.000Z'));
expect(commandIntentRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
status: 'retrying',
nextAttemptAt: new Date('2026-05-10T10:00:15.000Z'),
}),
);
});
it('expires a timed-out command once the retry budget is exhausted', async () => {
commandIntentRepository.find.mockResolvedValue([
{
...intent,
status: 'dispatched',
attemptCount: 3,
maxAttempts: 3,
nextAttemptAt: new Date('2026-05-10T10:00:45.000Z'),
},
]);
await worker.processTimedOutCommands(new Date('2026-05-10T10:00:46.000Z'));
expect(commandIntentRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ status: 'expired' }),
);
});
- Step 2: Run recovery tests and verify they fail
Run from backend/: bun test -- src/automation/runtime/automation-runtime-worker.service.spec.ts
Expected: FAIL because timeout handling and Redis lease helpers are not implemented.
- Step 3: Implement retry scheduling and worker coordination
async acquireLease(key: string, value: string, seconds: number): Promise<boolean> {
const result = await this.client.set(key, value, { NX: true, EX: seconds });
return result === 'OK';
}
async releaseLease(key: string, value: string): Promise<void> {
const currentValue = await this.client.get(key);
if (currentValue === value) {
await this.client.del(key);
}
}
@Cron(CronExpression.EVERY_5_SECONDS)
async processTimedOutCommands(now = new Date()): Promise<void> {
const intents = await this.commandIntentRepository.find({
where: { status: In([AutomationCommandIntentStatus.DISPATCHED, AutomationCommandIntentStatus.RETRYING]) },
order: { nextAttemptAt: 'ASC' },
take: 50,
});
for (const intent of intents) {
if (!intent.nextAttemptAt || intent.nextAttemptAt > now) {
continue;
}
const nextAttemptAt = computeNextAttemptAt(now, intent.attemptCount + 1);
if (nextAttemptAt) {
intent.status = AutomationCommandIntentStatus.RETRYING;
intent.nextAttemptAt = nextAttemptAt;
intent.lastError = 'Command timeout';
await this.commandIntentRepository.save(intent);
continue;
}
intent.status = AutomationCommandIntentStatus.EXPIRED;
intent.lastError = 'Retry budget exhausted';
await this.commandIntentRepository.save(intent);
}
}
- Step 4: Run tests and then lint/format
Run from backend/: bun test -- src/automation/runtime/automation-runtime-worker.service.spec.ts src/automation/runtime/automation-command-dispatcher.service.spec.ts src/automation/runtime/automation-reconciliation.service.spec.ts
Expected: PASS with retry, expiry, and restart pickup behavior covered.
Run from backend/: bun run lint:fix
Expected: PASS.
Run from backend/: bun run format
Expected: PASS.
- Step 5: Commit
git add backend/src/redis/redis.service.ts \
backend/src/automation/runtime/automation-runtime-worker.service.ts \
backend/src/automation/runtime/automation-runtime-worker.service.spec.ts \
backend/src/automation/runtime/automation-command-dispatcher.service.ts \
backend/src/automation/runtime/automation-reconciliation.service.ts
git commit -m "feat: add automation retry and recovery workers"
Task 8: Add observability, diagnostics queries, contract tests, and end-to-end runtime coverage
Files:
Create:
backend/src/automation/runtime/automation-runtime-diagnostics.service.tsTest:
backend/src/automation/runtime/automation-runtime-diagnostics.service.spec.tsTest:
backend/src/automation/automation-runtime.contract.spec.tsTest:
backend/src/automation/automation-runtime.integration.spec.tsModify:
backend/src/automation/runtime/automation-runtime-worker.service.tsModify:
backend/src/automation/runtime/automation-command-dispatcher.service.tsModify:
backend/src/automation/runtime/automation-reconciliation.service.tsStep 1: Write the failing diagnostics and integration tests
it('returns retrying and uncertain command lists for production debugging', async () => {
commandIntentRepository.find.mockResolvedValue([
{ id: 'intent-1', status: 'retrying', targetSerialNumber: 'GW-001' },
{ id: 'intent-2', status: 'uncertain', targetSerialNumber: 'GW-002' },
]);
await expect(service.listRetryingCommands()).resolves.toHaveLength(1);
await expect(service.listUncertainCommands()).resolves.toHaveLength(1);
});
it('runs telemetry -> event -> intent -> dispatch -> ack -> applied as a single runtime flow', async () => {
await telemetryService.save({
deviceId: 'device-1',
timestamp: new Date('2026-05-10T10:00:00.000Z'),
data: { do: 3.8 },
});
await runtimeWorker.processPendingEndpointEvents();
await runtimeWorker.processPendingDispatches();
await reconciliationService.recordRpcReceipt({
serialNumber: 'GW-001',
commandId: 'cmd_1',
requestId: 'req-1',
payload: { commandId: 'cmd_1', status: 'accepted', resultCode: 'ok', message: null },
});
await reconciliationService.recordStateReport({
serialNumber: 'GW-001',
commandId: 'cmd_1',
payload: { commandId: 'cmd_1', reportedState: { io1: true }, appliedAt: '2026-05-10T10:00:02.100Z' },
});
expect(await diagnosticsService.getExecutionTimeline('rule-execution-1')).toEqual(
expect.arrayContaining([
expect.objectContaining({ stage: 'event_persisted' }),
expect.objectContaining({ stage: 'command_applied' }),
]),
);
});
- Step 2: Run the diagnostics and integration tests and verify they fail
Run from backend/: bun test -- src/automation/runtime/automation-runtime-diagnostics.service.spec.ts src/automation/automation-runtime.contract.spec.ts src/automation/automation-runtime.integration.spec.ts
Expected: FAIL because the diagnostics service and full runtime flow assertions are not implemented.
- Step 3: Implement structured runtime observability and diagnostics
@Injectable()
export class AutomationRuntimeDiagnosticsService {
constructor(
@InjectRepository(AutomationCommandIntent)
private commandIntentRepository: Repository<AutomationCommandIntent>,
@InjectRepository(DeviceCommandReceipt)
private receiptRepository: Repository<DeviceCommandReceipt>,
@InjectRepository(AutomationRuleExecution)
private executionRepository: Repository<AutomationRuleExecution>,
) {}
async listRetryingCommands(): Promise<AutomationCommandIntent[]> {
return this.commandIntentRepository.find({
where: { status: AutomationCommandIntentStatus.RETRYING },
order: { updatedAt: 'DESC' },
take: 100,
});
}
async listUncertainCommands(): Promise<AutomationCommandIntent[]> {
return this.commandIntentRepository.find({
where: { status: AutomationCommandIntentStatus.UNCERTAIN },
order: { updatedAt: 'DESC' },
take: 100,
});
}
async getExecutionTimeline(ruleExecutionId: string): Promise<Array<{ stage: string; at: string }>> {
const execution = await this.executionRepository.findOne({ where: { id: ruleExecutionId } });
const receipts = await this.receiptRepository.find({
where: { commandIntentId: execution?.commandIntentId ?? '' },
order: { receivedAt: 'ASC' },
});
return [
{ stage: 'execution_created', at: execution?.triggeredAt.toISOString() ?? '' },
...receipts.map((receipt) => ({
stage: receipt.receiptType,
at: receipt.receivedAt.toISOString(),
})),
{ stage: `execution_${execution?.status ?? 'unknown'}`, at: execution?.updatedAt?.toISOString() ?? '' },
];
}
}
this.logger.log({
eventId: event.id,
ruleId: intent.ruleId,
ruleExecutionId: intent.ruleExecutionId,
commandIntentId: intent.id,
deviceCommandId: intent.deviceCommandId,
deviceId: intent.targetDeviceId,
serialNumber: intent.targetSerialNumber,
stage: 'dispatch_succeeded',
});
- Step 4: Run the full automation verification suite and then lint/format
Run from backend/: bun test -- src/automation/runtime/automation-runtime-diagnostics.service.spec.ts src/automation/automation-runtime.contract.spec.ts src/automation/automation-runtime.integration.spec.ts src/automation/automation-engine.service.spec.ts src/telemetry/telemetry.service.spec.ts src/mqtt/mqtt.service.spec.ts
Expected: PASS with happy path, duplicate handling, timeout, retry, and diagnostics coverage.
Run from backend/: bun run lint:fix
Expected: PASS.
Run from backend/: bun run format
Expected: PASS.
- Step 5: Commit
git add backend/src/automation/runtime/automation-runtime-diagnostics.service.ts \
backend/src/automation/runtime/automation-runtime-diagnostics.service.spec.ts \
backend/src/automation/automation-runtime.contract.spec.ts \
backend/src/automation/automation-runtime.integration.spec.ts \
backend/src/automation/runtime/automation-runtime-worker.service.ts \
backend/src/automation/runtime/automation-command-dispatcher.service.ts \
backend/src/automation/runtime/automation-reconciliation.service.ts
git commit -m "feat: add automation runtime observability coverage"
Self-Review
Spec coverage
- Telemetry ingestion decoupling: Task 3.
- Durable endpoint events and command ledger: Tasks 2 and 3.
- Rule evaluation worker and idempotent command-intent creation: Task 4.
- MQTT correlation payload, dispatcher, and serialization: Task 5.
- Ack versus applied-state reconciliation: Task 6.
- Retry, timeout, dedupe, and restart recovery: Task 7.
- Structured logs, diagnostics, and reliability coverage: Task 8.
- Deferred items remain deferred: manual control unification, scheduler UI, conflict engine, replay console, and broader distributed platform work are intentionally not planned.
Placeholder scan
- No
TODO,TBD, or “implement later” placeholders remain. - Every task lists concrete file paths, commands, and code snippets.
Type consistency
- Command intent status values are consistently
pending_dispatch,dispatched,acknowledged,applied,retrying,failed,expired,uncertain. - Execution envelope status values mirror the runtime lifecycle to keep diagnostics and final outcome mapping simple.
- Correlation terminology is consistent across plan tasks:
commandId,mqttRequestId,deviceCommandId,correlationKey.