Skip to content

Commit da4f2c4

Browse files
khaliqgantclaude
andauthored
fix(cli): pre-parse workflow script files with actionable error hints (#727)
* fix(cli): pre-parse workflow script files with actionable error hints agent-relay run workflows/foo.ts previously shipped template-literal parse errors straight through as cryptic esbuild TransformError output ("Expected '}' but found '<token>'") that didn't hint at the actual cause. Workflow authors kept hitting the same class of mistakes inside command: and task: template literals: - raw backticks inside prose or commit messages terminating the outer JavaScript template literal early - unescaped ${...} triggering JS interpolation when a shell variable was intended - literal \n inside shell comments getting eaten as a real newline and turning the rest of the "comment" into broken shell Pre-parse workflow .ts files with esbuild.transformSync BEFORE spawning tsx, catch the error, and wrap it with a specific diagnostic pointing at the likely cause. Non-parse errors are rethrown unchanged. New preParseWorkflowFile() is exported so it can be unit-tested and reused by other workflow-loading code paths. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): resolve workspace key via direct broker HTTP fetch Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fc9135c commit da4f2c4

3 files changed

Lines changed: 234 additions & 15 deletions

File tree

src/cli/commands/messaging.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
13
import { Command } from 'commander';
24
import { RelayCast, AgentRelayClient } from '@agent-relay/sdk';
35
import { getProjectPaths } from '@agent-relay/config';
@@ -314,28 +316,56 @@ async function resolveRelaycastApiKey(cwd: string): Promise<string> {
314316
return envApiKey;
315317
}
316318

317-
let client: AgentRelayClient | undefined;
319+
const connectionPath = path.join(getProjectPaths(cwd).dataDir, 'connection.json');
320+
let raw: string;
318321
try {
319-
client = AgentRelayClient.connect({ cwd });
322+
raw = fs.readFileSync(connectionPath, 'utf-8');
320323
} catch {
321324
throw new Error(
322-
'Relaycast workspace key is not configured. Set RELAY_API_KEY, or start the local broker with `agent-relay up --workspace-key <key>`.'
325+
'Failed to read broker connection metadata. Start the broker with `agent-relay up` or set RELAY_API_KEY.'
323326
);
324327
}
325328

329+
let parsed: { port?: unknown; api_key?: unknown };
326330
try {
327-
const session = await client.getSession();
328-
const brokerApiKey = session.workspace_key?.trim();
329-
if (brokerApiKey) {
330-
return brokerApiKey;
331+
parsed = JSON.parse(raw) as { port?: unknown; api_key?: unknown };
332+
} catch {
333+
throw new Error(
334+
'Invalid broker connection metadata. Start the broker with `agent-relay up` or set RELAY_API_KEY.'
335+
);
336+
}
337+
338+
const port = parsed.port;
339+
const apiKey = parsed.api_key;
340+
if (typeof port !== 'number' || !Number.isInteger(port) || typeof apiKey !== 'string' || !apiKey.trim()) {
341+
throw new Error(
342+
'Invalid broker connection metadata. Start the broker with `agent-relay up` or set RELAY_API_KEY.'
343+
);
344+
}
345+
346+
try {
347+
const response = await fetch(`http://127.0.0.1:${port}/api/session`, {
348+
headers: { Authorization: `Bearer ${apiKey}` },
349+
});
350+
351+
if (!response.ok) {
352+
throw new Error(`broker session request failed (${response.status})`);
353+
}
354+
355+
const session = (await response.json()) as {
356+
workspaceKey?: string | null;
357+
workspace_key?: string | null;
358+
};
359+
const workspaceKey = session.workspaceKey ?? session.workspace_key;
360+
if (workspaceKey && typeof workspaceKey === 'string' && workspaceKey.trim()) {
361+
return workspaceKey.trim();
331362
}
332-
} finally {
333-
client.disconnect();
363+
} catch (err) {
364+
const detail = err instanceof Error ? err.message : String(err);
365+
throw new Error(`Failed to query broker session: ${detail}`);
334366
}
335367

336-
throw new Error(
337-
'The running local broker has no Relaycast workspace key, so history and inbox are unavailable in local-only mode. Restart with RELAY_API_KEY or `agent-relay up --workspace-key <key>`.'
338-
);
368+
throw new Error('No Relaycast workspace key found. Set RELAY_API_KEY or start broker with agent-relay up.');
339369
}
340370

341371
async function createDefaultRelaycastClient(options: {

src/cli/commands/setup.test.ts

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import fs from 'node:fs';
55
import os from 'node:os';
66
import path from 'node:path';
77

8-
import { ensureLocalSdkWorkflowRuntime, findLocalSdkWorkspace, registerSetupCommands, type SetupDependencies } from './setup.js';
8+
import {
9+
ensureLocalSdkWorkflowRuntime,
10+
findLocalSdkWorkspace,
11+
preParseWorkflowFile,
12+
registerSetupCommands,
13+
type SetupDependencies,
14+
} from './setup.js';
915

1016
class ExitSignal extends Error {
1117
constructor(public readonly code: number) {
@@ -83,7 +89,7 @@ describe('local SDK workflow runtime bootstrapping', () => {
8389
expect(execRunner).toHaveBeenCalledWith(
8490
'npm',
8591
['run', 'build:sdk'],
86-
expect.objectContaining({ cwd: tempRoot, stdio: 'inherit' }),
92+
expect.objectContaining({ cwd: tempRoot, stdio: 'inherit' })
8793
);
8894
fs.rmSync(tempRoot, { recursive: true, force: true });
8995
});
@@ -125,7 +131,16 @@ describe('registerSetupCommands', () => {
125131
const { program, deps } = createHarness();
126132

127133
await runCommand(program, ['run', 'workflow.yaml', '--workflow', 'main']);
128-
await runCommand(program, ['run', 'workflow.py', '--resume', 'run-123', '--start-from', 'step-a', '--previous-run-id', 'run-122']);
134+
await runCommand(program, [
135+
'run',
136+
'workflow.py',
137+
'--resume',
138+
'run-123',
139+
'--start-from',
140+
'step-a',
141+
'--previous-run-id',
142+
'run-122',
143+
]);
129144

130145
expect(deps.runYamlWorkflow).toHaveBeenCalledWith('workflow.yaml', {
131146
workflow: 'main',
@@ -178,3 +193,65 @@ describe('registerSetupCommands', () => {
178193
expect(exitCode).toBe(1);
179194
});
180195
});
196+
197+
describe('preParseWorkflowFile', () => {
198+
function writeTempWorkflow(name: string, contents: string): string {
199+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'preparse-'));
200+
const full = path.join(dir, name);
201+
fs.writeFileSync(full, contents, 'utf8');
202+
return full;
203+
}
204+
205+
it('returns silently for a valid TypeScript workflow file', () => {
206+
const file = writeTempWorkflow(
207+
'valid.ts',
208+
`
209+
import { workflow } from '@agent-relay/sdk/workflows';
210+
workflow('w')
211+
.pattern('dag')
212+
.step('one', {
213+
type: 'deterministic',
214+
command: 'echo hi',
215+
});
216+
`.trim()
217+
);
218+
expect(() => preParseWorkflowFile(file)).not.toThrow();
219+
});
220+
221+
it('wraps a raw backtick inside a template literal with an actionable hint', () => {
222+
// A raw backtick inside a command: template literal terminates
223+
// the outer JS template literal early and produces an esbuild
224+
// parse error. We want the error message to tell the user how
225+
// to fix it.
226+
const file = writeTempWorkflow(
227+
'bad-backtick.ts',
228+
['const step = {', ' command: `git commit -m "use `npm install` here"`,', '};'].join('\n')
229+
);
230+
expect(() => preParseWorkflowFile(file)).toThrow(/Workflow file failed to parse/);
231+
try {
232+
preParseWorkflowFile(file);
233+
} catch (err) {
234+
const msg = (err as Error).message;
235+
expect(msg).toMatch(/Hint:/);
236+
expect(msg).toMatch(/single quotes/);
237+
}
238+
});
239+
240+
it('wraps an unescaped ${} interpolation with an actionable hint', () => {
241+
// Not strictly a parse error in isolation, but combined with a
242+
// bad identifier makes esbuild fail. We mostly want to verify the
243+
// hint path fires for the common error text.
244+
const file = writeTempWorkflow(
245+
'bad-dollar.ts',
246+
['const step = {', ' command: `echo ${NOT a valid JS expression}`,', '};'].join('\n')
247+
);
248+
expect(() => preParseWorkflowFile(file)).toThrow(/Workflow file failed to parse/);
249+
});
250+
251+
it('propagates non-parse errors unchanged', () => {
252+
// Non-existent file should throw the fs-level error, not a fake parse wrapper.
253+
expect(() => preParseWorkflowFile('/tmp/does-not-exist-' + Date.now() + '.ts')).toThrow(
254+
/Cannot read workflow file/
255+
);
256+
});
257+
});

src/cli/commands/setup.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from 'node:path';
33
import readline from 'node:readline';
44
import { execFileSync, spawn as spawnProcess } from 'node:child_process';
55
import { Command } from 'commander';
6+
import { transformSync } from 'esbuild';
67
import { getProjectPaths } from '@agent-relay/config';
78
import { readBrokerConnection } from '../lib/broker-lifecycle.js';
89
import { enableTelemetry, disableTelemetry, getStatus, isDisabledByEnv } from '@agent-relay/telemetry';
@@ -155,6 +156,106 @@ export function ensureLocalSdkWorkflowRuntime(
155156
}
156157
}
157158

159+
/**
160+
* Pre-parse a TypeScript workflow file with esbuild to catch template-literal
161+
* and syntax errors before handing off to tsx. Wraps the raw esbuild error
162+
* with hints targeting the most common mistakes in workflow `command:` /
163+
* `task:` blocks — raw backticks in prose, unescaped `${...}` that was meant
164+
* as a shell variable, etc.
165+
*
166+
* These all produce cryptic esbuild errors (`Expected "}" but found "<word>"`,
167+
* `Unterminated template literal`) that don't hint at the actual cause when
168+
* you're writing workflow files.
169+
*/
170+
export function preParseWorkflowFile(filePath: string): void {
171+
let source: string;
172+
try {
173+
source = fs.readFileSync(filePath, 'utf8');
174+
} catch (err) {
175+
throw new Error(`Cannot read workflow file ${filePath}: ${(err as Error).message}`);
176+
}
177+
178+
try {
179+
transformSync(source, {
180+
loader: 'ts',
181+
sourcemap: false,
182+
logLevel: 'silent',
183+
});
184+
return;
185+
} catch (err) {
186+
const errors = (err as { errors?: EsbuildError[] }).errors;
187+
if (!Array.isArray(errors) || errors.length === 0) {
188+
throw err;
189+
}
190+
throw formatWorkflowParseError(filePath, errors[0]!);
191+
}
192+
}
193+
194+
interface EsbuildError {
195+
text: string;
196+
location?: {
197+
file?: string;
198+
line?: number;
199+
column?: number;
200+
lineText?: string;
201+
};
202+
}
203+
204+
function formatWorkflowParseError(filePath: string, e: EsbuildError): Error {
205+
const loc = e.location ?? {};
206+
const where =
207+
loc.line !== undefined
208+
? `${filePath}:${loc.line}${loc.column !== undefined ? `:${loc.column}` : ''}`
209+
: filePath;
210+
211+
const hints: string[] = [];
212+
const text = e.text ?? '';
213+
214+
if (/Expected "\}" but found/i.test(text) || /Unterminated template literal/i.test(text)) {
215+
hints.push(
216+
'Likely a JavaScript template literal metacharacter inside a `command:` or `task:` block. ' +
217+
'Inside workflow .ts files every `command: \\`...\\`` is a JavaScript template literal — ' +
218+
'backticks terminate it and `${...}` triggers JS interpolation before the shell ever sees the string.',
219+
'Fixes: use single quotes instead of backticks in prose/commit messages; ' +
220+
'for shell variables use `$VAR` (no braces) or escape as `\\${VAR}`; ' +
221+
'never write literal `\\n` inside a shell comment (it becomes a real newline).'
222+
);
223+
}
224+
225+
if (/Unexpected "\$"/.test(text)) {
226+
hints.push(
227+
'Unexpected `$` inside a template literal usually means `${...}` was interpreted as JS interpolation. ' +
228+
'Escape it as `\\${...}` or drop the braces and use plain `$VAR`.'
229+
);
230+
}
231+
232+
if (/Expected identifier/.test(text) && /template/i.test(text)) {
233+
hints.push(
234+
'A template literal interpolation `${...}` needs a valid JS expression inside. ' +
235+
'If you meant a shell variable, escape the `$` or drop the braces.'
236+
);
237+
}
238+
239+
const lines = ['', `Workflow file failed to parse: ${where}`, ` ${text}`];
240+
if (loc.lineText) {
241+
lines.push(` | ${loc.lineText}`);
242+
if (loc.column !== undefined && loc.column >= 0) {
243+
lines.push(` | ${' '.repeat(loc.column)}^`);
244+
}
245+
}
246+
if (hints.length > 0) {
247+
lines.push('');
248+
for (const hint of hints) {
249+
lines.push(`Hint: ${hint}`);
250+
}
251+
}
252+
lines.push('');
253+
254+
const wrapped = new Error(lines.join('\n'));
255+
(wrapped as Error & { code?: string }).code = 'WORKFLOW_PARSE_ERROR';
256+
return wrapped;
257+
}
258+
158259
function runScriptFile(
159260
filePath: string,
160261
options: { dryRun?: boolean; resume?: string; startFrom?: string; previousRunId?: string } = {}
@@ -211,6 +312,17 @@ Run ID: ${runId}`;
211312
if (ext === '.ts' || ext === '.tsx') {
212313
ensureLocalSdkWorkflowRuntime(path.dirname(resolved));
213314

315+
// Pre-parse the file with esbuild so template-literal mistakes (raw
316+
// backticks inside prose, unescaped ${} in shell commands, etc.) fail
317+
// fast with an actionable error message instead of a cryptic tsx
318+
// TransformError dumped mid-run.
319+
try {
320+
preParseWorkflowFile(resolved);
321+
} catch (err) {
322+
cleanupRunIdFile();
323+
throw err;
324+
}
325+
214326
const runners = ['tsx', 'ts-node'];
215327
for (const runner of runners) {
216328
try {

0 commit comments

Comments
 (0)