@tradejs/app 3.1.29-beta.272 → 3.1.29-beta.275

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tradejs/app",
3
- "version": "3.1.29-beta.272",
3
+ "version": "3.1.29-beta.275",
4
4
  "description": "Installable Next.js UI for the TradeJS TypeScript framework: dashboards, backtests, charts, and runtime data.",
5
5
  "keywords": [
6
6
  "tradejs",
@@ -59,11 +59,11 @@
59
59
  "@emotion/react": "^11.14.0",
60
60
  "@modelcontextprotocol/sdk": "^1.32.1",
61
61
  "@node-oauth/oauth2-server": "^5.3.0",
62
- "@tradejs/core": "^3.1.29-beta.272",
63
- "@tradejs/indicators": "^3.1.29-beta.272",
64
- "@tradejs/infra": "^3.1.29-beta.272",
65
- "@tradejs/node": "^3.1.29-beta.272",
66
- "@tradejs/types": "^3.1.29-beta.272",
62
+ "@tradejs/core": "^3.1.29-beta.275",
63
+ "@tradejs/indicators": "^3.1.29-beta.275",
64
+ "@tradejs/infra": "^3.1.29-beta.275",
65
+ "@tradejs/node": "^3.1.29-beta.275",
66
+ "@tradejs/types": "^3.1.29-beta.275",
67
67
  "@types/bcryptjs": "2.4.6",
68
68
  "@types/lodash": "4.17.24",
69
69
  "@types/node": "24.13.3",
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { open, readdir, readFile, realpath, stat } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import {
@@ -10,6 +11,7 @@ import {
10
11
  mcpId,
11
12
  mcpProjectRoot,
12
13
  readMcpDeployments,
14
+ redactMcpJobLog,
13
15
  } from '@tradejs/node/mcp';
14
16
 
15
17
  interface CatalogEntry {
@@ -107,6 +109,8 @@ export interface McpArtifactListOptions {
107
109
  kind?: CatalogEntry['kind'];
108
110
  offset?: number;
109
111
  limit?: number;
112
+ startTime?: number;
113
+ endTime?: number;
110
114
  }
111
115
 
112
116
  export const listMcpArtifacts = async (
@@ -123,6 +127,15 @@ export const listMcpArtifacts = async (
123
127
  limit > 100
124
128
  )
125
129
  throw new Error('Invalid artifact list pagination');
130
+ if (
131
+ (options.startTime !== undefined &&
132
+ !Number.isSafeInteger(options.startTime)) ||
133
+ (options.endTime !== undefined && !Number.isSafeInteger(options.endTime)) ||
134
+ (options.startTime !== undefined &&
135
+ options.endTime !== undefined &&
136
+ options.startTime >= options.endTime)
137
+ )
138
+ throw new Error('Invalid artifact window');
126
139
  const { deployments } = await readMcpDeployments(userName);
127
140
  const allowed = new Set(deployments.map((row) => row.id));
128
141
  const pending = [
@@ -151,6 +164,20 @@ export const listMcpArtifacts = async (
151
164
  )
152
165
  continue;
153
166
  if (options.kind && artifact.kind !== options.kind) continue;
167
+ const window = artifact.manifest.window as {
168
+ startTime: number;
169
+ endTime: number;
170
+ };
171
+ if (
172
+ options.startTime !== undefined &&
173
+ window.endTime <= options.startTime
174
+ )
175
+ continue;
176
+ if (
177
+ options.endTime !== undefined &&
178
+ window.startTime >= options.endTime
179
+ )
180
+ continue;
154
181
  await mcpStorage.put(storageKey(userName, artifact.id), artifact, 3600);
155
182
  items.push({
156
183
  id: artifact.id,
@@ -158,7 +185,10 @@ export const listMcpArtifacts = async (
158
185
  deploymentId: artifact.deploymentId,
159
186
  sha256: artifact.sha256,
160
187
  bytes: artifact.bytes,
161
- window: artifact.manifest.window,
188
+ window,
189
+ artifactId: artifact.manifest.artifactId,
190
+ deploymentCompositionId: artifact.manifest.deploymentCompositionId,
191
+ runtimeEvidence: artifact.manifest.runtimeEvidence,
162
192
  });
163
193
  } catch {
164
194
  invalidBundles++;
@@ -173,7 +203,11 @@ export const listMcpArtifacts = async (
173
203
  });
174
204
  }
175
205
  }
176
- items.sort((left, right) => left.id.localeCompare(right.id));
206
+ items.sort(
207
+ (left, right) =>
208
+ right.window.endTime - left.window.endTime ||
209
+ left.id.localeCompare(right.id),
210
+ );
177
211
  const page = items.slice(offset, offset + limit);
178
212
  const nextOffset =
179
213
  offset + page.length < items.length ? offset + page.length : null;
@@ -186,7 +220,12 @@ export const listMcpArtifacts = async (
186
220
  truncated: pending.length > 0 || nextOffset != null,
187
221
  scanTruncated: pending.length > 0,
188
222
  invalidOrInaccessibleBundles: invalidBundles,
189
- note: 'Only verified, user-owned bundles available on this host are included.',
223
+ order: 'window.endTime_desc_id_asc',
224
+ coverage: {
225
+ retentionStartTime: null,
226
+ completeness: 'host_local_verified_bundles_only',
227
+ },
228
+ note: 'Only verified, user-owned bundles available on this host are included. Excluded bundle counts combine inaccessible and invalid data; reasons and paths are withheld to protect other users. Missing bundles do not prove no activity.',
190
229
  };
191
230
  };
192
231
 
@@ -205,28 +244,84 @@ export const resolveMcpArtifact = async (userName: string, id: string) => {
205
244
  return verified;
206
245
  };
207
246
 
247
+ type ArtifactFile = 'payload' | 'manifest.json' | '.complete' | 'replay.log';
248
+
249
+ const artifactFile = async (artifact: CatalogEntry, file: ArtifactFile) => {
250
+ const target =
251
+ file === 'payload'
252
+ ? artifact.payloadPath
253
+ : path.join(artifact.bundleDir, file);
254
+ if (
255
+ !['payload', 'manifest.json', '.complete', 'replay.log'].includes(file) ||
256
+ (file === 'replay.log' && artifact.kind !== 'runtime-feedback-replay')
257
+ )
258
+ throw new Error('Invalid artifact file');
259
+ const resolved = await checkPayload(target);
260
+ if (file === 'payload')
261
+ return {
262
+ path: resolved,
263
+ file,
264
+ bytes: artifact.bytes,
265
+ sha256: artifact.sha256,
266
+ };
267
+ const bytes = await readFile(resolved);
268
+ // Log files are original bundle bytes. Refuse export if credential redaction
269
+ // would be required; never silently change bytes advertised for verification.
270
+ if (
271
+ file === 'replay.log' &&
272
+ bytes
273
+ .toString('utf8')
274
+ .split('\n')
275
+ .some(
276
+ (line) =>
277
+ (line.length <= 1000 && redactMcpJobLog(line) !== line) ||
278
+ line.length > 1000,
279
+ )
280
+ )
281
+ throw new Error(
282
+ 'Invalid artifact file: log requires redaction; use job_get_logs',
283
+ );
284
+ return {
285
+ path: resolved,
286
+ file,
287
+ bytes: bytes.length,
288
+ sha256: createHash('sha256').update(bytes).digest('hex'),
289
+ };
290
+ };
291
+
208
292
  export const readMcpArtifactChunk = async (
209
293
  userName: string,
210
294
  id: string,
211
295
  offset: number,
212
296
  length: number,
297
+ file: ArtifactFile = 'payload',
213
298
  ) => {
299
+ if (
300
+ !Number.isSafeInteger(offset) ||
301
+ offset < 0 ||
302
+ !Number.isSafeInteger(length) ||
303
+ length < 1
304
+ )
305
+ throw new Error('Invalid artifact chunk range');
214
306
  const artifact = await resolveMcpArtifact(userName, id);
215
- const handle = await open(await confinedPath(artifact.payloadPath), 'r');
307
+ const selected = await artifactFile(artifact, file);
308
+ if (offset > selected.bytes) throw new Error('Invalid artifact chunk offset');
309
+ const handle = await open(selected.path, 'r');
216
310
  try {
217
311
  const buffer = Buffer.alloc(
218
- Math.min(length, Math.max(0, artifact.bytes - offset)),
312
+ Math.min(length, Math.max(0, selected.bytes - offset)),
219
313
  );
220
314
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
221
315
  return {
222
316
  id,
223
317
  offset,
224
- bytes: artifact.bytes,
225
- sha256: artifact.sha256,
318
+ file,
319
+ bytes: selected.bytes,
320
+ sha256: selected.sha256,
226
321
  encoding: 'base64',
227
322
  data: buffer.subarray(0, bytesRead).toString('base64'),
228
323
  nextOffset:
229
- offset + bytesRead < artifact.bytes ? offset + bytesRead : null,
324
+ offset + bytesRead < selected.bytes ? offset + bytesRead : null,
230
325
  };
231
326
  } finally {
232
327
  await handle.close();
@@ -236,10 +331,29 @@ export const readMcpArtifactChunk = async (
236
331
  export const readMcpDiagnosticReport = async (userName: string, id: string) => {
237
332
  const artifact = await resolveMcpArtifact(userName, id);
238
333
  const payload = JSON.parse(await readFile(artifact.payloadPath, 'utf8'));
334
+ const files = [];
335
+ for (const file of [
336
+ 'payload',
337
+ 'manifest.json',
338
+ '.complete',
339
+ ...(artifact.kind === 'runtime-feedback-replay' ? ['replay.log'] : []),
340
+ ] as ArtifactFile[]) {
341
+ try {
342
+ const { path: _path, ...descriptor } = await artifactFile(artifact, file);
343
+ files.push({ ...descriptor, available: true });
344
+ } catch {
345
+ files.push({
346
+ file,
347
+ available: false,
348
+ reason: 'file_unavailable_or_requires_redaction',
349
+ });
350
+ }
351
+ }
239
352
  // Large reports stay available through artifact_get with original checksums.
240
353
  return {
241
354
  id,
242
355
  manifest: artifact.manifest,
356
+ files,
243
357
  bytes: artifact.bytes,
244
358
  sha256: artifact.sha256,
245
359
  ...(artifact.bytes <= 96000
@@ -6,6 +6,7 @@ import {
6
6
  cancelMcpJob,
7
7
  enqueueMcpJob,
8
8
  mcpId,
9
+ McpCursorError,
9
10
  publicMcpJob,
10
11
  readAuthorizedMcpJob,
11
12
  readMcpBacktestPage,
@@ -82,6 +83,7 @@ const runtimePage = {
82
83
  endTime: timestamp,
83
84
  strategy: identifier.optional(),
84
85
  symbol: symbol.optional(),
86
+ limit: z.number().int().min(1).max(50).default(50),
85
87
  };
86
88
 
87
89
  export const createTradejsMcpServer = (principal: McpPrincipal) => {
@@ -165,6 +167,21 @@ export const createTradejsMcpServer = (principal: McpPrincipal) => {
165
167
  structuredContent: { data: result },
166
168
  };
167
169
  } catch (error) {
170
+ if (error instanceof McpCursorError) {
171
+ const data = {
172
+ code: error.code,
173
+ message: error.message,
174
+ recovery:
175
+ error.code === 'CURSOR_EXPIRED'
176
+ ? 'Restart cursor=0 with the same window and filters; deduplicate record identities. Do not move startTime to the last record: storage order is not chronological.'
177
+ : 'Restore the original user, account and filters or start a new traversal.',
178
+ };
179
+ return {
180
+ isError: true,
181
+ content: [{ type: 'text' as const, text: JSON.stringify(data) }],
182
+ structuredContent: { data },
183
+ };
184
+ }
168
185
  const message = error instanceof Error ? error.message : '';
169
186
  // Filesystem, connector and database errors may contain secrets or paths.
170
187
  const safe =
@@ -268,7 +285,7 @@ export const createTradejsMcpServer = (principal: McpPrincipal) => {
268
285
  for (const kind of ['signals', 'evaluations', 'orders'] as const) {
269
286
  tool(
270
287
  `runtime_list_${kind}`,
271
- `Read a bounded storage page of ${kind}. Window is half-open [startTime,endTime), at most 7 days. Continue even after an empty page until cursor=0.`,
288
+ `Read a bounded storage page of ${kind}. Window is half-open [startTime,endTime), at most 7 days. Continue even after an empty page until cursor=0. Shared cursors last 24 hours; retry the identical request after transport errors. Storage order is not chronological; CURSOR_EXPIRED requires restarting the same window and deduplicating identities.`,
272
289
  ['runtime:read'],
273
290
  runtimePage,
274
291
  async (args) => {
@@ -401,6 +418,8 @@ export const createTradejsMcpServer = (principal: McpPrincipal) => {
401
418
  kind: z.enum(['runtime-evidence', 'runtime-feedback-replay']).optional(),
402
419
  offset: z.number().int().min(0).default(0),
403
420
  limit: z.number().int().min(1).max(100).default(20),
421
+ startTime: timestamp.optional(),
422
+ endTime: timestamp.optional(),
404
423
  },
405
424
  async (args) => listMcpArtifacts(principal.userName, args),
406
425
  );
@@ -414,12 +433,15 @@ export const createTradejsMcpServer = (principal: McpPrincipal) => {
414
433
  );
415
434
  tool(
416
435
  'artifact_get',
417
- 'Read an owned verified artifact in base64 chunks. Concatenate decoded bytes, verify total size and SHA-256 before local analysis. No filesystem path is accepted.',
436
+ 'Read an owned verified artifact in base64 chunks. Concatenate decoded bytes, verify total size and SHA-256 before local analysis. Select a file listed by diagnostics_get_report (payload by default). Each file has its own size and SHA-256. No filesystem path is accepted.',
418
437
  ['diagnostics:read'],
419
438
  {
420
439
  artifactId,
421
440
  offset: z.number().int().min(0).default(0),
422
441
  length: z.number().int().min(1).max(48000).default(48000),
442
+ file: z
443
+ .enum(['payload', 'manifest.json', '.complete', 'replay.log'])
444
+ .default('payload'),
423
445
  },
424
446
  async (args) =>
425
447
  readMcpArtifactChunk(
@@ -427,6 +449,7 @@ export const createTradejsMcpServer = (principal: McpPrincipal) => {
427
449
  args.artifactId,
428
450
  args.offset,
429
451
  args.length,
452
+ args.file,
430
453
  ),
431
454
  );
432
455
  tool(
@@ -14,7 +14,6 @@ export const HOURS_OPTIONS = [
14
14
  { label: 'Last 90d', value: '2160' },
15
15
  ];
16
16
 
17
- export const INTERVAL_OPTIONS = ['15m', '1h'] as const;
18
17
  export const FIXED_SYMBOLS = ['BTCUSDT', 'ETHUSDT'] as const;
19
18
 
20
19
  const DERIVATIVES_TO_KLINE_INTERVAL = {
@@ -1,21 +1,13 @@
1
1
  'use client';
2
2
 
3
3
  import { Box, ClientOnly, Flex, Heading } from '@chakra-ui/react';
4
- import { Segment, Select } from '#ui';
5
- import { HOURS_OPTIONS, INTERVAL_OPTIONS } from './derivativesDashboardConfig';
4
+ import { Select } from '#ui';
5
+ import { HOURS_OPTIONS } from './derivativesDashboardConfig';
6
6
  import { DerivativesDashboardView } from './DerivativesDashboardView';
7
- import type { DerivativesInterval } from './derivativesViewModel';
8
7
  import { useDerivativesDashboard } from './useDerivativesDashboard';
9
8
 
10
9
  const DerivativesPage = () => {
11
- const {
12
- hours,
13
- setHours,
14
- selectedInterval,
15
- setSelectedInterval,
16
- chartWindow,
17
- dashboard,
18
- } = useDerivativesDashboard();
10
+ const { hours, setHours, chartWindow, dashboard } = useDerivativesDashboard();
19
11
 
20
12
  return (
21
13
  <ClientOnly>
@@ -38,19 +30,6 @@ const DerivativesPage = () => {
38
30
  items={HOURS_OPTIONS}
39
31
  width="180px"
40
32
  />
41
- <Segment
42
- defaultValue={selectedInterval}
43
- value={selectedInterval}
44
- onChange={(value) =>
45
- setSelectedInterval(
46
- (value as DerivativesInterval | null) ?? '1h',
47
- )
48
- }
49
- items={INTERVAL_OPTIONS.map((value) => ({
50
- label: value,
51
- value,
52
- }))}
53
- />
54
33
  </Flex>
55
34
  </Flex>
56
35
  <DerivativesDashboardView
@@ -13,10 +13,10 @@ import {
13
13
  type SummaryResponse,
14
14
  } from './derivativesViewModel';
15
15
 
16
+ const selectedInterval: DerivativesInterval = '15m';
17
+
16
18
  export const useDerivativesDashboard = () => {
17
19
  const [hours, setHours] = useState('24');
18
- const [selectedInterval, setSelectedInterval] =
19
- useState<DerivativesInterval>('1h');
20
20
  const [summary, setSummary] = useState<SummaryResponse | null>(null);
21
21
  const [detailsBySymbol, setDetailsBySymbol] = useState<
22
22
  Record<string, DetailRow[]>
@@ -69,7 +69,7 @@ export const useDerivativesDashboard = () => {
69
69
  return () => {
70
70
  if (requestRevision.current === revision) requestRevision.current += 1;
71
71
  };
72
- }, [hours, selectedInterval]);
72
+ }, [hours]);
73
73
 
74
74
  const dashboard = useMemo(
75
75
  () =>
@@ -84,21 +84,12 @@ export const useDerivativesDashboard = () => {
84
84
  summaryError: error,
85
85
  detailError: error,
86
86
  }),
87
- [
88
- detailsBySymbol,
89
- error,
90
- loading,
91
- pricesBySymbol,
92
- selectedInterval,
93
- summary,
94
- ],
87
+ [detailsBySymbol, error, loading, pricesBySymbol, summary],
95
88
  );
96
89
 
97
90
  return {
98
91
  hours,
99
92
  setHours,
100
- selectedInterval,
101
- setSelectedInterval,
102
93
  chartWindow,
103
94
  dashboard,
104
95
  };