Skip to content

Commit 1bb4ba0

Browse files
authored
Merge pull request #67 from muxinc/dj/robots-polish
Polish up some robots commands
2 parents 27651eb + 06bfb77 commit 1bb4ba0

15 files changed

+1374
-154
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import type { AnyRobotsJob } from './_shared.ts';
3+
import { assertJobCompleted } from './_shared.ts';
4+
5+
const baseJob = {
6+
id: 'rjob_xyz',
7+
workflow: 'summarize',
8+
created_at: 0,
9+
updated_at: 0,
10+
units_consumed: 0,
11+
parameters: { asset_id: 'asset_x' },
12+
} as unknown as AnyRobotsJob;
13+
14+
describe('assertJobCompleted', () => {
15+
test('returns silently on status=completed', () => {
16+
expect(() =>
17+
assertJobCompleted({ ...baseJob, status: 'completed' } as AnyRobotsJob),
18+
).not.toThrow();
19+
});
20+
21+
test('throws on status=errored', () => {
22+
expect(() =>
23+
assertJobCompleted({ ...baseJob, status: 'errored' } as AnyRobotsJob),
24+
).toThrow(/errored/i);
25+
});
26+
27+
test('throws on status=cancelled', () => {
28+
expect(() =>
29+
assertJobCompleted({ ...baseJob, status: 'cancelled' } as AnyRobotsJob),
30+
).toThrow(/cancelled/i);
31+
});
32+
33+
test('includes job.errors details when present', () => {
34+
const job = {
35+
...baseJob,
36+
status: 'errored',
37+
errors: [
38+
{ type: 'processing_error', message: 'asset not ready' },
39+
{ type: 'timeout', message: 'took too long' },
40+
],
41+
} as unknown as AnyRobotsJob;
42+
expect(() => assertJobCompleted(job)).toThrow(
43+
/processing_error: asset not ready.*timeout: took too long/,
44+
);
45+
});
46+
47+
test('includes job id in the error message', () => {
48+
expect(() =>
49+
assertJobCompleted({
50+
...baseJob,
51+
id: 'rjob_abc123',
52+
status: 'errored',
53+
} as AnyRobotsJob),
54+
).toThrow(/rjob_abc123/);
55+
});
56+
});

