@llm-dev-ops/agentics-cli 1.5.74 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/pipeline/phase4/phase4-coordinator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phase4-coordinator.js +2 -0
- package/dist/pipeline/phase4/phase4-coordinator.js.map +1 -1
- package/dist/pipeline/phase4/phases/build-time-services.d.ts +28 -0
- package/dist/pipeline/phase4/phases/build-time-services.d.ts.map +1 -0
- package/dist/pipeline/phase4/phases/build-time-services.js +232 -0
- package/dist/pipeline/phase4/phases/build-time-services.js.map +1 -0
- package/dist/pipeline/phase4/phases/deployment-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/deployment-generator.js +107 -15
- package/dist/pipeline/phase4/phases/deployment-generator.js.map +1 -1
- package/dist/pipeline/phase4/phases/erp-client-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/erp-client-generator.js +303 -52
- package/dist/pipeline/phase4/phases/erp-client-generator.js.map +1 -1
- package/dist/pipeline/phase4/phases/http-server-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/http-server-generator.js +748 -37
- package/dist/pipeline/phase4/phases/http-server-generator.js.map +1 -1
- package/dist/pipeline/phase4/phases/infra-adapter-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/infra-adapter-generator.js +51 -0
- package/dist/pipeline/phase4/phases/infra-adapter-generator.js.map +1 -1
- package/dist/pipeline/phase4/phases/platform-catalog.d.ts +48 -0
- package/dist/pipeline/phase4/phases/platform-catalog.d.ts.map +1 -0
- package/dist/pipeline/phase4/phases/platform-catalog.js +242 -0
- package/dist/pipeline/phase4/phases/platform-catalog.js.map +1 -0
- package/dist/pipeline/phase4/phases/rust-optimizer-generator.d.ts +1 -1
- package/dist/pipeline/phase4/phases/rust-optimizer-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/rust-optimizer-generator.js +176 -31
- package/dist/pipeline/phase4/phases/rust-optimizer-generator.js.map +1 -1
- package/dist/pipeline/phase4/phases/schema-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/schema-generator.js +22 -0
- package/dist/pipeline/phase4/phases/schema-generator.js.map +1 -1
- package/dist/pipeline/phase4/types.d.ts +1 -1
- package/dist/pipeline/phase4/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -34,28 +34,28 @@ const GENERATED_HEADER_RS = '// Generated by Phase 4 pipeline — do not edit ma
|
|
|
34
34
|
function resolveErpClientNames(erpSystem) {
|
|
35
35
|
const n = erpSystem.toLowerCase();
|
|
36
36
|
if (n.includes('netsuite'))
|
|
37
|
-
return { className: 'NetSuiteClient', mapperName: 'NetSuiteMapper', wirePrefix: 'NetSuite', displayName: 'NetSuite' };
|
|
37
|
+
return { className: 'NetSuiteClient', mapperName: 'NetSuiteMapper', wirePrefix: 'NetSuite', displayName: 'NetSuite', envPrefix: 'NETSUITE' };
|
|
38
38
|
if (n.includes('sap'))
|
|
39
|
-
return { className: 'SAPClient', mapperName: 'SAPMapper', wirePrefix: 'SAP', displayName: 'SAP' };
|
|
39
|
+
return { className: 'SAPClient', mapperName: 'SAPMapper', wirePrefix: 'SAP', displayName: 'SAP', envPrefix: 'SAP' };
|
|
40
40
|
if (n.includes('dynamics'))
|
|
41
|
-
return { className: 'DynamicsClient', mapperName: 'DynamicsMapper', wirePrefix: 'Dynamics', displayName: 'Dynamics 365' };
|
|
41
|
+
return { className: 'DynamicsClient', mapperName: 'DynamicsMapper', wirePrefix: 'Dynamics', displayName: 'Dynamics 365', envPrefix: 'DYNAMICS' };
|
|
42
42
|
if (n.includes('deltek') || n.includes('costpoint'))
|
|
43
|
-
return { className: 'DeltekClient', mapperName: 'DeltekMapper', wirePrefix: 'Deltek', displayName: 'Deltek Costpoint' };
|
|
43
|
+
return { className: 'DeltekClient', mapperName: 'DeltekMapper', wirePrefix: 'Deltek', displayName: 'Deltek Costpoint', envPrefix: 'DELTEK' };
|
|
44
44
|
if (n.includes('guidewire'))
|
|
45
|
-
return { className: 'GuidewireClient', mapperName: 'GuidewireMapper', wirePrefix: 'Guidewire', displayName: 'Guidewire' };
|
|
45
|
+
return { className: 'GuidewireClient', mapperName: 'GuidewireMapper', wirePrefix: 'Guidewire', displayName: 'Guidewire', envPrefix: 'GUIDEWIRE' };
|
|
46
46
|
if (n.includes('epicor'))
|
|
47
|
-
return { className: 'EpicorClient', mapperName: 'EpicorMapper', wirePrefix: 'Epicor', displayName: 'Epicor' };
|
|
47
|
+
return { className: 'EpicorClient', mapperName: 'EpicorMapper', wirePrefix: 'Epicor', displayName: 'Epicor', envPrefix: 'EPICOR' };
|
|
48
48
|
if (n.includes('infor'))
|
|
49
|
-
return { className: 'InforClient', mapperName: 'InforMapper', wirePrefix: 'Infor', displayName: 'Infor' };
|
|
49
|
+
return { className: 'InforClient', mapperName: 'InforMapper', wirePrefix: 'Infor', displayName: 'Infor', envPrefix: 'INFOR' };
|
|
50
50
|
if (n.includes('workday'))
|
|
51
|
-
return { className: 'WorkdayClient', mapperName: 'WorkdayMapper', wirePrefix: 'Workday', displayName: 'Workday' };
|
|
51
|
+
return { className: 'WorkdayClient', mapperName: 'WorkdayMapper', wirePrefix: 'Workday', displayName: 'Workday', envPrefix: 'WORKDAY' };
|
|
52
52
|
if (n.includes('ifs'))
|
|
53
|
-
return { className: 'IFSClient', mapperName: 'IFSMapper', wirePrefix: 'IFS', displayName: 'IFS' };
|
|
53
|
+
return { className: 'IFSClient', mapperName: 'IFSMapper', wirePrefix: 'IFS', displayName: 'IFS', envPrefix: 'IFS' };
|
|
54
54
|
if (n.includes('sage') || n.includes('intacct'))
|
|
55
|
-
return { className: 'SageClient', mapperName: 'SageMapper', wirePrefix: 'Sage', displayName: 'Sage Intacct' };
|
|
55
|
+
return { className: 'SageClient', mapperName: 'SageMapper', wirePrefix: 'Sage', displayName: 'Sage Intacct', envPrefix: 'SAGE' };
|
|
56
56
|
if (n.includes('oracle'))
|
|
57
|
-
return { className: 'OracleERPClient', mapperName: 'OracleERPMapper', wirePrefix: 'OracleERP', displayName: 'Oracle ERP' };
|
|
58
|
-
return { className: 'ERPClient', mapperName: 'ERPMapper', wirePrefix: 'ERP', displayName: 'ERP' };
|
|
57
|
+
return { className: 'OracleERPClient', mapperName: 'OracleERPMapper', wirePrefix: 'OracleERP', displayName: 'Oracle ERP', envPrefix: 'ORACLE' };
|
|
58
|
+
return { className: 'ERPClient', mapperName: 'ERPMapper', wirePrefix: 'ERP', displayName: 'ERP', envPrefix: 'ERP' };
|
|
59
59
|
}
|
|
60
60
|
// ============================================================================
|
|
61
61
|
// TypeScript Generation — ERP ACL
|
|
@@ -197,19 +197,25 @@ function tsTypes(erpSystem) {
|
|
|
197
197
|
' readonly audit_enabled: boolean;',
|
|
198
198
|
' readonly pii_redaction: boolean;',
|
|
199
199
|
' readonly max_tokens_per_request: number;',
|
|
200
|
+
' readonly subsidiary_id?: string; // ADR-071: parameterized — defaults to env ORACLE_SUBSIDIARY_ID or \'1\'',
|
|
200
201
|
'}',
|
|
201
202
|
'',
|
|
202
203
|
'// ============================================================================',
|
|
203
204
|
'// Sync Queue Types',
|
|
204
205
|
'// ============================================================================',
|
|
205
206
|
'',
|
|
207
|
+
'// ADR-072: Persistent sync queue entry — stored in PostgreSQL, not in-memory',
|
|
206
208
|
'export interface SyncQueueEntry {',
|
|
207
209
|
' readonly id: string;',
|
|
208
210
|
' readonly operation: string;',
|
|
209
|
-
' readonly payload:
|
|
211
|
+
' readonly payload: string; // JSON-serialized wire format',
|
|
212
|
+
' readonly idempotency_key: string;',
|
|
213
|
+
' readonly status: \'pending\' | \'in_flight\' | \'completed\' | \'failed\';',
|
|
210
214
|
' readonly created_at: string;',
|
|
215
|
+
' readonly last_attempt_at: string | null;',
|
|
211
216
|
' readonly retry_count: number;',
|
|
212
|
-
' readonly
|
|
217
|
+
' readonly error_message: string | null;',
|
|
218
|
+
' readonly correlation_id: string;',
|
|
213
219
|
'}',
|
|
214
220
|
'',
|
|
215
221
|
'export interface SyncStatus {',
|
|
@@ -276,6 +282,47 @@ function tsMapper(erpSystem) {
|
|
|
276
282
|
'}',
|
|
277
283
|
'',
|
|
278
284
|
'// ============================================================================',
|
|
285
|
+
'// ADR-072: Wire Format Validation Schemas',
|
|
286
|
+
'// ============================================================================',
|
|
287
|
+
'',
|
|
288
|
+
"import { z } from 'zod';",
|
|
289
|
+
'',
|
|
290
|
+
`const ${wp}TransferOrderSchema = z.object({`,
|
|
291
|
+
" subsidiary: z.object({ id: z.string().min(1) }),",
|
|
292
|
+
" location: z.object({ id: z.string().min(1) }),",
|
|
293
|
+
" transferLocation: z.object({ id: z.string().min(1) }),",
|
|
294
|
+
" memo: z.string(),",
|
|
295
|
+
" item: z.object({ items: z.array(z.object({ item: z.object({ id: z.string() }), quantity: z.number(), units: z.object({ id: z.string() }).optional() })) }),",
|
|
296
|
+
'});',
|
|
297
|
+
'',
|
|
298
|
+
`const ${wp}PurchaseOrderSchema = z.object({`,
|
|
299
|
+
" entity: z.object({ id: z.string().min(1) }),",
|
|
300
|
+
" subsidiary: z.object({ id: z.string().min(1) }),",
|
|
301
|
+
" location: z.object({ id: z.string().min(1) }),",
|
|
302
|
+
" memo: z.string(),",
|
|
303
|
+
" item: z.object({ items: z.array(z.object({ item: z.object({ id: z.string() }), quantity: z.number() })) }),",
|
|
304
|
+
'});',
|
|
305
|
+
'',
|
|
306
|
+
`const ${wp}ItemReceiptSchema = z.object({`,
|
|
307
|
+
" createdFrom: z.object({ id: z.string().min(1) }),",
|
|
308
|
+
" subsidiary: z.object({ id: z.string().min(1) }),",
|
|
309
|
+
" item: z.object({ items: z.array(z.object({ item: z.object({ id: z.string() }), quantity: z.number(), location: z.object({ id: z.string() }) })) }),",
|
|
310
|
+
'});',
|
|
311
|
+
'',
|
|
312
|
+
'/** ADR-072: Validate wire payload before queuing or sending to ERP */',
|
|
313
|
+
'function validateWirePayload(operation: string, payload: unknown): void {',
|
|
314
|
+
' const schemas: Record<string, z.ZodSchema> = {',
|
|
315
|
+
` createTransferOrder: ${wp}TransferOrderSchema,`,
|
|
316
|
+
` createPurchaseOrder: ${wp}PurchaseOrderSchema,`,
|
|
317
|
+
` updateItemReceipt: ${wp}ItemReceiptSchema,`,
|
|
318
|
+
' };',
|
|
319
|
+
' const schema = schemas[operation];',
|
|
320
|
+
' if (schema) {',
|
|
321
|
+
' schema.parse(payload); // Throws ZodError if invalid',
|
|
322
|
+
' }',
|
|
323
|
+
'}',
|
|
324
|
+
'',
|
|
325
|
+
'// ============================================================================',
|
|
279
326
|
`// ${names.mapperName} — Domain ↔ Wire Type Translation`,
|
|
280
327
|
'// ============================================================================',
|
|
281
328
|
'',
|
|
@@ -288,7 +335,7 @@ function tsMapper(erpSystem) {
|
|
|
288
335
|
'',
|
|
289
336
|
` toTransferOrder(domain: DomainTransferOrder): ${wp}TransferOrder {`,
|
|
290
337
|
` const wire: ${wp}TransferOrder = {`,
|
|
291
|
-
' subsidiary: { id: \'1\' },',
|
|
338
|
+
' subsidiary: { id: this.governance.subsidiary_id ?? process.env.ORACLE_SUBSIDIARY_ID ?? \'1\' }, // ADR-071: parameterized',
|
|
292
339
|
' location: { id: domain.from_facility },',
|
|
293
340
|
' transferLocation: { id: domain.to_facility },',
|
|
294
341
|
' memo: `Transfer ${domain.sku} — priority: ${domain.priority}`,',
|
|
@@ -309,7 +356,7 @@ function tsMapper(erpSystem) {
|
|
|
309
356
|
` toPurchaseOrder(domain: DomainPurchaseOrder): ${wp}PurchaseOrder {`,
|
|
310
357
|
` const wire: ${wp}PurchaseOrder = {`,
|
|
311
358
|
' entity: { id: domain.vendor_id },',
|
|
312
|
-
' subsidiary: { id: \'1\' },',
|
|
359
|
+
' subsidiary: { id: this.governance.subsidiary_id ?? process.env.ORACLE_SUBSIDIARY_ID ?? \'1\' }, // ADR-071: parameterized',
|
|
313
360
|
' location: { id: domain.facility_id },',
|
|
314
361
|
' memo: `PO for ${domain.sku} — qty: ${domain.quantity}`,',
|
|
315
362
|
' item: {',
|
|
@@ -328,7 +375,7 @@ function tsMapper(erpSystem) {
|
|
|
328
375
|
` toItemReceipt(domain: DomainItemReceipt, transferNsId: string): ${wp}ItemReceipt {`,
|
|
329
376
|
' return {',
|
|
330
377
|
' createdFrom: { id: transferNsId },',
|
|
331
|
-
' subsidiary: { id: \'1\' },',
|
|
378
|
+
' subsidiary: { id: this.governance.subsidiary_id ?? process.env.ORACLE_SUBSIDIARY_ID ?? \'1\' }, // ADR-071: parameterized',
|
|
332
379
|
' item: {',
|
|
333
380
|
' items: [{',
|
|
334
381
|
' item: { id: domain.sku },',
|
|
@@ -376,7 +423,7 @@ function tsClient(erpSystem, paths) {
|
|
|
376
423
|
` SyncStatus,`,
|
|
377
424
|
`} from './types.js';`,
|
|
378
425
|
`import { ${names.mapperName} } from './mapper.js';`,
|
|
379
|
-
`import { withRetry, CircuitBreaker } from './retry.js';`,
|
|
426
|
+
`import { withRetry, CircuitBreaker, TokenBucketRateLimiter, translateErpError } from './retry.js';`,
|
|
380
427
|
'',
|
|
381
428
|
'const GCP_METADATA_TOKEN_URL =',
|
|
382
429
|
' \'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity\';',
|
|
@@ -384,6 +431,35 @@ function tsClient(erpSystem, paths) {
|
|
|
384
431
|
'const REQUEST_TIMEOUT_MS = parseInt(process.env.ERP_REQUEST_TIMEOUT_MS ?? \'30000\', 10);',
|
|
385
432
|
'const BULK_TIMEOUT_MS = parseInt(process.env.ERP_BULK_TIMEOUT_MS ?? \'120000\', 10);',
|
|
386
433
|
'',
|
|
434
|
+
'// ADR-076: Shield outbound scanning for ERP payloads',
|
|
435
|
+
'async function shieldScanOutbound(payload: unknown, operation: string, correlationId: string): Promise<void> {',
|
|
436
|
+
' const shieldUrl = process.env.AGENTICS_SHIELD_URL;',
|
|
437
|
+
' if (!shieldUrl) return; // Shield not configured — skip (log in dev)',
|
|
438
|
+
' try {',
|
|
439
|
+
' const response = await fetch(`${shieldUrl}/api/v1/scan`, {',
|
|
440
|
+
' method: \'POST\',',
|
|
441
|
+
' headers: { \'Content-Type\': \'application/json\', \'X-Correlation-ID\': correlationId },',
|
|
442
|
+
' body: JSON.stringify({',
|
|
443
|
+
' content: JSON.stringify(payload),',
|
|
444
|
+
' checks: [\'pii\', \'secrets\', \'credential_exposure\'],',
|
|
445
|
+
' context: { destination: \'erp_writeback\', operation },',
|
|
446
|
+
' }),',
|
|
447
|
+
' signal: AbortSignal.timeout(5_000),',
|
|
448
|
+
' });',
|
|
449
|
+
' if (response.ok) {',
|
|
450
|
+
' const result = await response.json() as { blocked?: boolean; findings?: Array<{ type: string }> };',
|
|
451
|
+
' if (result.blocked) {',
|
|
452
|
+
' throw new Error(`ERP writeback blocked by Shield: ${result.findings?.map(f => f.type).join(\', \') ?? \'security policy violation\'}`);',
|
|
453
|
+
' }',
|
|
454
|
+
' }',
|
|
455
|
+
' } catch (err) {',
|
|
456
|
+
' // ADR-076: Shield is must-have for outbound ERP — rethrow to prevent unscanned writeback',
|
|
457
|
+
' if (err instanceof Error && err.message.includes(\'blocked by Shield\')) throw err;',
|
|
458
|
+
' console.error(JSON.stringify({ level: \'error\', type: \'SHIELD_OUTBOUND_UNAVAILABLE\', correlationId, operation, error: err instanceof Error ? err.message : String(err) }));',
|
|
459
|
+
' throw new Error(\'Security scanning unavailable — cannot send unscanned payload to ERP\');',
|
|
460
|
+
' }',
|
|
461
|
+
'}',
|
|
462
|
+
'',
|
|
387
463
|
'// ============================================================================',
|
|
388
464
|
'// Audit Logger Interface',
|
|
389
465
|
'// ============================================================================',
|
|
@@ -401,9 +477,10 @@ function tsClient(erpSystem, paths) {
|
|
|
401
477
|
' private readonly serviceAccountEmail: string;',
|
|
402
478
|
` private readonly mapper: ${names.mapperName};`,
|
|
403
479
|
' private readonly breaker: CircuitBreaker;',
|
|
480
|
+
' private readonly rateLimiter: TokenBucketRateLimiter;',
|
|
404
481
|
' private readonly governance: GovernanceConfig;',
|
|
405
482
|
' private readonly audit: AuditLogger | null;',
|
|
406
|
-
' private readonly
|
|
483
|
+
' private readonly db: { execute: (sql: string, params?: unknown[]) => Promise<void>; query: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]> } | null;',
|
|
407
484
|
' private readonly recentErrors: Array<{ timestamp: string; operation: string; error: string }> = [];',
|
|
408
485
|
' private lastSuccessfulSync: string | null = null;',
|
|
409
486
|
'',
|
|
@@ -412,9 +489,16 @@ function tsClient(erpSystem, paths) {
|
|
|
412
489
|
' serviceAccountEmail: string;',
|
|
413
490
|
' governance: GovernanceConfig;',
|
|
414
491
|
' audit?: AuditLogger;',
|
|
492
|
+
' db?: { execute: (sql: string, params?: unknown[]) => Promise<void>; query: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]> };',
|
|
415
493
|
' circuitBreakerThreshold?: number;',
|
|
416
494
|
' circuitBreakerResetMs?: number;',
|
|
495
|
+
' rateLimitMaxTokens?: number;',
|
|
496
|
+
' rateLimitRefillRate?: number;',
|
|
417
497
|
' }) {',
|
|
498
|
+
' // ADR-084: Fail-fast on unconfigured ERP URL — no fallback to example.com',
|
|
499
|
+
' if (!config.baseUrl || config.baseUrl.includes(\'example.com\')) {',
|
|
500
|
+
` throw new Error('${names.displayName} base URL not configured. Set ${names.envPrefix}_BASE_URL to a valid endpoint — do not use example.com fallback.');`,
|
|
501
|
+
' }',
|
|
418
502
|
' this.baseUrl = config.baseUrl.replace(/\\/$/, \'\');',
|
|
419
503
|
' this.serviceAccountEmail = config.serviceAccountEmail;',
|
|
420
504
|
' this.governance = config.governance;',
|
|
@@ -424,6 +508,29 @@ function tsClient(erpSystem, paths) {
|
|
|
424
508
|
' config.circuitBreakerResetMs ?? 60_000,',
|
|
425
509
|
' );',
|
|
426
510
|
' this.audit = config.audit ?? null;',
|
|
511
|
+
' this.rateLimiter = new TokenBucketRateLimiter(config.rateLimitMaxTokens ?? 10, config.rateLimitRefillRate ?? 2);',
|
|
512
|
+
' this.db = config.db ?? null;',
|
|
513
|
+
' }',
|
|
514
|
+
'',
|
|
515
|
+
' // ADR-072: Initialize persistent sync queue table',
|
|
516
|
+
' async initSyncQueue(): Promise<void> {',
|
|
517
|
+
' if (!this.db) return;',
|
|
518
|
+
' await this.db.execute(`',
|
|
519
|
+
' CREATE TABLE IF NOT EXISTS sync_queue (',
|
|
520
|
+
' id TEXT PRIMARY KEY,',
|
|
521
|
+
' operation TEXT NOT NULL,',
|
|
522
|
+
' payload TEXT NOT NULL,',
|
|
523
|
+
' idempotency_key TEXT UNIQUE NOT NULL,',
|
|
524
|
+
' status TEXT NOT NULL DEFAULT \'pending\',',
|
|
525
|
+
' retry_count INTEGER NOT NULL DEFAULT 0,',
|
|
526
|
+
' created_at TEXT NOT NULL,',
|
|
527
|
+
' last_attempt_at TEXT,',
|
|
528
|
+
' error_message TEXT,',
|
|
529
|
+
' correlation_id TEXT NOT NULL',
|
|
530
|
+
' )',
|
|
531
|
+
' `);',
|
|
532
|
+
' await this.db.execute(\'CREATE INDEX IF NOT EXISTS idx_sync_queue_status ON sync_queue(status)\');',
|
|
533
|
+
' await this.db.execute(\'CREATE INDEX IF NOT EXISTS idx_sync_queue_created ON sync_queue(created_at)\');',
|
|
427
534
|
' }',
|
|
428
535
|
'',
|
|
429
536
|
' // --------------------------------------------------------------------------',
|
|
@@ -488,52 +595,92 @@ function tsClient(erpSystem, paths) {
|
|
|
488
595
|
' // Sync Queue & Status',
|
|
489
596
|
' // --------------------------------------------------------------------------',
|
|
490
597
|
'',
|
|
491
|
-
' getSyncStatus(): SyncStatus {',
|
|
598
|
+
' async getSyncStatus(): Promise<SyncStatus> {',
|
|
599
|
+
' let pendingDepth = 0;',
|
|
600
|
+
' if (this.db) {',
|
|
601
|
+
' const rows = await this.db.query("SELECT COUNT(*) as cnt FROM sync_queue WHERE status IN (\'pending\', \'in_flight\')", []);',
|
|
602
|
+
' pendingDepth = Number(rows[0]?.[\'cnt\'] ?? 0);',
|
|
603
|
+
' }',
|
|
492
604
|
' return {',
|
|
493
605
|
' last_successful_sync: this.lastSuccessfulSync,',
|
|
494
|
-
' pending_queue_depth:
|
|
606
|
+
' pending_queue_depth: pendingDepth,',
|
|
495
607
|
' circuit_breaker_state: this.breaker.state,',
|
|
496
608
|
' recent_errors: this.recentErrors.slice(-20),',
|
|
497
609
|
' };',
|
|
498
610
|
' }',
|
|
499
611
|
'',
|
|
500
|
-
' getPendingQueue(): ReadonlyArray<SyncQueueEntry
|
|
501
|
-
'
|
|
612
|
+
' async getPendingQueue(): Promise<ReadonlyArray<SyncQueueEntry>> {',
|
|
613
|
+
' if (!this.db) return [];',
|
|
614
|
+
' const rows = await this.db.query("SELECT * FROM sync_queue WHERE status IN (\'pending\', \'failed\') ORDER BY created_at ASC", []);',
|
|
615
|
+
' return rows.map(r => ({',
|
|
616
|
+
' id: String(r[\'id\']),',
|
|
617
|
+
' operation: String(r[\'operation\']),',
|
|
618
|
+
' payload: String(r[\'payload\']),',
|
|
619
|
+
' idempotency_key: String(r[\'idempotency_key\']),',
|
|
620
|
+
' status: String(r[\'status\']) as SyncQueueEntry[\'status\'],',
|
|
621
|
+
' created_at: String(r[\'created_at\']),',
|
|
622
|
+
' last_attempt_at: r[\'last_attempt_at\'] ? String(r[\'last_attempt_at\']) : null,',
|
|
623
|
+
' retry_count: Number(r[\'retry_count\'] ?? 0),',
|
|
624
|
+
' error_message: r[\'error_message\'] ? String(r[\'error_message\']) : null,',
|
|
625
|
+
' correlation_id: String(r[\'correlation_id\']),',
|
|
626
|
+
' })) as SyncQueueEntry[];',
|
|
502
627
|
' }',
|
|
503
628
|
'',
|
|
504
629
|
' async drainSyncQueue(): Promise<{ processed: number; failed: number }> {',
|
|
505
630
|
' // ADR-049: Governance check — drainSyncQueue must respect read_only mode',
|
|
506
631
|
' this.assertWriteAllowed(\'drainSyncQueue\');',
|
|
507
632
|
'',
|
|
633
|
+
' if (!this.db) return { processed: 0, failed: 0 };',
|
|
634
|
+
'',
|
|
635
|
+
' // ADR-072: Read pending entries from database',
|
|
636
|
+
' const pending = await this.db.query(',
|
|
637
|
+
' "SELECT * FROM sync_queue WHERE status IN (\'pending\', \'failed\') AND retry_count < 10 ORDER BY created_at ASC",',
|
|
638
|
+
' [],',
|
|
639
|
+
' );',
|
|
640
|
+
'',
|
|
508
641
|
' let processed = 0;',
|
|
509
642
|
' let failed = 0;',
|
|
510
|
-
' const remaining: SyncQueueEntry[] = [];',
|
|
511
643
|
'',
|
|
512
|
-
' for (const
|
|
644
|
+
' for (const row of pending) {',
|
|
645
|
+
' const entryId = String(row[\'id\']);',
|
|
646
|
+
' const operation = String(row[\'operation\']);',
|
|
647
|
+
' const payload = JSON.parse(String(row[\'payload\']));',
|
|
648
|
+
' const retryCount = Number(row[\'retry_count\'] ?? 0);',
|
|
649
|
+
' const correlationId = String(row[\'correlation_id\']);',
|
|
650
|
+
' const now = new Date().toISOString();',
|
|
651
|
+
'',
|
|
652
|
+
' // Mark as in_flight',
|
|
653
|
+
' await this.db.execute(',
|
|
654
|
+
' "UPDATE sync_queue SET status = \'in_flight\', last_attempt_at = :1 WHERE id = :2",',
|
|
655
|
+
' [now, entryId],',
|
|
656
|
+
' );',
|
|
657
|
+
'',
|
|
513
658
|
' try {',
|
|
514
659
|
' await this.breaker.execute(() =>',
|
|
515
660
|
' withRetry(() =>',
|
|
516
|
-
' this.post(this.operationToPath(
|
|
661
|
+
' this.post(this.operationToPath(operation), payload, correlationId),',
|
|
517
662
|
' ),',
|
|
518
663
|
' );',
|
|
519
664
|
' processed++;',
|
|
520
|
-
' this.lastSuccessfulSync =
|
|
521
|
-
' // ADR-
|
|
522
|
-
' await this.
|
|
665
|
+
' this.lastSuccessfulSync = now;',
|
|
666
|
+
' // ADR-072: Mark as completed in database',
|
|
667
|
+
' await this.db.execute(',
|
|
668
|
+
' "UPDATE sync_queue SET status = \'completed\', last_attempt_at = :1 WHERE id = :2",',
|
|
669
|
+
' [now, entryId],',
|
|
670
|
+
' );',
|
|
671
|
+
' await this.auditAfter(operation, entryId, correlationId, { status: \'drained\', retry_count: retryCount });',
|
|
523
672
|
' } catch (err) {',
|
|
524
673
|
' failed++;',
|
|
525
|
-
'
|
|
526
|
-
'
|
|
527
|
-
'
|
|
528
|
-
'
|
|
529
|
-
'
|
|
530
|
-
'
|
|
531
|
-
' });',
|
|
674
|
+
' const errorMsg = err instanceof Error ? err.message : String(err);',
|
|
675
|
+
' // ADR-072: Update retry count and error in database',
|
|
676
|
+
' await this.db.execute(',
|
|
677
|
+
' "UPDATE sync_queue SET status = \'failed\', retry_count = :1, error_message = :2, last_attempt_at = :3 WHERE id = :4",',
|
|
678
|
+
' [retryCount + 1, errorMsg, now, entryId],',
|
|
679
|
+
' );',
|
|
680
|
+
' await this.auditAfter(operation, entryId, correlationId, { status: \'drain_failed\', error: errorMsg, retry_count: retryCount + 1 });',
|
|
532
681
|
' }',
|
|
533
682
|
' }',
|
|
534
683
|
'',
|
|
535
|
-
' this.syncQueue.length = 0;',
|
|
536
|
-
' this.syncQueue.push(...remaining);',
|
|
537
684
|
' return { processed, failed };',
|
|
538
685
|
' }',
|
|
539
686
|
'',
|
|
@@ -542,6 +689,8 @@ function tsClient(erpSystem, paths) {
|
|
|
542
689
|
' // --------------------------------------------------------------------------',
|
|
543
690
|
'',
|
|
544
691
|
' private async post(path: string, body: unknown, correlationId: string): Promise<ErpSurfaceResponse> {',
|
|
692
|
+
' // ADR-084: Rate limit before making external call',
|
|
693
|
+
' await this.rateLimiter.acquire();',
|
|
545
694
|
' const token = await this.getIdentityToken();',
|
|
546
695
|
' const controller = new AbortController();',
|
|
547
696
|
' const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);',
|
|
@@ -561,7 +710,9 @@ function tsClient(erpSystem, paths) {
|
|
|
561
710
|
' });',
|
|
562
711
|
'',
|
|
563
712
|
' if (!response.ok) {',
|
|
564
|
-
|
|
713
|
+
' // ADR-084: Translate ERP-specific error codes to domain exceptions',
|
|
714
|
+
' const responseBody = await response.text();',
|
|
715
|
+
' throw translateErpError(response.status, responseBody, path);',
|
|
565
716
|
' }',
|
|
566
717
|
' return response.json() as Promise<ErpSurfaceResponse>;',
|
|
567
718
|
' } finally {',
|
|
@@ -570,6 +721,8 @@ function tsClient(erpSystem, paths) {
|
|
|
570
721
|
' }',
|
|
571
722
|
'',
|
|
572
723
|
' private async get(path: string, correlationId: string): Promise<ErpSurfaceResponse> {',
|
|
724
|
+
' // ADR-084: Rate limit before making external call',
|
|
725
|
+
' await this.rateLimiter.acquire();',
|
|
573
726
|
' const token = await this.getIdentityToken();',
|
|
574
727
|
' const controller = new AbortController();',
|
|
575
728
|
' const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);',
|
|
@@ -585,7 +738,8 @@ function tsClient(erpSystem, paths) {
|
|
|
585
738
|
' });',
|
|
586
739
|
'',
|
|
587
740
|
' if (!response.ok) {',
|
|
588
|
-
|
|
741
|
+
' const responseBody = await response.text();',
|
|
742
|
+
' throw translateErpError(response.status, responseBody, path);',
|
|
589
743
|
' }',
|
|
590
744
|
' return response.json() as Promise<ErpSurfaceResponse>;',
|
|
591
745
|
' } finally {',
|
|
@@ -627,6 +781,10 @@ function tsClient(erpSystem, paths) {
|
|
|
627
781
|
' fn: () => Promise<ErpSurfaceResponse>,',
|
|
628
782
|
' payload: unknown,',
|
|
629
783
|
' ): Promise<ErpSurfaceResponse> {',
|
|
784
|
+
' // ADR-072: Validate wire payload before sending or queuing',
|
|
785
|
+
' validateWirePayload(operation, payload);',
|
|
786
|
+
' // ADR-076: Shield outbound scan — block if PII, secrets, or credentials detected',
|
|
787
|
+
' await shieldScanOutbound(payload, operation, correlationId);',
|
|
630
788
|
' await this.auditBefore(operation, entityId, correlationId, payload);',
|
|
631
789
|
' try {',
|
|
632
790
|
' const result = await this.breaker.execute(() => withRetry(fn));',
|
|
@@ -635,19 +793,24 @@ function tsClient(erpSystem, paths) {
|
|
|
635
793
|
' return result;',
|
|
636
794
|
' } catch (err) {',
|
|
637
795
|
' this.recordError(operation, err);',
|
|
796
|
+
' const errorMsg = err instanceof Error ? err.message : String(err);',
|
|
638
797
|
' // ADR-049: Audit the failure before queuing',
|
|
639
|
-
' await this.auditAfter(operation, entityId, correlationId, { status: \'queued\', error:
|
|
640
|
-
' // ADR-
|
|
641
|
-
'
|
|
642
|
-
'
|
|
643
|
-
'
|
|
644
|
-
'
|
|
645
|
-
'
|
|
646
|
-
'
|
|
647
|
-
'
|
|
648
|
-
'
|
|
649
|
-
'
|
|
650
|
-
'
|
|
798
|
+
' await this.auditAfter(operation, entityId, correlationId, { status: \'queued\', error: errorMsg });',
|
|
799
|
+
' // ADR-072: Persist to sync_queue table (survives instance restarts)',
|
|
800
|
+
' if (this.db) {',
|
|
801
|
+
' const { randomUUID } = await import(\'node:crypto\');',
|
|
802
|
+
' const idempotencyKey = `${correlationId}-${operation}`;',
|
|
803
|
+
' try {',
|
|
804
|
+
' await this.db.execute(',
|
|
805
|
+
' \'INSERT INTO sync_queue (id, operation, payload, idempotency_key, status, created_at, error_message, correlation_id) VALUES (:1, :2, :3, :4, :5, :6, :7, :8)\',',
|
|
806
|
+
' [randomUUID(), operation, JSON.stringify(payload), idempotencyKey, \'pending\', new Date().toISOString(), errorMsg, correlationId],',
|
|
807
|
+
' );',
|
|
808
|
+
' } catch (dbErr) {',
|
|
809
|
+
' // ADR-072: idempotency_key UNIQUE constraint prevents duplicates',
|
|
810
|
+
' if (!(dbErr instanceof Error && dbErr.message.includes(\'UNIQUE\'))) {',
|
|
811
|
+
' console.error(JSON.stringify({ level: \'error\', type: \'SYNC_QUEUE_INSERT_FAILED\', correlationId, error: dbErr instanceof Error ? dbErr.message : String(dbErr) }));',
|
|
812
|
+
' }',
|
|
813
|
+
' }',
|
|
651
814
|
' }',
|
|
652
815
|
' // Return a queued response so domain services can continue',
|
|
653
816
|
' return {',
|
|
@@ -779,6 +942,38 @@ function tsRetry() {
|
|
|
779
942
|
' this.failures++;',
|
|
780
943
|
' if (this.failures >= this.threshold) {',
|
|
781
944
|
' this.openedAt = Date.now();',
|
|
945
|
+
' // ADR-077: Alert Sentinel when circuit breaker opens',
|
|
946
|
+
' const sentinelUrl = process.env.AGENTICS_SENTINEL_URL;',
|
|
947
|
+
' if (sentinelUrl) {',
|
|
948
|
+
' fetch(`${sentinelUrl}/api/v1/alert`, {',
|
|
949
|
+
' method: \'POST\',',
|
|
950
|
+
' headers: { \'Content-Type\': \'application/json\' },',
|
|
951
|
+
' body: JSON.stringify({',
|
|
952
|
+
' type: \'circuit_breaker_open\',',
|
|
953
|
+
' service: \'erp\',',
|
|
954
|
+
' threshold: this.threshold,',
|
|
955
|
+
' failures: this.failures,',
|
|
956
|
+
' timestamp: new Date().toISOString(),',
|
|
957
|
+
' }),',
|
|
958
|
+
' signal: AbortSignal.timeout(5_000),',
|
|
959
|
+
' }).catch(() => { /* operational tier */ });',
|
|
960
|
+
' }',
|
|
961
|
+
' // ADR-079: Escalate circuit breaker open to Incident-Manager',
|
|
962
|
+
' const incidentUrl = process.env.AGENTICS_INCIDENT_URL;',
|
|
963
|
+
' if (incidentUrl) {',
|
|
964
|
+
' fetch(`${incidentUrl}/api/v1/escalate`, {',
|
|
965
|
+
' method: \'POST\',',
|
|
966
|
+
' headers: { \'Content-Type\': \'application/json\' },',
|
|
967
|
+
' body: JSON.stringify({',
|
|
968
|
+
' severity: \'high\',',
|
|
969
|
+
' type: \'circuit_breaker_open\',',
|
|
970
|
+
' service: \'erp\',',
|
|
971
|
+
' context: { threshold: this.threshold, failures: this.failures },',
|
|
972
|
+
' timestamp: new Date().toISOString(),',
|
|
973
|
+
' }),',
|
|
974
|
+
' signal: AbortSignal.timeout(5_000),',
|
|
975
|
+
' }).catch(() => { /* operational tier */ });',
|
|
976
|
+
' }',
|
|
782
977
|
' }',
|
|
783
978
|
' throw err;',
|
|
784
979
|
' }',
|
|
@@ -790,6 +985,62 @@ function tsRetry() {
|
|
|
790
985
|
' }',
|
|
791
986
|
'}',
|
|
792
987
|
'',
|
|
988
|
+
'// ADR-084: Token bucket rate limiter for ERP API quota protection',
|
|
989
|
+
'export class TokenBucketRateLimiter {',
|
|
990
|
+
' private tokens: number;',
|
|
991
|
+
' private lastRefill: number;',
|
|
992
|
+
'',
|
|
993
|
+
' constructor(',
|
|
994
|
+
' private readonly maxTokens: number = 10,',
|
|
995
|
+
' private readonly refillRate: number = 2, // tokens per second',
|
|
996
|
+
' ) {',
|
|
997
|
+
' this.tokens = maxTokens;',
|
|
998
|
+
' this.lastRefill = Date.now();',
|
|
999
|
+
' }',
|
|
1000
|
+
'',
|
|
1001
|
+
' async acquire(): Promise<void> {',
|
|
1002
|
+
' this.refill();',
|
|
1003
|
+
' if (this.tokens < 1) {',
|
|
1004
|
+
' const waitMs = (1 / this.refillRate) * 1000;',
|
|
1005
|
+
' await new Promise(resolve => setTimeout(resolve, waitMs));',
|
|
1006
|
+
' this.refill();',
|
|
1007
|
+
' }',
|
|
1008
|
+
' this.tokens--;',
|
|
1009
|
+
' }',
|
|
1010
|
+
'',
|
|
1011
|
+
' private refill(): void {',
|
|
1012
|
+
' const now = Date.now();',
|
|
1013
|
+
' const elapsed = (now - this.lastRefill) / 1000;',
|
|
1014
|
+
' this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);',
|
|
1015
|
+
' this.lastRefill = now;',
|
|
1016
|
+
' }',
|
|
1017
|
+
'}',
|
|
1018
|
+
'',
|
|
1019
|
+
'// ADR-084: ERP error translator — maps vendor error codes to domain exceptions',
|
|
1020
|
+
'export function translateErpError(status: number, body: string, operation: string): Error {',
|
|
1021
|
+
' try {',
|
|
1022
|
+
' const parsed = JSON.parse(body);',
|
|
1023
|
+
" const code = parsed?.error?.code ?? parsed?.code ?? '';",
|
|
1024
|
+
" const message = parsed?.error?.message ?? parsed?.message ?? body;",
|
|
1025
|
+
'',
|
|
1026
|
+
' const knownErrors: Record<string, { status: number; domain: string }> = {',
|
|
1027
|
+
" 'INVALID_SEARCH_FILTER': { status: 400, domain: 'Invalid query parameters' },",
|
|
1028
|
+
" 'INSUFFICIENT_PERMISSION': { status: 403, domain: 'ERP permission denied' },",
|
|
1029
|
+
" 'RECORD_LOCKED': { status: 409, domain: 'Record locked by another user' },",
|
|
1030
|
+
" 'RECORD_NOT_FOUND': { status: 404, domain: 'ERP record not found' },",
|
|
1031
|
+
" 'RATE_LIMIT_EXCEEDED': { status: 429, domain: 'ERP rate limit exceeded — retry later' },",
|
|
1032
|
+
" 'CONCURRENCY_VIOLATION': { status: 409, domain: 'Record modified by another user' },",
|
|
1033
|
+
" 'INVALID_KEY_OR_REF': { status: 400, domain: 'Invalid reference or foreign key' },",
|
|
1034
|
+
' };',
|
|
1035
|
+
'',
|
|
1036
|
+
' const known = knownErrors[code];',
|
|
1037
|
+
' if (known) return new Error(`[ERP-${known.status}] ${known.domain}: ${message}`);',
|
|
1038
|
+
' return new Error(`[ERP-${status}] ${operation} failed: ${message}`);',
|
|
1039
|
+
' } catch {',
|
|
1040
|
+
' return new Error(`[ERP-${status}] ${operation} failed: ${body.slice(0, 200)}`);',
|
|
1041
|
+
' }',
|
|
1042
|
+
'}',
|
|
1043
|
+
'',
|
|
793
1044
|
].join('\n');
|
|
794
1045
|
}
|
|
795
1046
|
// ============================================================================
|