@cephalization/phoenix-insight 0.1.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 (54) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +620 -0
  3. package/dist/agent/index.js +230 -0
  4. package/dist/cli.js +640 -0
  5. package/dist/commands/index.js +2 -0
  6. package/dist/commands/px-fetch-more-spans.js +98 -0
  7. package/dist/commands/px-fetch-more-trace.js +110 -0
  8. package/dist/config/index.js +165 -0
  9. package/dist/config/loader.js +141 -0
  10. package/dist/config/schema.js +53 -0
  11. package/dist/index.js +1 -0
  12. package/dist/modes/index.js +17 -0
  13. package/dist/modes/local.js +134 -0
  14. package/dist/modes/sandbox.js +121 -0
  15. package/dist/modes/types.js +1 -0
  16. package/dist/observability/index.js +65 -0
  17. package/dist/progress.js +209 -0
  18. package/dist/prompts/index.js +1 -0
  19. package/dist/prompts/system.js +30 -0
  20. package/dist/snapshot/client.js +74 -0
  21. package/dist/snapshot/context.js +332 -0
  22. package/dist/snapshot/datasets.js +68 -0
  23. package/dist/snapshot/experiments.js +135 -0
  24. package/dist/snapshot/index.js +262 -0
  25. package/dist/snapshot/projects.js +44 -0
  26. package/dist/snapshot/prompts.js +199 -0
  27. package/dist/snapshot/spans.js +80 -0
  28. package/dist/tsconfig.esm.tsbuildinfo +1 -0
  29. package/package.json +75 -0
  30. package/src/agent/index.ts +323 -0
  31. package/src/cli.ts +782 -0
  32. package/src/commands/index.ts +8 -0
  33. package/src/commands/px-fetch-more-spans.ts +174 -0
  34. package/src/commands/px-fetch-more-trace.ts +183 -0
  35. package/src/config/index.ts +225 -0
  36. package/src/config/loader.ts +173 -0
  37. package/src/config/schema.ts +66 -0
  38. package/src/index.ts +1 -0
  39. package/src/modes/index.ts +21 -0
  40. package/src/modes/local.ts +163 -0
  41. package/src/modes/sandbox.ts +144 -0
  42. package/src/modes/types.ts +31 -0
  43. package/src/observability/index.ts +90 -0
  44. package/src/progress.ts +239 -0
  45. package/src/prompts/index.ts +1 -0
  46. package/src/prompts/system.ts +31 -0
  47. package/src/snapshot/client.ts +129 -0
  48. package/src/snapshot/context.ts +462 -0
  49. package/src/snapshot/datasets.ts +132 -0
  50. package/src/snapshot/experiments.ts +246 -0
  51. package/src/snapshot/index.ts +403 -0
  52. package/src/snapshot/projects.ts +58 -0
  53. package/src/snapshot/prompts.ts +267 -0
  54. package/src/snapshot/spans.ts +142 -0