src/commands/robots/_shared.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { readFile } from 'node:fs/promises';
2+
import type Mux from '@mux/mux-node';
3+
import type {
4+
AskQuestionsJob,
5+
FindKeyMomentsJob,
6+
GenerateChaptersJob,
7+
ModerateJob,
8+
SummarizeJob,
9+
TranslateCaptionsJob,
10+
} from '@mux/mux-node/resources/robots-preview/jobs';
11+
12+
export type AnyRobotsJob =
13+
| AskQuestionsJob
14+
| FindKeyMomentsJob
15+
| GenerateChaptersJob
16+
| ModerateJob
17+
| SummarizeJob
18+
| TranslateCaptionsJob;
19+
20+
export type RobotsWorkflow = AnyRobotsJob['workflow'];
21+
22+
export const FILE_MUTEX_MSG =
23+
'--file cannot be combined with other parameter flags. Use one or the other.';
24+
25+
const TERMINAL_STATUSES = new Set(['completed', 'cancelled', 'errored']);
26+
27+
export function isTerminalStatus(status: string): boolean {
28+
return TERMINAL_STATUSES.has(status);
29+
}
30+
31+
export async function loadJobParameters<T extends { asset_id?: string }>(
32+
filePath: string,
33+
assetIdFromPositional: string,
34+
): Promise<T> {
35+
let content: string;
36+
try {
37+
content = await readFile(filePath, 'utf-8');
38+
} catch (err) {
39+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
40+
throw new Error(`Config file not found: ${filePath}`);
41+
}
42+
throw err;
43+
}
44+
45+
let parsed: T;
46+
try {
47+
parsed = JSON.parse(content) as T;
48+
} catch (err) {
49+
throw new Error(
50+
`Invalid JSON in config file ${filePath}: ${(err as Error).message}`,
51+
);
52+
}
53+
54+
if (parsed.asset_id && parsed.asset_id !== assetIdFromPositional) {
55+
throw new Error(
56+
`asset_id in config file (${parsed.asset_id}) does not match positional argument (${assetIdFromPositional}).`,
57+
);
58+
}
59+
parsed.asset_id = assetIdFromPositional;
60+
return parsed;
61+
}
62+
63+
export function retrieveRobotsJob(
64+
mux: Mux,
65+
workflow: RobotsWorkflow,
66+
jobId: string,
67+
): Promise<AnyRobotsJob> {
68+
switch (workflow) {
69+
case 'ask-questions':
70+
return mux.robotsPreview.jobs.askQuestions.retrieve(jobId);
71+
case 'find-key-moments':
72+
return mux.robotsPreview.jobs.findKeyMoments.retrieve(jobId);
73+
case 'generate-chapters':
74+
return mux.robotsPreview.jobs.generateChapters.retrieve(jobId);
75+
case 'moderate':
76+
return mux.robotsPreview.jobs.moderate.retrieve(jobId);
77+
case 'summarize':
78+
return mux.robotsPreview.jobs.summarize.retrieve(jobId);
79+
case 'translate-captions':
80+
return mux.robotsPreview.jobs.translateCaptions.retrieve(jobId);
81+
}
82+
}
83+
84+
export function assertJobCompleted(job: AnyRobotsJob): void {
85+
if (job.status === 'completed') return;
86+
const details = job.errors?.length
87+
? `: ${job.errors.map((e) => `${e.type}: ${e.message}`).join('; ')}`
88+
: '';
89+
throw new Error(`Job ${job.id} ended with status "${job.status}"${details}`);
90+
}
91+
92+
export async function pollForRobotsJob(
93+
mux: Mux,
94+
workflow: RobotsWorkflow,
95+
jobId: string,
96+
jsonOutput: boolean,
97+
): Promise<AnyRobotsJob> {
98+
const POLL_INTERVAL_MS = 3000;
99+
const MAX_POLL_TIME_MS = 15 * 60 * 1000;
100+
const start = Date.now();
101+
102+
if (!jsonOutput) {
103+
process.stderr.write('Waiting for job to complete');
104+
}
105+
106+
while (Date.now() - start < MAX_POLL_TIME_MS) {
107+
const job = await retrieveRobotsJob(mux, workflow, jobId);
108+
if (isTerminalStatus(job.status)) {
109+
if (!jsonOutput) {
110+
process.stderr.write(` ${job.status}!\n`);
111+
}
112+
return job;
113+
}
114+
if (!jsonOutput) {
115+
process.stderr.write('.');
116+
}
117+
await sleep(POLL_INTERVAL_MS);
118+
}
119+
120+
throw new Error(`Timed out waiting for job ${jobId} to complete`);
121+
}
122+
123+
function sleep(ms: number): Promise<void> {
124+
return new Promise((resolve) => setTimeout(resolve, ms));
125+
}

src/commands/robots/ask-questions.test.ts

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,31 @@ import {
77
spyOn,
88
test,
99
} from 'bun:test';
10-
import { askQuestionsCommand } from './ask-questions.ts';
10+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
11+
import { tmpdir } from 'node:os';
12+
import { join } from 'node:path';
13+
import { askQuestionsCommand, parseQuestion } from './ask-questions.ts';
1114

1215
// Note: These tests focus on CLI flag parsing and command structure
1316
// They do NOT test the actual Mux API integration (that's tested via E2E)
1417

