diff --git a/internal-api.sh b/internal-api.sh index 5446fc9..0004630 100755 --- a/internal-api.sh +++ b/internal-api.sh @@ -43,11 +43,23 @@ Commands: heap-snapshot Generate and download a heap snapshot for memory analysis memory-stats Show detailed memory usage breakdown db-stats Show database checkpoint storage statistics + game-export Export a game (design state and artifacts) for local import + game-import Import an exported game into local environment help Show this help message Options: --url Base URL (default: http://localhost:3000) --token Internal API token (or set CHAINCRAFT_GAMEBUILDER_INTERNAL_API_TOKEN) + + game-export options: + --game-id Game/conversation ID to export (required) + --version Specific version to export (optional, defaults to latest) + --artifacts Include spec processing artifacts (optional) + --output Output directory (default: data/exports) + + game-import options: + --file Path to exported game JSON file (required) + --force Overwrite existing checkpoints Environment Variables: CHAINCRAFT_GAMEBUILDER_INTERNAL_API_TOKEN Internal API authentication token @@ -63,8 +75,15 @@ Examples: # Check memory stats ./internal-api.sh memory-stats - # Run cleanup on Railway with explicit token - ./internal-api.sh cleanup --url https://your-app.up.railway.app --token your-token + # Export latest version of a game with artifacts + ./internal-api.sh game-export --game-id abc123 --artifacts + + # Export specific version from production + ./internal-api.sh game-export --game-id abc123 --version 2 --artifacts \\ + --url https://your-app.up.railway.app --token your-token + + # Import a game into local environment + ./internal-api.sh game-import --file data/exports/abc123-v2.json EOF } @@ -234,11 +253,133 @@ cmd_db_stats() { fi } +# Command: game-export +cmd_game_export() { + check_token + + # Validate required parameters + if [ -z "$GAME_ID" ]; then + print_error "Game ID is required" + print_info "Usage: ./internal-api.sh game-export --game-id [--version N] [--artifacts]" + exit 1 + fi + + # Build query parameters + QUERY_PARAMS="gameId=$GAME_ID" + + if [ -n "$VERSION" ]; then + QUERY_PARAMS="$QUERY_PARAMS&version=$VERSION" + fi + + if [ "$ARTIFACTS" = "true" ]; then + QUERY_PARAMS="$QUERY_PARAMS&artifacts=true" + fi + + # Determine output filename + if [ -n "$VERSION" ]; then + OUTPUT_FILE="${OUTPUT_DIR}/${GAME_ID}-v${VERSION}.json" + else + OUTPUT_FILE="${OUTPUT_DIR}/${GAME_ID}-latest.json" + fi + + # Ensure output directory exists + mkdir -p "$OUTPUT_DIR" + + print_info "Exporting game $GAME_ID from $BASE_URL" + if [ -n "$VERSION" ]; then + print_info "Version: $VERSION" + else + print_info "Version: latest" + fi + if [ "$ARTIFACTS" = "true" ]; then + print_info "Including artifacts: yes" + fi + + RESPONSE=$(curl -s -w "\n%{http_code}" -X GET \ + -H "X-Internal-Token: $INTERNAL_TOKEN" \ + "$BASE_URL/internal/game-export?$QUERY_PARAMS") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" = "200" ]; then + # Save to file + echo "$BODY" > "$OUTPUT_FILE" + + FILE_SIZE=$(ls -lh "$OUTPUT_FILE" | awk '{print $5}') + print_success "Game exported: $OUTPUT_FILE ($FILE_SIZE)" + + # Show summary if jq is available + if command -v jq &> /dev/null; then + echo "" + print_info "Export Summary:" + echo "$BODY" | jq -r ' + " Game ID: \(.metadata.gameId)", + " Version: \(.metadata.version)", + " Timestamp: \(.metadata.timestamp)", + " Has Artifacts: \(.metadata.hasArtifacts)", + " Title: \(.design.title // "Untitled")" + ' + fi + + echo "" + print_info "To import this game locally:" + echo " npm run import-game -- --file $OUTPUT_FILE" + else + print_error "Failed to export game (HTTP $HTTP_CODE)" + echo "$BODY" + exit 1 + fi +} + +# Command: game-import +cmd_game_import() { + # Note: This command does not require authentication token + # It runs locally and imports into local databases + + # Validate required parameters + if [ -z "$IMPORT_FILE" ]; then + print_error "Import file is required" + print_info "Usage: ./internal-api.sh game-import --file [--wallet
]" + exit 1 + fi + + if [ ! -f "$IMPORT_FILE" ]; then + print_error "File not found: $IMPORT_FILE" + exit 1 + fi + + print_info "Importing game from: $IMPORT_FILE" + echo "" + + print_info "Building import script..." + npm run build > /dev/null 2>&1 + + if [ $? -ne 0 ]; then + print_error "Build failed" + exit 1 + fi + + # Run the import script + if [ "$FORCE_IMPORT" = "true" ]; then + node ./dist/scripts/import-game.js --file "$IMPORT_FILE" --force + else + node ./dist/scripts/import-game.js --file "$IMPORT_FILE" + fi +} + # Parse arguments COMMAND="" +GAME_ID="" +VERSION="" +ARTIFACTS="false" +OUTPUT_DIR="data/exports" +IMPORT_FILE="" +FORCE_IMPORT="false" + while [[ $# -gt 0 ]]; do case $1 in - cleanup|heap-snapshot|memory-stats|db-stats|help) + cleanup|heap-snapshot|memory-stats|db-stats|game-export|game-import|help) COMMAND=$1 shift ;; @@ -250,8 +391,31 @@ while [[ $# -gt 0 ]]; do INTERNAL_TOKEN="$2" shift 2 ;; + --game-id) + GAME_ID="$2" + shift 2 + ;; + --version) + VERSION="$2" + shift 2 + ;; + --artifacts) + ARTIFACTS="true" + shift + ;; + --output) + OUTPUT_DIR="$2" + shift 2 + ;; + --file) + IMPORT_FILE="$2" + shift 2 + ;; + --force) + FORCE_IMPORT="true" + shift + ;; *) - print_error "Unknown option: $1" print_usage exit 1 ;; @@ -275,6 +439,12 @@ case $COMMAND in db-stats) cmd_db_stats ;; + game-export) + cmd_game_export + ;; + game-import) + cmd_game_import + ;; help|"") print_usage exit 0 diff --git a/package.json b/package.json index fd1ee08..931906b 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "pm2:logs:api": "pm2 logs chaincraft-web-api", "pm2:logs:bot": "pm2 logs chaincraft-discord-bot", "pm2:status": "pm2 status", - "pm2:monit": "pm2 monit" + "pm2:monit": "pm2 monit", + "import-game": "npm run build && node ./dist/scripts/import-game.js" }, "keywords": [], "author": "", diff --git a/scripts/import-game.d.ts b/scripts/import-game.d.ts new file mode 100644 index 0000000..301d26d --- /dev/null +++ b/scripts/import-game.d.ts @@ -0,0 +1,17 @@ +#!/usr/bin/env node +/** + * Import Game Script (LangGraph Checkpoints Only) + * + * Imports LangGraph checkpoints from an exported game into local SQLite. + * This includes: + * 1. Injecting design checkpoint (conversation state, spec, narratives) + * 2. Injecting artifacts checkpoint (schema, transitions, instructions) + * 3. Validating the import was successful + * + * Note: This script only handles LangGraph state. To create the Supabase games + * record, use the orchestrator import script or create manually via Supabase Studio. + * + * Usage: + * ./internal-api.sh game-import --file data/exports/game-abc123-v2.json + */ +import "dotenv/config.js"; diff --git a/scripts/import-game.ts b/scripts/import-game.ts new file mode 100644 index 0000000..21d0592 --- /dev/null +++ b/scripts/import-game.ts @@ -0,0 +1,401 @@ +#!/usr/bin/env node +/** + * Import Game Script (LangGraph Checkpoints Only) + * + * Imports LangGraph checkpoints from an exported game into local SQLite. + * This includes: + * 1. Injecting design checkpoint (conversation state, spec, narratives) + * 2. Injecting artifacts checkpoint (schema, transitions, instructions) + * 3. Validating the import was successful + * + * Note: This script only handles LangGraph state. To create the Supabase games + * record, use the orchestrator import script or create manually via Supabase Studio. + * + * Usage: + * ./internal-api.sh game-import --file data/exports/game-abc123-v2.json + */ + +import "dotenv/config.js"; +import { readFile } from "fs/promises"; +import { getSaver } from "#chaincraft/ai/memory/checkpoint-memory.js"; +import { getConfig } from "#chaincraft/config.js"; +import { getCachedDesign } from "#chaincraft/ai/design/design-workflow.js"; +import { getCachedSpecArtifacts } from "#chaincraft/ai/simulate/simulate-workflow.js"; + +// Color codes for terminal output +const RED = '\x1b[31m'; +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const BLUE = '\x1b[34m'; +const RESET = '\x1b[0m'; + +function printError(msg: string) { + console.error(`${RED}✗ ${msg}${RESET}`); +} + +function printSuccess(msg: string) { + console.log(`${GREEN}✓ ${msg}${RESET}`); +} + +function printInfo(msg: string) { + console.log(`${BLUE}ℹ ${msg}${RESET}`); +} + +function printWarning(msg: string) { + console.log(`${YELLOW}⚠ ${msg}${RESET}`); +} + +interface ExportedGame { + metadata: { + gameId: string; + version: number; + timestamp: string; + hasArtifacts: boolean; + }; + design: { + title: string; + specification?: { + summary: string; + playerCount: { min: number; max: number }; + designSpecification: string; + version: number; + }; + specNarratives?: Record; + pendingSpecChanges?: string[]; + consolidationThreshold?: number; + consolidationCharLimit?: number; + }; + artifacts?: { + gameRules: string; + stateSchema: string; + stateTransitions: string; + playerPhaseInstructions: Record; + transitionInstructions: Record; + specNarratives?: Record; + }; +} + +/** + * Check if checkpoint already exists + */ +async function checkpointExists(threadId: string, graphType: string): Promise { + try { + const saver = await getSaver(threadId, graphType); + const config = { configurable: { thread_id: threadId } }; + const tuple = await saver.getTuple(config); + return tuple !== undefined; + } catch (error) { + return false; + } +} + +/** + * Inject design checkpoint into local LangGraph storage + */ +async function injectDesignCheckpoint(exportedGame: ExportedGame, force: boolean): Promise { + const { gameId } = exportedGame.metadata; + const { design } = exportedGame; + + // Check if already exists + const exists = await checkpointExists(gameId, getConfig("design-graph-type")); + if (exists && !force) { + printWarning(`Design checkpoint for game ${gameId} already exists`); + printInfo('Skipping import. Use --force to overwrite existing checkpoint'); + return; + } + + if (exists && force) { + printWarning(`Overwriting existing design checkpoint for game ${gameId}`); + } + + printInfo(`Injecting design checkpoint for game ${gameId}...`); + + // Get saver for design workflow + const saver = await getSaver(gameId, getConfig("design-graph-type")); + const config = { configurable: { thread_id: gameId } }; + + // Create checkpoint state that matches GameDesignState structure + const checkpointState = { + title: design.title, + currentSpec: design.specification, + specNarratives: design.specNarratives, + pendingSpecChanges: design.pendingSpecChanges?.map(change => ({ changes: change })), + consolidationThreshold: design.consolidationThreshold, + consolidationCharLimit: design.consolidationCharLimit, + specVersion: design.specification?.version ?? 0, + + // Initialize required fields + messages: [] as Array<{ type: string; content: string }>, + systemPromptVersion: "imported", + specUpdateNeeded: false, + metadataUpdateNeeded: false, + }; + + // Create checkpoint structure + const checkpoint = { + v: 1, + id: crypto.randomUUID(), + ts: new Date().toISOString(), + channel_values: checkpointState, + channel_versions: { + __start__: 1, + }, + versions_seen: { + __start__: { + __start__: 1, + }, + }, + pending_sends: [], + }; + + await saver.put(config, checkpoint as any, { source: "input", step: -1, parents: {} } as any, {}); + + printSuccess(`Design checkpoint injected successfully`); + + // Initialize conversation with welcome message if no messages exist + if (!checkpointState.messages || checkpointState.messages.length === 0) { + printInfo(`Adding welcome message to imported conversation...`); + checkpointState.messages = [{ + type: 'ai', + content: `Welcome! This game "${design.title}" was imported from a previous export. I'm ready to help you continue developing it.` + }]; + + // Update the checkpoint with the message + const updatedCheckpoint = { + ...checkpoint, + channel_values: checkpointState + }; + await saver.put(config, updatedCheckpoint as any, { source: "input", step: -1, parents: {} } as any, {}); + printSuccess(`Welcome message added to conversation`); + } +} + +/** + * Inject artifacts checkpoint into local LangGraph storage + */ +async function injectArtifactsCheckpoint(exportedGame: ExportedGame, force: boolean): Promise { + const { gameId, version, hasArtifacts } = exportedGame.metadata; + + if (!hasArtifacts || !exportedGame.artifacts) { + printInfo(`No artifacts to inject for game ${gameId}`); + return; + } + + const specKey = `${gameId}-v${version}`; + + // Check if already exists + const exists = await checkpointExists(specKey, getConfig("simulation-graph-type")); + if (exists && !force) { + printWarning(`Artifacts checkpoint for ${specKey} already exists`); + printInfo('Skipping import. Use --force to overwrite existing checkpoint'); + return; + } + + if (exists && force) { + printWarning(`Overwriting existing artifacts checkpoint for ${specKey}`); + } + + printInfo(`Injecting artifacts checkpoint for ${specKey}...`); + + // Get saver for simulation workflow + const saver = await getSaver(specKey, getConfig("simulation-graph-type")); + const config = { configurable: { thread_id: specKey } }; + + // Create checkpoint state that matches SpecProcessingState structure + const checkpointState = { + gameRules: exportedGame.artifacts.gameRules, + stateSchema: exportedGame.artifacts.stateSchema, + stateTransitions: exportedGame.artifacts.stateTransitions, + playerPhaseInstructions: exportedGame.artifacts.playerPhaseInstructions, + transitionInstructions: exportedGame.artifacts.transitionInstructions, + specNarratives: exportedGame.artifacts.specNarratives, + + // Add validation flags to indicate artifacts are complete + schemaValidationErrors: [], + transitionsValidationErrors: [], + instructionsValidationErrors: [], + }; + + // Create checkpoint structure + const checkpoint = { + v: 1, + id: crypto.randomUUID(), + ts: new Date().toISOString(), + channel_values: checkpointState, + channel_versions: { + __start__: 1, + }, + versions_seen: { + __start__: { + __start__: 1, + }, + }, + pending_sends: [], + }; + + await saver.put(config, checkpoint as any, { source: "input", step: -1, parents: {} } as any, {}); + + printSuccess(`Artifacts checkpoint injected successfully`); +} + +/** + * Validate that imported data can be loaded correctly + */ +async function validateImport(exportedGame: ExportedGame): Promise { + const { gameId, version, hasArtifacts } = exportedGame.metadata; + + printInfo(`Validating import for game ${gameId}...`); + + try { + // Validate design checkpoint + const design = await getCachedDesign(gameId); + if (!design) { + printWarning(`Could not validate design checkpoint - conversation may need time to be available`); + } else { + if (design.title !== exportedGame.design.title) { + printWarning(`Title mismatch: expected "${exportedGame.design.title}", got "${design.title}"`); + } + + if (design.specification?.version !== version) { + printWarning(`Version mismatch: expected ${version}, got ${design.specification?.version}`); + } + + printSuccess(`Design checkpoint validated`); + } + + // Validate artifacts checkpoint if applicable + if (hasArtifacts) { + const specKey = `${gameId}-v${version}`; + const artifacts = await getCachedSpecArtifacts(specKey); + + if (!artifacts) { + printWarning(`Could not validate artifacts checkpoint - may need time to be available`); + } else { + printSuccess(`Artifacts checkpoint validated`); + } + } + + } catch (error) { + printWarning(`Validation check encountered an error: ${error instanceof Error ? error.message : 'Unknown error'}`); + printInfo(`Checkpoints were injected successfully - validation is informational only`); + } + + printSuccess(`Import completed`); +} + +/** + * Main import function + */ +async function importGame(filePath: string, force: boolean): Promise { + try { + printInfo(`Reading export file: ${filePath}`); + + // Read and parse export file + const fileContent = await readFile(filePath, 'utf-8'); + const exportedGame: ExportedGame = JSON.parse(fileContent); + + printInfo(`Game ID: ${exportedGame.metadata.gameId}`); + printInfo(`Version: ${exportedGame.metadata.version}`); + printInfo(`Title: ${exportedGame.design.title}`); + printInfo(`Has Artifacts: ${exportedGame.metadata.hasArtifacts}`); + console.log(''); + + // Step 1: Inject design checkpoint + await injectDesignCheckpoint(exportedGame, force); + + // Step 2: Inject artifacts checkpoint (if available) + await injectArtifactsCheckpoint(exportedGame, force); + + console.log(''); + + // Step 3: Validate import + await validateImport(exportedGame); + + console.log(''); + printSuccess(`Game ${exportedGame.metadata.gameId} imported successfully!`); + console.log(''); + printInfo('LangGraph checkpoints imported. To complete the setup:'); + printInfo(' 1. Create Supabase games record (via orchestrator or Supabase Studio)'); + printInfo(' 2. Test with: GET /design/conversation?conversationId=' + exportedGame.metadata.gameId); + + } catch (error) { + console.log(''); + printError('Import failed'); + if (error instanceof Error) { + console.error(error.message); + if (process.env.DEBUG) { + console.error(error.stack); + } + } + process.exit(1); + } +} + +/** + * Parse command line arguments + */ +function parseArgs(): { filePath: string; force: boolean } { + const args = process.argv.slice(2); + let filePath = ''; + let force = false; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--file': + filePath = args[++i]; + break; + case '--force': + force = true; + break; + case '--help': + console.log(` +Import Game Script (LangGraph Checkpoints) + +Usage: + ./internal-api.sh game-import --file [--force] + +Options: + --file Path to exported game JSON file (required) + --force Overwrite existing checkpoints (default: skip if exists) + --help Show this help message + +Examples: + # Import latest version + ./internal-api.sh game-import --file data/exports/abc123-latest.json + + # Import specific version + ./internal-api.sh game-import --file data/exports/abc123-v2.json + + # Force overwrite existing checkpoint + ./internal-api.sh game-import --file data/exports/abc123-v2.json --force + +Environment Variables: + CHECKPOINT_DB_TYPE Database type (sqlite or postgres) + CHAINCRAFT_DESIGN_GRAPH_TYPE Design graph type + CHAINCRAFT_SIMULATION_GRAPH_TYPE Simulation graph type + +Note: + This script only imports LangGraph checkpoints. To create the Supabase + games record, use the orchestrator import script or Supabase Studio. +`); + process.exit(0); + break; + default: + printError(`Unknown option: ${args[i]}`); + printInfo('Use --help for usage information'); + process.exit(1); + } + } + + if (!filePath) { + printError('--file parameter is required'); + printInfo('Use --help for usage information'); + process.exit(1); + } + + return { filePath, force }; +} + +// Main execution +const { filePath, force } = parseArgs(); +await importGame(filePath, force); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index e508c84..53cc26e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,7 +25,7 @@ ], "types": ["node", "jest"] }, - "include": ["src/**/*"], + "include": ["src/**/*", "scripts/**/*"], "exclude": [ "src/ai/simulate/index.ts", "src/ai/simulate/runtime.ts", diff --git a/tsconfig.prod.json b/tsconfig.prod.json index 78c1871..5b726c6 100644 --- a/tsconfig.prod.json +++ b/tsconfig.prod.json @@ -1,8 +1,13 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "types": ["node"] + "types": ["node"], + "rootDir": "." }, + "include": [ + "src/**/*", + "scripts/**/*" + ], "exclude": [ "src/ai/simulate/index.ts", "src/ai/simulate/runtime.ts", @@ -10,6 +15,8 @@ "node_modules", "modules", "**/__tests__/**", - "**/*.test.ts" + "**/*.test.ts", + "scripts/test-*.ts", + "scripts/**/*.js" ] }