@datarecce/ui 0.1.39 → 0.1.41
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/dist/api.d.mts +1 -1
- package/dist/api.d.ts +1 -1
- package/dist/{components-DTLQ2djq.js → components-DfXnN1Hx.js} +592 -13
- package/dist/components-DfXnN1Hx.js.map +1 -0
- package/dist/{components-B6oaPB5f.mjs → components-jh6r4tQn.mjs} +593 -14
- package/dist/components-jh6r4tQn.mjs.map +1 -0
- package/dist/components.d.mts +1 -1
- package/dist/components.d.ts +1 -1
- package/dist/components.js +1 -1
- package/dist/components.mjs +1 -1
- package/dist/hooks.d.mts +1 -1
- package/dist/hooks.d.ts +1 -1
- package/dist/{index-DzBojsjY.d.mts → index-B5bpmv0i.d.mts} +70 -70
- package/dist/{index-DzBojsjY.d.mts.map → index-B5bpmv0i.d.mts.map} +1 -1
- package/dist/{index-DvKRw-cR.d.ts → index-B9lSPJTi.d.ts} +70 -70
- package/dist/index-B9lSPJTi.d.ts.map +1 -0
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/theme.d.mts +1 -1
- package/dist/theme.d.ts +1 -1
- package/dist/types.d.mts +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
- package/recce-source/docs/plans/2024-12-31-csv-download-design.md +121 -0
- package/recce-source/docs/plans/2024-12-31-csv-download-implementation.md +930 -0
- package/recce-source/js/src/components/run/RunResultPane.tsx +138 -14
- package/recce-source/js/src/lib/csv/extractors.test.ts +456 -0
- package/recce-source/js/src/lib/csv/extractors.ts +468 -0
- package/recce-source/js/src/lib/csv/format.test.ts +211 -0
- package/recce-source/js/src/lib/csv/format.ts +44 -0
- package/recce-source/js/src/lib/csv/index.test.ts +155 -0
- package/recce-source/js/src/lib/csv/index.ts +109 -0
- package/recce-source/js/src/lib/hooks/useCSVExport.ts +136 -0
- package/recce-source/recce/mcp_server.py +54 -30
- package/recce-source/recce/models/check.py +10 -2
- package/dist/components-B6oaPB5f.mjs.map +0 -1
- package/dist/components-DTLQ2djq.js.map +0 -1
- package/dist/index-DvKRw-cR.d.ts.map +0 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV formatting utilities with Excel-friendly output
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Escape a value for CSV format
|
|
7
|
+
* - Wrap in quotes if contains comma, quote, or newline
|
|
8
|
+
* - Escape quotes by doubling them
|
|
9
|
+
*/
|
|
10
|
+
function escapeCSVValue(value: unknown): string {
|
|
11
|
+
if (value === null || value === undefined) {
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const stringValue =
|
|
16
|
+
typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
17
|
+
|
|
18
|
+
// Check if escaping is needed
|
|
19
|
+
if (
|
|
20
|
+
stringValue.includes(",") ||
|
|
21
|
+
stringValue.includes('"') ||
|
|
22
|
+
stringValue.includes("\n") ||
|
|
23
|
+
stringValue.includes("\r")
|
|
24
|
+
) {
|
|
25
|
+
return `"${stringValue.replace(/"/g, '""')}"`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return stringValue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Convert tabular data to CSV string
|
|
33
|
+
* @param columns - Column headers
|
|
34
|
+
* @param rows - Row data (array of arrays)
|
|
35
|
+
* @returns CSV string with UTF-8 BOM for Excel compatibility
|
|
36
|
+
*/
|
|
37
|
+
export function toCSV(columns: string[], rows: unknown[][]): string {
|
|
38
|
+
const BOM = "\uFEFF";
|
|
39
|
+
|
|
40
|
+
const headerRow = columns.map(escapeCSVValue).join(",");
|
|
41
|
+
const dataRows = rows.map((row) => row.map(escapeCSVValue).join(","));
|
|
42
|
+
|
|
43
|
+
return BOM + [headerRow, ...dataRows].join("\r\n");
|
|
44
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for CSV utility functions
|
|
3
|
+
*/
|
|
4
|
+
import { generateCSVFilename, generateTimestamp } from "./index";
|
|
5
|
+
|
|
6
|
+
describe("generateTimestamp", () => {
|
|
7
|
+
test("should return timestamp in YYYYMMDD-HHmmss format", () => {
|
|
8
|
+
const timestamp = generateTimestamp();
|
|
9
|
+
|
|
10
|
+
// Should match format like 20240101-123456
|
|
11
|
+
expect(timestamp).toMatch(/^\d{8}-\d{6}$/);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("should generate consistent length timestamps", () => {
|
|
15
|
+
const timestamps = Array.from({ length: 5 }, () => generateTimestamp());
|
|
16
|
+
|
|
17
|
+
for (const ts of timestamps) {
|
|
18
|
+
expect(ts.length).toBe(15); // 8 + 1 + 6 = 15 characters
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("generateCSVFilename", () => {
|
|
24
|
+
describe("basic filename generation", () => {
|
|
25
|
+
test("should include run type in filename", () => {
|
|
26
|
+
const filename = generateCSVFilename("query_diff", {});
|
|
27
|
+
|
|
28
|
+
expect(filename).toMatch(/^query-diff-.*\.csv$/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("should replace underscores with hyphens in run type", () => {
|
|
32
|
+
const filename = generateCSVFilename("row_count_diff", {});
|
|
33
|
+
|
|
34
|
+
expect(filename).toContain("row-count-diff");
|
|
35
|
+
expect(filename).not.toContain("_");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("should end with .csv extension", () => {
|
|
39
|
+
const filename = generateCSVFilename("query", {});
|
|
40
|
+
|
|
41
|
+
expect(filename).toMatch(/\.csv$/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("should include timestamp", () => {
|
|
45
|
+
const filename = generateCSVFilename("query", {});
|
|
46
|
+
|
|
47
|
+
// Should have timestamp pattern before .csv
|
|
48
|
+
expect(filename).toMatch(/\d{8}-\d{6}\.csv$/);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("node name extraction", () => {
|
|
53
|
+
test("should include single node name from node_names array", () => {
|
|
54
|
+
const filename = generateCSVFilename("query", {
|
|
55
|
+
node_names: ["my_model"],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(filename).toContain("my_model");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("should not include node name if multiple in array", () => {
|
|
62
|
+
const filename = generateCSVFilename("query", {
|
|
63
|
+
node_names: ["model1", "model2"],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(filename).not.toContain("model1");
|
|
67
|
+
expect(filename).not.toContain("model2");
|
|
68
|
+
expect(filename).toContain("result");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("should include model name from params", () => {
|
|
72
|
+
const filename = generateCSVFilename("profile", {
|
|
73
|
+
model: "customers",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(filename).toContain("customers");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("should prefer node_names over model", () => {
|
|
80
|
+
const filename = generateCSVFilename("query", {
|
|
81
|
+
node_names: ["from_node"],
|
|
82
|
+
model: "from_model",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(filename).toContain("from_node");
|
|
86
|
+
expect(filename).not.toContain("from_model");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("node name sanitization", () => {
|
|
91
|
+
test("should convert to lowercase", () => {
|
|
92
|
+
const filename = generateCSVFilename("query", {
|
|
93
|
+
node_names: ["MyModel"],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(filename).toContain("mymodel");
|
|
97
|
+
expect(filename).not.toContain("MyModel");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("should preserve dots for schema.table patterns", () => {
|
|
101
|
+
const filename = generateCSVFilename("query", {
|
|
102
|
+
node_names: ["schema.table_name"],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(filename).toContain("schema.table_name");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("should replace special characters with hyphens", () => {
|
|
109
|
+
const filename = generateCSVFilename("query", {
|
|
110
|
+
node_names: ["model/with/slashes"],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(filename).toContain("model-with-slashes");
|
|
114
|
+
expect(filename).not.toContain("/");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("should handle spaces", () => {
|
|
118
|
+
const filename = generateCSVFilename("query", {
|
|
119
|
+
node_names: ["model with spaces"],
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(filename).toContain("model-with-spaces");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("should preserve underscores and hyphens", () => {
|
|
126
|
+
const filename = generateCSVFilename("query", {
|
|
127
|
+
node_names: ["my_model-name"],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(filename).toContain("my_model-name");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("fallback behavior", () => {
|
|
135
|
+
test("should use 'result' when no node name available", () => {
|
|
136
|
+
const filename = generateCSVFilename("query", {});
|
|
137
|
+
|
|
138
|
+
expect(filename).toMatch(/^query-result-\d{8}-\d{6}\.csv$/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("should handle undefined params", () => {
|
|
142
|
+
const filename = generateCSVFilename("query", undefined);
|
|
143
|
+
|
|
144
|
+
expect(filename).toMatch(/^query-result-\d{8}-\d{6}\.csv$/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("should handle empty node_names array", () => {
|
|
148
|
+
const filename = generateCSVFilename("query", {
|
|
149
|
+
node_names: [],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(filename).toContain("result");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV export utilities
|
|
3
|
+
*/
|
|
4
|
+
import saveAs from "file-saver";
|
|
5
|
+
|
|
6
|
+
export { toCSV } from "./format";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Trigger browser download of CSV file
|
|
10
|
+
*/
|
|
11
|
+
export function downloadCSV(content: string, filename: string): void {
|
|
12
|
+
const blob = new Blob([content], { type: "text/csv;charset=utf-8" });
|
|
13
|
+
saveAs(blob, filename);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Copy CSV content to clipboard
|
|
18
|
+
* Uses modern Clipboard API with fallback for older browsers
|
|
19
|
+
*/
|
|
20
|
+
export async function copyCSVToClipboard(content: string): Promise<void> {
|
|
21
|
+
// Prefer modern async Clipboard API when available in a browser context
|
|
22
|
+
if (
|
|
23
|
+
typeof navigator !== "undefined" &&
|
|
24
|
+
navigator.clipboard &&
|
|
25
|
+
typeof navigator.clipboard.writeText === "function"
|
|
26
|
+
) {
|
|
27
|
+
await navigator.clipboard.writeText(content);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Fallback for older browsers or non-secure contexts using execCommand
|
|
32
|
+
if (typeof document === "undefined") {
|
|
33
|
+
// In non-DOM environments (e.g., SSR), throw error
|
|
34
|
+
throw new Error("Clipboard API not available in this environment");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const textarea = document.createElement("textarea");
|
|
38
|
+
textarea.value = content;
|
|
39
|
+
textarea.style.position = "fixed"; // avoid scrolling to bottom
|
|
40
|
+
textarea.style.opacity = "0";
|
|
41
|
+
textarea.setAttribute("readonly", "");
|
|
42
|
+
document.body.appendChild(textarea);
|
|
43
|
+
|
|
44
|
+
textarea.focus();
|
|
45
|
+
textarea.select();
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const success = document.execCommand("copy");
|
|
49
|
+
if (!success) {
|
|
50
|
+
throw new Error("execCommand('copy') failed");
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
document.body.removeChild(textarea);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generate timestamp string for filenames
|
|
59
|
+
* Format: YYYYMMDD-HHmmss
|
|
60
|
+
*/
|
|
61
|
+
export function generateTimestamp(): string {
|
|
62
|
+
const now = new Date();
|
|
63
|
+
const year = now.getFullYear();
|
|
64
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
65
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
66
|
+
const hours = String(now.getHours()).padStart(2, "0");
|
|
67
|
+
const minutes = String(now.getMinutes()).padStart(2, "0");
|
|
68
|
+
const seconds = String(now.getSeconds()).padStart(2, "0");
|
|
69
|
+
return `${year}${month}${day}-${hours}${minutes}${seconds}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Generate context-aware CSV filename
|
|
74
|
+
*/
|
|
75
|
+
export function generateCSVFilename(
|
|
76
|
+
runType: string,
|
|
77
|
+
params?: Record<string, unknown>,
|
|
78
|
+
): string {
|
|
79
|
+
const timestamp = generateTimestamp();
|
|
80
|
+
const type = runType.replace(/_/g, "-");
|
|
81
|
+
|
|
82
|
+
// Try to extract node name from params
|
|
83
|
+
let nodeName: string | undefined;
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
params?.node_names &&
|
|
87
|
+
Array.isArray(params.node_names) &&
|
|
88
|
+
params.node_names.length === 1
|
|
89
|
+
) {
|
|
90
|
+
nodeName = String(params.node_names[0]);
|
|
91
|
+
} else if (params?.model && typeof params.model === "string") {
|
|
92
|
+
nodeName = params.model;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Sanitize node name for filesystem (preserve dots for schema.table patterns)
|
|
96
|
+
if (nodeName) {
|
|
97
|
+
nodeName = nodeName.replace(/[^a-zA-Z0-9_.-]/g, "-").toLowerCase();
|
|
98
|
+
return `${type}-${nodeName}-${timestamp}.csv`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return `${type}-result-${timestamp}.csv`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export {
|
|
105
|
+
type CSVData,
|
|
106
|
+
type CSVExportOptions,
|
|
107
|
+
extractCSVData,
|
|
108
|
+
supportsCSVExport,
|
|
109
|
+
} from "./extractors";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for CSV export functionality
|
|
3
|
+
*/
|
|
4
|
+
import { useCallback, useMemo } from "react";
|
|
5
|
+
import { toaster } from "@/components/ui/toaster";
|
|
6
|
+
import type { Run } from "@/lib/api/types";
|
|
7
|
+
import {
|
|
8
|
+
type CSVExportOptions,
|
|
9
|
+
copyCSVToClipboard,
|
|
10
|
+
downloadCSV,
|
|
11
|
+
extractCSVData,
|
|
12
|
+
generateCSVFilename,
|
|
13
|
+
supportsCSVExport,
|
|
14
|
+
toCSV,
|
|
15
|
+
} from "@/lib/csv";
|
|
16
|
+
|
|
17
|
+
interface UseCSVExportOptions {
|
|
18
|
+
run?: Run;
|
|
19
|
+
/** View options - displayMode is extracted if present (for query_diff views) */
|
|
20
|
+
viewOptions?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface UseCSVExportResult {
|
|
24
|
+
/** Whether CSV export is available for this run type */
|
|
25
|
+
canExportCSV: boolean;
|
|
26
|
+
/** Copy result data as CSV to clipboard */
|
|
27
|
+
copyAsCSV: () => Promise<void>;
|
|
28
|
+
/** Download result data as CSV file */
|
|
29
|
+
downloadAsCSV: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useCSVExport({
|
|
33
|
+
run,
|
|
34
|
+
viewOptions,
|
|
35
|
+
}: UseCSVExportOptions): UseCSVExportResult {
|
|
36
|
+
const canExportCSV = useMemo(() => {
|
|
37
|
+
if (!run?.type || !run?.result) return false;
|
|
38
|
+
return supportsCSVExport(run.type);
|
|
39
|
+
}, [run?.type, run?.result]);
|
|
40
|
+
|
|
41
|
+
const getCSVContent = useCallback((): string | null => {
|
|
42
|
+
if (!run?.type || !run?.result) return null;
|
|
43
|
+
|
|
44
|
+
// Extract display_mode from viewOptions if it exists (for query_diff)
|
|
45
|
+
const displayMode = viewOptions?.display_mode as
|
|
46
|
+
| "inline"
|
|
47
|
+
| "side_by_side"
|
|
48
|
+
| undefined;
|
|
49
|
+
|
|
50
|
+
// Extract primary_keys from run params (for query_diff with primary keys)
|
|
51
|
+
const primaryKeys = (run?.params as { primary_keys?: string[] })
|
|
52
|
+
?.primary_keys;
|
|
53
|
+
|
|
54
|
+
const exportOptions: CSVExportOptions = {
|
|
55
|
+
displayMode,
|
|
56
|
+
primaryKeys,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const csvData = extractCSVData(run.type, run.result, exportOptions);
|
|
60
|
+
if (!csvData) return null;
|
|
61
|
+
|
|
62
|
+
return toCSV(csvData.columns, csvData.rows);
|
|
63
|
+
}, [run?.type, run?.result, run?.params, viewOptions]);
|
|
64
|
+
|
|
65
|
+
const copyAsCSV = useCallback(async () => {
|
|
66
|
+
const content = getCSVContent();
|
|
67
|
+
if (!content) {
|
|
68
|
+
toaster.create({
|
|
69
|
+
title: "Export failed",
|
|
70
|
+
description: "Unable to extract data for CSV export",
|
|
71
|
+
type: "error",
|
|
72
|
+
duration: 3000,
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await copyCSVToClipboard(content);
|
|
79
|
+
toaster.create({
|
|
80
|
+
title: "Copied to clipboard",
|
|
81
|
+
description: "CSV data copied successfully",
|
|
82
|
+
type: "success",
|
|
83
|
+
duration: 2000,
|
|
84
|
+
});
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error("Failed to copy CSV to clipboard:", error);
|
|
87
|
+
toaster.create({
|
|
88
|
+
title: "Copy failed",
|
|
89
|
+
description: "Failed to copy to clipboard",
|
|
90
|
+
type: "error",
|
|
91
|
+
duration: 3000,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}, [getCSVContent]);
|
|
95
|
+
|
|
96
|
+
const downloadAsCSV = useCallback(() => {
|
|
97
|
+
const content = getCSVContent();
|
|
98
|
+
if (!content) {
|
|
99
|
+
toaster.create({
|
|
100
|
+
title: "Export failed",
|
|
101
|
+
description: "Unable to extract data for CSV export",
|
|
102
|
+
type: "error",
|
|
103
|
+
duration: 3000,
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const filename = generateCSVFilename(
|
|
110
|
+
run?.type ?? "",
|
|
111
|
+
run?.params as Record<string, unknown>,
|
|
112
|
+
);
|
|
113
|
+
downloadCSV(content, filename);
|
|
114
|
+
toaster.create({
|
|
115
|
+
title: "Downloaded",
|
|
116
|
+
description: filename,
|
|
117
|
+
type: "success",
|
|
118
|
+
duration: 3000,
|
|
119
|
+
});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error("Failed to download CSV file:", error);
|
|
122
|
+
toaster.create({
|
|
123
|
+
title: "Download failed",
|
|
124
|
+
description: "Failed to download CSV file",
|
|
125
|
+
type: "error",
|
|
126
|
+
duration: 3000,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}, [getCSVContent, run]);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
canExportCSV,
|
|
133
|
+
copyAsCSV,
|
|
134
|
+
downloadAsCSV,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -115,8 +115,10 @@ class RecceMCPServer:
|
|
|
115
115
|
mode: Optional[RecceServerMode] = None,
|
|
116
116
|
debug: bool = False,
|
|
117
117
|
log_file: str = "logs/recce-mcp.json",
|
|
118
|
+
state_loader=None,
|
|
118
119
|
):
|
|
119
120
|
self.context = context
|
|
121
|
+
self.state_loader = state_loader
|
|
120
122
|
self.mode = mode or RecceServerMode.server
|
|
121
123
|
self.server = Server("recce")
|
|
122
124
|
self.mcp_logger = MCPLogger(debug=debug, log_file=log_file)
|
|
@@ -733,8 +735,30 @@ class RecceMCPServer:
|
|
|
733
735
|
|
|
734
736
|
async def run(self):
|
|
735
737
|
"""Run the MCP server in stdio mode"""
|
|
736
|
-
|
|
737
|
-
|
|
738
|
+
try:
|
|
739
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
740
|
+
await self.server.run(read_stream, write_stream, self.server.create_initialization_options())
|
|
741
|
+
finally:
|
|
742
|
+
# Export state on shutdown if state_loader is available
|
|
743
|
+
if self.state_loader and self.context:
|
|
744
|
+
try:
|
|
745
|
+
from rich.console import Console
|
|
746
|
+
|
|
747
|
+
console = Console()
|
|
748
|
+
|
|
749
|
+
# Export the state
|
|
750
|
+
msg = self.state_loader.export(self.context.export_state())
|
|
751
|
+
if msg is not None:
|
|
752
|
+
console.print(f"[yellow]On shutdown:[/yellow] {msg}")
|
|
753
|
+
else:
|
|
754
|
+
if hasattr(self.state_loader, "state_file") and self.state_loader.state_file:
|
|
755
|
+
console.print(
|
|
756
|
+
f"[yellow]On shutdown:[/yellow] State exported to '{self.state_loader.state_file}'"
|
|
757
|
+
)
|
|
758
|
+
else:
|
|
759
|
+
console.print("[yellow]On shutdown:[/yellow] State exported successfully")
|
|
760
|
+
except Exception as e:
|
|
761
|
+
logger.exception(f"Failed to export state on shutdown: {e}")
|
|
738
762
|
|
|
739
763
|
async def run_sse(self, host: str = "localhost", port: int = 8000):
|
|
740
764
|
"""Run the MCP server in HTTP mode using Server-Sent Events (SSE)
|
|
@@ -743,6 +767,8 @@ class RecceMCPServer:
|
|
|
743
767
|
host: Host to bind to (default: localhost)
|
|
744
768
|
port: Port to bind to (default: 8000)
|
|
745
769
|
"""
|
|
770
|
+
from contextlib import asynccontextmanager
|
|
771
|
+
|
|
746
772
|
import uvicorn
|
|
747
773
|
from mcp.server.sse import SseServerTransport
|
|
748
774
|
from starlette.applications import Starlette
|
|
@@ -775,7 +801,22 @@ class RecceMCPServer:
|
|
|
775
801
|
"""Handle health check endpoint (GET /health)"""
|
|
776
802
|
return Response(content='{"status":"ok"}', media_type="application/json")
|
|
777
803
|
|
|
778
|
-
|
|
804
|
+
@asynccontextmanager
|
|
805
|
+
async def lifespan(app):
|
|
806
|
+
"""Handle startup and shutdown events"""
|
|
807
|
+
# Startup
|
|
808
|
+
yield
|
|
809
|
+
# Shutdown - this runs when server exits (SIGINT, SIGTERM, etc.)
|
|
810
|
+
if self.state_loader and self.context:
|
|
811
|
+
try:
|
|
812
|
+
logger.info("Exporting state on shutdown...")
|
|
813
|
+
msg = self.state_loader.export(self.context.export_state())
|
|
814
|
+
if msg:
|
|
815
|
+
logger.info(f"State export: {msg}")
|
|
816
|
+
except Exception as e:
|
|
817
|
+
logger.exception(f"Failed to export state on shutdown: {e}")
|
|
818
|
+
|
|
819
|
+
# Create Starlette app with lifespan
|
|
779
820
|
app = Starlette(
|
|
780
821
|
debug=self.mcp_logger.debug,
|
|
781
822
|
routes=[
|
|
@@ -783,6 +824,7 @@ class RecceMCPServer:
|
|
|
783
824
|
Route("/sse", endpoint=handle_sse_request, methods=["GET"]),
|
|
784
825
|
Mount("/", app=handle_post_message),
|
|
785
826
|
],
|
|
827
|
+
lifespan=lifespan,
|
|
786
828
|
)
|
|
787
829
|
|
|
788
830
|
# Run with uvicorn
|
|
@@ -831,31 +873,13 @@ async def run_mcp_server(
|
|
|
831
873
|
# Extract debug flag from kwargs
|
|
832
874
|
debug = kwargs.get("debug", False)
|
|
833
875
|
|
|
834
|
-
# Create MCP server
|
|
835
|
-
server = RecceMCPServer(context, mode=mode, debug=debug)
|
|
836
|
-
|
|
837
|
-
try:
|
|
838
|
-
# Run in either stdio or SSE mode
|
|
839
|
-
if sse:
|
|
840
|
-
await server.run_sse(host=host, port=port)
|
|
841
|
-
else:
|
|
842
|
-
await server.run()
|
|
843
|
-
finally:
|
|
844
|
-
# Export state on shutdown if state_loader is available
|
|
845
|
-
if state_loader:
|
|
846
|
-
try:
|
|
847
|
-
from rich.console import Console
|
|
848
|
-
|
|
849
|
-
console = Console()
|
|
876
|
+
# Create MCP server with state_loader for graceful shutdown
|
|
877
|
+
server = RecceMCPServer(context, mode=mode, debug=debug, state_loader=state_loader)
|
|
850
878
|
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
else:
|
|
859
|
-
console.print("[yellow]On shutdown:[/yellow] State exported successfully")
|
|
860
|
-
except Exception as e:
|
|
861
|
-
logger.exception(f"Failed to export state on shutdown: {e}")
|
|
879
|
+
# Run in either stdio or SSE mode
|
|
880
|
+
if sse:
|
|
881
|
+
# SSE mode: lifespan handler in Starlette manages shutdown and state export
|
|
882
|
+
await server.run_sse(host=host, port=port)
|
|
883
|
+
else:
|
|
884
|
+
# Stdio mode: run() method handles shutdown and state export via try-finally
|
|
885
|
+
await server.run()
|
|
@@ -183,6 +183,14 @@ class CheckDAO:
|
|
|
183
183
|
# Parse the type
|
|
184
184
|
check_type = RunType(cloud_data.get("type"))
|
|
185
185
|
|
|
186
|
+
def parse_iso_datetime(iso_string: str):
|
|
187
|
+
"""Parse ISO format datetime string, handling 'Z' suffix for UTC"""
|
|
188
|
+
if not iso_string:
|
|
189
|
+
return None
|
|
190
|
+
# Replace 'Z' with '+00:00' for Python's fromisoformat compatibility
|
|
191
|
+
iso_string = iso_string.replace("Z", "+00:00")
|
|
192
|
+
return datetime.fromisoformat(iso_string)
|
|
193
|
+
|
|
186
194
|
return Check(
|
|
187
195
|
check_id=UUID(cloud_data.get("id")),
|
|
188
196
|
session_id=UUID(cloud_data.get("session_id")),
|
|
@@ -195,8 +203,8 @@ class CheckDAO:
|
|
|
195
203
|
is_preset=cloud_data.get("is_preset", False),
|
|
196
204
|
created_by=(cloud_data.get("created_by") or {}).get("email", ""),
|
|
197
205
|
updated_by=(cloud_data.get("updated_by") or {}).get("email", ""),
|
|
198
|
-
created_at=
|
|
199
|
-
updated_at=
|
|
206
|
+
created_at=parse_iso_datetime(cloud_data.get("created_at")),
|
|
207
|
+
updated_at=parse_iso_datetime(cloud_data.get("updated_at")),
|
|
200
208
|
)
|
|
201
209
|
|
|
202
210
|
def create(self, check: Check) -> Check:
|