@agents-at-scale/ark 0.1.38 → 0.1.40

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.
@@ -69,6 +69,15 @@ const defaultArkServices = {
69
69
  k8sDeploymentName: 'ark-controller',
70
70
  k8sDevDeploymentName: 'ark-controller-devspace',
71
71
  },
72
+ 'ark-tenant': {
73
+ name: 'ark-tenant',
74
+ helmReleaseName: 'ark-tenant',
75
+ description: 'Tenant provisioning with RBAC and resource quotas',
76
+ enabled: true,
77
+ category: 'core',
78
+ chartPath: `${REGISTRY_BASE}/ark-tenant`,
79
+ installArgs: [],
80
+ },
72
81
  'ark-api': {
73
82
  name: 'ark-api',
74
83
  helmReleaseName: 'ark-api',
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander';
2
- import output from '../../lib/output.js';
2
+ import chalk from 'chalk';
3
3
  import { executeQuery, parseTarget } from '../../lib/executeQuery.js';
4
+ import { ExitCodes } from '../../lib/errors.js';
4
5
  export function createQueryCommand(_) {
5
6
  const queryCommand = new Command('query');
6
7
  queryCommand
@@ -8,11 +9,10 @@ export function createQueryCommand(_) {
8
9
  .argument('<target>', 'Query target (e.g., model/default, agent/my-agent)')
9
10
  .argument('<message>', 'Message to send')
10
11
  .action(async (target, message) => {
11
- // Parse and validate target format
12
12
  const parsed = parseTarget(target);
13
13
  if (!parsed) {
14
- output.error('Invalid target format. Use: model/name or agent/name etc');
15
- process.exit(1);
14
+ console.error(chalk.red('Invalid target format. Use: model/name or agent/name etc'));
15
+ process.exit(ExitCodes.CliError);
16
16
  }
17
17
  await executeQuery({
18
18
  targetType: parsed.type,
@@ -15,6 +15,9 @@ jest.unstable_mockModule('../../lib/output.js', () => ({
15
15
  const mockExit = jest.spyOn(process, 'exit').mockImplementation((() => {
16
16
  throw new Error('process.exit called');
17
17
  }));
18
+ const mockConsoleError = jest
19
+ .spyOn(console, 'error')
20
+ .mockImplementation(() => { });
18
21
  const { createQueryCommand } = await import('./index.js');
19
22
  describe('createQueryCommand', () => {
20
23
  beforeEach(() => {
@@ -47,7 +50,7 @@ describe('createQueryCommand', () => {
47
50
  await expect(command.parseAsync(['node', 'test', 'invalid-target', 'Hello'])).rejects.toThrow('process.exit called');
48
51
  expect(mockParseTarget).toHaveBeenCalledWith('invalid-target');
49
52
  expect(mockExecuteQuery).not.toHaveBeenCalled();
50
- expect(mockOutput.error).toHaveBeenCalledWith('Invalid target format. Use: model/name or agent/name etc');
53
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Invalid target format'));
51
54
  expect(mockExit).toHaveBeenCalledWith(1);
52
55
  });
53
56
  });
@@ -6,6 +6,7 @@ export class ArkApiClient {
6
6
  baseURL: `${arkApiUrl}/openai/v1`,
7
7
  apiKey: 'dummy', // ark-api doesn't require an API key
8
8
  dangerouslyAllowBrowser: false,
9
+ maxRetries: 0, // Disable automatic retries for query errors
9
10
  });
10
11
  }
11
12
  getBaseUrl() {
@@ -0,0 +1 @@
1
+ export declare function parseDuration(duration: string): number;
@@ -0,0 +1,20 @@
1
+ export function parseDuration(duration) {
2
+ const match = duration.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)$/);
3
+ if (!match) {
4
+ throw new Error(`Invalid duration format: ${duration}`);
5
+ }
6
+ const value = parseFloat(match[1]);
7
+ const unit = match[2];
8
+ switch (unit) {
9
+ case 'ms':
10
+ return value;
11
+ case 's':
12
+ return value * 1000;
13
+ case 'm':
14
+ return value * 60 * 1000;
15
+ case 'h':
16
+ return value * 60 * 60 * 1000;
17
+ default:
18
+ throw new Error(`Unknown duration unit: ${unit}`);
19
+ }
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ import { parseDuration } from './duration.js';
2
+ describe('parseDuration', () => {
3
+ it('should parse durations correctly', () => {
4
+ expect(parseDuration('100ms')).toBe(100);
5
+ expect(parseDuration('30s')).toBe(30000);
6
+ expect(parseDuration('5m')).toBe(300000);
7
+ expect(parseDuration('1h')).toBe(3600000);
8
+ });
9
+ it('should throw on invalid format', () => {
10
+ expect(() => parseDuration('invalid')).toThrow('Invalid duration format');
11
+ expect(() => parseDuration('10')).toThrow('Invalid duration format');
12
+ });
13
+ });
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * Centralized error handling for ARK CLI
3
3
  */
4
+ export declare const ExitCodes: {
5
+ readonly Success: 0;
6
+ readonly CliError: 1;
7
+ readonly OperationError: 2;
8
+ readonly Timeout: 3;
9
+ };
4
10
  export declare enum ErrorCode {
5
11
  INVALID_INPUT = "INVALID_INPUT",
6
12
  FILE_NOT_FOUND = "FILE_NOT_FOUND",
@@ -3,6 +3,12 @@
3
3
  */
4
4
  import chalk from 'chalk';
5
5
  import fs from 'fs';
6
+ export const ExitCodes = {
7
+ Success: 0,
8
+ CliError: 1,
9
+ OperationError: 2,
10
+ Timeout: 3,
11
+ };
6
12
  export var ErrorCode;
7
13
  (function (ErrorCode) {
8
14
  ErrorCode["INVALID_INPUT"] = "INVALID_INPUT";
@@ -6,6 +6,8 @@ export interface QueryOptions {
6
6
  targetType: string;
7
7
  targetName: string;
8
8
  message: string;
9
+ timeout?: string;
10
+ watchTimeout?: string;
9
11
  verbose?: boolean;
10
12
  }
11
13
  /**
@@ -3,17 +3,24 @@
3
3
  */
4
4
  import { execa } from 'execa';
5
5
  import ora from 'ora';
6
+ import chalk from 'chalk';
6
7
  import output from './output.js';
8
+ import { ExitCodes } from './errors.js';
9
+ import { parseDuration } from './duration.js';
7
10
  /**
8
11
  * Execute a query against any ARK target (model, agent, team)
9
12
  * This is the shared implementation used by all query commands
10
13
  */
11
14
  export async function executeQuery(options) {
12
15
  const spinner = ora('Creating query...').start();
13
- // Generate a unique query name
16
+ const queryTimeoutMs = options.timeout
17
+ ? parseDuration(options.timeout)
18
+ : parseDuration('5m');
19
+ const watchTimeoutMs = options.watchTimeout
20
+ ? parseDuration(options.watchTimeout)
21
+ : queryTimeoutMs + 60000;
14
22
  const timestamp = Date.now();
15
23
  const queryName = `cli-query-${timestamp}`;
16
- // Create the Query resource
17
24
  const queryManifest = {
18
25
  apiVersion: 'ark.mckinsey.com/v1alpha1',
19
26
  kind: 'Query',
@@ -22,6 +29,7 @@ export async function executeQuery(options) {
22
29
  },
23
30
  spec: {
24
31
  input: options.message,
32
+ ...(options.timeout && { timeout: options.timeout }),
25
33
  targets: [
26
34
  {
27
35
  type: options.targetType,
@@ -37,86 +45,65 @@ export async function executeQuery(options) {
37
45
  input: JSON.stringify(queryManifest),
38
46
  stdio: ['pipe', 'pipe', 'pipe'],
39
47
  });
40
- // Watch for query completion
41
- spinner.text = 'Query status: initializing';
42
- let queryComplete = false;
43
- let attempts = 0;
44
- const maxAttempts = 300; // 5 minutes with 1 second intervals
45
- while (!queryComplete && attempts < maxAttempts) {
46
- attempts++;
47
- try {
48
- const { stdout } = await execa('kubectl', ['get', 'query', queryName, '-o', 'json'], { stdio: 'pipe' });
49
- const query = JSON.parse(stdout);
50
- const phase = query.status?.phase;
51
- // Update spinner with current phase
52
- if (phase) {
53
- spinner.text = `Query status: ${phase}`;
54
- }
55
- // Check if query is complete based on phase
56
- if (phase === 'done') {
57
- queryComplete = true;
58
- spinner.succeed('Query completed');
59
- // Extract and display the response from responses array
60
- if (query.status?.responses && query.status.responses.length > 0) {
61
- const response = query.status.responses[0];
62
- console.log('\n' + (response.content || response));
63
- }
64
- else {
65
- output.warning('No response received');
66
- }
67
- }
68
- else if (phase === 'error') {
69
- queryComplete = true;
70
- spinner.fail('Query failed');
71
- // Try to get error message from conditions or status
72
- const errorCondition = query.status?.conditions?.find((c) => {
73
- return c.type === 'Complete' && c.status === 'False';
74
- });
75
- if (errorCondition?.message) {
76
- output.error(errorCondition.message);
77
- }
78
- else if (query.status?.error) {
79
- output.error(query.status.error);
80
- }
81
- else {
82
- output.error('Query failed with unknown error');
83
- }
48
+ // Watch for query completion using kubectl wait
49
+ spinner.text = 'Waiting for query completion...';
50
+ try {
51
+ await execa('kubectl', [
52
+ 'wait',
53
+ '--for=condition=Completed',
54
+ `query/${queryName}`,
55
+ `--timeout=${Math.floor(watchTimeoutMs / 1000)}s`,
56
+ ], { timeout: watchTimeoutMs });
57
+ }
58
+ catch (error) {
59
+ spinner.stop();
60
+ // Check if it's a timeout or other error
61
+ if (error instanceof Error &&
62
+ error.message.includes('timed out waiting')) {
63
+ console.error(chalk.red(`Query did not complete within ${options.watchTimeout ?? `${Math.floor(watchTimeoutMs / 1000)}s`}`));
64
+ process.exit(ExitCodes.Timeout);
65
+ }
66
+ // For other errors, fetch the query to check status
67
+ }
68
+ spinner.stop();
69
+ // Fetch final query state
70
+ try {
71
+ const { stdout } = await execa('kubectl', ['get', 'query', queryName, '-o', 'json'], { stdio: 'pipe' });
72
+ const query = JSON.parse(stdout);
73
+ const phase = query.status?.phase;
74
+ // Check if query completed successfully or with error
75
+ if (phase === 'done') {
76
+ // Extract and display the response from responses array
77
+ if (query.status?.responses && query.status.responses.length > 0) {
78
+ const response = query.status.responses[0];
79
+ console.log(response.content || response);
84
80
  }
85
- else if (phase === 'canceled') {
86
- queryComplete = true;
87
- spinner.warn('Query canceled');
88
- // Try to get cancellation reason if available
89
- if (query.status?.message) {
90
- output.warning(query.status.message);
91
- }
81
+ else {
82
+ output.warning('No response received');
92
83
  }
93
84
  }
94
- catch {
95
- // Query might not exist yet, continue waiting
96
- spinner.text = 'Query status: waiting for query to be created';
85
+ else if (phase === 'error') {
86
+ const response = query.status?.responses?.[0];
87
+ console.error(chalk.red(response?.content || 'Query failed with unknown error'));
88
+ process.exit(ExitCodes.OperationError);
97
89
  }
98
- if (!queryComplete) {
99
- await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait 1 second
90
+ else if (phase === 'canceled') {
91
+ spinner.warn('Query canceled');
92
+ if (query.status?.message) {
93
+ output.warning(query.status.message);
94
+ }
95
+ process.exit(ExitCodes.OperationError);
100
96
  }
101
97
  }
102
- if (!queryComplete) {
103
- spinner.fail('Query timed out');
104
- output.error('Query did not complete within 5 minutes');
98
+ catch (error) {
99
+ console.error(chalk.red(error instanceof Error ? error.message : 'Failed to fetch query result'));
100
+ process.exit(ExitCodes.CliError);
105
101
  }
106
102
  }
107
103
  catch (error) {
108
- spinner.fail('Query failed');
109
- output.error(error instanceof Error ? error.message : 'Unknown error');
110
- process.exit(1);
111
- }
112
- finally {
113
- // Clean up the query resource
114
- try {
115
- await execa('kubectl', ['delete', 'query', queryName], { stdio: 'pipe' });
116
- }
117
- catch {
118
- // Ignore cleanup errors
119
- }
104
+ spinner.stop();
105
+ console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error'));
106
+ process.exit(ExitCodes.CliError);
120
107
  }
121
108
  }
122
109
  /**
@@ -8,6 +8,7 @@ const mockSpinner = {
8
8
  succeed: jest.fn(),
9
9
  fail: jest.fn(),
10
10
  warn: jest.fn(),
11
+ stop: jest.fn(),
11
12
  text: '',
12
13
  };
13
14
  const mockOra = jest.fn(() => mockSpinner);
@@ -25,7 +26,11 @@ const mockExit = jest.spyOn(process, 'exit').mockImplementation((() => {
25
26
  throw new Error('process.exit called');
26
27
  }));
27
28
  const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => { });
29
+ const mockConsoleError = jest
30
+ .spyOn(console, 'error')
31
+ .mockImplementation(() => { });
28
32
  const { executeQuery, parseTarget } = await import('./executeQuery.js');
33
+ const { ExitCodes } = await import('./errors.js');
29
34
  describe('executeQuery', () => {
30
35
  beforeEach(() => {
31
36
  jest.clearAllMocks();
@@ -71,9 +76,6 @@ describe('executeQuery', () => {
71
76
  exitCode: 0,
72
77
  };
73
78
  }
74
- if (args.includes('delete')) {
75
- return { stdout: '', stderr: '', exitCode: 0 };
76
- }
77
79
  return { stdout: '', stderr: '', exitCode: 0 };
78
80
  });
79
81
  await executeQuery({
@@ -82,14 +84,14 @@ describe('executeQuery', () => {
82
84
  message: 'Hello',
83
85
  });
84
86
  expect(mockSpinner.start).toHaveBeenCalled();
85
- expect(mockSpinner.succeed).toHaveBeenCalledWith('Query completed');
86
- expect(mockConsoleLog).toHaveBeenCalledWith('\nTest response');
87
+ expect(mockSpinner.stop).toHaveBeenCalled();
88
+ expect(mockConsoleLog).toHaveBeenCalledWith('Test response');
87
89
  });
88
- it('should handle query error phase', async () => {
90
+ it('should handle query error phase and exit with code 2', async () => {
89
91
  const mockQueryResponse = {
90
92
  status: {
91
93
  phase: 'error',
92
- error: 'Query failed with test error',
94
+ responses: [{ content: 'Query failed with test error' }],
93
95
  },
94
96
  };
95
97
  mockExeca.mockImplementation(async (command, args) => {
@@ -103,20 +105,23 @@ describe('executeQuery', () => {
103
105
  exitCode: 0,
104
106
  };
105
107
  }
106
- if (args.includes('delete')) {
107
- return { stdout: '', stderr: '', exitCode: 0 };
108
- }
109
108
  return { stdout: '', stderr: '', exitCode: 0 };
110
109
  });
111
- await executeQuery({
112
- targetType: 'model',
113
- targetName: 'default',
114
- message: 'Hello',
115
- });
116
- expect(mockSpinner.fail).toHaveBeenCalledWith('Query failed');
117
- expect(mockOutput.error).toHaveBeenCalledWith('Query failed with test error');
110
+ try {
111
+ await executeQuery({
112
+ targetType: 'model',
113
+ targetName: 'default',
114
+ message: 'Hello',
115
+ });
116
+ }
117
+ catch (error) {
118
+ expect(error.message).toBe('process.exit called');
119
+ }
120
+ expect(mockSpinner.stop).toHaveBeenCalled();
121
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Query failed with test error'));
122
+ expect(mockExit).toHaveBeenCalledWith(ExitCodes.OperationError);
118
123
  });
119
- it('should handle query canceled phase', async () => {
124
+ it('should handle query canceled phase and exit with code 2', async () => {
120
125
  const mockQueryResponse = {
121
126
  status: {
122
127
  phase: 'canceled',
@@ -134,27 +139,27 @@ describe('executeQuery', () => {
134
139
  exitCode: 0,
135
140
  };
136
141
  }
137
- if (args.includes('delete')) {
138
- return { stdout: '', stderr: '', exitCode: 0 };
139
- }
140
142
  return { stdout: '', stderr: '', exitCode: 0 };
141
143
  });
142
- await executeQuery({
143
- targetType: 'agent',
144
- targetName: 'test-agent',
145
- message: 'Hello',
146
- });
144
+ try {
145
+ await executeQuery({
146
+ targetType: 'agent',
147
+ targetName: 'test-agent',
148
+ message: 'Hello',
149
+ });
150
+ }
151
+ catch (error) {
152
+ expect(error.message).toBe('process.exit called');
153
+ }
147
154
  expect(mockSpinner.warn).toHaveBeenCalledWith('Query canceled');
148
155
  expect(mockOutput.warning).toHaveBeenCalledWith('Query was canceled');
156
+ expect(mockExit).toHaveBeenCalledWith(ExitCodes.OperationError);
149
157
  });
150
- it('should clean up query resource even on failure', async () => {
158
+ it('should handle kubectl apply failures with exit code 1', async () => {
151
159
  mockExeca.mockImplementation(async (command, args) => {
152
160
  if (args.includes('apply')) {
153
161
  throw new Error('Failed to apply');
154
162
  }
155
- if (args.includes('delete')) {
156
- return { stdout: '', stderr: '', exitCode: 0 };
157
- }
158
163
  return { stdout: '', stderr: '', exitCode: 0 };
159
164
  });
160
165
  await expect(executeQuery({
@@ -162,9 +167,37 @@ describe('executeQuery', () => {
162
167
  targetName: 'default',
163
168
  message: 'Hello',
164
169
  })).rejects.toThrow('process.exit called');
165
- expect(mockSpinner.fail).toHaveBeenCalledWith('Query failed');
166
- expect(mockOutput.error).toHaveBeenCalledWith('Failed to apply');
167
- expect(mockExit).toHaveBeenCalledWith(1);
170
+ expect(mockSpinner.stop).toHaveBeenCalled();
171
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Failed to apply'));
172
+ expect(mockExit).toHaveBeenCalledWith(ExitCodes.CliError);
173
+ });
174
+ it('should handle query timeout and exit with code 3', async () => {
175
+ mockExeca.mockImplementation(async (command, args) => {
176
+ if (args.includes('apply')) {
177
+ return { stdout: '', stderr: '', exitCode: 0 };
178
+ }
179
+ if (args.includes('wait')) {
180
+ // Simulate kubectl wait timeout
181
+ const error = new Error('timed out waiting for the condition');
182
+ throw error;
183
+ }
184
+ return { stdout: '', stderr: '', exitCode: 0 };
185
+ });
186
+ try {
187
+ await executeQuery({
188
+ targetType: 'model',
189
+ targetName: 'default',
190
+ message: 'Hello',
191
+ timeout: '100ms',
192
+ watchTimeout: '200ms',
193
+ });
194
+ }
195
+ catch (error) {
196
+ expect(error.message).toBe('process.exit called');
197
+ }
198
+ expect(mockSpinner.stop).toHaveBeenCalled();
199
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Query did not complete within 200ms'));
200
+ expect(mockExit).toHaveBeenCalledWith(ExitCodes.Timeout);
168
201
  });
169
202
  });
170
203
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-at-scale/ark",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "description": "Ark CLI - Interactive terminal interface for ARK agents",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -62,6 +62,7 @@ kubectl get queries
62
62
  | `image.repository` | string | `"{{ .Values.mcpServerName }}"` | Image repository |
63
63
  | `image.tag` | string | `"latest"` | Image tag |
64
64
  | `service.port` | int | `8080` | Service port |
65
+ | `mcpServer.timeout` | string | `"30s"` | Timeout for MCP tool calls (e.g., "5m", "10m") |
65
66
 
66
67
  {{- if .Values.requiresAuth }}
67
68
  | `auth.token` | string | `""` | Authentication token |
@@ -15,6 +15,9 @@ spec:
15
15
  path: {{ .Values.mcpServer.path }}
16
16
  {{- end }}
17
17
  transport: sse
18
+ {{- if .Values.mcpServer.timeout }}
19
+ timeout: {{ .Values.mcpServer.timeout | quote }}
20
+ {{- end }}
18
21
  {{- if .Values.mcpServer.description }}
19
22
  description: {{ .Values.mcpServer.description | quote }}
20
23
  {{- end }}
@@ -81,4 +81,5 @@ mcpServer:
81
81
  create: true
82
82
  name: ""
83
83
  description: "{{ .Values.description }}"
84
- path: "/mcp"
84
+ path: "/mcp"
85
+ # timeout: "30s" # Uncomment and adjust for long-running operations (e.g., "5m", "10m")