crewly 1.11.6 → 1.12.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.
Files changed (142) hide show
  1. package/config/skills/agent/onboarding/synthesize-hierarchy/SKILL.md +65 -0
  2. package/config/skills/agent/onboarding/synthesize-hierarchy/execute.sh +61 -0
  3. package/config/skills/agent/web-search/SKILL.md +70 -0
  4. package/config/skills/agent/web-search/execute.sh +170 -0
  5. package/config/skills/agent/web-search/skill.json +23 -0
  6. package/dist/backend/backend/src/constants.d.ts +12 -0
  7. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  8. package/dist/backend/backend/src/constants.js +12 -0
  9. package/dist/backend/backend/src/constants.js.map +1 -1
  10. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts +22 -0
  11. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
  12. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +58 -0
  13. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
  14. package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts.map +1 -1
  15. package/dist/backend/backend/src/controllers/cloud/cloud.routes.js +3 -1
  16. package/dist/backend/backend/src/controllers/cloud/cloud.routes.js.map +1 -1
  17. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.d.ts +27 -0
  18. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.d.ts.map +1 -1
  19. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.js +108 -0
  20. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.js.map +1 -1
  21. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.d.ts +6 -2
  22. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.d.ts.map +1 -1
  23. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.js +9 -3
  24. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.js.map +1 -1
  25. package/dist/backend/backend/src/index.d.ts.map +1 -1
  26. package/dist/backend/backend/src/index.js +36 -2
  27. package/dist/backend/backend/src/index.js.map +1 -1
  28. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts +18 -0
  29. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
  30. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +24 -2
  31. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
  32. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts +102 -0
  33. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -0
  34. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +164 -0
  35. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -0
  36. package/dist/backend/backend/src/services/fission/fission-guard.service.d.ts +21 -0
  37. package/dist/backend/backend/src/services/fission/fission-guard.service.d.ts.map +1 -1
  38. package/dist/backend/backend/src/services/fission/fission-guard.service.js +30 -0
  39. package/dist/backend/backend/src/services/fission/fission-guard.service.js.map +1 -1
  40. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.d.ts +4 -0
  41. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.d.ts.map +1 -1
  42. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.js +8 -0
  43. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.js.map +1 -1
  44. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.d.ts +79 -58
  45. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.d.ts.map +1 -1
  46. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js +140 -65
  47. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js.map +1 -1
  48. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.d.ts +117 -0
  49. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.d.ts.map +1 -0
  50. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.js +189 -0
  51. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.js.map +1 -0
  52. package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.d.ts.map +1 -1
  53. package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.js +1 -0
  54. package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.js.map +1 -1
  55. package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.d.ts.map +1 -1
  56. package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.js +2 -0
  57. package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.js.map +1 -1
  58. package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.d.ts.map +1 -1
  59. package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.js +17 -1
  60. package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.js.map +1 -1
  61. package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts +50 -0
  62. package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts.map +1 -1
  63. package/dist/backend/backend/src/services/reconciler/reconcile-rules.js +71 -0
  64. package/dist/backend/backend/src/services/reconciler/reconcile-rules.js.map +1 -1
  65. package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts +18 -0
  66. package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts.map +1 -1
  67. package/dist/backend/backend/src/services/reconciler/reconciler.service.js +75 -1
  68. package/dist/backend/backend/src/services/reconciler/reconciler.service.js.map +1 -1
  69. package/dist/backend/backend/src/services/session/pty/pty-session-backend.d.ts +115 -0
  70. package/dist/backend/backend/src/services/session/pty/pty-session-backend.d.ts.map +1 -1
  71. package/dist/backend/backend/src/services/session/pty/pty-session-backend.js +189 -3
  72. package/dist/backend/backend/src/services/session/pty/pty-session-backend.js.map +1 -1
  73. package/dist/backend/backend/src/services/session/pty/pty-session.d.ts +28 -0
  74. package/dist/backend/backend/src/services/session/pty/pty-session.d.ts.map +1 -1
  75. package/dist/backend/backend/src/services/session/pty/pty-session.js +61 -1
  76. package/dist/backend/backend/src/services/session/pty/pty-session.js.map +1 -1
  77. package/dist/backend/backend/src/services/template/template.service.d.ts.map +1 -1
  78. package/dist/backend/backend/src/services/template/template.service.js +67 -2
  79. package/dist/backend/backend/src/services/template/template.service.js.map +1 -1
  80. package/dist/backend/backend/src/services/v3/cascade-request-status.d.ts +19 -1
  81. package/dist/backend/backend/src/services/v3/cascade-request-status.d.ts.map +1 -1
  82. package/dist/backend/backend/src/services/v3/cascade-request-status.js +39 -2
  83. package/dist/backend/backend/src/services/v3/cascade-request-status.js.map +1 -1
  84. package/dist/backend/backend/src/services/v3/escalation-router.service.d.ts +41 -0
  85. package/dist/backend/backend/src/services/v3/escalation-router.service.d.ts.map +1 -1
  86. package/dist/backend/backend/src/services/v3/escalation-router.service.js +169 -0
  87. package/dist/backend/backend/src/services/v3/escalation-router.service.js.map +1 -1
  88. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.d.ts +4 -1
  89. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.d.ts.map +1 -1
  90. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.js +21 -0
  91. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.js.map +1 -1
  92. package/dist/backend/backend/src/types/intent-task.types.d.ts.map +1 -1
  93. package/dist/backend/backend/src/types/intent-task.types.js +8 -0
  94. package/dist/backend/backend/src/types/intent-task.types.js.map +1 -1
  95. package/dist/backend/backend/src/types/v2/request.types.d.ts +1 -1
  96. package/dist/backend/backend/src/types/v2/request.types.d.ts.map +1 -1
  97. package/dist/backend/backend/src/types/v2/request.types.js +1 -0
  98. package/dist/backend/backend/src/types/v2/request.types.js.map +1 -1
  99. package/dist/cli/backend/src/constants.d.ts +12 -0
  100. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  101. package/dist/cli/backend/src/constants.js +12 -0
  102. package/dist/cli/backend/src/constants.js.map +1 -1
  103. package/package.json +9 -3
  104. package/packages/crewly-agent/README.md +27 -0
  105. package/packages/crewly-agent/bin/crewly-agent +33 -0
  106. package/packages/crewly-agent/package.json +39 -0
  107. package/packages/crewly-agent/src/cli.ts +168 -0
  108. package/packages/crewly-agent/src/runtime/agent-runner.service.test.ts +2355 -0
  109. package/packages/crewly-agent/src/runtime/agent-runner.service.ts +1827 -0
  110. package/packages/crewly-agent/src/runtime/agent-stream.service.test.ts +153 -0
  111. package/packages/crewly-agent/src/runtime/agent-stream.service.ts +225 -0
  112. package/packages/crewly-agent/src/runtime/agent-worker.test.ts +171 -0
  113. package/packages/crewly-agent/src/runtime/agent-worker.ts +193 -0
  114. package/packages/crewly-agent/src/runtime/api-client.ts +143 -0
  115. package/packages/crewly-agent/src/runtime/approval-queue.service.ts +307 -0
  116. package/packages/crewly-agent/src/runtime/audit-log.service.test.ts +208 -0
  117. package/packages/crewly-agent/src/runtime/audit-log.service.ts +332 -0
  118. package/packages/crewly-agent/src/runtime/audit-trail.service.test.ts +178 -0
  119. package/packages/crewly-agent/src/runtime/audit-trail.service.ts +151 -0
  120. package/packages/crewly-agent/src/runtime/auditor-tools.test.ts +274 -0
  121. package/packages/crewly-agent/src/runtime/auditor-tools.ts +311 -0
  122. package/packages/crewly-agent/src/runtime/cloud-config.ts +67 -0
  123. package/packages/crewly-agent/src/runtime/deepseek-sse-transform.test.ts +165 -0
  124. package/packages/crewly-agent/src/runtime/deepseek-sse-transform.ts +168 -0
  125. package/packages/crewly-agent/src/runtime/env-isolation.service.ts +246 -0
  126. package/packages/crewly-agent/src/runtime/in-process-log-buffer.test.ts +280 -0
  127. package/packages/crewly-agent/src/runtime/in-process-log-buffer.ts +317 -0
  128. package/packages/crewly-agent/src/runtime/index.ts +38 -0
  129. package/packages/crewly-agent/src/runtime/mcp-tool-bridge.test.ts +352 -0
  130. package/packages/crewly-agent/src/runtime/mcp-tool-bridge.ts +244 -0
  131. package/packages/crewly-agent/src/runtime/model-manager.test.ts +326 -0
  132. package/packages/crewly-agent/src/runtime/model-manager.ts +363 -0
  133. package/packages/crewly-agent/src/runtime/output-filter.service.ts +175 -0
  134. package/packages/crewly-agent/src/runtime/prompt-guard.service.ts +303 -0
  135. package/packages/crewly-agent/src/runtime/rate-limiter.test.ts +228 -0
  136. package/packages/crewly-agent/src/runtime/rate-limiter.ts +353 -0
  137. package/packages/crewly-agent/src/runtime/tool-registry.test.ts +2510 -0
  138. package/packages/crewly-agent/src/runtime/tool-registry.ts +2104 -0
  139. package/packages/crewly-agent/src/runtime/types.test.ts +519 -0
  140. package/packages/crewly-agent/src/runtime/types.ts +637 -0
  141. package/packages/crewly-agent/src/runtime/web-search.tool.test.ts +131 -0
  142. package/packages/crewly-agent/src/runtime/web-search.tool.ts +140 -0
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Agent Worker Process
3
+ *
4
+ * Standalone Node.js child process that wraps AgentRunnerService for
5
+ * isolated agent execution. Communicates with the parent process via
6
+ * IPC messages (Node.js child_process.fork() protocol).
7
+ *
8
+ * This enables hot-reload of agent code without restarting the main
9
+ * backend process, and crash isolation so a worker failure doesn't
10
+ * bring down the server.
11
+ *
12
+ * @module services/agent/crewly-agent/agent-worker
13
+ */
14
+
15
+ import { AgentRunnerService } from './agent-runner.service.js';
16
+ import type {
17
+ CrewlyAgentConfig,
18
+ AgentRunResult,
19
+ StreamingEventCallbacks,
20
+ } from './types.js';
21
+
22
+ // ===== IPC Message Types =====
23
+
24
+ /**
25
+ * Messages sent from the parent process to this worker.
26
+ */
27
+ export type ParentMessage =
28
+ | { type: 'init'; config: CrewlyAgentConfig }
29
+ | { type: 'run'; message: string; conversationId?: string; metadata?: Record<string, string> }
30
+ | { type: 'abort' }
31
+ | { type: 'get-state' }
32
+ | { type: 'shutdown' };
33
+
34
+ /**
35
+ * Messages sent from this worker to the parent process.
36
+ */
37
+ export type WorkerMessage =
38
+ | { type: 'ready' }
39
+ | { type: 'result'; data: AgentRunResult }
40
+ | { type: 'error'; error: string; code?: string }
41
+ | { type: 'log'; level: 'debug' | 'info' | 'warn' | 'error'; message: string }
42
+ | { type: 'stream'; event: 'text'; data: { chunk: string } }
43
+ | { type: 'stream'; event: 'toolStart'; data: { toolName: string; args: Record<string, unknown> } }
44
+ | { type: 'stream'; event: 'toolFinish'; data: { toolName: string; args: Record<string, unknown>; result: unknown; durationMs: number } }
45
+ | { type: 'stream'; event: 'stepFinish'; data: { stepIndex: number; hasToolCalls: boolean } }
46
+ | { type: 'state'; data: { historyLength: number; isProcessing: boolean; isInitialized: boolean } };
47
+
48
+ // ===== Worker State =====
49
+
50
+ let runner: AgentRunnerService | null = null;
51
+
52
+ /**
53
+ * Send a typed message to the parent process.
54
+ *
55
+ * @param msg - Worker message to send
56
+ */
57
+ function send(msg: WorkerMessage): void {
58
+ if (process.send) {
59
+ process.send(msg);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Build streaming callbacks that forward events to the parent via IPC.
65
+ *
66
+ * @returns StreamingEventCallbacks that emit IPC messages
67
+ */
68
+ function buildStreamingCallbacks(): StreamingEventCallbacks {
69
+ return {
70
+ onTextChunk: (chunk: string) => {
71
+ send({ type: 'stream', event: 'text', data: { chunk } });
72
+ },
73
+ onToolCallStart: (toolName: string, args: Record<string, unknown>) => {
74
+ send({ type: 'stream', event: 'toolStart', data: { toolName, args } });
75
+ },
76
+ onToolCallFinish: (toolName: string, args: Record<string, unknown>, result: unknown, durationMs: number) => {
77
+ send({ type: 'stream', event: 'toolFinish', data: { toolName, args, result, durationMs } });
78
+ },
79
+ onStepFinish: (stepIndex: number, hasToolCalls: boolean) => {
80
+ send({ type: 'stream', event: 'stepFinish', data: { stepIndex, hasToolCalls } });
81
+ },
82
+ };
83
+ }
84
+
85
+ /** AbortController for the currently executing run */
86
+ let currentAbort: AbortController | null = null;
87
+
88
+ /**
89
+ * Handle incoming messages from the parent process.
90
+ *
91
+ * @param msg - Parent message received via IPC
92
+ */
93
+ async function handleMessage(msg: ParentMessage): Promise<void> {
94
+ switch (msg.type) {
95
+ case 'init': {
96
+ try {
97
+ runner = new AgentRunnerService(msg.config);
98
+ await runner.initialize();
99
+ send({ type: 'ready' });
100
+ send({ type: 'log', level: 'info', message: `Worker initialized (${msg.config.model.provider}/${msg.config.model.modelId})` });
101
+ } catch (err) {
102
+ const errMsg = err instanceof Error ? err.message : String(err);
103
+ send({ type: 'error', error: `Init failed: ${errMsg}`, code: 'INIT_FAILED' });
104
+ runner = null;
105
+ }
106
+ break;
107
+ }
108
+
109
+ case 'run': {
110
+ if (!runner || !runner.isInitialized()) {
111
+ send({ type: 'error', error: 'Worker not initialized', code: 'NOT_INITIALIZED' });
112
+ return;
113
+ }
114
+
115
+ currentAbort = new AbortController();
116
+ const streamingCallbacks = buildStreamingCallbacks();
117
+
118
+ try {
119
+ const result = await runner.run(msg.message, msg.conversationId, msg.metadata, {
120
+ abortSignal: currentAbort.signal,
121
+ streaming: streamingCallbacks,
122
+ });
123
+ currentAbort = null;
124
+ send({ type: 'result', data: result });
125
+ } catch (err) {
126
+ currentAbort = null;
127
+ const errMsg = err instanceof Error ? err.message : String(err);
128
+ send({ type: 'error', error: errMsg, code: 'RUN_FAILED' });
129
+ }
130
+ break;
131
+ }
132
+
133
+ case 'abort': {
134
+ if (currentAbort) {
135
+ currentAbort.abort();
136
+ currentAbort = null;
137
+ send({ type: 'log', level: 'warn', message: 'Run aborted by parent' });
138
+ }
139
+ if (runner) {
140
+ runner.abortCurrentRun();
141
+ }
142
+ break;
143
+ }
144
+
145
+ case 'get-state': {
146
+ send({
147
+ type: 'state',
148
+ data: {
149
+ historyLength: runner ? runner.getHistoryLength() : 0,
150
+ isProcessing: runner ? runner.isProcessing() : false,
151
+ isInitialized: runner ? runner.isInitialized() : false,
152
+ },
153
+ });
154
+ break;
155
+ }
156
+
157
+ case 'shutdown': {
158
+ send({ type: 'log', level: 'info', message: 'Worker shutting down' });
159
+ if (runner) {
160
+ await runner.shutdown();
161
+ runner = null;
162
+ }
163
+ // Give IPC a moment to flush, then exit
164
+ setTimeout(() => process.exit(0), 100);
165
+ break;
166
+ }
167
+ }
168
+ }
169
+
170
+ // ===== Process Setup =====
171
+
172
+ // Listen for IPC messages from parent
173
+ process.on('message', (msg: ParentMessage) => {
174
+ handleMessage(msg).catch((err) => {
175
+ const errMsg = err instanceof Error ? err.message : String(err);
176
+ send({ type: 'error', error: `Unhandled worker error: ${errMsg}`, code: 'UNHANDLED' });
177
+ });
178
+ });
179
+
180
+ // Handle uncaught exceptions — report to parent before crashing
181
+ process.on('uncaughtException', (err) => {
182
+ send({ type: 'error', error: `Worker crash (uncaughtException): ${err.message}`, code: 'CRASH' });
183
+ setTimeout(() => process.exit(1), 100);
184
+ });
185
+
186
+ process.on('unhandledRejection', (reason) => {
187
+ const msg = reason instanceof Error ? reason.message : String(reason);
188
+ send({ type: 'error', error: `Worker crash (unhandledRejection): ${msg}`, code: 'CRASH' });
189
+ setTimeout(() => process.exit(1), 100);
190
+ });
191
+
192
+ // Signal ready to receive init
193
+ send({ type: 'log', level: 'info', message: 'Agent worker process started, awaiting init' });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Crewly Agent API Client
3
+ *
4
+ * Lightweight HTTP client for calling the Crewly backend REST API.
5
+ * Replaces the bash curl wrapper used by orchestrator skill scripts
6
+ * with direct fetch() calls for in-process tool execution.
7
+ *
8
+ * @module services/agent/crewly-agent/api-client
9
+ */
10
+
11
+ import { CREWLY_AGENT_DEFAULTS, type ApiCallResult } from './types.js';
12
+
13
+ /**
14
+ * HTTP client for the Crewly backend REST API.
15
+ *
16
+ * Each tool in the ToolRegistry uses this client to make API calls
17
+ * instead of shelling out to bash scripts.
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * const client = new CrewlyApiClient('http://localhost:8787', 'crewly-orc');
22
+ * const result = await client.get('/teams');
23
+ * const result2 = await client.post('/terminal/agent-sam/deliver', { message: 'Hello' });
24
+ * ```
25
+ */
26
+ export class CrewlyApiClient {
27
+ private baseUrl: string;
28
+ private sessionName: string;
29
+ private timeoutMs: number;
30
+
31
+ /**
32
+ * Create a new API client instance.
33
+ *
34
+ * @param baseUrl - Base URL for the Crewly API (e.g., 'http://localhost:8787')
35
+ * @param sessionName - Agent session name for the X-Agent-Session header
36
+ * @param timeoutMs - Request timeout in milliseconds
37
+ */
38
+ constructor(
39
+ baseUrl: string = CREWLY_AGENT_DEFAULTS.API_BASE_URL,
40
+ sessionName: string = 'crewly-orc',
41
+ timeoutMs: number = CREWLY_AGENT_DEFAULTS.API_TIMEOUT_MS,
42
+ ) {
43
+ this.baseUrl = baseUrl.replace(/\/$/, '');
44
+ this.sessionName = sessionName;
45
+ this.timeoutMs = timeoutMs;
46
+ }
47
+
48
+ /**
49
+ * Make a GET request to the Crewly API.
50
+ *
51
+ * @param endpoint - API endpoint path (e.g., '/teams')
52
+ * @returns API call result with parsed JSON data
53
+ */
54
+ async get<T = unknown>(endpoint: string): Promise<ApiCallResult<T>> {
55
+ return this.request<T>('GET', endpoint);
56
+ }
57
+
58
+ /**
59
+ * Make a POST request to the Crewly API.
60
+ *
61
+ * @param endpoint - API endpoint path (e.g., '/terminal/agent-sam/deliver')
62
+ * @param body - Request body to send as JSON
63
+ * @returns API call result with parsed JSON data
64
+ */
65
+ async post<T = unknown>(endpoint: string, body: unknown): Promise<ApiCallResult<T>> {
66
+ return this.request<T>('POST', endpoint, body);
67
+ }
68
+
69
+ /**
70
+ * Make a DELETE request to the Crewly API.
71
+ *
72
+ * @param endpoint - API endpoint path (e.g., '/schedule/check-123')
73
+ * @returns API call result with parsed JSON data
74
+ */
75
+ async delete<T = unknown>(endpoint: string): Promise<ApiCallResult<T>> {
76
+ return this.request<T>('DELETE', endpoint);
77
+ }
78
+
79
+ /**
80
+ * Internal request method handling fetch, timeout, and error mapping.
81
+ *
82
+ * @param method - HTTP method
83
+ * @param endpoint - API endpoint path
84
+ * @param body - Optional request body
85
+ * @returns Parsed API call result
86
+ */
87
+ private async request<T>(method: string, endpoint: string, body?: unknown): Promise<ApiCallResult<T>> {
88
+ const url = `${this.baseUrl}/api${endpoint}`;
89
+ const controller = new AbortController();
90
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
91
+
92
+ try {
93
+ const headers: Record<string, string> = {
94
+ 'Content-Type': 'application/json',
95
+ };
96
+ if (this.sessionName) {
97
+ headers['X-Agent-Session'] = this.sessionName;
98
+ }
99
+
100
+ const init: RequestInit = {
101
+ method,
102
+ headers,
103
+ signal: controller.signal,
104
+ };
105
+ if (body !== undefined) {
106
+ init.body = JSON.stringify(body);
107
+ }
108
+
109
+ const response = await fetch(url, init);
110
+ const responseBody = await response.text();
111
+
112
+ let data: T | undefined;
113
+ try {
114
+ const parsed = JSON.parse(responseBody);
115
+ data = parsed.data !== undefined ? parsed.data : parsed;
116
+ } catch {
117
+ data = responseBody as unknown as T;
118
+ }
119
+
120
+ if (response.ok) {
121
+ return { success: true, data, status: response.status };
122
+ }
123
+ return {
124
+ success: false,
125
+ error: typeof data === 'object' && data !== null && 'error' in data
126
+ ? String((data as Record<string, unknown>).error)
127
+ : `HTTP ${response.status}`,
128
+ status: response.status,
129
+ };
130
+ } catch (error) {
131
+ if (error instanceof Error && error.name === 'AbortError') {
132
+ return { success: false, error: `Request timeout after ${this.timeoutMs}ms`, status: 0 };
133
+ }
134
+ return {
135
+ success: false,
136
+ error: error instanceof Error ? error.message : String(error),
137
+ status: 0,
138
+ };
139
+ } finally {
140
+ clearTimeout(timeout);
141
+ }
142
+ }
143
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Approval Queue Service
3
+ *
4
+ * Manages pending tool approval requests for the Crewly Agent runtime.
5
+ * When a tool requires approval (based on SecurityPolicy.requireApproval),
6
+ * execution is blocked and a PendingApproval entry is created. External
7
+ * callers (API, auditor) can then approve or reject the request.
8
+ *
9
+ * @module services/agent/crewly-agent/approval-queue.service
10
+ */
11
+
12
+ import type { ToolSensitivity } from './types.js';
13
+
14
+ /** Maximum number of pending approvals before oldest are auto-rejected */
15
+ const MAX_PENDING_APPROVALS = 100;
16
+
17
+ /** Default TTL for pending approvals in milliseconds (10 minutes) */
18
+ const DEFAULT_APPROVAL_TTL_MS = 10 * 60 * 1000;
19
+
20
+ /**
21
+ * Status of an approval request.
22
+ */
23
+ export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'expired';
24
+
25
+ /**
26
+ * A single pending approval entry.
27
+ */
28
+ export interface PendingApproval {
29
+ /** Unique approval ID */
30
+ id: string;
31
+ /** Agent session that requested the tool call */
32
+ sessionName: string;
33
+ /** Name of the tool awaiting approval */
34
+ toolName: string;
35
+ /** Sensitivity classification of the tool */
36
+ sensitivity: ToolSensitivity;
37
+ /** Sanitized arguments passed to the tool */
38
+ args: Record<string, unknown>;
39
+ /** Current status */
40
+ status: ApprovalStatus;
41
+ /** ISO timestamp when the approval was requested */
42
+ requestedAt: string;
43
+ /** ISO timestamp when the approval was resolved (approved/rejected/expired) */
44
+ resolvedAt?: string;
45
+ /** Who resolved the approval (e.g. 'api', 'auditor', 'auto-expire') */
46
+ resolvedBy?: string;
47
+ /** Reason for rejection (if rejected) */
48
+ reason?: string;
49
+ }
50
+
51
+ /**
52
+ * Result of resolving (approve/reject) an approval request.
53
+ */
54
+ export interface ApprovalResolution {
55
+ /** Whether the resolution was successful */
56
+ success: boolean;
57
+ /** The resolved approval entry */
58
+ approval?: PendingApproval;
59
+ /** Error message if resolution failed */
60
+ error?: string;
61
+ }
62
+
63
+ /**
64
+ * In-memory approval queue for tool execution requests.
65
+ *
66
+ * Stores pending approvals and provides approve/reject operations.
67
+ * Approvals that exceed the TTL are automatically marked as expired
68
+ * when queried.
69
+ *
70
+ * @example
71
+ * ```typescript
72
+ * const queue = new ApprovalQueueService();
73
+ * const approval = queue.enqueue('session-1', 'edit_file', 'destructive', { path: '/foo' });
74
+ * // Later, approve it:
75
+ * const result = queue.approve(approval.id, 'api');
76
+ * ```
77
+ */
78
+ export class ApprovalQueueService {
79
+ private static instance: ApprovalQueueService | null = null;
80
+ private approvals: Map<string, PendingApproval> = new Map();
81
+ private idCounter = 0;
82
+ private ttlMs: number;
83
+
84
+ /**
85
+ * Get the shared singleton instance.
86
+ * All agent-runners and API controllers share the same queue.
87
+ *
88
+ * @returns The shared ApprovalQueueService instance
89
+ */
90
+ static getInstance(): ApprovalQueueService {
91
+ if (!ApprovalQueueService.instance) {
92
+ ApprovalQueueService.instance = new ApprovalQueueService();
93
+ }
94
+ return ApprovalQueueService.instance;
95
+ }
96
+
97
+ /**
98
+ * Reset the singleton instance (for testing only).
99
+ */
100
+ static resetInstance(): void {
101
+ ApprovalQueueService.instance = null;
102
+ }
103
+
104
+ /**
105
+ * Create a new ApprovalQueueService.
106
+ *
107
+ * @param ttlMs - Time-to-live for pending approvals in milliseconds
108
+ */
109
+ constructor(ttlMs: number = DEFAULT_APPROVAL_TTL_MS) {
110
+ this.ttlMs = ttlMs;
111
+ }
112
+
113
+ /**
114
+ * Add a new approval request to the queue.
115
+ *
116
+ * @param sessionName - Agent session requesting approval
117
+ * @param toolName - Name of the tool
118
+ * @param sensitivity - Sensitivity classification
119
+ * @param args - Sanitized tool arguments
120
+ * @returns The created PendingApproval entry
121
+ */
122
+ enqueue(
123
+ sessionName: string,
124
+ toolName: string,
125
+ sensitivity: ToolSensitivity,
126
+ args: Record<string, unknown>,
127
+ ): PendingApproval {
128
+ this.expireStale();
129
+
130
+ // Enforce max pending limit — reject oldest if full
131
+ if (this.getPendingCount() >= MAX_PENDING_APPROVALS) {
132
+ const oldest = this.getOldestPending();
133
+ if (oldest) {
134
+ oldest.status = 'rejected';
135
+ oldest.resolvedAt = new Date().toISOString();
136
+ oldest.resolvedBy = 'auto-overflow';
137
+ oldest.reason = 'Approval queue overflow — auto-rejected oldest entry';
138
+ }
139
+ }
140
+
141
+ this.idCounter++;
142
+ const id = `approval-${this.idCounter}-${Date.now()}`;
143
+
144
+ const approval: PendingApproval = {
145
+ id,
146
+ sessionName,
147
+ toolName,
148
+ sensitivity,
149
+ args,
150
+ status: 'pending',
151
+ requestedAt: new Date().toISOString(),
152
+ };
153
+
154
+ this.approvals.set(id, approval);
155
+ return approval;
156
+ }
157
+
158
+ /**
159
+ * Approve a pending approval request.
160
+ *
161
+ * @param id - Approval ID to approve
162
+ * @param resolvedBy - Who is approving (e.g. 'api', 'auditor')
163
+ * @returns Resolution result
164
+ */
165
+ approve(id: string, resolvedBy: string = 'api'): ApprovalResolution {
166
+ return this.resolve(id, 'approved', resolvedBy);
167
+ }
168
+
169
+ /**
170
+ * Reject a pending approval request.
171
+ *
172
+ * @param id - Approval ID to reject
173
+ * @param resolvedBy - Who is rejecting
174
+ * @param reason - Reason for rejection
175
+ * @returns Resolution result
176
+ */
177
+ reject(id: string, resolvedBy: string = 'api', reason?: string): ApprovalResolution {
178
+ return this.resolve(id, 'rejected', resolvedBy, reason);
179
+ }
180
+
181
+ /**
182
+ * Get all pending approval requests.
183
+ *
184
+ * @param sessionName - Optional filter by session name
185
+ * @returns Array of pending approvals
186
+ */
187
+ getPending(sessionName?: string): PendingApproval[] {
188
+ this.expireStale();
189
+ const pending: PendingApproval[] = [];
190
+ for (const approval of this.approvals.values()) {
191
+ if (approval.status !== 'pending') continue;
192
+ if (sessionName && approval.sessionName !== sessionName) continue;
193
+ pending.push({ ...approval });
194
+ }
195
+ return pending;
196
+ }
197
+
198
+ /**
199
+ * Get a specific approval by ID.
200
+ *
201
+ * @param id - Approval ID
202
+ * @returns The approval entry or undefined
203
+ */
204
+ getById(id: string): PendingApproval | undefined {
205
+ const approval = this.approvals.get(id);
206
+ if (!approval) return undefined;
207
+ return { ...approval };
208
+ }
209
+
210
+ /**
211
+ * Get the count of currently pending approvals.
212
+ *
213
+ * @returns Number of pending approvals
214
+ */
215
+ getPendingCount(): number {
216
+ let count = 0;
217
+ for (const approval of this.approvals.values()) {
218
+ if (approval.status === 'pending') count++;
219
+ }
220
+ return count;
221
+ }
222
+
223
+ /**
224
+ * Clear all approvals (for testing or reset).
225
+ */
226
+ clear(): void {
227
+ this.approvals.clear();
228
+ this.idCounter = 0;
229
+ }
230
+
231
+ /**
232
+ * Resolve a pending approval with the given status.
233
+ *
234
+ * @param id - Approval ID
235
+ * @param status - New status
236
+ * @param resolvedBy - Who resolved it
237
+ * @param reason - Optional reason (for rejections)
238
+ * @returns Resolution result
239
+ */
240
+ private resolve(
241
+ id: string,
242
+ status: 'approved' | 'rejected',
243
+ resolvedBy: string,
244
+ reason?: string,
245
+ ): ApprovalResolution {
246
+ const approval = this.approvals.get(id);
247
+ if (!approval) {
248
+ return { success: false, error: `Approval '${id}' not found` };
249
+ }
250
+ if (approval.status !== 'pending') {
251
+ return {
252
+ success: false,
253
+ error: `Approval '${id}' is already ${approval.status}`,
254
+ approval: { ...approval },
255
+ };
256
+ }
257
+
258
+ approval.status = status;
259
+ approval.resolvedAt = new Date().toISOString();
260
+ approval.resolvedBy = resolvedBy;
261
+ if (reason) approval.reason = reason;
262
+
263
+ return { success: true, approval: { ...approval } };
264
+ }
265
+
266
+ /**
267
+ * Expire stale pending approvals that have exceeded the TTL.
268
+ */
269
+ private expireStale(): void {
270
+ const now = Date.now();
271
+ const resolvedRetentionMs = this.ttlMs * 3; // Keep resolved entries 3x TTL then prune
272
+
273
+ for (const [id, approval] of this.approvals.entries()) {
274
+ if (approval.status === 'pending') {
275
+ const requestedAt = new Date(approval.requestedAt).getTime();
276
+ if (now - requestedAt > this.ttlMs) {
277
+ approval.status = 'expired';
278
+ approval.resolvedAt = new Date().toISOString();
279
+ approval.resolvedBy = 'auto-expire';
280
+ approval.reason = 'Approval request expired';
281
+ }
282
+ } else if (approval.resolvedAt) {
283
+ // Prune resolved entries that are older than retention window
284
+ const resolvedAt = new Date(approval.resolvedAt).getTime();
285
+ if (now - resolvedAt > resolvedRetentionMs) {
286
+ this.approvals.delete(id);
287
+ }
288
+ }
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Get the oldest pending approval.
294
+ *
295
+ * @returns Oldest pending approval or undefined
296
+ */
297
+ private getOldestPending(): PendingApproval | undefined {
298
+ let oldest: PendingApproval | undefined;
299
+ for (const approval of this.approvals.values()) {
300
+ if (approval.status !== 'pending') continue;
301
+ if (!oldest || approval.requestedAt < oldest.requestedAt) {
302
+ oldest = approval;
303
+ }
304
+ }
305
+ return oldest;
306
+ }
307
+ }