1518
describe('mux robots ask-questions', () => {
19+
let tempDir: string;
1620
let exitSpy: Mock<typeof process.exit>;
1721
let consoleErrorSpy: Mock<typeof console.error>;
1822

19-
beforeEach(() => {
23+
beforeEach(async () => {
24+
tempDir = await mkdtemp(join(tmpdir(), 'mux-cli-robots-test-'));
25+
2026
exitSpy = spyOn(process, 'exit').mockImplementation((() => {
2127
throw new Error('process.exit called');
2228
}) as never);
2329

2430
consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {});
2531
});
2632

27-
afterEach(() => {
33+
afterEach(async () => {
34+
await rm(tempDir, { recursive: true, force: true });
2835
exitSpy?.mockRestore();
2936
consoleErrorSpy?.mockRestore();
3037
});
@@ -71,6 +78,20 @@ describe('mux robots ask-questions', () => {
7178
.find((o) => o.name === 'json');
7279
expect(opt).toBeDefined();
7380
});
81+
82+
test('has --wait flag', () => {
83+
const opt = askQuestionsCommand
84+
.getOptions()
85+
.find((o) => o.name === 'wait');
86+
expect(opt).toBeDefined();
87+
});
88+
89+
test('has --file flag', () => {
90+
const opt = askQuestionsCommand
91+
.getOptions()
92+
.find((o) => o.name === 'file');
93+
expect(opt).toBeDefined();
94+
});
7495
});
7596

7697
describe('Input validation', () => {
@@ -84,4 +105,103 @@ describe('mux robots ask-questions', () => {
84105
expect(exitSpy).toHaveBeenCalled();
85106
});
86107
});
108+
109+
describe('parseQuestion helper', () => {
110+
test('plain question without pipe yields no answer_options', () => {
111+
expect(parseQuestion('What is this?')).toEqual({
112+
question: 'What is this?',
113+
});
114+
});
115+
116+
test('question with pipe splits answer_options on commas', () => {
117+
expect(parseQuestion('How many speakers?|one,two,three or more')).toEqual(
118+
{
119+
question: 'How many speakers?',
120+
answer_options: ['one', 'two', 'three or more'],
121+
},
122+
);
123+
});
124+
125+
test('trims whitespace around question and options', () => {
126+
expect(parseQuestion(' Q? | a , b , c ')).toEqual({
127+
question: 'Q?',
128+
answer_options: ['a', 'b', 'c'],
129+
});
130+
});
131+
132+
test('splits only on first pipe so options may contain pipes', () => {
133+
expect(parseQuestion('Choose?|a|b,c|d')).toEqual({
134+
question: 'Choose?',
135+
answer_options: ['a|b', 'c|d'],
136+
});
137+
});
138+
139+
test('throws on empty question', () => {
140+
expect(() => parseQuestion('|a,b')).toThrow(/question/i);
141+
});
142+
143+
test('throws when pipe is present but options list is empty', () => {
144+
expect(() => parseQuestion('Q?|')).toThrow(/answer_options/i);
145+
});
146+
});
147+
148+
describe('--file mode', () => {
149+
test('errors when config file does not exist', async () => {
150+
const configPath = join(tempDir, 'nope.json');
151+
try {
152+
await askQuestionsCommand.parse(['asset_abc', '--file', configPath]);
153+
} catch (_error) {
154+
// Expected
155+
}
156+
expect(exitSpy).toHaveBeenCalledWith(1);
157+
const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? '';
158+
expect(msg).toMatch(/file not found/i);
159+
});
160+
161+
test('errors when config file is invalid JSON', async () => {
162+
const configPath = join(tempDir, 'bad.json');
163+
await writeFile(configPath, '{ bad');
164+
try {
165+
await askQuestionsCommand.parse(['asset_abc', '--file', configPath]);
166+
} catch (_error) {
167+
// Expected
168+
}
169+
expect(exitSpy).toHaveBeenCalledWith(1);
170+
const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? '';
171+
expect(msg).toMatch(/invalid json/i);
172+
});
173+
174+
test('errors when --file combined with --question', async () => {
175+
const configPath = join(tempDir, 'config.json');
176+
await writeFile(
177+
configPath,
178+
JSON.stringify({
179+
questions: [{ question: 'What is this?' }],
180+
}),
181+
);
182+
try {
183+
await askQuestionsCommand.parse([
184+
'asset_abc',
185+
'--file',
186+
configPath,
187+
'--question',
188+
'Other?',
189+
]);
190+
} catch (_error) {
191+
// Expected
192+
}
193+
expect(exitSpy).toHaveBeenCalledWith(1);
194+
const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? '';
195+
expect(msg).toMatch(/--file cannot be combined/i);
196+
});
197+
198+
test('requires either --file or --question', async () => {
199+
try {
200+
await askQuestionsCommand.parse(['asset_abc']);
201+
} catch (_error) {
202+
// Expected
203+
}
204+
expect(exitSpy).toHaveBeenCalled();
205+
});
206+
});
87207
});

0 commit comments

Comments
 (0)