From 3f6898e5cc2a02e53286f939528f6fa499b52238 Mon Sep 17 00:00:00 2001 From: Jeremy Moseley Date: Wed, 19 Feb 2025 22:01:05 -0800 Subject: [PATCH] feat: Print URL to link to execution. (#293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Proposed changes If `printUrl` is set when running the workflow, the checkpointer prints the URL to access the execution in the console. Unfortunately, due to the background nature of checkpointing, there is no way for us to ensure that this outputs first. ``` pnpm start > @gensx-examples/hacker-news-analyzer@0.0.0 start /Users/jeremy/code/cortexclick/gensx/examples/hackerNewsAnalyzer > NODE_OPTIONS='--enable-source-maps' tsx ./index.ts šŸš€ Starting HN analysis workflow...\ (node:43650) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead. (Use `node --trace-deprecation ...` to show where the warning was created) šŸ“š Collecting up to 500 HN posts (text posts only)... [GenSX] View execution at: http://localhost:3000/gensx/workflows/AnalyzeHackerNewsWorkflow/01JMGFCP7VEQ2KFZPFDTBSVGZS šŸ“ Found 43 text posts out of 500 total posts āš ļø Note: Requested 500 posts but only found 43 text posts in the top 500 stories āœ… Analysis complete! Check hn_analysis_report.md and hn_analysis_tweet.txt ``` --- examples/blogWriter/index.ts | 11 ++-- examples/contexts/index.tsx | 2 +- examples/deepResearch/index.ts | 9 ++- examples/gsxChatCompletion/index.tsx | 12 ++-- examples/hackerNewsAnalyzer/index.ts | 9 ++- examples/providers/index.tsx | 9 ++- examples/reflection/index.tsx | 9 ++- examples/streaming/index.tsx | 10 +++- examples/structuredOutputs/index.tsx | 18 ++++-- .../typescript/src/index.tsx.template | 9 ++- packages/gensx-cli/src/commands/login.ts | 3 + packages/gensx/src/checkpoint.ts | 60 +++++++++++++------ packages/gensx/src/config.ts | 3 + packages/gensx/src/execute.ts | 39 ++++++++++-- packages/gensx/tests/checkpoint.test.tsx | 4 +- packages/gensx/tests/execute.test.tsx | 26 ++++---- .../tests/utils/executeWithCheckpoints.ts | 22 +++++-- 17 files changed, 178 insertions(+), 77 deletions(-) diff --git a/examples/blogWriter/index.ts b/examples/blogWriter/index.ts index af5c07bc..dd2ac1c9 100644 --- a/examples/blogWriter/index.ts +++ b/examples/blogWriter/index.ts @@ -5,10 +5,13 @@ import { WriteBlog } from "./writeBlog.js"; async function main() { console.log("\nšŸš€ Starting blog writing workflow"); const wf = gsx.Workflow("WriteBlogWorkflow", WriteBlog); - const stream = await wf.run({ - stream: true, - prompt: "Write a blog post about the future of AI", - }); + const stream = await wf.run( + { + stream: true, + prompt: "Write a blog post about the future of AI", + }, + { printUrl: true }, + ); for await (const chunk of stream) { process.stdout.write(chunk); } diff --git a/examples/contexts/index.tsx b/examples/contexts/index.tsx index 97e7231f..c97ff790 100644 --- a/examples/contexts/index.tsx +++ b/examples/contexts/index.tsx @@ -29,7 +29,7 @@ async function main() { // Provide a value to the context const result = await gsx .Workflow("ContextExampleWorkflow", ContextExample) - .run({}); + .run({}, { printUrl: true }); console.log(result); } diff --git a/examples/deepResearch/index.ts b/examples/deepResearch/index.ts index a68b6d35..34cbdc2d 100644 --- a/examples/deepResearch/index.ts +++ b/examples/deepResearch/index.ts @@ -6,9 +6,12 @@ async function main() { const prompt = "find research comparing the writing style of humans and LLMs. We want to figure out how to quantify the differences."; console.log("\nšŸš€ Starting deep research workflow with prompt: ", prompt); - const result = await gsx.Workflow("DeepResearchWorkflow", DeepResearch).run({ - prompt, - }); + const result = await gsx.Workflow("DeepResearchWorkflow", DeepResearch).run( + { + prompt, + }, + { printUrl: true }, + ); console.log("\n\n"); console.log("=".repeat(50)); diff --git a/examples/gsxChatCompletion/index.tsx b/examples/gsxChatCompletion/index.tsx index b6d8165a..e2c038e9 100644 --- a/examples/gsxChatCompletion/index.tsx +++ b/examples/gsxChatCompletion/index.tsx @@ -41,7 +41,7 @@ function basicCompletion() { BasicCompletionExample, ); - return workflow.run({}); + return workflow.run({}, { printUrl: true }); } function tools() { @@ -93,7 +93,7 @@ function tools() { const workflow = gsx.Workflow("ToolsExampleWorkflow", ToolsExample); - return workflow.run({}); + return workflow.run({}, { printUrl: true }); } function toolsStreaming() { @@ -149,7 +149,7 @@ function toolsStreaming() { ToolsStreamingExample, ); - return workflow.run({}); + return workflow.run({}, { printUrl: true }); } function streamingCompletion() { @@ -182,7 +182,7 @@ function streamingCompletion() { StreamingCompletionWorkflow, ); - return workflow.run({}); + return workflow.run({}, { printUrl: true }); } function structuredOutput() { @@ -235,7 +235,7 @@ function structuredOutput() { StructuredOutputWorkflow, ); - return workflow.run({}); + return workflow.run({}, { printUrl: true }); } function multiStepTools() { @@ -320,7 +320,7 @@ Please explain your thinking as you go through this analysis.`, MultiStepToolsWorkflow, ); - return workflow.run({}); + return workflow.run({}, { printUrl: true }); } async function main() { diff --git a/examples/hackerNewsAnalyzer/index.ts b/examples/hackerNewsAnalyzer/index.ts index 8dea8ff3..2cc6c9fd 100644 --- a/examples/hackerNewsAnalyzer/index.ts +++ b/examples/hackerNewsAnalyzer/index.ts @@ -8,9 +8,12 @@ async function main() { console.log("\nšŸš€ Starting HN analysis workflow..."); const { report, tweet } = await gsx .Workflow("AnalyzeHackerNewsWorkflow", AnalyzeHackerNewsTrends) - .run({ - postCount: 500, - }); + .run( + { + postCount: 500, + }, + { printUrl: true }, + ); // Write outputs to files await fs.writeFile("hn_analysis_report.md", report); diff --git a/examples/providers/index.tsx b/examples/providers/index.tsx index 28c3633d..8aa2eeb3 100644 --- a/examples/providers/index.tsx +++ b/examples/providers/index.tsx @@ -19,9 +19,12 @@ async function main() { const workflow = gsx.Workflow("ScrapePageExampleWorkflow", ScrapePageExample); console.log("\nšŸš€ Scraping page from url:", url); - const markdown = await workflow.run({ - url, - }); + const markdown = await workflow.run( + { + url, + }, + { printUrl: true }, + ); console.log("\nāœ… Scraping complete"); console.log("\nšŸš€ Scraped markdown:", markdown); } diff --git a/examples/reflection/index.tsx b/examples/reflection/index.tsx index 9ffced44..49dda8c6 100644 --- a/examples/reflection/index.tsx +++ b/examples/reflection/index.tsx @@ -83,12 +83,15 @@ async function main() { "CleanBuzzwordsWorkflow", CleanBuzzwordsReflectionLoop, ); - const withoutBuzzwords = await workflow.run({ - text: `We are a cutting-edge technology company leveraging bleeding-edge AI solutions to deliver best-in-class products to our customers. Our agile development methodology ensures we stay ahead of the curve with paradigm-shifting innovations. + const withoutBuzzwords = await workflow.run( + { + text: `We are a cutting-edge technology company leveraging bleeding-edge AI solutions to deliver best-in-class products to our customers. Our agile development methodology ensures we stay ahead of the curve with paradigm-shifting innovations. Our mission-critical systems utilize cloud-native architectures and next-generation frameworks to create synergistic solutions that drive digital transformation. By thinking outside the box, we empower stakeholders with scalable and future-proof applications that maximize ROI. Through our holistic approach to disruptive innovation, we create game-changing solutions that move the needle and generate impactful results. Our best-of-breed technology stack combined with our customer-centric focus allows us to ideate and iterate rapidly in this fast-paced market.`, - }); + }, + { printUrl: true }, + ); console.log("result:\n", withoutBuzzwords); } diff --git a/examples/streaming/index.tsx b/examples/streaming/index.tsx index 4dccfa0b..b7b86c01 100644 --- a/examples/streaming/index.tsx +++ b/examples/streaming/index.tsx @@ -107,6 +107,7 @@ async function runStreamingWithChildrenExample() { const workflow = gsx.Workflow( "StreamingStoryWithChildrenWorkflow", StreamStoryWithChildren, + { printUrl: true }, ); console.log("\nšŸ“ Non-streaming version (waiting for full response):"); @@ -122,14 +123,19 @@ async function runStreamingExample() { console.log("\nšŸš€ Starting streaming example with prompt:", prompt); - const workflow = gsx.Workflow("StreamStoryWorkflow", StreamStory); + const workflow = gsx.Workflow("StreamStoryWorkflow", StreamStory, { + printUrl: true, + }); console.log("\nšŸ“ Non-streaming version (waiting for full response):"); const finalResult = await workflow.run({ prompt }); console.log("āœ… Complete response:", finalResult); console.log("\nšŸ“ Streaming version (processing tokens as they arrive):"); - const response = await workflow.run({ prompt, stream: true }); + const response = await workflow.run( + { prompt, stream: true }, + { printUrl: true }, + ); for await (const token of response) { process.stdout.write(token); diff --git a/examples/structuredOutputs/index.tsx b/examples/structuredOutputs/index.tsx index 946fc730..8412c80a 100644 --- a/examples/structuredOutputs/index.tsx +++ b/examples/structuredOutputs/index.tsx @@ -92,9 +92,12 @@ async function main() { console.log("\nšŸŽÆ Getting structured outputs with GSXChatCompletion"); const workflow = gsx.Workflow("ExtractEntities", ExtractEntities); - const result = await workflow.run({ - text: "John Doe is a software engineer at Google.", - }); + const result = await workflow.run( + { + text: "John Doe is a software engineer at Google.", + }, + { printUrl: true }, + ); console.log(result); console.log("\nšŸŽÆ Getting structured outputs without helpers"); @@ -102,9 +105,12 @@ async function main() { "ExtractEntitiesWithoutHelpers", ExtractEntitiesWithoutHelpers, ); - const resultWithoutHelpers = await workflowWithoutHelpers.run({ - text: "John Doe is a software engineer at Google.", - }); + const resultWithoutHelpers = await workflowWithoutHelpers.run( + { + text: "John Doe is a software engineer at Google.", + }, + { printUrl: true }, + ); console.log(resultWithoutHelpers); console.log("\nāœ… Structured outputs example complete"); } diff --git a/packages/create-gensx/src/templates/typescript/src/index.tsx.template b/packages/create-gensx/src/templates/typescript/src/index.tsx.template index 39ca3cee..7af9ef1d 100644 --- a/packages/create-gensx/src/templates/typescript/src/index.tsx.template +++ b/packages/create-gensx/src/templates/typescript/src/index.tsx.template @@ -33,8 +33,11 @@ const WorkflowComponent = gsx.Component<{ userInput: string }, string>( const workflow = gsx.Workflow("MyGSXWorkflow", WorkflowComponent); -const result = await workflow.run({ - userInput: "Hi there! Say 'Hello, World!' and nothing else.", -}); +const result = await workflow.run( + { + userInput: "Hi there! Say 'Hello, World!' and nothing else.", + }, + { printUrl: true }, +); console.log(result); diff --git a/packages/gensx-cli/src/commands/login.ts b/packages/gensx-cli/src/commands/login.ts index 8bed2fee..6c00280a 100644 --- a/packages/gensx-cli/src/commands/login.ts +++ b/packages/gensx-cli/src/commands/login.ts @@ -86,6 +86,9 @@ async function saveConfig(config: Config): Promise { org: config.orgSlug, baseUrl: API_BASE_URL, }, + console: { + baseUrl: APP_BASE_URL, + }, }); // Add a helpful header comment diff --git a/packages/gensx/src/checkpoint.ts b/packages/gensx/src/checkpoint.ts index 1d08bf58..8df68444 100644 --- a/packages/gensx/src/checkpoint.ts +++ b/packages/gensx/src/checkpoint.ts @@ -67,7 +67,9 @@ export class CheckpointManager implements CheckpointWriter { private version = 1; private org: string; private apiKey: string; - private baseUrl: string; + private apiBaseUrl: string; + private consoleBaseUrl: string; + private printUrl = false; // Provide unified view of all secrets get secretValues(): Set { @@ -84,20 +86,29 @@ export class CheckpointManager implements CheckpointWriter { apiKey: string; org: string; disabled?: boolean; - baseUrl?: string; + apiBaseUrl?: string; + consoleBaseUrl?: string; }) { // Priority order: constructor opts > env vars > config file const config = readConfig(); const apiKey = opts?.apiKey ?? process.env.GENSX_API_KEY ?? config.api?.token; const org = opts?.org ?? process.env.GENSX_ORG ?? config.api?.org; - const baseUrl = - opts?.baseUrl ?? process.env.GENSX_CHECKPOINT_URL ?? config.api?.baseUrl; + const apiBaseUrl = + opts?.apiBaseUrl ?? + process.env.GENSX_CHECKPOINT_URL ?? + config.api?.baseUrl; + const consoleBaseUrl = + opts?.consoleBaseUrl ?? + process.env.GENSX_CONSOLE_URL ?? + config.console?.baseUrl; this.checkpointsEnabled = apiKey !== undefined; this.org = org ?? ""; this.apiKey = apiKey ?? ""; - this.baseUrl = baseUrl ?? "https://api.gensx.com"; + this.apiBaseUrl = apiBaseUrl ?? "https://api.gensx.com"; + this.consoleBaseUrl = consoleBaseUrl ?? "https://app.gensx.com"; + if ( opts?.disabled || process.env.GENSX_CHECKPOINTS === "false" || @@ -230,6 +241,7 @@ export class CheckpointManager implements CheckpointWriter { }); } + private havePrintedUrl = false; private async writeCheckpoint() { if (!this.root) return; @@ -255,7 +267,7 @@ export class CheckpointManager implements CheckpointWriter { const treeCopy = cloneWithoutFunctions(this.root); const maskedRoot = this.maskExecutionTree(treeCopy as ExecutionNode); - const url = join(this.baseUrl, `/org/${this.org}/executions`); + const url = join(this.apiBaseUrl, `/org/${this.org}/executions`); const steps = this.countSteps(this.root); // Separately gzip the rawExecution data @@ -271,11 +283,12 @@ export class CheckpointManager implements CheckpointWriter { const base64CompressedExecution = Buffer.from(compressedExecution).toString("base64"); + const workflowName = this.workflowName ?? this.root.componentName; const payload = { executionId: this.root.id, version: this.version, schemaVersion: 2, - workflowName: this.root.componentName, + workflowName, startedAt: this.root.startTime, completedAt: this.root.endTime, rawExecution: base64CompressedExecution, @@ -301,6 +314,24 @@ export class CheckpointManager implements CheckpointWriter { message: await response.text(), }); } + + if (this.printUrl && !this.havePrintedUrl && response.ok) { + const responseBody = (await response.json()) as { + status: "ok"; + data: { + executionId: string; + workflowName?: string; + }; + }; + const executionUrl = new URL( + `/${this.org}/workflows/${responseBody.data.workflowName ?? workflowName}/${responseBody.data.executionId}`, + this.consoleBaseUrl, + ); + this.havePrintedUrl = true; + console.info( + `\n\n\x1b[33m[GenSX] View execution at:\x1b[0m \x1b[1;34m${executionUrl.toString()}\x1b[0m\n\n`, + ); + } } catch (error) { console.error(`[Checkpoint] Failed to save checkpoint:`, { error }); } @@ -599,10 +630,6 @@ export class CheckpointManager implements CheckpointWriter { // Handle root node case if (!this.root) { this.root = node; - // If the workflow name is set, update the root node name. - if (this.workflowName) { - this.root.componentName = this.workflowName; - } } else if (this.root.parentId === node.id) { // Current root was waiting for this node as parent this.attachToParent(this.root, node); @@ -666,13 +693,13 @@ export class CheckpointManager implements CheckpointWriter { } } + // TODO: What if we have already sent some checkpoints? setWorkflowName(name: string) { - // Right now we just update the name of the root node. Eventually this should be separated from the workflow name. this.workflowName = name; + } - if (this.root) { - this.root.componentName = name; - } + setPrintUrl(printUrl: boolean) { + this.printUrl = printUrl; } updateNode(id: string, updates: Partial) { @@ -694,9 +721,6 @@ export class CheckpointManager implements CheckpointWriter { } Object.assign(node, updates); - if (node.id === this.root?.id) { - node.componentName = this.workflowName ?? node.componentName; - } this.updateCheckpoint(); } else { console.warn(`[Tracker] Attempted to update unknown node:`, { id }); diff --git a/packages/gensx/src/config.ts b/packages/gensx/src/config.ts index 6c3bc447..f3bd4a2d 100644 --- a/packages/gensx/src/config.ts +++ b/packages/gensx/src/config.ts @@ -10,6 +10,9 @@ export interface GensxConfig { org?: string; baseUrl?: string; }; + console?: { + baseUrl?: string; + }; } export function getConfigPath(): string { diff --git a/packages/gensx/src/execute.ts b/packages/gensx/src/execute.ts index 2cbb034e..a60b8e70 100644 --- a/packages/gensx/src/execute.ts +++ b/packages/gensx/src/execute.ts @@ -28,32 +28,61 @@ export function Workflow( name: string, component: GsxComponent, opts?: { + printUrl?: boolean; metadata?: Record; }, -): { run: (props: P) => Promise }; +): { + run: ( + props: P, + runOpts?: { printUrl?: boolean; metadata?: Record }, + ) => Promise; +}; // Overload for GsxStreamComponent export function Workflow

( name: string, component: GsxStreamComponent

, opts?: { + printUrl?: boolean; metadata?: Record; }, -): { run: (props: T) => RunResult }; +): { + run: ( + props: T, + runOpts?: { printUrl?: boolean; metadata?: Record }, + ) => RunResult; +}; + +// Overload for GsxComponent or GsxStreamComponent export function Workflow

