Skip to content

Commit d98d929

Browse files
authored
Merge pull request #765 from objectstack-ai/copilot/complete-automation-api-implementation
2 parents b7d41ba + d916c72 commit d98d929

5 files changed

Lines changed: 204 additions & 11 deletions

File tree

ROADMAP.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,14 +147,14 @@ Multi-stage triggers, action pipelines, execution logs, and cron scheduling stan
147147
| Action pipeline (webhook, email, CRUD, notification) || `automation/flow.zod.ts` (HTTP, CRUD, script nodes) |
148148
| State machine & approval processes || `automation/state-machine.zod.ts`, `automation/workflow.zod.ts` |
149149
| Retry policies with exponential backoff || `automation/webhook.zod.ts` |
150-
| `IAutomationService` contract || `contracts/automation-service.ts` (getFlow, toggleFlow, listRuns, getRun) |
150+
| `IAutomationService` contract || `contracts/automation-service.ts` (typed: `FlowParsed`, `ExecutionLog`) |
151151
| `service-automation` DAG engine (MVP) || `@objectstack/service-automation` (42 tests) |
152152
| Execution log/history storage protocol || `automation/execution.zod.ts``ExecutionLogSchema`, `ExecutionStepLogSchema` |
153153
| Execution error tracking & diagnostics || `automation/execution.zod.ts``ExecutionErrorSchema`, `ExecutionErrorSeverity` |
154154
| Conflict resolution for concurrent executions || `automation/execution.zod.ts``ConcurrencyPolicySchema` |
155155
| Checkpointing/resume for interrupted flows || `automation/execution.zod.ts``CheckpointSchema` |
156156
| Scheduled execution persistence (next-run, pause/resume) || `automation/execution.zod.ts``ScheduleStateSchema` |
157-
| Automation API protocol (REST CRUD schemas) || `api/automation-api.zod.ts` → 9 endpoints, `AutomationApiContracts` |
157+
| Automation API protocol (REST CRUD schemas) || `api/automation-api.zod.ts` → 9 endpoints, all with `input`/`output` schemas |
158158
| Automation HTTP route handler (9 routes) || `runtime/http-dispatcher.ts``handleAutomation()` CRUD + toggle + runs |
159159
| Client SDK `automation` namespace (10 methods) || `client/src/index.ts``list`, `get`, `create`, `update`, `delete`, `toggle`, `runs.*` |
160160

@@ -717,7 +717,7 @@ Final polish and advanced features.
717717
| 16 | Search Service | `ISearchService` || `@objectstack/service-search` (planned) | Spec only |
718718
| 17 | Notification Service | `INotificationService` || `@objectstack/service-notification` (planned) | Spec only |
719719
| 18 | AI Service | `IAIService` || `@objectstack/service-ai` (planned) | Spec only |
720-
| 19 | Automation Service | `IAutomationService` || `@objectstack/service-automation` | DAG engine + HTTP API CRUD + Client SDK (42 tests) |
720+
| 19 | Automation Service | `IAutomationService` || `@objectstack/service-automation` | DAG engine + HTTP API CRUD + Client SDK + typed returns (42 tests) |
721721
| 20 | Workflow Service | `IWorkflowService` || `@objectstack/service-workflow` (planned) | Spec only |
722722
| 21 | GraphQL Service | `IGraphQLService` || `@objectstack/service-graphql` (planned) | Spec only |
723723
| 22 | i18n Service | `II18nService` || `@objectstack/service-i18n` | File-based locale loading |

packages/spec/src/api/automation-api.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
UpdateFlowResponseSchema,
1414
DeleteFlowRequestSchema,
1515
DeleteFlowResponseSchema,
16+
TriggerFlowRequestSchema,
17+
TriggerFlowResponseSchema,
1618
ToggleFlowRequestSchema,
1719
ToggleFlowResponseSchema,
1820
ListRunsRequestSchema,
@@ -259,6 +261,60 @@ describe('DeleteFlowResponseSchema', () => {
259261
});
260262
});
261263

