@tradejs/app 3.1.29-beta.273 → 3.1.29-beta.276

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.273",
3
+ "version": "3.1.29-beta.276",
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.273",
63
- "@tradejs/indicators": "^3.1.29-beta.273",
64
- "@tradejs/infra": "^3.1.29-beta.273",
65
- "@tradejs/node": "^3.1.29-beta.273",
66
- "@tradejs/types": "^3.1.29-beta.273",
62
+ "@tradejs/core": "^3.1.29-beta.276",
63
+ "@tradejs/indicators": "^3.1.29-beta.276",
64
+ "@tradejs/infra": "^3.1.29-beta.276",
65
+ "@tradejs/node": "^3.1.29-beta.276",
66
+ "@tradejs/types": "^3.1.29-beta.276",
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
@@ -1,3 +1,4 @@
1
+ import { readDeploymentVerification } from './verification';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
4
  import { z } from 'zod';
@@ -6,6 +7,7 @@ import {
6
7
  cancelMcpJob,
7
8
  enqueueMcpJob,
8
9
  mcpId,
10
+ McpCursorError,
9
11
  publicMcpJob,
10
12
  readAuthorizedMcpJob,
11
13
  readMcpBacktestPage,
@@ -82,6 +84,7 @@ const runtimePage = {
82
84
  endTime: timestamp,
83
85
  strategy: identifier.optional(),
84
86
  symbol: symbol.optional(),
87
+ limit: z.number().int().min(1).max(50).default(50),
85
88
  };
86
89
 
87
90
  export const createTradejsMcpServer = (principal: McpPrincipal) => {
@@ -165,6 +168,21 @@ export const createTradejsMcpServer = (principal: McpPrincipal) => {
165
168
  structuredContent: { data: result },
166
169
  };
167
170
  } catch (error) {
171
+ if (error instanceof McpCursorError) {
172
+ const data = {
173
+ code: error.code,
174
+ message: error.message,
175
+ recovery:
176
+ error.code === 'CURSOR_EXPIRED'
177
+ ? '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.'
178
+ : 'Restore the original user, account and filters or start a new traversal.',
179
+ };
180
+ return {
181
+ isError: true,
182
+ content: [{ type: 'text' as const, text: JSON.stringify(data) }],
183
+ structuredContent: { data },
184
+ };
185
+ }
168
186
  const message = error instanceof Error ? error.message : '';
169
187
  // Filesystem, connector and database errors may contain secrets or paths.
170
188
  const safe =
@@ -258,6 +276,27 @@ export const createTradejsMcpServer = (principal: McpPrincipal) => {
258
276
  {},
259
277
  async () => readMcpDeployments(principal.userName),
260
278
  );
279
+ tool(
280
+ 'runtime_get_verification',
281
+ 'Read the checksum-verified Deploy-owned package manifest, actual image digest, deployment verification and health observation. fresh=false means the collector is stale; heartbeat is not a replacement. No job or runtime mutation is performed.',
282
+ ['runtime:read'],
283
+ { deploymentId: identifier },
284
+ async (args) => {
285
+ const deployment = await requireMcpDeployment(
286
+ principal.userName,
287
+ args.deploymentId,
288
+ );
289
+ return readDeploymentVerification(
290
+ String(process.env.PROJECT_CWD || process.cwd()),
291
+ {
292
+ userName: principal.userName,
293
+ deploymentId: deployment.id,
294
+ accountId: deployment.accountId,
295
+ deploymentCompositionId: deployment.deploymentCompositionId,
296
+ },
297
+ );
298
+ },
299
+ );
261
300
  tool(
262
301
  'runtime_list_strategies',
263
302
  'Read effective strategies, configs and immutable revisions for a deployment bound to this user account.',
@@ -268,7 +307,7 @@ export const createTradejsMcpServer = (principal: McpPrincipal) => {
268
307
  for (const kind of ['signals', 'evaluations', 'orders'] as const) {
269
308
  tool(
270
309
  `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.`,
310
+ `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
311
  ['runtime:read'],
273
312
  runtimePage,
274
313
  async (args) => {
@@ -401,6 +440,8 @@ export const createTradejsMcpServer = (principal: McpPrincipal) => {
401
440
  kind: z.enum(['runtime-evidence', 'runtime-feedback-replay']).optional(),
402
441
  offset: z.number().int().min(0).default(0),
403
442
  limit: z.number().int().min(1).max(100).default(20),
443
+ startTime: timestamp.optional(),
444
+ endTime: timestamp.optional(),
404
445
  },
405
446
  async (args) => listMcpArtifacts(principal.userName, args),
406
447
  );
@@ -414,12 +455,15 @@ export const createTradejsMcpServer = (principal: McpPrincipal) => {
414
455
  );
415
456
  tool(
416
457
  '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.',
458
+ '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
459
  ['diagnostics:read'],
419
460
  {
420
461
  artifactId,
421
462
  offset: z.number().int().min(0).default(0),
422
463
  length: z.number().int().min(1).max(48000).default(48000),
464
+ file: z
465
+ .enum(['payload', 'manifest.json', '.complete', 'replay.log'])
466
+ .default('payload'),
423
467
  },
424
468
  async (args) =>
425
469
  readMcpArtifactChunk(
@@ -427,6 +471,7 @@ export const createTradejsMcpServer = (principal: McpPrincipal) => {
427
471
  args.artifactId,
428
472
  args.offset,
429
473
  args.length,
474
+ args.file,
430
475
  ),
431
476
  );
432
477
  tool(
@@ -0,0 +1,181 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, realpath, stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ type Binding = {
6
+ userName: string;
7
+ deploymentId: string;
8
+ accountId: string;
9
+ deploymentCompositionId: string;
10
+ };
11
+ const hash = (bytes: Buffer) =>
12
+ createHash('sha256').update(bytes).digest('hex');
13
+
14
+ export const parseDeploymentVerification = (
15
+ bytes: Buffer,
16
+ pointer: { sha256: string; bytes: number },
17
+ binding: Binding,
18
+ manifestBytes: Buffer,
19
+ imageDigest: string,
20
+ now = Date.now(),
21
+ ) => {
22
+ if (bytes.length !== pointer.bytes || hash(bytes) !== pointer.sha256)
23
+ throw Error('MCP verification checksum mismatch');
24
+ const report = JSON.parse(bytes.toString('utf8'));
25
+ if (
26
+ report.schemaVersion !== 1 ||
27
+ report.reportType !== 'runtime-deployment-verification' ||
28
+ Object.entries(binding).some(([key, value]) => report[key] !== value)
29
+ )
30
+ throw Error('MCP verification binding mismatch');
31
+ if (
32
+ typeof report.ok !== 'boolean' ||
33
+ typeof report.verification?.ok !== 'boolean' ||
34
+ !Array.isArray(report.verification?.strategies) ||
35
+ !report.verification.strategies.length ||
36
+ typeof report.health?.status !== 'string' ||
37
+ report.verification.strategies.some(
38
+ (strategy: {
39
+ strategyName?: string;
40
+ strategyRevision?: string;
41
+ controlState?: string;
42
+ }) =>
43
+ !strategy.strategyName ||
44
+ !/^sr1:[a-f0-9]+$/.test(strategy.strategyRevision ?? '') ||
45
+ !['active', 'entries_paused'].includes(strategy.controlState ?? ''),
46
+ ) ||
47
+ report.ok !== (report.verification.ok && report.health.status === 'healthy')
48
+ )
49
+ throw Error('MCP verification result invalid');
50
+ const manifest = JSON.parse(manifestBytes.toString('utf8'));
51
+ if (
52
+ !/^sha256:[a-f0-9]{64}$/.test(imageDigest) ||
53
+ report.projectSha !== manifest.projectSha ||
54
+ report.imageDigest !== imageDigest ||
55
+ report.runtimePackageManifest?.sha256 !== hash(manifestBytes) ||
56
+ report.runtimePackageManifest?.bytes !== manifestBytes.length ||
57
+ JSON.stringify(report.runtimePackageManifest.manifest) !==
58
+ JSON.stringify(manifest)
59
+ )
60
+ throw Error('MCP verification image or manifest mismatch');
61
+ if (
62
+ !Number.isSafeInteger(report.observedAt) ||
63
+ report.observedAt > now + 30000 ||
64
+ (report.deploymentVerifiedAt !== null &&
65
+ (!Number.isSafeInteger(report.deploymentVerifiedAt) ||
66
+ report.deploymentVerifiedAt > report.observedAt))
67
+ )
68
+ throw Error('MCP verification timestamp invalid');
69
+ return {
70
+ report,
71
+ payload: { encoding: 'base64', data: bytes.toString('base64') },
72
+ sha256: pointer.sha256,
73
+ bytes: pointer.bytes,
74
+ observedAt: now,
75
+ fresh: now - report.observedAt <= 10 * 60000,
76
+ source: 'deploy_owned_verification_bundle',
77
+ };
78
+ };
79
+
80
+ const readVerification = async (projectRoot: string, binding: Binding) => {
81
+ const manifestBytes = await readFile(
82
+ path.join(projectRoot, 'runtime-package-manifest.json'),
83
+ );
84
+ const manifest = JSON.parse(manifestBytes.toString('utf8'));
85
+ if (
86
+ !/^[a-f0-9]{40}$/.test(manifest.projectSha) ||
87
+ !/^[\w-]+$/.test(binding.userName) ||
88
+ !/^[\w-]+$/.test(binding.deploymentId) ||
89
+ !/^dc1:[a-f0-9]+$/.test(binding.deploymentCompositionId)
90
+ )
91
+ throw Error('MCP verification identity invalid');
92
+ const root = await realpath(
93
+ path.join(projectRoot, 'data', 'runtime-verification'),
94
+ );
95
+ const dir = path.join(
96
+ root,
97
+ binding.userName,
98
+ binding.deploymentId,
99
+ manifest.projectSha,
100
+ binding.deploymentCompositionId,
101
+ );
102
+ const confined = async (name: string) => {
103
+ const resolved = await realpath(path.join(dir, name));
104
+ if (!resolved.startsWith(root + path.sep))
105
+ throw Error('MCP verification outside data root');
106
+ return resolved;
107
+ };
108
+ const pointerBytes = await readFile(await confined('latest.json'));
109
+ if (pointerBytes.length > 1024)
110
+ throw Error('MCP verification pointer too large');
111
+ const pointer = JSON.parse(pointerBytes.toString('utf8'));
112
+ if (
113
+ !/^[a-f0-9]{64}\.json$/.test(pointer.file) ||
114
+ pointer.file !== `${pointer.sha256}.json` ||
115
+ !Number.isSafeInteger(pointer.bytes) ||
116
+ pointer.bytes < 1 ||
117
+ pointer.bytes > 48000
118
+ )
119
+ throw Error('MCP verification pointer invalid');
120
+ const payloadPath = await confined(pointer.file);
121
+ if ((await stat(payloadPath)).size !== pointer.bytes)
122
+ throw Error('MCP verification size mismatch');
123
+ const bytes = await readFile(payloadPath);
124
+ const result = parseDeploymentVerification(
125
+ bytes,
126
+ pointer,
127
+ binding,
128
+ manifestBytes,
129
+ String(process.env.TRADEJS_PROJECT_IMAGE_DIGEST ?? ''),
130
+ );
131
+ const attemptPath = path.join(
132
+ root,
133
+ binding.userName,
134
+ binding.deploymentId,
135
+ 'latest-attempt.json',
136
+ );
137
+ try {
138
+ const resolved = await realpath(attemptPath);
139
+ if (
140
+ !resolved.startsWith(root + path.sep) ||
141
+ (await stat(resolved)).size > 2048
142
+ )
143
+ throw Error('MCP verification attempt invalid');
144
+ const attempt = JSON.parse(await readFile(resolved, 'utf8'));
145
+ if (
146
+ attempt.projectSha !== manifest.projectSha ||
147
+ attempt.imageDigest !== result.report.imageDigest ||
148
+ !Number.isSafeInteger(attempt.observedAt) ||
149
+ !['running', 'failed', 'succeeded'].includes(attempt.status)
150
+ )
151
+ throw Error('MCP verification attempt binding invalid');
152
+ return {
153
+ ...result,
154
+ fresh:
155
+ result.fresh &&
156
+ !(
157
+ attempt.status !== 'succeeded' &&
158
+ attempt.observedAt + 1000 >= result.report.observedAt
159
+ ),
160
+ collection: attempt,
161
+ };
162
+ } catch (error) {
163
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
164
+ return result;
165
+ }
166
+ };
167
+
168
+ export const readDeploymentVerification = async (
169
+ projectRoot: string,
170
+ binding: Binding,
171
+ ) => {
172
+ try {
173
+ return await readVerification(projectRoot, binding);
174
+ } catch (error) {
175
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT')
176
+ throw new Error(
177
+ 'MCP deployment verification is not published for the current image and composition',
178
+ );
179
+ throw error;
180
+ }
181
+ };