( name: string, component: GsxComponent | GsxStreamComponent

, opts?: { + printUrl?: boolean; metadata?: Record; }, ): { - run: (props: P) => Promise; + run: ( + props: P, + runOpts?: { printUrl?: boolean; metadata?: Record }, + ) => Promise; } { return { - run: async (props) => { + run: async (props, runOpts = {}) => { const context = new ExecutionContext({}); + const mergedOpts = { + ...opts, + ...runOpts, + ...(opts?.metadata + ? { metadata: { ...opts.metadata, ...runOpts.metadata } } + : { metadata: runOpts.metadata }), + }; + const workflowContext = context.getWorkflowContext(); + workflowContext.checkpointManager.setPrintUrl( + mergedOpts.printUrl ?? false, + ); workflowContext.checkpointManager.setWorkflowName(name); const result = await withContext(context, async () => { @@ -68,7 +97,7 @@ export function Workflow

( if (rootId) { workflowContext.checkpointManager.addMetadata( rootId, - opts?.metadata ?? {}, + mergedOpts.metadata ?? {}, ); } else { console.warn( diff --git a/packages/gensx/tests/checkpoint.test.tsx b/packages/gensx/tests/checkpoint.test.tsx index 85f00ede..bbbac29b 100644 --- a/packages/gensx/tests/checkpoint.test.tsx +++ b/packages/gensx/tests/checkpoint.test.tsx @@ -483,7 +483,9 @@ suite("tree reconstruction", () => { expect(lastCall).toBeDefined(); const options = lastCall![1]; expect(options?.body).toBeDefined(); - const lastCallBody = getExecutionFromBody(options?.body as string); + const { node: lastCallBody } = getExecutionFromBody( + options?.body as string, + ); expect(lastCallBody.componentName).toBe("Parent"); expect(lastCallBody.children[0].componentName).toBe("Child1"); diff --git a/packages/gensx/tests/execute.test.tsx b/packages/gensx/tests/execute.test.tsx index a5e6e355..b928e2d4 100644 --- a/packages/gensx/tests/execute.test.tsx +++ b/packages/gensx/tests/execute.test.tsx @@ -95,26 +95,24 @@ suite("execute", () => { } else { checkpoints = r2.checkpoints; } - expect(Object.keys(checkpoints).length).toBeGreaterThanOrEqual(2); + let workflowNames: typeof r1.workflowNames; + if (r1.workflowNames.size > 0) { + workflowNames = r1.workflowNames; + } else { + workflowNames = r2.workflowNames; + } + expect(workflowNames.size).toBeGreaterThanOrEqual(2); expect( Object.values(checkpoints).some((c) => c.metadata?.num === "1"), ).toBe(true); expect( Object.values(checkpoints).some((c) => c.metadata?.num === "2"), ).toBe(true); - }); - - test("sets the workflow name on the root node", async () => { - const result = await executeWorkflowWithCheckpoints( - , - ); - expect(result.result).toBe("hello"); - expect(Object.keys(result.checkpoints).length).toBeGreaterThan(0); - - // The executeWorkflowWithCheckpoints helper sets the workflow name to be something like executeWorkflowWithCheckpoints1 - expect(Object.values(result.checkpoints)[0].componentName).toMatch( - /executeWorkflowWithCheckpoints\d+/, - ); + expect( + Array.from(workflowNames).every((wn) => + /executeWorkflowWithCheckpoints\d+/.exec(wn), + ), + ).toBe(true); }); }); diff --git a/packages/gensx/tests/utils/executeWithCheckpoints.ts b/packages/gensx/tests/utils/executeWithCheckpoints.ts index 71be9e12..fb94e380 100644 --- a/packages/gensx/tests/utils/executeWithCheckpoints.ts +++ b/packages/gensx/tests/utils/executeWithCheckpoints.ts @@ -39,7 +39,7 @@ export async function executeWithCheckpoints( // Set up fetch mock to capture checkpoints mockFetch((_input: FetchInput, options?: FetchInit) => { if (!options?.body) throw new Error("No body provided"); - const checkpoint = getExecutionFromBody(options.body as string); + const { node: checkpoint } = getExecutionFromBody(options.body as string); checkpoints.push(checkpoint); return new Response(null, { status: 200 }); }); @@ -74,6 +74,7 @@ export async function executeWorkflowWithCheckpoints( ): Promise<{ result: T; checkpoints: Record; + workflowNames: Set; }> { const oldOrg = process.env.GENSX_ORG; const oldApiKey = process.env.GENSX_API_KEY; @@ -81,12 +82,16 @@ export async function executeWorkflowWithCheckpoints( process.env.GENSX_API_KEY = "test-api-key"; const checkpoints: Record = {}; + const workflowNames = new Set(); // Set up fetch mock to capture checkpoints mockFetch((_input: FetchInput, options?: FetchInit) => { if (!options?.body) throw new Error("No body provided"); - const checkpoint = getExecutionFromBody(options.body as string); + const { node: checkpoint, workflowName } = getExecutionFromBody( + options.body as string, + ); checkpoints[checkpoint.id] = checkpoint; + workflowNames.add(workflowName); return new Response(null, { status: 200 }); }); @@ -112,16 +117,23 @@ export async function executeWorkflowWithCheckpoints( process.env.GENSX_API_KEY = oldApiKey; // This is all checkpoints that happen during the workflow execution, not just the ones for this specific execution, due to how we mock fetch to extract them. - return { result, checkpoints }; + return { result, checkpoints, workflowNames }; } -export function getExecutionFromBody(bodyStr: string): ExecutionNode { +export function getExecutionFromBody(bodyStr: string): { + node: ExecutionNode; + workflowName: string; +} { const body = JSON.parse(zlib.gunzipSync(bodyStr).toString()) as { + workflowName: string; rawExecution: string; }; const compressedExecution = Buffer.from(body.rawExecution, "base64"); const decompressedExecution = zlib.gunzipSync(compressedExecution); - return JSON.parse(decompressedExecution.toString("utf-8")) as ExecutionNode; + return { + node: JSON.parse(decompressedExecution.toString("utf-8")) as ExecutionNode, + workflowName: body.workflowName, + }; } export function mockFetch(