264+
// ==========================================
265+
// Trigger Flow
266+
// ==========================================
267+
268+
describe('TriggerFlowRequestSchema', () => {
269+
it('should accept minimal trigger request', () => {
270+
const result = TriggerFlowRequestSchema.parse({ name: 'my_flow' });
271+
expect(result.name).toBe('my_flow');
272+
expect(result.record).toBeUndefined();
273+
});
274+
275+
it('should accept full trigger request', () => {
276+
const result = TriggerFlowRequestSchema.parse({
277+
name: 'approval_flow',
278+
record: { id: 'rec-1', amount: 50000 },
279+
object: 'opportunity',
280+
event: 'on_create',
281+
userId: 'user_123',
282+
params: { priority: 'high' },
283+
});
284+
expect(result.name).toBe('approval_flow');
285+
expect(result.object).toBe('opportunity');
286+
expect(result.event).toBe('on_create');
287+
});
288+
});
289+
290+
describe('TriggerFlowResponseSchema', () => {
291+
it('should accept a successful trigger response', () => {
292+
const result = TriggerFlowResponseSchema.parse({
293+
success: true,
294+
data: {
295+
success: true,
296+
output: { status: 'approved' },
297+
durationMs: 42,
298+
},
299+
});
300+
expect(result.data.success).toBe(true);
301+
expect(result.data.durationMs).toBe(42);
302+
});
303+
304+
it('should accept a failed trigger response', () => {
305+
const result = TriggerFlowResponseSchema.parse({
306+
success: true,
307+
data: {
308+
success: false,
309+
error: 'Flow step 3 failed: timeout',
310+
durationMs: 5000,
311+
},
312+
});
313+
expect(result.data.success).toBe(false);
314+
expect(result.data.error).toContain('timeout');
315+
});
316+
});
317+
262318
// ==========================================
263319
// Toggle Flow
264320
// ==========================================
@@ -437,4 +493,11 @@ describe('AutomationApiContracts', () => {
437493
expect(AutomationApiContracts.listRuns.path).toBe('/api/automation/:name/runs');
438494
expect(AutomationApiContracts.getRun.path).toBe('/api/automation/:name/runs/:runId');
439495
});
496+
497+
it('should have input and output schemas for all endpoints', () => {
498+
for (const [key, contract] of Object.entries(AutomationApiContracts)) {
499+
expect(contract.input, `${key} should have input schema`).toBeDefined();
500+
expect(contract.output, `${key} should have output schema`).toBeDefined();
501+
}
502+
});
440503
});

