@studio-foundation/api 0.3.0-beta.1 → 0.3.0-beta.6

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 (62) hide show
  1. package/package.json +7 -4
  2. package/ARCHITECTURE.md +0 -52
  3. package/src/.gitkeep +0 -0
  4. package/src/api.ts +0 -8
  5. package/src/bootstrap.ts +0 -259
  6. package/src/event-bus.ts +0 -64
  7. package/src/index.ts +0 -58
  8. package/src/integration-runtime.ts +0 -180
  9. package/src/integration-store.ts +0 -125
  10. package/src/integrations/linear/failure-handler.ts +0 -93
  11. package/src/integrations/linear/webhook-handler.ts +0 -156
  12. package/src/integrations/registry.ts +0 -12
  13. package/src/integrations/types.ts +0 -37
  14. package/src/launcher.ts +0 -214
  15. package/src/logger.ts +0 -50
  16. package/src/plans.ts +0 -43
  17. package/src/routes/agents.ts +0 -131
  18. package/src/routes/config.ts +0 -154
  19. package/src/routes/contracts.ts +0 -205
  20. package/src/routes/pipelines.ts +0 -146
  21. package/src/routes/projects.ts +0 -237
  22. package/src/routes/runs.ts +0 -468
  23. package/src/routes/skills.ts +0 -136
  24. package/src/routes/tools.ts +0 -222
  25. package/src/routes/users.ts +0 -169
  26. package/src/routes/validate.ts +0 -196
  27. package/src/routes/webhooks.ts +0 -120
  28. package/src/server.ts +0 -155
  29. package/src/spawners/http-api-spawner.ts +0 -96
  30. package/src/user-store-pg.ts +0 -138
  31. package/src/user-store.ts +0 -125
  32. package/src/utils/repo-resolver.ts +0 -3
  33. package/src/webhook-dispatcher.ts +0 -142
  34. package/src/webhook-store.ts +0 -141
  35. package/tests/agents.test.ts +0 -164
  36. package/tests/cancel.test.ts +0 -120
  37. package/tests/config.test.ts +0 -196
  38. package/tests/contracts.test.ts +0 -302
  39. package/tests/event-bus.test.ts +0 -53
  40. package/tests/http-api-spawner.test.ts +0 -158
  41. package/tests/integration-runtime.test.ts +0 -199
  42. package/tests/integration-store.test.ts +0 -66
  43. package/tests/integrations/linear/failure-handler.test.ts +0 -113
  44. package/tests/integrations/linear/webhook-handler.test.ts +0 -191
  45. package/tests/launcher.test.ts +0 -380
  46. package/tests/linear-webhook.test.ts +0 -390
  47. package/tests/pipelines.test.ts +0 -166
  48. package/tests/projects.test.ts +0 -283
  49. package/tests/runs.test.ts +0 -379
  50. package/tests/server.test.ts +0 -208
  51. package/tests/skills.test.ts +0 -149
  52. package/tests/sse.test.ts +0 -129
  53. package/tests/tools.test.ts +0 -233
  54. package/tests/user-store.test.ts +0 -93
  55. package/tests/users.test.ts +0 -189
  56. package/tests/utils/repo-resolver.test.ts +0 -105
  57. package/tests/validate.test.ts +0 -233
  58. package/tests/webhook-dispatcher.test.ts +0 -214
  59. package/tests/webhook-store.test.ts +0 -98
  60. package/tests/webhooks.test.ts +0 -176
  61. package/tsconfig.json +0 -24
  62. package/vitest.config.ts +0 -7