@@ -0,0 +1,246 @@
1
+ import type { PhoenixClient } from "@arizeai/phoenix-client";
2
+ import type { ExecutionMode } from "../modes/types.js";
3
+ import { withErrorHandling, extractData } from "./client.js";
4
+
5
+ interface Dataset {
6
+ id: string;
7
+ name: string;
8
+ }
9
+
10
+ interface Experiment {
11
+ id: string;
12
+ dataset_id: string;
13
+ dataset_version_id: string;
14
+ repetitions: number;
15
+ metadata: Record<string, unknown>;
16
+ project_name: string | null;
17
+ created_at: string;
18
+ updated_at: string;
19
+ example_count: number;
20
+ successful_run_count: number;
21
+ failed_run_count: number;
22
+ missing_run_count: number;
23
+ }
24
+
25
+ interface ExperimentRun {
26
+ id: string;
27
+ experiment_id: string;
28
+ dataset_example_id: string;
29
+ start_time: string;
30
+ end_time: string;
31
+ output: unknown;
32
+ error?: string | null;
33
+ trace_id?: string | null;
34
+ repetition_number?: number;
35
+ }
36
+
37
+ interface FetchExperimentsOptions {
38
+ /**
39
+ * Maximum number of experiments to fetch per dataset
40
+ */
41
+ limit?: number;
42
+ /**
43
+ * Include experiment runs in the snapshot
44
+ */
45
+ includeRuns?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Converts an array to JSONL format
50
+ */
51
+ function toJSONL(items: unknown[]): string {
52
+ if (items.length === 0) {
53
+ return "";
54
+ }
55
+ return items.map((item) => JSON.stringify(item)).join("\n");
56
+ }
57
+
58
+ /**
59
+ * Fetches all experiments and their runs from Phoenix
60
+ * Note: Experiments are fetched per dataset since there's no direct "all experiments" endpoint
61
+ */
62
+ export async function fetchExperiments(
63
+ client: PhoenixClient,
64
+ mode: ExecutionMode,
65
+ options: FetchExperimentsOptions = {}
66
+ ): Promise<void> {
67
+ const { limit = 100, includeRuns = true } = options;
68
+
69
+ // First, we need to get all datasets to fetch their experiments
70
+ const datasetsResponse = await withErrorHandling(
71
+ () => client.GET("/v1/datasets", { params: { query: { limit: 1000 } } }),
72
+ "fetching datasets for experiments"
73
+ );
74
+
75
+ const datasetsData = extractData(datasetsResponse);
76
+ const datasets: Dataset[] = datasetsData.data;
77
+
78
+ // Collect all experiments from all datasets
79
+ const allExperiments: Array<Experiment & { datasetName: string }> = [];
80
+
81
+ for (const dataset of datasets) {
82
+ try {
83
+ // Fetch experiments for this dataset with pagination
84
+ const experiments: Experiment[] = [];
85
+ let cursor: string | null = null;
86
+
87
+ do {
88
+ const response = await withErrorHandling(
89
+ () =>
90
+ client.GET("/v1/datasets/{dataset_id}/experiments", {
91
+ params: {
92
+ path: {
93
+ dataset_id: dataset.id,
94
+ },
95
+ query: {
96
+ cursor,
97
+ limit: 50,
98
+ },
99
+ },
100
+ }),
101
+ `fetching experiments for dataset ${dataset.name}`
102
+ );
103
+
104
+ const data = extractData(response);
105
+ experiments.push(...(data.data || []));
106
+ cursor = data.next_cursor || null;
107
+
108
+ // Stop if we've reached the overall limit
109
+ if (allExperiments.length + experiments.length >= limit) {
110
+ const remaining = limit - allExperiments.length;
111
+ experiments.splice(remaining);
112
+ cursor = null;
113
+ }
114
+ } while (cursor != null);
115
+
116
+ // Add dataset name to each experiment for context
117
+ const experimentsWithDatasetName = experiments.map((exp) => ({
118
+ ...exp,
119
+ datasetName: dataset.name,
120
+ }));
121
+
122
+ allExperiments.push(...experimentsWithDatasetName);
123
+
124
+ // Apply limit if specified
125
+ if (allExperiments.length >= limit) {
126
+ break;
127
+ }
128
+ } catch (error) {
129
+ // If fetching experiments for a dataset fails, log and continue
130
+ console.warn(
131
+ `Failed to fetch experiments for dataset ${dataset.name}:`,
132
+ error
133
+ );
134
+ }
135
+ }
136
+
137
+ // Write experiments index
138
+ await mode.writeFile(
139
+ "/phoenix/experiments/index.jsonl",
140
+ toJSONL(allExperiments)
141
+ );
142
+
143
+ // Fetch runs for each experiment if requested
144
+ if (includeRuns) {
145
+ for (const experiment of allExperiments) {
146
+ try {
147
+ // Write experiment metadata
148
+ await mode.writeFile(
149
+ `/phoenix/experiments/${experiment.id}/metadata.json`,
150
+ JSON.stringify(
151
+ {
152
+ id: experiment.id,
153
+ dataset_id: experiment.dataset_id,
154
+ dataset_name: experiment.datasetName,
155
+ dataset_version_id: experiment.dataset_version_id,
156
+ repetitions: experiment.repetitions,
157
+ metadata: experiment.metadata,
158
+ project_name: experiment.project_name,
159
+ created_at: experiment.created_at,
160
+ updated_at: experiment.updated_at,
161
+ example_count: experiment.example_count,
162
+ successful_run_count: experiment.successful_run_count,
163
+ failed_run_count: experiment.failed_run_count,
164
+ missing_run_count: experiment.missing_run_count,
165
+ snapshot_timestamp: new Date().toISOString(),
166
+ },
167
+ null,
168
+ 2
169
+ )
170
+ );
171
+
172
+ // Fetch runs for this experiment with pagination
173
+ const runs: ExperimentRun[] = [];
174
+ let cursor: string | null = null;
175
+
176
+ do {
177
+ const runsResponse = await withErrorHandling(
178
+ () =>
179
+ client.GET("/v1/experiments/{experiment_id}/runs", {
180
+ params: {
181
+ path: {
182
+ experiment_id: experiment.id,
183
+ },
184
+ query: {
185
+ cursor,
186
+ limit: 100,
187
+ },
188
+ },
189
+ }),
190
+ `fetching runs for experiment ${experiment.id}`
191
+ );
192
+
193
+ const runsData = extractData(runsResponse);
194
+ runs.push(...(runsData.data || []));
195
+ cursor = runsData.next_cursor || null;
196
+ } while (cursor != null);
197
+
198
+ // Write runs as JSONL
199
+ await mode.writeFile(
200
+ `/phoenix/experiments/${experiment.id}/runs.jsonl`,
201
+ toJSONL(runs)
202
+ );
203
+
204
+ // Write experiment summary with run stats
205
+ await mode.writeFile(
206
+ `/phoenix/experiments/${experiment.id}/summary.json`,
207
+ JSON.stringify(
208
+ {
209
+ experiment_id: experiment.id,
210
+ dataset_name: experiment.datasetName,
211
+ project_name: experiment.project_name,
212
+ total_runs: runs.length,
213
+ successful_runs: experiment.successful_run_count,
214
+ failed_runs: experiment.failed_run_count,
215
+ missing_runs: experiment.missing_run_count,
216
+ created_at: experiment.created_at,
217
+ updated_at: experiment.updated_at,
218
+ },
219
+ null,
220
+ 2
221
+ )
222
+ );
223
+ } catch (error) {
224
+ // If fetching runs for an experiment fails, log and continue
225
+ console.warn(
226
+ `Failed to fetch runs for experiment ${experiment.id}:`,
227
+ error
228
+ );
229
+
230
+ // Still create the experiment metadata without runs
231
+ await mode.writeFile(
232
+ `/phoenix/experiments/${experiment.id}/metadata.json`,
233
+ JSON.stringify(
234
+ {
235
+ ...experiment,
236
+ error: "Failed to fetch runs",
237
+ snapshot_timestamp: new Date().toISOString(),
238
+ },
239
+ null,
240
+ 2
241
+ )
242
+ );
243
+ }
244
+ }
245
+ }
246
+ }
@@ -0,0 +1,403 @@
1
+ // Export all snapshot modules
2
+ export {
3
+ createPhoenixClient,
4
+ PhoenixClientError,
5
+ type PhoenixClientConfig,
6
+ } from "./client.js";
7
+ export { fetchProjects } from "./projects.js";
8
+ export { snapshotSpans, type SnapshotSpansOptions } from "./spans.js";
9
+ export { fetchDatasets } from "./datasets.js";
10
+ export { fetchExperiments } from "./experiments.js";
11
+ export { fetchPrompts } from "./prompts.js";
12
+ export { generateContext } from "./context.js";
13
+
14
+ // Import necessary types and modules for orchestration
15
+ import type { ExecutionMode } from "../modes/types.js";
16
+ import type { PhoenixClient } from "@arizeai/phoenix-client";
17
+ import {
18
+ createPhoenixClient,
19
+ PhoenixClientError,
20
+ type PhoenixClientConfig,
21
+ } from "./client.js";
22
+ import { fetchProjects } from "./projects.js";
23
+ import { snapshotSpans, type SnapshotSpansOptions } from "./spans.js";
24
+ import { fetchDatasets } from "./datasets.js";
25
+ import { fetchExperiments } from "./experiments.js";
26
+ import { fetchPrompts } from "./prompts.js";
27
+ import { generateContext } from "./context.js";
28
+ import { SnapshotProgress } from "../progress.js";
29
+
30
+ export interface SnapshotOptions {
31
+ /**
32
+ * Phoenix server base URL
33
+ */
34
+ baseURL: string;
35
+ /**
36
+ * Optional API key for authentication
37
+ */
38
+ apiKey?: string;
39
+ /**
40
+ * Maximum number of spans per project
41
+ */
42
+ spansPerProject?: number;
43
+ /**
44
+ * Time range filter for spans (ISO 8601 format)
45
+ */
46
+ startTime?: string;
47
+ endTime?: string;
48
+ /**
49
+ * Whether to show progress indicators
50
+ */
51
+ showProgress?: boolean;
52
+ }
53
+
54
+ export interface SnapshotMetadata {
55
+ created_at: string;
56
+ phoenix_url: string;
57
+ cursors: {
58
+ spans?: Record<string, { last_end_time?: string; cursor?: string }>;
59
+ datasets?: { last_fetch: string };
60
+ experiments?: { last_fetch: string };
61
+ prompts?: { last_fetch: string };
62
+ };
63
+ limits: {
64
+ spans_per_project: number;
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Orchestrates all data fetchers to create a complete Phoenix snapshot
70
+ * @param mode - The execution mode (sandbox or local)
71
+ * @param options - Snapshot options including server URL and limits
72
+ */
73
+ export async function createSnapshot(
74
+ mode: ExecutionMode,
75
+ options: SnapshotOptions
76
+ ): Promise<void> {
77
+ const {
78
+ baseURL,
79
+ apiKey,
80
+ spansPerProject = 1000,
81
+ startTime,
82
+ endTime,
83
+ showProgress = false,
84
+ } = options;
85
+
86
+ // Create progress indicator
87
+ const progress = new SnapshotProgress(showProgress);
88
+ progress.start("Creating Phoenix data snapshot");
89
+
90
+ // Create Phoenix client
91
+ const clientConfig: PhoenixClientConfig = {
92
+ baseURL,
93
+ apiKey,
94
+ };
95
+ const client = createPhoenixClient(clientConfig);
96
+
97
+ try {
98
+ // 1. Fetch projects first (required for spans)
99
+ progress.update("Fetching projects");
100
+ try {
101
+ await fetchProjects(client, mode);
102
+ } catch (error) {
103
+ progress.fail("Failed to fetch projects");
104
+ throw new PhoenixClientError(
105
+ `Failed to fetch projects: ${error instanceof Error ? error.message : String(error)}`,
106
+ error instanceof PhoenixClientError ? error.code : "UNKNOWN_ERROR",
107
+ error
108
+ );
109
+ }
110
+
111
+ // 2. Fetch spans and other data in parallel
112
+ progress.update(
113
+ "Fetching all data",
114
+ "spans, datasets, experiments, prompts"
115
+ );
116
+ const spansOptions: SnapshotSpansOptions = {
117
+ spansPerProject,
118
+ startTime,
119
+ endTime,
120
+ };
121
+
122
+ // Fetch all data types in parallel for better performance
123
+ const results = await Promise.allSettled([
124
+ snapshotSpans(client, mode, spansOptions),
125
+ fetchDatasets(client, mode),
126
+ fetchExperiments(client, mode),
127
+ fetchPrompts(client, mode),
128
+ ]);
129
+
130
+ // Check for failures and collect errors
131
+ const errors: Array<{ type: string; error: unknown }> = [];
132
+ const dataTypes = ["spans", "datasets", "experiments", "prompts"];
133
+
134
+ results.forEach((result, index) => {
135
+ if (result.status === "rejected") {
136
+ errors.push({
137
+ type: dataTypes[index] || "unknown",
138
+ error: result.reason,
139
+ });
140
+ }
141
+ });
142
+
143
+ if (errors.length > 0) {
144
+ // Log individual errors
145
+ errors.forEach(({ type, error }) => {
146
+ console.error(
147
+ `Warning: Failed to fetch ${type}:`,
148
+ error instanceof Error ? error.message : String(error)
149
+ );
150
+ });
151
+
152
+ // If spans failed, that's critical - throw error
153
+ if (errors.some((e) => e.type === "spans")) {
154
+ progress.fail("Failed to fetch spans");
155
+ throw new PhoenixClientError(
156
+ `Failed to fetch spans: ${errors.find((e) => e.type === "spans")?.error}`,
157
+ "UNKNOWN_ERROR",
158
+ errors
159
+ );
160
+ }
161
+
162
+ // If all other data failed, throw error. If partial success, continue with warning
163
+ if (errors.length === 4) {
164
+ progress.fail("Failed to fetch all data");
165
+ throw new PhoenixClientError(
166
+ "Failed to fetch all data types",
167
+ "UNKNOWN_ERROR",
168
+ errors
169
+ );
170
+ }
171
+ }
172
+
173
+ // 4. Generate context file
174
+ progress.update("Generating context");
175
+ await generateContext(mode, {
176
+ phoenixUrl: baseURL,
177
+ snapshotTime: new Date(),
178
+ spansPerProject,
179
+ });
180
+
181
+ // 5. Write metadata file
182
+ progress.update("Writing metadata");
183
+ const metadata: SnapshotMetadata = {
184
+ created_at: new Date().toISOString(),
185
+ phoenix_url: baseURL,
186
+ cursors: {
187
+ spans: {}, // TODO: Track span cursors when span fetching supports it
188
+ datasets: { last_fetch: new Date().toISOString() },
189
+ experiments: { last_fetch: new Date().toISOString() },
190
+ prompts: { last_fetch: new Date().toISOString() },
191
+ },
192
+ limits: {
193
+ spans_per_project: spansPerProject,
194
+ },
195
+ };
196
+
197
+ await mode.writeFile(
198
+ "/_meta/snapshot.json",
199
+ JSON.stringify(metadata, null, 2)
200
+ );
201
+
202
+ progress.succeed("✅ Snapshot created successfully!");
203
+ } catch (error) {
204
+ // Stop progress if not already stopped
205
+ progress.stop();
206
+
207
+ // Enhance error with context before rethrowing
208
+ if (error instanceof PhoenixClientError) {
209
+ throw error; // Already has good context
210
+ }
211
+
212
+ throw new PhoenixClientError(
213
+ `Failed to create snapshot: ${error instanceof Error ? error.message : String(error)}`,
214
+ "UNKNOWN_ERROR",
215
+ error
216
+ );
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Loads existing snapshot metadata if available
222
+ * @param mode - The execution mode (sandbox or local)
223
+ * @returns The snapshot metadata or null if not found
224
+ */
225
+ export async function loadSnapshotMetadata(
226
+ mode: ExecutionMode
227
+ ): Promise<SnapshotMetadata | null> {
228
+ try {
229
+ const result = await mode.exec(
230
+ "cat /phoenix/_meta/snapshot.json 2>/dev/null"
231
+ );
232
+ if (result.exitCode === 0) {
233
+ return JSON.parse(result.stdout);
234
+ }
235
+ } catch (error) {
236
+ // File doesn't exist or parse error
237
+ }
238
+ return null;
239
+ }
240
+
241
+ /**
242
+ * Creates an incremental snapshot, fetching only new/updated data
243
+ * @param mode - The execution mode (sandbox or local)
244
+ * @param options - Snapshot options including server URL and limits
245
+ */
246
+ export async function createIncrementalSnapshot(
247
+ mode: ExecutionMode,
248
+ options: SnapshotOptions
249
+ ): Promise<void> {
250
+ // Load existing metadata to get cursors
251
+ const existingMetadata = await loadSnapshotMetadata(mode);
252
+
253
+ if (!existingMetadata) {
254
+ // No existing snapshot, create a full one
255
+ await createSnapshot(mode, options);
256
+ return;
257
+ }
258
+
259
+ const {
260
+ baseURL,
261
+ apiKey,
262
+ spansPerProject = 1000,
263
+ showProgress = false,
264
+ } = options;
265
+
266
+ // Create progress indicator
267
+ const progress = new SnapshotProgress(showProgress);
268
+ progress.start("Updating Phoenix data snapshot");
269
+
270
+ // Create Phoenix client
271
+ const clientConfig: PhoenixClientConfig = {
272
+ baseURL,
273
+ apiKey,
274
+ };
275
+ const client = createPhoenixClient(clientConfig);
276
+
277
+ try {
278
+ // Show time since last snapshot
279
+ const lastSnapshotDate = new Date(existingMetadata.created_at);
280
+ const timeSince = formatTimeSince(lastSnapshotDate);
281
+ progress.update("Checking for updates", `last snapshot ${timeSince} ago`);
282
+
283
+ // For incremental updates, we'll need to:
284
+ // 1. Fetch projects (always fetch all as they're small)
285
+ progress.update("Updating projects");
286
+ await fetchProjects(client, mode);
287
+
288
+ // 2. Fetch spans and other data in parallel for better performance
289
+ progress.update("Fetching updates", "new spans and refreshing other data");
290
+
291
+ const spansOptions: SnapshotSpansOptions = {
292
+ spansPerProject,
293
+ // Use the last end time from previous snapshot as start time
294
+ startTime: existingMetadata.cursors.spans
295
+ ? Object.values(existingMetadata.cursors.spans)
296
+ .map((cursor) => cursor.last_end_time)
297
+ .filter(Boolean)
298
+ .sort()
299
+ .pop()
300
+ : undefined,
301
+ };
302
+
303
+ // For datasets/experiments/prompts, check if they've been updated
304
+ const datasetsLastFetch = existingMetadata.cursors.datasets?.last_fetch;
305
+ const experimentsLastFetch =
306
+ existingMetadata.cursors.experiments?.last_fetch;
307
+ const promptsLastFetch = existingMetadata.cursors.prompts?.last_fetch;
308
+
309
+ // Fetch all data types in parallel
310
+ // For now, we'll refetch all as the API doesn't support filtering by updated_at
311
+ // In a future enhancement, we could check individual items for updates
312
+ const updateResults = await Promise.allSettled([
313
+ snapshotSpans(client, mode, spansOptions),
314
+ fetchDatasets(client, mode),
315
+ fetchExperiments(client, mode),
316
+ fetchPrompts(client, mode),
317
+ ]);
318
+
319
+ // Check for critical errors
320
+ const updateErrors: Array<{ type: string; error: unknown }> = [];
321
+ const updateDataTypes = ["spans", "datasets", "experiments", "prompts"];
322
+
323
+ updateResults.forEach((result, index) => {
324
+ if (result.status === "rejected") {
325
+ updateErrors.push({
326
+ type: updateDataTypes[index] || "unknown",
327
+ error: result.reason,
328
+ });
329
+ }
330
+ });
331
+
332
+ if (updateErrors.length > 0) {
333
+ // Log individual errors
334
+ updateErrors.forEach(({ type, error }) => {
335
+ console.error(
336
+ `Warning: Failed to update ${type}:`,
337
+ error instanceof Error ? error.message : String(error)
338
+ );
339
+ });
340
+ }
341
+
342
+ // 4. Regenerate context with updated data
343
+ progress.update("Regenerating context");
344
+ await generateContext(mode, {
345
+ phoenixUrl: baseURL,
346
+ snapshotTime: new Date(),
347
+ spansPerProject,
348
+ });
349
+
350
+ // 5. Update metadata
351
+ progress.update("Updating metadata");
352
+ const updatedSpansCursors = existingMetadata.cursors.spans || {};
353
+ const metadata: SnapshotMetadata = {
354
+ created_at: new Date().toISOString(),
355
+ phoenix_url: baseURL,
356
+ cursors: {
357
+ spans: updatedSpansCursors,
358
+ datasets: { last_fetch: new Date().toISOString() },
359
+ experiments: { last_fetch: new Date().toISOString() },
360
+ prompts: { last_fetch: new Date().toISOString() },
361
+ },
362
+ limits: {
363
+ spans_per_project: spansPerProject,
364
+ },
365
+ };
366
+
367
+ await mode.writeFile(
368
+ "/_meta/snapshot.json",
369
+ JSON.stringify(metadata, null, 2)
370
+ );
371
+
372
+ progress.succeed("✅ Incremental update complete!");
373
+ } catch (error) {
374
+ // Stop progress if not already stopped
375
+ progress.stop();
376
+
377
+ // Enhance error with context before rethrowing
378
+ if (error instanceof PhoenixClientError) {
379
+ throw error; // Already has good context
380
+ }
381
+
382
+ throw new PhoenixClientError(
383
+ `Failed to create incremental snapshot: ${error instanceof Error ? error.message : String(error)}`,
384
+ "UNKNOWN_ERROR",
385
+ error
386
+ );
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Format time since a date in human-readable format
392
+ */
393
+ function formatTimeSince(date: Date): string {
394
+ const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
395
+
396
+ if (seconds < 60) return `${seconds}s`;
397
+ const minutes = Math.floor(seconds / 60);
398
+ if (minutes < 60) return `${minutes}m`;
399
+ const hours = Math.floor(minutes / 60);
400
+ if (hours < 24) return `${hours}h`;
401
+ const days = Math.floor(hours / 24);
402
+ return `${days}d`;
403
+ }
@@ -0,0 +1,58 @@
1
+ import type { PhoenixClient } from "@arizeai/phoenix-client";
2
+ import type { ExecutionMode } from "../modes/types.js";
3
+ import { withErrorHandling } from "./client.js";
4
+
5
+ /**
6
+ * Converts an array of items to JSONL format (one JSON object per line)
7
+ */
8
+ function toJSONL(items: unknown[]): string {
9
+ return items.map((item) => JSON.stringify(item)).join("\n");
10
+ }
11
+
12
+ /**
13
+ * Fetches all projects and writes them to the filesystem
14
+ * @param client - The Phoenix client instance
15
+ * @param mode - The execution mode (sandbox or local)
16
+ */
17
+ export async function fetchProjects(
18
+ client: PhoenixClient,
19
+ mode: ExecutionMode
20
+ ): Promise<void> {
21
+ // Fetch all projects with error handling
22
+ const projectsData = await withErrorHandling(async () => {
23
+ const response = await client.GET("/v1/projects", {
24
+ params: {
25
+ query: {
26
+ include_experiment_projects: false,
27
+ },
28
+ },
29
+ });
30
+
31
+ if (!response.data) {
32
+ throw new Error("No data returned from projects endpoint");
33
+ }
34
+
35
+ return response.data;
36
+ }, "fetching projects");
37
+
38
+ // Extract projects from the response
39
+ const projects = projectsData.data || [];
40
+
41
+ // Write projects list as JSONL to /phoenix/projects/index.jsonl
42
+ const projectsPath = "/phoenix/projects/index.jsonl";
43
+ await mode.writeFile(projectsPath, toJSONL(projects));
44
+
45
+ // For each project, create a metadata.json file
46
+ for (const project of projects) {
47
+ const projectDir = `/phoenix/projects/${project.name}`;
48
+ const metadataPath = `${projectDir}/metadata.json`;
49
+
50
+ // Write project metadata
51
+ await mode.writeFile(metadataPath, JSON.stringify(project, null, 2));
52
+
53
+ // Create empty spans directory (will be populated by snapshot-spans task)
54
+ const spansDir = `${projectDir}/spans`;
55
+ // Create directory by writing a placeholder that will be overwritten later
56
+ await mode.writeFile(`${spansDir}/.gitkeep`, "");
57
+ }
58
+ }