packages/spec/src/api/automation-api.zod.ts

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,44 @@ export const DeleteFlowResponseSchema = BaseResponseSchema.extend({
178178
export type DeleteFlowResponse = z.infer<typeof DeleteFlowResponseSchema>;
179179

180180
// ==========================================
181-
// 7. Toggle Flow (POST /api/automation/:name/toggle)
181+
// 7. Trigger Flow (POST /api/automation/:name/trigger)
182+
// ==========================================
183+
184+
/**
185+
* Request body for triggering a flow execution.
186+
*
187+
* @example POST /api/automation/approval_flow/trigger
188+
* { record: { id: 'rec-1' }, object: 'account', event: 'on_create' }
189+
*/
190+
export const TriggerFlowRequestSchema = AutomationFlowPathParamsSchema.extend({
191+
record: z.record(z.string(), z.unknown()).optional()
192+
.describe('Record that triggered the automation'),
193+
object: z.string().optional()
194+
.describe('Object name the record belongs to'),
195+
event: z.string().optional()
196+
.describe('Trigger event type'),
197+
userId: z.string().optional()
198+
.describe('User who triggered the automation'),
199+
params: z.record(z.string(), z.unknown()).optional()
200+
.describe('Additional contextual data'),
201+
});
202+
export type TriggerFlowRequest = z.infer<typeof TriggerFlowRequestSchema>;
203+
204+
/**
205+
* Response after triggering a flow execution.
206+
*/
207+
export const TriggerFlowResponseSchema = BaseResponseSchema.extend({
208+
data: z.object({
209+
success: z.boolean().describe('Whether the automation completed successfully'),
210+
output: z.unknown().optional().describe('Output data from the automation'),
211+
error: z.string().optional().describe('Error message if execution failed'),
212+
durationMs: z.number().optional().describe('Execution duration in milliseconds'),
213+
}),
214+
});
215+
export type TriggerFlowResponse = z.infer<typeof TriggerFlowResponseSchema>;
216+
217+
// ==========================================
218+
// 8. Toggle Flow (POST /api/automation/:name/toggle)
182219
// ==========================================
183220

184221
/**
@@ -204,7 +241,7 @@ export const ToggleFlowResponseSchema = BaseResponseSchema.extend({
204241
export type ToggleFlowResponse = z.infer<typeof ToggleFlowResponseSchema>;
205242

206243
// ==========================================
207-
// 8. List Runs (GET /api/automation/:name/runs)
244+
// 9. List Runs (GET /api/automation/:name/runs)
208245
// ==========================================
209246

210247
/**
@@ -236,7 +273,7 @@ export const ListRunsResponseSchema = BaseResponseSchema.extend({
236273
export type ListRunsResponse = z.infer<typeof ListRunsResponseSchema>;
237274

238275
// ==========================================
239-
// 9. Get Run (GET /api/automation/:name/runs/:runId)
276+
// 10. Get Run (GET /api/automation/:name/runs/:runId)
240277
// ==========================================
241278

242279
/**
@@ -254,7 +291,7 @@ export const GetRunResponseSchema = BaseResponseSchema.extend({
254291
export type GetRunResponse = z.infer<typeof GetRunResponseSchema>;
255292

256293
// ==========================================
257-
// 10. Automation API Error Codes
294+
// 11. Automation API Error Codes
258295
// ==========================================
259296

260297
/**
@@ -274,7 +311,7 @@ export const AutomationApiErrorCode = z.enum([
274311
export type AutomationApiErrorCode = z.infer<typeof AutomationApiErrorCode>;
275312

276313
// ==========================================
277-
// 11. Automation API Contract Registry
314+
// 12. Automation API Contract Registry
278315
// ==========================================
279316

280317
/**
@@ -315,6 +352,8 @@ export const AutomationApiContracts = {
315352
triggerFlow: {
316353
method: 'POST' as const,
317354
path: '/api/automation/:name/trigger',
355+
input: TriggerFlowRequestSchema,
356+
output: TriggerFlowResponseSchema,
318357
},
319358
toggleFlow: {
320359
method: 'POST' as const,

packages/spec/src/contracts/automation-service.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22
import type { IAutomationService, AutomationResult } from './automation-service';
3+
import type { FlowParsed } from '../automation/flow.zod';
4+
import type { ExecutionLog } from '../automation/execution.zod';
35

46
describe('Automation Service Contract', () => {
57
it('should allow a minimal IAutomationService implementation with required methods', () => {
@@ -79,4 +81,90 @@ describe('Automation Service Contract', () => {
7981
expect(flows).toHaveLength(3);
8082
expect(flows).toContain('approval_flow');
8183
});
84+
85+
it('should return typed FlowParsed from getFlow', async () => {
86+
const mockFlow: FlowParsed = {
87+
name: 'approval_flow',
88+
label: 'Approval Flow',
89+
type: 'autolaunched',
90+
status: 'draft',
91+
version: 1,
92+
enabled: true,
93+
nodes: [
94+
{ id: 'start', type: 'start', label: 'Start' },
95+
{ id: 'end', type: 'end', label: 'End' },
96+
],
97+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
98+
};
99+
100+
const service: IAutomationService = {
101+
execute: async () => ({ success: true }),
102+
listFlows: async () => ['approval_flow'],
103+
getFlow: async (name) => name === 'approval_flow' ? mockFlow : null,
104+
};
105+
106+
const flow = await service.getFlow!('approval_flow');
107+
expect(flow).not.toBeNull();
108+
expect(flow!.name).toBe('approval_flow');
109+
expect(flow!.nodes).toHaveLength(2);
110+
111+
const missing = await service.getFlow!('nonexistent');
112+
expect(missing).toBeNull();
113+
});
114+
115+
it('should return typed ExecutionLog from listRuns and getRun', async () => {
116+
const mockRun: ExecutionLog = {
117+
id: 'exec_001',
118+
flowName: 'approval_flow',
119+
status: 'completed',
120+
trigger: { type: 'api' },
121+
steps: [{
122+
nodeId: 'start',
123+
nodeType: 'start',
124+
status: 'success',
125+
startedAt: '2026-02-01T10:00:00Z',
126+
durationMs: 1,
127+
}],
128+
startedAt: '2026-02-01T10:00:00Z',
129+
completedAt: '2026-02-01T10:00:01Z',
130+
durationMs: 1000,
131+
};
132+
133+
const service: IAutomationService = {
134+
execute: async () => ({ success: true }),
135+
listFlows: async () => ['approval_flow'],
136+
listRuns: async (_flowName, _options?) => [mockRun],
137+
getRun: async (runId) => runId === 'exec_001' ? mockRun : null,
138+
};
139+
140+
const runs = await service.listRuns!('approval_flow');
141+
expect(runs).toHaveLength(1);
142+
expect(runs[0].id).toBe('exec_001');
143+
expect(runs[0].status).toBe('completed');
144+
expect(runs[0].steps).toHaveLength(1);
145+
146+
const run = await service.getRun!('exec_001');
147+
expect(run).not.toBeNull();
148+
expect(run!.flowName).toBe('approval_flow');
149+
expect(run!.durationMs).toBe(1000);
150+
151+
const missingRun = await service.getRun!('nonexistent');
152+
expect(missingRun).toBeNull();
153+
});
154+
155+
it('should support toggleFlow to enable/disable flows', async () => {
156+
let flowEnabled = true;
157+
158+
const service: IAutomationService = {
159+
execute: async () => ({ success: true }),
160+
listFlows: async () => ['test_flow'],
161+
toggleFlow: async (_name, enabled) => { flowEnabled = enabled; },
162+
};
163+
164+
await service.toggleFlow!('test_flow', false);
165+
expect(flowEnabled).toBe(false);
166+
167+
await service.toggleFlow!('test_flow', true);
168+
expect(flowEnabled).toBe(true);
169+
});
82170
});

packages/spec/src/contracts/automation-service.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
* Aligned with CoreServiceName 'automation' in core-services.zod.ts.
1414
*/
1515

16+
import type { FlowParsed } from '../automation/flow.zod';
17+
import type { ExecutionLog } from '../automation/execution.zod';
18+
1619
/**
1720
* Context passed to a flow/script execution
1821
*/
@@ -76,7 +79,7 @@ export interface IAutomationService {
7679
* @param name - Flow name (snake_case)
7780
* @returns Flow definition or null if not found
7881
*/
79-
getFlow?(name: string): Promise<unknown | null>;
82+
getFlow?(name: string): Promise<FlowParsed | null>;
8083

8184
/**
8285
* Enable or disable a flow
@@ -91,12 +94,12 @@ export interface IAutomationService {
9194
* @param options - Pagination options
9295
* @returns Array of execution logs
9396
*/
94-
listRuns?(flowName: string, options?: { limit?: number; cursor?: string }): Promise<unknown[]>;
97+
listRuns?(flowName: string, options?: { limit?: number; cursor?: string }): Promise<ExecutionLog[]>;
9598

9699
/**
97100
* Get a single execution run by ID
98101
* @param runId - Execution run ID
99102
* @returns Execution log or null if not found
100103
*/
101-
getRun?(runId: string): Promise<unknown | null>;
104+
getRun?(runId: string): Promise<ExecutionLog | null>;
102105
}

0 commit comments

Comments
 (0)