@@ -1,302 +0,0 @@
1
- import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
- import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
3
- import { resolve } from 'node:path';
4
- import { buildServer } from '../src/server.js';
5
- import { InMemoryRunStore } from '@studio-foundation/engine';
6
- import { WebhookStore } from '../src/webhook-store.js';
7
- import type { IntegrationRuntime } from '../src/integration-runtime.js';
8
- import type { IntegrationStore } from '../src/integration-store.js';
9
-
10
- const TMP = resolve('/tmp', `.studio-contracts-test-${Date.now()}`);
11
- const CONTRACTS_DIR = resolve(TMP, 'contracts');
12
-
13
- const nullIntegrationRuntime = { registerRoutes: () => {} } as unknown as IntegrationRuntime;
14
- const nullIntegrationStore = {} as unknown as IntegrationStore;
15
-
16
- function makeServer() {
17
- return buildServer({
18
- store: new InMemoryRunStore(),
19
- launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
20
- configsDir: TMP,
21
- projectName: 'test-project',
22
- apiConfig: {},
23
- studioVersion: '0.0.0-test',
24
- maskedConfig: { providers: [] },
25
- webhookStore: new WebhookStore(resolve(TMP, 'runs.db')),
26
- integrationRuntime: nullIntegrationRuntime,
27
- integrationStore: nullIntegrationStore,
28
- });
29
- }
30
-
31
- beforeAll(() => {
32
- mkdirSync(CONTRACTS_DIR, { recursive: true });
33
- writeFileSync(
34
- resolve(CONTRACTS_DIR, 'brief-analysis.contract.yaml'),
35
- 'name: brief-analysis\nversion: 1\n'
36
- );
37
- writeFileSync(
38
- resolve(CONTRACTS_DIR, 'code-generation.contract.yaml'),
39
- 'name: code-generation\nversion: 1\ntool_calls:\n minimum: 1\n'
40
- );
41
- writeFileSync(resolve(CONTRACTS_DIR, 'ignored.yaml'), ''); // must be ignored
42
-
43
- writeFileSync(
44
- resolve(CONTRACTS_DIR, 'with-tool-calls.contract.yaml'),
45
- [
46
- 'name: with-tool-calls',
47
- 'version: 1',
48
- 'schema:',
49
- ' required_fields:',
50
- ' - summary',
51
- 'tool_calls:',
52
- ' minimum: 1',
53
- ].join('\n')
54
- );
55
-
56
- writeFileSync(
57
- resolve(CONTRACTS_DIR, 'with-post-validation.contract.yaml'),
58
- [
59
- 'name: with-post-validation',
60
- 'version: 1',
61
- 'schema:',
62
- ' required_fields:',
63
- ' - status',
64
- 'post_validation:',
65
- ' rejection_detection:',
66
- ' field: status',
67
- ' approved_values:',
68
- ' - approved',
69
- ' rejected_values:',
70
- ' - rejected',
71
- ].join('\n')
72
- );
73
- });
74
-
75
- afterAll(() => {
76
- rmSync(TMP, { recursive: true, force: true });
77
- });
78
-
79
- describe('GET /api/contracts', () => {
80
- it('returns only *.contract.yaml files as contract names', async () => {
81
- const server = makeServer();
82
- const res = await server.inject({ method: 'GET', url: '/api/contracts' });
83
- expect(res.statusCode).toBe(200);
84
- const { contracts } = res.json() as { contracts: string[] };
85
- expect(contracts).toContain('brief-analysis');
86
- expect(contracts).toContain('code-generation');
87
- expect(contracts).not.toContain('ignored');
88
- expect(contracts).not.toContain('brief-analysis.contract.yaml');
89
- });
90
-
91
- it('returns empty array when contracts dir is missing', async () => {
92
- const emptyDir = resolve('/tmp', `.studio-no-contracts-${Date.now()}`);
93
- mkdirSync(emptyDir, { recursive: true });
94
- const server = buildServer({
95
- store: new InMemoryRunStore(),
96
- launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
97
- configsDir: emptyDir,
98
- projectName: 'test-project',
99
- apiConfig: {},
100
- studioVersion: '0.0.0-test',
101
- maskedConfig: { providers: [] },
102
- webhookStore: new WebhookStore(resolve(emptyDir, 'runs.db')),
103
- integrationRuntime: nullIntegrationRuntime,
104
- integrationStore: nullIntegrationStore,
105
- });
106
- const res = await server.inject({ method: 'GET', url: '/api/contracts' });
107
- expect(res.statusCode).toBe(200);
108
- expect((res.json() as { contracts: string[] }).contracts).toEqual([]);
109
- });
110
- });
111
-
112
- describe('GET /api/contracts/:name', () => {
113
- it('returns parsed contract content as JSON', async () => {
114
- const server = makeServer();
115
- const res = await server.inject({ method: 'GET', url: '/api/contracts/brief-analysis' });
116
- expect(res.statusCode).toBe(200);
117
- const body = res.json() as { name: string; version: number };
118
- expect(body.name).toBe('brief-analysis');
119
- expect(body.version).toBe(1);
120
- });
121
-
122
- it('returns nested fields from YAML', async () => {
123
- const server = makeServer();
124
- const res = await server.inject({ method: 'GET', url: '/api/contracts/code-generation' });
125
- expect(res.statusCode).toBe(200);
126
- const body = res.json() as { tool_calls: { minimum: number } };
127
- expect(body.tool_calls.minimum).toBe(1);
128
- });
129
-
130
- it('returns 404 for unknown contract', async () => {
131
- const server = makeServer();
132
- const res = await server.inject({ method: 'GET', url: '/api/contracts/nonexistent' });
133
- expect(res.statusCode).toBe(404);
134
- expect((res.json() as { error: string }).error).toBe('Contract not found');
135
- });
136
- });
137
-
138
- describe('PUT /api/contracts/:name', () => {
139
- it('creates a new contract file', async () => {
140
- const server = makeServer();
141
- const res = await server.inject({
142
- method: 'PUT',
143
- url: '/api/contracts/new-contract',
144
- payload: { name: 'new-contract', version: 1, schema: { required_fields: ['summary'] } },
145
- });
146
- expect(res.statusCode).toBe(200);
147
- // Verify it can be read back
148
- const getRes = await server.inject({ method: 'GET', url: '/api/contracts/new-contract' });
149
- expect(getRes.statusCode).toBe(200);
150
- expect((getRes.json() as { version: number }).version).toBe(1);
151
- });
152
-
153
- it('updates an existing contract', async () => {
154
- const server = makeServer();
155
- const res = await server.inject({
156
- method: 'PUT',
157
- url: '/api/contracts/brief-analysis',
158
- payload: { name: 'brief-analysis', version: 2 },
159
- });
160
- expect(res.statusCode).toBe(200);
161
- // Verify version updated
162
- const getRes = await server.inject({ method: 'GET', url: '/api/contracts/brief-analysis' });
163
- expect((getRes.json() as { version: number }).version).toBe(2);
164
- });
165
-
166
- it('returns 400 when name field is missing', async () => {
167
- const server = makeServer();
168
- const res = await server.inject({
169
- method: 'PUT',
170
- url: '/api/contracts/foo',
171
- payload: { version: 1 },
172
- });
173
- expect(res.statusCode).toBe(400);
174
- });
175
-
176
- it('returns 400 when version field is missing', async () => {
177
- const server = makeServer();
178
- const res = await server.inject({
179
- method: 'PUT',
180
- url: '/api/contracts/foo',
181
- payload: { name: 'foo' },
182
- });
183
- expect(res.statusCode).toBe(400);
184
- });
185
- });
186
-
187
- describe('DELETE /api/contracts/:name', () => {
188
- it('deletes a contract and returns 204', async () => {
189
- const server = makeServer();
190
- // Create it first via PUT
191
- await server.inject({
192
- method: 'PUT',
193
- url: '/api/contracts/to-delete',
194
- payload: { name: 'to-delete', version: 1 },
195
- });
196
- const res = await server.inject({ method: 'DELETE', url: '/api/contracts/to-delete' });
197
- expect(res.statusCode).toBe(204);
198
- // Verify it's gone
199
- const getRes = await server.inject({ method: 'GET', url: '/api/contracts/to-delete' });
200
- expect(getRes.statusCode).toBe(404);
201
- });
202
-
203
- it('returns 404 for nonexistent contract', async () => {
204
- const server = makeServer();
205
- const res = await server.inject({ method: 'DELETE', url: '/api/contracts/nonexistent' });
206
- expect(res.statusCode).toBe(404);
207
- expect((res.json() as { error: string }).error).toBe('Contract not found');
208
- });
209
- });
210
-
211
- describe('POST /api/contracts/:name/validate', () => {
212
- it('returns valid: true for output that satisfies schema-only contract', async () => {
213
- const server = makeServer();
214
- const res = await server.inject({
215
- method: 'POST',
216
- url: '/api/contracts/brief-analysis/validate',
217
- payload: { output: {} },
218
- });
219
- expect(res.statusCode).toBe(200);
220
- const body = res.json() as { valid: boolean; errors: string[]; warnings: string[] };
221
- expect(body.valid).toBe(true);
222
- expect(body.errors).toEqual([]);
223
- });
224
-
225
- it('returns valid: false with error when tool_calls minimum not met', async () => {
226
- const server = makeServer();
227
- const res = await server.inject({
228
- method: 'POST',
229
- url: '/api/contracts/with-tool-calls/validate',
230
- payload: { output: { summary: 'ok' }, tool_calls: [] },
231
- });
232
- expect(res.statusCode).toBe(200);
233
- const body = res.json() as { valid: boolean; errors: string[] };
234
- expect(body.valid).toBe(false);
235
- expect(body.errors.some((e: string) => e.includes('at least 1 successful tool call'))).toBe(true);
236
- });
237
-
238
- it('returns valid: true when tool_calls requirement met', async () => {
239
- const server = makeServer();
240
- const res = await server.inject({
241
- method: 'POST',
242
- url: '/api/contracts/with-tool-calls/validate',
243
- payload: {
244
- output: { summary: 'ok' },
245
- tool_calls: [{ id: 'call-1', name: 'repo_manager-write_file', arguments: { path: 'a.ts' }, result: 'ok' }],
246
- },
247
- });
248
- expect(res.statusCode).toBe(200);
249
- const body = res.json() as { valid: boolean };
250
- expect(body.valid).toBe(true);
251
- });
252
-
253
- it('returns post_validation.accepted: false when output has rejected value', async () => {
254
- const server = makeServer();
255
- const res = await server.inject({
256
- method: 'POST',
257
- url: '/api/contracts/with-post-validation/validate',
258
- payload: { output: { status: 'rejected' } },
259
- });
260
- expect(res.statusCode).toBe(200);
261
- const body = res.json() as {
262
- valid: boolean;
263
- post_validation: { accepted: boolean; rejection_reason: string };
264
- };
265
- expect(body.valid).toBe(true);
266
- expect(body.post_validation.accepted).toBe(false);
267
- expect(body.post_validation.rejection_reason).toBeTruthy();
268
- });
269
-
270
- it('returns post_validation.accepted: true when output has approved value', async () => {
271
- const server = makeServer();
272
- const res = await server.inject({
273
- method: 'POST',
274
- url: '/api/contracts/with-post-validation/validate',
275
- payload: { output: { status: 'approved' } },
276
- });
277
- expect(res.statusCode).toBe(200);
278
- const body = res.json() as { post_validation: { accepted: boolean } };
279
- expect(body.post_validation.accepted).toBe(true);
280
- });
281
-
282
- it('returns 404 for unknown contract', async () => {
283
- const server = makeServer();
284
- const res = await server.inject({
285
- method: 'POST',
286
- url: '/api/contracts/nonexistent/validate',
287
- payload: { output: {} },
288
- });
289
- expect(res.statusCode).toBe(404);
290
- expect((res.json() as { error: string }).error).toBe('Contract not found');
291
- });
292
-
293
- it('returns 400 when output field is missing from body', async () => {
294
- const server = makeServer();
295
- const res = await server.inject({
296
- method: 'POST',
297
- url: '/api/contracts/brief-analysis/validate',
298
- payload: {},
299
- });
300
- expect(res.statusCode).toBe(400);
301
- });
302
- });
@@ -1,53 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { RunEventBus } from '../src/event-bus.js';
3
-
4
- describe('RunEventBus', () => {
5
- it('delivers events to subscribers', () => {
6
- const bus = new RunEventBus();
7
- const received: unknown[] = [];
8
- bus.subscribe('run-1', (e) => received.push(e));
9
- bus.emit('run-1', 'stage_complete', { stage: 'brief-analysis' });
10
- expect(received).toHaveLength(1);
11
- expect(received[0]).toMatchObject({ type: 'stage_complete', data: { stage: 'brief-analysis' } });
12
- });
13
-
14
- it('unsubscribe stops delivery', () => {
15
- const bus = new RunEventBus();
16
- const received: unknown[] = [];
17
- const unsub = bus.subscribe('run-1', (e) => received.push(e));
18
- unsub();
19
- bus.emit('run-1', 'stage_complete', {});
20
- expect(received).toHaveLength(0);
21
- });
22
-
23
- it('close emits done then cleans up', () => {
24
- const bus = new RunEventBus();
25
- const types: string[] = [];
26
- bus.subscribe('run-1', (e) => types.push(e.type));
27
- bus.close('run-1');
28
- expect(types).toEqual(['done']);
29
- // After close, no more events
30
- bus.emit('run-1', 'stage_complete', {});
31
- expect(types).toHaveLength(1);
32
- });
33
-
34
- it('isolates events between runs', () => {
35
- const bus = new RunEventBus();
36
- const run1: unknown[] = [];
37
- const run2: unknown[] = [];
38
- bus.subscribe('run-1', (e) => run1.push(e));
39
- bus.subscribe('run-2', (e) => run2.push(e));
40
- bus.emit('run-1', 'stage_complete', {});
41
- expect(run1).toHaveLength(1);
42
- expect(run2).toHaveLength(0);
43
- });
44
-
45
- it('supports multiple subscribers on same run', () => {
46
- const bus = new RunEventBus();
47
- let count = 0;
48
- bus.subscribe('run-1', () => count++);
49
- bus.subscribe('run-1', () => count++);
50
- bus.emit('run-1', 'pipeline_complete', {});
51
- expect(count).toBe(2);
52
- });
53
- });
@@ -1,158 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { HttpApiSpawner } from '../src/spawners/http-api-spawner.js';
3
-
4
- // Helper: create a fake SSE stream that emits events then closes
5
- function makeFakeSseResponse(events: Array<{ type: string; data: unknown }>) {
6
- const lines: string[] = [];
7
- for (const e of events) {
8
- lines.push(`event: ${e.type}`);
9
- lines.push(`data: ${JSON.stringify(e.data)}`);
10
- lines.push('');
11
- }
12
- const body = lines.join('\n');
13
- const encoder = new TextEncoder();
14
- const encoded = encoder.encode(body);
15
-
16
- const stream = new ReadableStream({
17
- start(controller) {
18
- controller.enqueue(encoded);
19
- controller.close();
20
- },
21
- });
22
-
23
- return new Response(stream, {
24
- headers: { 'content-type': 'text/event-stream' },
25
- });
26
- }
27
-
28
- describe('HttpApiSpawner', () => {
29
- let fetchMock: ReturnType<typeof vi.fn>;
30
-
31
- beforeEach(() => {
32
- fetchMock = vi.fn();
33
- vi.stubGlobal('fetch', fetchMock);
34
- });
35
-
36
- it('POSTs to /api/runs with correct headers and body', async () => {
37
- fetchMock
38
- .mockResolvedValueOnce(
39
- new Response(JSON.stringify({ run_id: 'child-1', status: 'running', stream_url: '/api/runs/child-1/stream' }), {
40
- status: 201,
41
- headers: { 'content-type': 'application/json' },
42
- })
43
- )
44
- .mockResolvedValueOnce(
45
- makeFakeSseResponse([{ type: 'pipeline_complete', data: { status: 'success', run_id: 'child-1' } }])
46
- )
47
- .mockResolvedValueOnce(
48
- new Response(
49
- JSON.stringify({
50
- id: 'child-1',
51
- pipeline_name: 'test',
52
- status: 'success',
53
- started_at: new Date().toISOString(),
54
- stages: [{ id: 's1', stage_name: 'final', status: 'success', started_at: '', tasks: [], output: { ok: true } }],
55
- }),
56
- { headers: { 'content-type': 'application/json' } }
57
- )
58
- );
59
-
60
- const spawner = new HttpApiSpawner('http://localhost:3000');
61
- await spawner.spawnAndWait({ pipeline: 'test', input: { x: 1 }, parentRunId: 'p1', depth: 1 });
62
-
63
- const postCall = fetchMock.mock.calls[0];
64
- expect(postCall[0]).toBe('http://localhost:3000/api/runs');
65
- expect(postCall[1].method).toBe('POST');
66
- expect(postCall[1].headers['X-Studio-Depth']).toBe('1');
67
- expect(postCall[1].headers['X-Studio-Parent-Run-Id']).toBe('p1');
68
- });
69
-
70
- it('returns run_id, status, and output from last stage on success', async () => {
71
- fetchMock
72
- .mockResolvedValueOnce(
73
- new Response(JSON.stringify({ run_id: 'child-2', status: 'running', stream_url: '' }), {
74
- status: 201,
75
- headers: { 'content-type': 'application/json' },
76
- })
77
- )
78
- .mockResolvedValueOnce(
79
- makeFakeSseResponse([{ type: 'pipeline_complete', data: { status: 'success', run_id: 'child-2' } }])
80
- )
81
- .mockResolvedValueOnce(
82
- new Response(
83
- JSON.stringify({
84
- id: 'child-2',
85
- pipeline_name: 'p',
86
- status: 'success',
87
- started_at: '',
88
- stages: [{ id: 's1', stage_name: 'final', status: 'success', started_at: '', tasks: [], output: { recipe: 'pasta' } }],
89
- }),
90
- { headers: { 'content-type': 'application/json' } }
91
- )
92
- );
93
-
94
- const spawner = new HttpApiSpawner('http://localhost:3000');
95
- const result = await spawner.spawnAndWait({ pipeline: 'p', input: {}, parentRunId: 'x', depth: 1 });
96
-
97
- expect(result.run_id).toBe('child-2');
98
- expect(result.status).toBe('success');
99
- expect(result.output).toEqual({ recipe: 'pasta' });
100
- });
101
-
102
- it('sends Authorization header on all requests when apiKey is configured', async () => {
103
- fetchMock
104
- .mockResolvedValueOnce(
105
- new Response(JSON.stringify({ run_id: 'child-auth', status: 'running', stream_url: '' }), {
106
- status: 201,
107
- headers: { 'content-type': 'application/json' },
108
- })
109
- )
110
- .mockResolvedValueOnce(
111
- makeFakeSseResponse([{ type: 'pipeline_complete', data: { status: 'success', run_id: 'child-auth' } }])
112
- )
113
- .mockResolvedValueOnce(
114
- new Response(
115
- JSON.stringify({
116
- id: 'child-auth',
117
- pipeline_name: 'p',
118
- status: 'success',
119
- started_at: '',
120
- stages: [{ id: 's1', stage_name: 'final', status: 'success', started_at: '', tasks: [], output: {} }],
121
- }),
122
- { headers: { 'content-type': 'application/json' } }
123
- )
124
- );
125
-
126
- const spawner = new HttpApiSpawner('http://localhost:3000', 'my-secret-key');
127
- await spawner.spawnAndWait({ pipeline: 'p', input: {}, parentRunId: 'x', depth: 1 });
128
-
129
- // All 3 fetch calls (POST /runs, GET /runs/stream, GET /runs/:id) must include the auth header
130
- for (const call of fetchMock.mock.calls) {
131
- expect(call[1]?.headers?.['Authorization']).toBe('Bearer my-secret-key');
132
- }
133
- });
134
-
135
- it('throws when pipeline_complete has failed status', async () => {
136
- fetchMock
137
- .mockResolvedValueOnce(
138
- new Response(JSON.stringify({ run_id: 'child-3', status: 'running', stream_url: '' }), {
139
- status: 201,
140
- headers: { 'content-type': 'application/json' },
141
- })
142
- )
143
- .mockResolvedValueOnce(
144
- makeFakeSseResponse([{ type: 'pipeline_complete', data: { status: 'failed', run_id: 'child-3' } }])
145
- )
146
- .mockResolvedValueOnce(
147
- new Response(
148
- JSON.stringify({ id: 'child-3', pipeline_name: 'p', status: 'failed', started_at: '', stages: [] }),
149
- { headers: { 'content-type': 'application/json' } }
150
- )
151
- );
152
-
153
- const spawner = new HttpApiSpawner('http://localhost:3000');
154
- await expect(
155
- spawner.spawnAndWait({ pipeline: 'bad', input: {}, parentRunId: 'x', depth: 1 })
156
- ).rejects.toThrow('Child run child-3 failed');
157
- });
158
- });